feat(wiki): config UI overhaul — model strategy, search preview modal, graph fullscreen

This commit is contained in:
matevip 2026-04-24 10:03:45 +08:00
parent 4f67e31887
commit 27c4c3e5e2
20 changed files with 2358 additions and 493 deletions

View File

@ -298,11 +298,13 @@ public class WikiController {
// ==================== Wiki Pages ====================
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取 Wiki 页面列表")
@Operation(summary = "获取 Wiki 页面列表(可按原始材料过滤)")
@GetMapping("/knowledge-bases/{kbId}/pages")
public R<List<WikiPageEntity>> listPages(@PathVariable Long kbId,
@RequestParam(required = false) Long rawId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
if (rawId != null) return R.ok(pageService.listBySourceRawId(kbId, rawId));
return R.ok(pageService.listByKbId(kbId));
}

View File

@ -180,11 +180,22 @@ public class WikiPageService {
}
/**
* 创建新 Wiki 页面
* Create a new wiki page (without explicit pageType)
*/
@Transactional
public WikiPageEntity createPage(Long kbId, String slug, String title, String content,
String summary, String sourceRawIds) {
return createPage(kbId, slug, title, content, summary, sourceRawIds, null);
}
/**
* Create a new wiki page with explicit pageType classification.
* pageType is stored lowercase (concept / person / place / event / technology /
* organization / product / term / process / other).
*/
@Transactional
public WikiPageEntity createPage(Long kbId, String slug, String title, String content,
String summary, String sourceRawIds, String pageType) {
WikiPageEntity entity = new WikiPageEntity();
entity.setKbId(kbId);
entity.setSlug(slug);
@ -195,11 +206,28 @@ public class WikiPageService {
entity.setSourceRawIds(sourceRawIds);
entity.setVersion(1);
entity.setLastUpdatedBy("ai");
if (pageType != null && !pageType.isBlank()) {
entity.setPageType(pageType.toLowerCase());
}
pageMapper.insert(entity);
evictSummaryCache(kbId);
return entity;
}
/**
* List pages derived from a specific raw material (for UI sidebar filtering).
* Uses a LIKE search on sourceRawIds JSON field cheap and dialect-agnostic.
*/
public List<WikiPageEntity> listBySourceRawId(Long kbId, Long rawId) {
List<WikiPageEntity> pages = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)
.like(WikiPageEntity::getSourceRawIds, rawId.toString())
.orderByAsc(WikiPageEntity::getTitle));
pages.forEach(p -> p.setContent(null));
return pages;
}
/**
* AI 更新页面内容手动编辑的页面不覆盖内容仅追加来源
*/

View File

@ -675,7 +675,7 @@ public class WikiProcessingService {
// Phase B-1: BatchCreate (RFC-047 P1)
// One LLM call for all N creates instead of N individual calls.
// batchCreatePages handles sub-batching, liveIndex updates, and progress counting.
batchCreatePages(kb, raw, textContent, existingPagesIndex, createMetas, created, pc);
batchCreatePages(kb, raw, textContent, existingPagesIndex, createMetas, created, pc, documentMap);
List<CompletableFuture<Void>> createFutures = new ArrayList<>(0); // kept for allOf join below
// 阶段 B-2并行 merge
@ -754,7 +754,7 @@ public class WikiProcessingService {
private int batchCreatePages(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String chunkText, String existingPagesIndex,
List<JsonNode> createMetas, AtomicInteger created,
ProgressCounter pc) {
ProgressCounter pc, String documentMap) {
if (createMetas.isEmpty()) return 0;
Long kbId = kb.getId();
Long rawId = raw.getId();
@ -779,8 +779,12 @@ public class WikiProcessingService {
String batchSystem = PromptLoader.loadPrompt("wiki/batch-create-system");
String batchUserTemplate = PromptLoader.loadPrompt("wiki/batch-create-user");
String docMapSection = (documentMap != null && !documentMap.isBlank())
? "## 文档全局概念地图(预分析结果,供页面内容生成参考)\n\n```json\n" + documentMap + "\n```\n"
: "";
String batchUser = batchUserTemplate
.replace("{config}", configContent)
.replace("{document_map_section}", docMapSection)
.replace("{existing_pages}", liveIndex.toString())
.replace("{pages_to_create}", metasJson.toString())
.replace("{raw_title}", raw.getTitle())
@ -820,6 +824,7 @@ public class WikiProcessingService {
String title = pageJson.path("title").asText("");
String content = pageJson.path("content").asText("");
String pageSummary = pageJson.path("summary").asText("");
String pageType = pageJson.path("page_type").asText("");
if (content.isBlank()) {
log.info("[Wiki] BatchCreate: blank content for slug='{}', retrying individually", slug);
final String blankSlug = slug;
@ -869,7 +874,7 @@ public class WikiProcessingService {
boolean wasCreated = false;
boolean ok = false;
try {
wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary);
wasCreated = savePageContent(kb, raw, slug, title, content, pageSummary, pageType);
if (wasCreated) {
created.incrementAndGet();
totalCreated++;
@ -991,6 +996,12 @@ public class WikiProcessingService {
*/
private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary) {
return savePageContent(kb, raw, slug, title, content, pageSummary, null);
}
private boolean savePageContent(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw,
String slug, String title, String content, String pageSummary,
String pageType) {
Long kbId = kb.getId();
Long rawId = raw.getId();
@ -1037,7 +1048,7 @@ public class WikiProcessingService {
String sourceRawIds = "[" + rawId + "]";
try {
WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds);
WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds, pageType);
pageService.mergeSourceLineage(created.getId(), rawId, raw.getTitle());
log.info("[Wiki] Phase B create page slug='{}' done (created)", slug);
citationService.buildCitationsAsync(created.getId(), kbId);

View File

@ -26,7 +26,7 @@
每个页面输出一个 FILE 块,格式如下:
---FILE: {slug}---
{"slug":"...","title":"...","summary":"...","content":"## 标题\n\n摘要...\n\n### 章节\n..."}
{"slug":"...","title":"...","summary":"...","page_type":"concept","content":"## 标题\n\n摘要...\n\n### 章节\n..."}
---END FILE---
规则:
@ -40,3 +40,4 @@
- `title`:与 metadata 保持一致;如有更精确的描述可微调
- `content`:完整 markdown 正文
- `summary`:一段话简短摘要
- `page_type`页面类型从以下值中选一个concept / person / place / event / technology / organization / product / term / process / other

View File

@ -2,6 +2,8 @@
{config}
{document_map_section}
## 已有 Wiki 页面索引(用于建立 [[链接]]
{existing_pages}

View File

@ -432,7 +432,8 @@ export const wikiApi = {
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
// Wiki Pages
listPages: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/pages`),
listPages: (kbId: number, rawId?: number) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages`, rawId != null ? { params: { rawId } } : undefined),
getPage: (kbId: number, slug: string) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`),
updatePage: (kbId: number, slug: string, content: string) =>

View File

@ -245,11 +245,11 @@ watch(open, async (isOpen) => {
.model-dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
z-index: 4000;
}
.model-dropdown {
z-index: 2001;
z-index: 4001;
min-width: 280px;
max-width: 360px;
background: var(--mc-bg-elevated);

View File

@ -1299,7 +1299,15 @@ export default {
explainRelation: 'Explain relation',
},
configPanel: {
embeddingModel: 'Embedding Model',
embeddingModelHint: 'Semantic search model for this KB; leave empty for system default',
modelStrategy: 'Model Strategy',
globalDefault: 'Global default',
selectModel: 'Select a model…',
otherProvider: 'Other',
searchModel: 'Search models…',
noModelMatch: 'No matching models',
addFallback: '+ Add fallback',
stepModel: {
route: 'Analysis / Routing',
create_page: 'Page Creation',
@ -1319,6 +1327,34 @@ export default {
noPages: 'No pages yet',
noResults: 'No pages match your search',
loadMore: 'Load {n} more',
filteredByRaw: 'Filtered by source material',
clearFilter: 'Clear filter',
pageTypes: {
concept: 'Concepts',
person: 'People',
place: 'Places',
event: 'Events',
technology: 'Technology',
organization: 'Organizations',
product: 'Products',
term: 'Terms',
process: 'Processes',
other: 'Other',
},
graph: {
tab: 'Knowledge Graph',
nodes: 'nodes',
edges: 'edges',
orphans: 'orphans',
showOrphans: 'Show orphans',
allTypes: 'All types',
resetView: 'Reset view',
fullscreen: 'Fullscreen',
exitFullscreen: 'Exit fullscreen',
linksTo: 'Links to',
openPage: 'Open page',
empty: 'No graph data — process some raw materials first',
},
},
cronJobs: {
kicker: 'Automation',

View File

@ -1309,7 +1309,15 @@ export default {
explainRelation: '查看关联原因',
},
configPanel: {
embeddingModel: '向量化模型',
embeddingModelHint: '语义搜索模型,留空使用系统默认',
modelStrategy: '模型策略',
globalDefault: '跟随全局默认',
selectModel: '选择可用模型…',
otherProvider: '其他',
searchModel: '搜索模型…',
noModelMatch: '未找到匹配模型',
addFallback: '+ 添加备选',
stepModel: {
route: '分析/路由',
create_page: '页面生成',
@ -1329,6 +1337,34 @@ export default {
noPages: '暂无页面',
noResults: '未找到匹配页面',
loadMore: '加载更多 {n} 条',
filteredByRaw: '按原始材料过滤',
clearFilter: '清除过滤',
pageTypes: {
concept: '概念',
person: '人物',
place: '地点',
event: '事件',
technology: '技术',
organization: '组织',
product: '产品',
term: '术语',
process: '流程',
other: '其他',
},
graph: {
tab: '知识图谱',
nodes: '节点',
edges: '连接',
orphans: '孤立节点',
showOrphans: '显示孤立节点',
allTypes: '全部类型',
resetView: '重置视图',
fullscreen: '全屏',
exitFullscreen: '退出全屏',
linksTo: '链接到',
openPage: '打开页面',
empty: '暂无图谱数据,请先处理原始材料',
},
},
cronJobs: {
kicker: '自动执行',

View File

@ -58,6 +58,10 @@ export const useWikiStore = defineStore('wiki', () => {
const currentPage = ref<WikiPage | null>(null)
const loading = ref(false)
// Raw material filter state
const selectedRawId = ref<number | null>(null)
const totalPageCount = ref(0)
async function fetchKnowledgeBases() {
loading.value = true
try {
@ -98,9 +102,20 @@ export const useWikiStore = defineStore('wiki', () => {
rawMaterials.value = res.data || []
}
async function fetchPages(kbId: number) {
const res: any = await wikiApi.listPages(kbId)
async function fetchPages(kbId: number, rawId?: number | null) {
const res: any = await wikiApi.listPages(kbId, rawId ?? undefined)
pages.value = res.data || []
if (!rawId) totalPageCount.value = pages.value.length
}
async function filterPagesByRaw(kbId: number, rawId: number) {
selectedRawId.value = rawId
await fetchPages(kbId, rawId)
}
async function clearRawFilter(kbId: number) {
selectedRawId.value = null
await fetchPages(kbId)
}
async function loadPage(kbId: number, slug: string) {
@ -150,12 +165,16 @@ export const useWikiStore = defineStore('wiki', () => {
pages,
currentPage,
loading,
selectedRawId,
totalPageCount,
fetchKnowledgeBases,
selectKB,
createKB,
deleteKB,
fetchRawMaterials,
fetchPages,
filterPagesByRaw,
clearRawFilter,
loadPage,
addRawText,
uploadRawFile,

View File

@ -55,7 +55,13 @@
<div v-if="store.rawMaterials.length === 0" class="empty-hint">
{{ t('wiki.noRawMaterials') }}
</div>
<div v-for="raw in store.rawMaterials" :key="raw.id" class="raw-item">
<div
v-for="raw in store.rawMaterials"
:key="raw.id"
class="raw-item"
:class="{ 'raw-item--active': store.selectedRawId === raw.id }"
@click="toggleRawFilter(raw.id)"
>
<div class="raw-item-row">
<div class="raw-item-info">
<span class="raw-item-title">{{ raw.title }}</span>
@ -410,6 +416,16 @@ async function processAll() {
setTimeout(() => { store.fetchRawMaterials(kbId) }, 5000)
}
function toggleRawFilter(rawId: number) {
if (!store.currentKB) return
const kbId = store.currentKB.id
if (store.selectedRawId === rawId) {
store.clearRawFilter(kbId)
} else {
store.filterPagesByRaw(kbId, rawId)
}
}
async function handleScanDir() {
if (!store.currentKB || !dirPath.value.trim()) return
scanning.value = true
@ -476,8 +492,9 @@ async function handleScanDir() {
.raw-list-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); margin-bottom: 4px; }
.empty-hint { text-align: center; padding: 24px 0; font-size: 14px; color: var(--mc-text-tertiary); }
.raw-item { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: linear-gradient(180deg, var(--mc-bg-elevated), var(--mc-bg-muted)); border: 1px solid var(--mc-border-light); border-radius: 14px; font-size: 13px; transition: border-color 0.15s, transform 0.15s; }
.raw-item { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: linear-gradient(180deg, var(--mc-bg-elevated), var(--mc-bg-muted)); border: 1px solid var(--mc-border-light); border-radius: 14px; font-size: 13px; transition: border-color 0.15s, transform 0.15s; cursor: pointer; }
.raw-item:hover { border-color: var(--mc-border); transform: translateY(-1px); }
.raw-item--active { border-color: var(--mc-primary) !important; background: var(--mc-primary-bg) !important; transform: translateY(-1px); }
.raw-item-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.raw-item-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }

View File

@ -5,128 +5,162 @@
<p class="config-desc">{{ t('wiki.configDesc') }}</p>
</div>
<!-- Embedding model binding -->
<div class="embedding-config">
<label class="embedding-label">
Embedding Model
<span class="embedding-hint">Semantic search model for this KB; leave empty for system default</span>
</label>
<div class="embedding-row">
<select v-model="embeddingModelId" class="embedding-select" :disabled="savingEmbedding">
<option value="">Follow system default</option>
<option v-for="m in embeddingOptions" :key="m.id" :value="String(m.id)">
{{ m.name }} ({{ m.modelName }})
</option>
</select>
<button class="btn-secondary" @click="saveEmbeddingBinding" :disabled="savingEmbedding">
<!-- Embedding model -->
<div class="config-card">
<div class="config-card__head">
<div>
<div class="config-card__title">{{ t('wiki.configPanel.embeddingModel') }}</div>
<div class="config-card__hint">{{ t('wiki.configPanel.embeddingModelHint') }}</div>
</div>
<button class="btn-save" @click="saveEmbeddingBinding" :disabled="savingEmbedding">
{{ savingEmbedding ? t('wiki.saving') : t('common.save') }}
</button>
</div>
<WikiModelPicker v-model="embeddingModelId" :options="embeddingPickerOptions" :disabled="savingEmbedding" />
</div>
<!-- RFC-033: Step model strategy -->
<details class="config-section">
<summary class="section-toggle">{{ t('wiki.configPanel.modelStrategy') }}</summary>
<div class="step-models-grid">
<div v-for="step in stepKeys" :key="step" class="step-model-row">
<label class="step-label">{{ t(`wiki.configPanel.stepModel.${step}`) }}</label>
<select v-model="stepModels[step]" class="step-select">
<option value="">Global default</option>
<option v-for="m in chatModelOptions" :key="m.id" :value="String(m.id)">
{{ m.name }}
</option>
</select>
<!-- Model strategy -->
<div class="config-card config-card--clickable" @click="modelsOpen = true">
<div class="config-card__row">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<div class="config-card__row-text">
<div class="config-card__title">{{ t('wiki.configPanel.modelStrategy') }}</div>
<div class="config-card__hint">
<template v-if="wikiGlobalModelId && activeStepCount > 0">Wiki 全局已设置{{ activeStepCount }} 个步骤独立覆盖</template>
<template v-else-if="wikiGlobalModelId">Wiki 全局模型已设置步骤沿用</template>
<template v-else-if="activeStepCount > 0">{{ activeStepCount }} 个步骤已绑定自定义模型</template>
<template v-else>全部步骤使用系统全局默认模型</template>
</div>
</div>
</div>
<div class="fallback-section">
<label class="step-label">{{ t('wiki.configPanel.fallbackModels') }}</label>
<div class="fallback-list">
<span v-for="(fId, idx) in fallbackModelIds" :key="idx" class="fallback-tag">
{{ chatModelOptions.find(m => String(m.id) === String(fId))?.name || fId }}
<button class="fallback-remove" @click="fallbackModelIds.splice(idx, 1)">×</button>
</span>
<select class="fallback-add-select" @change="addFallback($event)">
<option value="">+ Add</option>
<option v-for="m in chatModelOptions" :key="m.id" :value="String(m.id)">
{{ m.name }}
</option>
</select>
<div class="card-badge" :class="{ 'card-badge--active': activeStepCount > 0 || !!wikiGlobalModelId }">
{{ activeStepCount }} / {{ stepKeys.length }}
</div>
<svg class="card-chevron" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</div>
<button class="btn-secondary btn-sm" @click="saveStepModels" :disabled="savingStepModels">
{{ savingStepModels ? t('wiki.saving') : t('common.save') }}
</button>
</details>
<!-- Config editor -->
<textarea
v-model="configContent"
class="config-editor"
rows="20"
:placeholder="t('wiki.configPlaceholder')"
></textarea>
<div class="config-actions">
<button class="btn-secondary" @click="loadConfig">{{ t('common.reset') }}</button>
<button class="btn-primary" @click="saveConfig" :disabled="saving">
{{ saving ? t('wiki.saving') : t('common.save') }}
</button>
</div>
<!-- RFC-033: Search preview -->
<WikiSearchPreview v-if="store.currentKB" :kb-id="store.currentKB.id" />
<!-- Processing rules -->
<div class="config-card config-card--clickable" @click="rulesOpen = true">
<div class="config-card__row" style="align-items: flex-start">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-top:2px">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
<div class="config-card__row-text" style="flex:1;min-width:0">
<div class="config-card__title">处理规则</div>
<div class="config-card__hint">AI 消化原始材料时遵循的质量格式和语言规则</div>
<!-- Preview snippet -->
<div v-if="configContent.trim()" class="rules-snippet">
<span v-for="(line, i) in snippetLines" :key="i" class="rules-snippet__line" :class="{ 'h': line.startsWith('#') }">{{ line }}</span>
<span v-if="totalLines > 4" class="rules-snippet__more">+{{ totalLines - 4 }} </span>
</div>
<div v-else class="rules-snippet rules-snippet--empty">点击配置 </div>
</div>
<svg class="card-chevron" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:2px">
<polyline points="9 18 15 12 9 6"/>
</svg>
</div>
</div>
<!-- Search preview card -->
<div v-if="store.currentKB" class="config-card config-card--clickable" @click="searchOpen = true">
<div class="config-card__row">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<div class="config-card__row-text">
<div class="config-card__title">{{ t('wiki.configPanel.searchPreview') }}</div>
<div class="config-card__hint">{{ t('wiki.configPanel.searchPreviewPlaceholder') }}</div>
</div>
<svg class="card-chevron" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</div>
</div>
<!-- Modals -->
<WikiConfigRules
:open="rulesOpen"
:model-value="configContent"
:kb-name="store.currentKB?.name"
:saving="savingRules"
@close="rulesOpen = false"
@save="saveRules"
/>
<WikiConfigModels
:open="modelsOpen"
:kb-name="store.currentKB?.name"
:saving="savingStepModels"
:step-keys="stepKeys"
:step-models="stepModels"
:fallback-model-ids="fallbackModelIds"
:providers="chatProviders"
:config-id-to-value="configIdToValue"
:value-to-config-id="valueToConfigId"
:config-id-to-label="configIdToLabel"
:wiki-global-model-id="wikiGlobalModelId"
@close="modelsOpen = false"
@save="saveStepModelsAndClose"
@reset="loadStepModels"
@add-fallback="onAddFallback"
@remove-fallback="(idx) => fallbackModelIds.splice(idx, 1)"
@update:wiki-global-model-id="wikiGlobalModelId = $event"
/>
<WikiSearchPreview
v-if="store.currentKB"
:open="searchOpen"
:kb-id="store.currentKB.id"
:kb-name="store.currentKB?.name"
@close="searchOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi, modelApi } from '@/api/index'
import type { ProviderInfo } from '@/types'
import WikiSearchPreview from './WikiSearchPreview.vue'
import WikiModelPicker, { type ModelOption } from './WikiModelPicker.vue'
import WikiConfigRules from './WikiConfigRules.vue'
import WikiConfigModels from './WikiConfigModels.vue'
const { t } = useI18n()
const store = useWikiStore()
// Rules state
const configContent = ref('')
const saving = ref(false)
const rulesOpen = ref(false)
const savingRules = ref(false)
// Embedding binding
interface ModelOption { id: string | number; name: string; modelName: string }
const snippetLines = computed(() => configContent.value.split('\n').filter(l => l.trim()).slice(0, 4))
const totalLines = computed(() => configContent.value.split('\n').filter(l => l.trim()).length)
async function saveRules(content: string) {
if (!store.currentKB) return
savingRules.value = true
try {
await wikiApi.updateConfig(store.currentKB.id, content)
configContent.value = content
rulesOpen.value = false
} catch (e) {
console.error('[WikiConfig] Failed to save rules', e)
} finally {
savingRules.value = false
}
}
// Embedding model
const embeddingModelId = ref<string>('')
const embeddingOptions = ref<ModelOption[]>([])
const savingEmbedding = ref(false)
// Step model strategy
const stepKeys = ['route', 'create_page', 'merge_page', 'enrich', 'summary']
const stepModels = reactive<Record<string, string>>({})
const fallbackModelIds = ref<string[]>([])
const chatModelOptions = ref<ModelOption[]>([])
const savingStepModels = ref(false)
async function loadEmbeddingOptions() {
try {
const res = await modelApi.listByType('embedding')
embeddingOptions.value = ((res.data as any[]) || []).filter(m => m.enabled !== false)
} catch (e) {
console.error('[WikiConfig] Failed to load embedding options', e)
}
}
async function loadChatModelOptions() {
try {
const res = await modelApi.listByType('chat')
chatModelOptions.value = ((res.data as any[]) || []).filter(m => m.enabled !== false)
} catch (e) {
console.error('[WikiConfig] Failed to load chat model options', e)
}
}
function loadEmbeddingBinding() {
const kb: any = store.currentKB
embeddingModelId.value = kb?.embeddingModelId ? String(kb.embeddingModelId) : ''
}
async function saveEmbeddingBinding() {
if (!store.currentKB) return
savingEmbedding.value = true
@ -143,11 +177,21 @@ async function saveEmbeddingBinding() {
}
}
function loadStepModels() {
// Parse from KB configContent if it contains stepModels
stepKeys.forEach(k => stepModels[k] = '')
fallbackModelIds.value = []
// Model strategy
const stepKeys = ['route', 'create_page', 'merge_page', 'enrich', 'summary']
const stepModels = reactive<Record<string, string>>({})
const fallbackModelIds = ref<string[]>([])
const wikiGlobalModelId = ref<string>('')
const modelsOpen = ref(false)
const savingStepModels = ref(false)
const searchOpen = ref(false)
const activeStepCount = computed(() => stepKeys.filter(k => !!stepModels[k]).length)
function loadStepModels() {
stepKeys.forEach(k => (stepModels[k] = ''))
fallbackModelIds.value = []
wikiGlobalModelId.value = ''
if (!store.currentKB) return
try {
const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null
@ -157,37 +201,30 @@ function loadStepModels() {
if (cfg.stepModels[fullKey]) stepModels[key] = String(cfg.stepModels[fullKey])
}
}
if (cfg?.fallbackModelIds) {
fallbackModelIds.value = cfg.fallbackModelIds.map(String)
}
} catch { /* config might not be JSON */ }
if (cfg?.fallbackModelIds) fallbackModelIds.value = cfg.fallbackModelIds.map(String)
if (cfg?.wikiDefaultModelId) wikiGlobalModelId.value = String(cfg.wikiDefaultModelId)
} catch { /* not JSON */ }
}
async function saveStepModels() {
async function saveStepModelsAndClose() {
if (!store.currentKB) return
savingStepModels.value = true
try {
// Build stepModels map
const stepMap: Record<string, number> = {}
for (const key of stepKeys) {
if (stepModels[key]) {
stepMap[`heavy_ingest.${key}`] = Number(stepModels[key])
}
if (stepModels[key]) stepMap[`heavy_ingest.${key}`] = Number(stepModels[key])
}
// Merge into existing config
let existingConfig: any = {}
try {
if (store.currentKB.configContent) {
existingConfig = JSON.parse(store.currentKB.configContent)
}
} catch { /* not JSON, will overwrite */ }
if (store.currentKB.configContent) existingConfig = JSON.parse(store.currentKB.configContent)
} catch { /* not JSON */ }
existingConfig.stepModels = Object.keys(stepMap).length > 0 ? stepMap : undefined
existingConfig.fallbackModelIds = fallbackModelIds.value.length > 0
? fallbackModelIds.value.map(Number)
: undefined
? fallbackModelIds.value.map(Number) : undefined
existingConfig.wikiDefaultModelId = wikiGlobalModelId.value
? Number(wikiGlobalModelId.value) : undefined
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
modelsOpen.value = false
} catch (e) {
console.error('[WikiConfig] Failed to save step models', e)
} finally {
@ -195,102 +232,188 @@ async function saveStepModels() {
}
}
function addFallback(event: Event) {
const select = event.target as HTMLSelectElement
const val = select.value
if (val && !fallbackModelIds.value.includes(val)) {
fallbackModelIds.value.push(val)
}
select.value = ''
function onAddFallback(id: string) {
if (id && !fallbackModelIds.value.includes(id)) fallbackModelIds.value.push(id)
}
async function loadConfig() {
// Raw model data
interface RawModel { id: number | string; name: string; modelName?: string; provider?: string; enabled?: boolean }
const chatRawModels = ref<RawModel[]>([])
const embeddingRawModels = ref<RawModel[]>([])
const providerNames = ref<Record<string, string>>({})
// Tracks whether a provider has a usable API key (available = true from ProviderInfoDTO)
const providerAvailable = ref<Record<string, boolean>>({})
// Full provider list for ModelSelector (only available providers)
const chatProviders = ref<ProviderInfo[]>([])
async function loadProviderNames() {
try {
const res: any = await modelApi.listProviders()
const list: any[] = res.data || []
chatProviders.value = list as ProviderInfo[]
const nameMap: Record<string, string> = {}
const availMap: Record<string, boolean> = {}
for (const p of list) {
nameMap[p.id] = p.name || p.id
// Local providers (Ollama etc.) don't need an API key treat as available
availMap[p.id] = !!(p.available || p.isLocal)
}
providerNames.value = nameMap
providerAvailable.value = availMap
} catch { /* ignore */ }
}
// Format bridge: numeric config ID ModelSelector's "providerId::modelId"
// config ID "providerId::modelName" (the value ModelSelector emits)
const configIdToValue = computed(() => {
const map = new Map<string, string>()
for (const m of chatRawModels.value) {
const modelName = (m as any).modelName
if (m.provider && modelName) map.set(String(m.id), `${m.provider}::${modelName}`)
}
return map
})
// "providerId::modelName" config ID
const valueToConfigId = computed(() => {
const map = new Map<string, string>()
for (const m of chatRawModels.value) {
const modelName = (m as any).modelName
if (m.provider && modelName) map.set(`${m.provider}::${modelName}`, String(m.id))
}
return map
})
// Display label for a stored config ID
function configIdToLabel(id: string): string {
if (!id) return ''
return chatRawModels.value.find(m => String(m.id) === id)?.name || id
}
async function loadChatModels() {
try {
const res: any = await modelApi.listByType('chat')
chatRawModels.value = ((res.data as RawModel[]) || []).filter(m => m.enabled !== false)
} catch (e) { console.error('[WikiConfig] Failed to load chat models', e) }
}
async function loadEmbeddingModels() {
try {
const res: any = await modelApi.listByType('embedding')
embeddingRawModels.value = ((res.data as RawModel[]) || []).filter(m => m.enabled !== false)
} catch (e) { console.error('[WikiConfig] Failed to load embedding models', e) }
}
function buildPickerOptions(models: RawModel[]): ModelOption[] {
return models.map(m => ({
id: String(m.id),
name: m.name,
modelId: (m as any).modelName,
providerId: m.provider,
providerName: m.provider ? (providerNames.value[m.provider] || m.provider) : undefined,
// If the provider ID is known and its availability is explicitly false, mark unavailable
available: m.provider ? (providerAvailable.value[m.provider] !== false) : true,
}))
}
const embeddingPickerOptions = computed<ModelOption[]>(() => buildPickerOptions(embeddingRawModels.value))
// Lifecycle
watch(() => store.currentKB, async () => {
if (!store.currentKB) return
try {
const res: any = await wikiApi.getConfig(store.currentKB.id)
configContent.value = res.data?.content || ''
} catch (e) {
console.error('Failed to load config', e)
}
}
async function saveConfig() {
if (!store.currentKB) return
saving.value = true
try {
await wikiApi.updateConfig(store.currentKB.id, configContent.value)
} catch (e) {
console.error('Failed to save config', e)
} finally {
saving.value = false
}
}
watch(() => store.currentKB, () => {
loadConfig()
loadEmbeddingBinding()
} catch { /* ignore */ }
embeddingModelId.value = (store.currentKB as any)?.embeddingModelId
? String((store.currentKB as any).embeddingModelId) : ''
loadStepModels()
}, { immediate: true })
loadEmbeddingOptions()
loadChatModelOptions()
loadProviderNames().then(() => {
loadChatModels()
loadEmbeddingModels()
})
</script>
<style scoped>
.wiki-config {
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
gap: 10px;
overflow-y: auto;
padding-bottom: 24px;
}
.config-header { padding-bottom: 10px; border-bottom: 1px solid var(--mc-border-light); }
.config-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 6px; letter-spacing: -0.02em; }
.config-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; letter-spacing: -0.02em; }
.config-desc { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.6; }
/* Embedding binding */
.embedding-config { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: var(--mc-bg-sunken); border-radius: 10px; border: 1px solid var(--mc-border-light); }
.embedding-label { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); display: flex; align-items: baseline; gap: 8px; }
.embedding-hint { font-size: 11px; font-weight: 400; color: var(--mc-text-tertiary); }
.embedding-row { display: flex; gap: 8px; align-items: center; }
.embedding-select { flex: 1; padding: 7px 12px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); font-size: 13px; outline: none; }
.embedding-select:focus { border-color: var(--mc-primary); }
.embedding-select:disabled { opacity: 0.6; cursor: not-allowed; }
/* Step model strategy */
.config-section { padding: 12px 14px; background: var(--mc-bg-sunken); border-radius: 10px; border: 1px solid var(--mc-border-light); }
.section-toggle { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); cursor: pointer; padding: 4px 0; }
.step-models-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.step-model-row { display: flex; align-items: center; gap: 12px; }
.step-label { font-size: 12px; color: var(--mc-text-secondary); min-width: 100px; }
.step-select { flex: 1; padding: 6px 10px; border: 1px solid var(--mc-border); border-radius: 6px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); font-size: 12px; outline: none; }
.fallback-section { margin-top: 12px; }
.fallback-list { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; }
.fallback-tag { display: flex; align-items: center; gap: 4px; padding: 3px 8px; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 6px; font-size: 11px; }
.fallback-remove { border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 14px; padding: 0 2px; }
.fallback-remove:hover { color: var(--mc-danger); }
.fallback-add-select { padding: 4px 8px; border: 1px dashed var(--mc-border); border-radius: 6px; background: transparent; font-size: 11px; color: var(--mc-text-secondary); cursor: pointer; }
/* Editor */
.config-editor { width: 100%; flex: 1; min-height: 0; padding: 16px; border: 1px solid var(--mc-border); border-radius: 14px; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.7; resize: none; overflow: auto; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
.config-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.config-actions { display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0; }
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
@media (max-width: 768px) {
.config-editor { min-height: 42vh; flex: none; resize: vertical; }
.config-actions { flex-direction: column-reverse; }
.config-actions .btn-primary, .config-actions .btn-secondary { width: 100%; justify-content: center; }
.step-model-row { flex-direction: column; align-items: flex-start; }
.step-label { min-width: 0; }
/* Cards */
.config-card {
padding: 12px 14px;
background: var(--mc-bg-sunken);
border-radius: 12px;
border: 1px solid var(--mc-border-light);
display: flex;
flex-direction: column;
gap: 10px;
}
.config-card--clickable { cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.config-card--clickable:hover { border-color: var(--mc-primary); background: var(--mc-bg-muted); }
.config-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.config-card__row { display: flex; align-items: center; gap: 10px; }
.config-card__row-text { flex: 1; min-width: 0; }
.config-card__title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
.config-card__hint { font-size: 11px; color: var(--mc-text-tertiary); margin-top: 2px; line-height: 1.4; }
.card-badge {
font-size: 10px;
padding: 1px 7px;
border-radius: 99px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated);
color: var(--mc-text-tertiary);
font-weight: 600;
flex-shrink: 0;
}
.card-badge--active { border-color: var(--mc-primary); color: var(--mc-primary); background: var(--mc-primary-bg); }
.card-chevron { color: var(--mc-text-tertiary); flex-shrink: 0; }
.config-card--clickable:hover .card-chevron { color: var(--mc-primary); }
/* Rules snippet preview */
.rules-snippet {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
margin-top: 6px;
}
.rules-snippet__line {
font-size: 11px;
font-family: 'JetBrains Mono', Consolas, monospace;
color: var(--mc-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.rules-snippet__line.h { color: var(--mc-text-secondary); font-weight: 600; }
.rules-snippet__more { font-size: 11px; color: var(--mc-text-tertiary); font-style: italic; }
.rules-snippet--empty { font-size: 11px; color: var(--mc-primary); font-style: italic; }
/* Save button */
.btn-save {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border: none;
border-radius: 8px;
background: var(--mc-primary);
color: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
flex-shrink: 0;
}
.btn-save:hover { opacity: 0.88; }
.btn-save:disabled { background: var(--mc-border); cursor: not-allowed; }
</style>

View File

@ -0,0 +1,291 @@
<template>
<Teleport to="body">
<Transition name="cfg-modal">
<div v-if="open" class="cfg-modal-overlay" @click.self="emit('close')">
<div class="cfg-modal" style="max-width:680px">
<!-- Header -->
<div class="cfg-modal__header">
<div class="cfg-modal__title-group">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span>{{ t('wiki.configPanel.modelStrategy') }}</span>
<span class="cfg-modal__kb">{{ kbName }}</span>
</div>
<div class="cfg-modal__actions">
<button class="btn-cfg-reset" @click="emit('reset')">{{ t('common.reset') }}</button>
<button class="btn-cfg-save" :disabled="saving" @click="emit('save')">
{{ saving ? t('wiki.saving') : t('common.save') }}
</button>
<button class="btn-cfg-close" @click="emit('close')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Body -->
<div class="cfg-modal__body models-body">
<!-- Priority hint -->
<div class="models-hint">
<span class="hint-chain">
<span class="hint-node hint-node--dim">系统全局</span>
<span class="hint-arrow"></span>
<span class="hint-node" :class="wikiGlobalModelId ? 'hint-node--wiki' : 'hint-node--dim'">Wiki 全局</span>
<span class="hint-arrow"></span>
<span class="hint-node hint-node--step">步骤绑定</span>
</span>
<span class="hint-desc">优先级从左到右递增留空则沿用上一级</span>
</div>
<!-- Wiki global model -->
<div class="models-section">
<div class="models-section__title">
Wiki 全局模型
<span v-if="wikiGlobalModelId" class="section-badge section-badge--on">已设置</span>
<span v-else class="section-badge">未设置</span>
<button
v-if="wikiGlobalModelId"
class="section-clear"
@click="emit('update:wikiGlobalModelId', '')"
>清除</button>
</div>
<div class="models-section__hint">为此知识库所有 Wiki 步骤统一指定专属模型优先于系统全局默认</div>
<ModelSelector
:providers="providers"
:active-value="configIdToValue.get(wikiGlobalModelId) || ''"
:active-label="configIdToLabel(wikiGlobalModelId)"
class="wiki-model-selector"
@select="emit('update:wikiGlobalModelId', valueToConfigId.get($event) || '')"
/>
</div>
<!-- Step models -->
<div class="models-section">
<div class="models-section__title">按步骤绑定</div>
<div class="models-section__hint">针对单个步骤覆盖上方全局设置最精细的控制层</div>
<div class="step-grid">
<div v-for="step in stepKeys" :key="step" class="step-row">
<div class="step-row__label">
<span class="step-index">{{ stepKeys.indexOf(step) + 1 }}</span>
{{ t(`wiki.configPanel.stepModel.${step}`) }}
</div>
<ModelSelector
:providers="providersWithDefault"
:active-value="configIdToValue.get(stepModels[step]) || ''"
:active-label="stepModelLabel(step)"
class="wiki-model-selector"
@select="onSelectStep(step, $event)"
/>
</div>
</div>
</div>
<!-- Fallback -->
<div class="models-section">
<div class="models-section__title">{{ t('wiki.configPanel.fallbackModels') }}</div>
<div class="models-section__hint">主模型失败时按顺序尝试备选最多 3 </div>
<div class="fallback-list">
<span v-for="(fId, idx) in fallbackModelIds" :key="idx" class="fallback-tag">
<span>{{ configIdToLabel(fId) }}</span>
<button class="fallback-tag__remove" @click="emit('removeFallback', idx)">×</button>
</span>
<ModelSelector
v-if="fallbackModelIds.length < 3"
:providers="providers"
:active-value="''"
:active-label="''"
class="wiki-model-selector fallback-add-selector"
@select="onAddFallback"
/>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ProviderInfo } from '@/types'
import ModelSelector from '@/components/chat/ModelSelector.vue'
const { t } = useI18n()
const props = defineProps<{
open: boolean
kbName?: string
saving?: boolean
stepKeys: string[]
stepModels: Record<string, string>
fallbackModelIds: string[]
providers: ProviderInfo[]
configIdToValue: Map<string, string>
valueToConfigId: Map<string, string>
configIdToLabel: (id: string) => string
wikiGlobalModelId: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'save'): void
(e: 'reset'): void
(e: 'addFallback', id: string): void
(e: 'removeFallback', idx: number): void
(e: 'update:wikiGlobalModelId', id: string): void
}>()
// Default-option label shown in step pickers when nothing is bound
const stepDefaultLabel = computed(() => {
if (props.wikiGlobalModelId) {
const name = props.configIdToLabel(props.wikiGlobalModelId)
return name ? `跟随 Wiki 全局 (${name})` : '跟随 Wiki 全局'
}
return '跟随全局默认'
})
// Providers list with a "default" entry prepended for step pickers
const providersWithDefault = computed<ProviderInfo[]>(() => {
const defaultProvider = {
id: '__default__',
name: '默认',
available: true,
isLocal: false,
models: [{ id: '', name: stepDefaultLabel.value }],
extraModels: [],
isCustom: false,
supportModelDiscovery: false,
supportConnectionCheck: false,
freezeUrl: false,
requireApiKey: false,
configured: true,
} as unknown as ProviderInfo
return [defaultProvider, ...props.providers]
})
function stepModelLabel(step: string): string {
const id = props.stepModels[step]
if (!id) return stepDefaultLabel.value
return props.configIdToLabel(id)
}
function onSelectStep(step: string, value: string) {
// Empty value = "default" option selected
props.stepModels[step] = value ? (props.valueToConfigId.get(value) || '') : ''
}
function onAddFallback(value: string) {
if (!value) return
const configId = props.valueToConfigId.get(value) || ''
if (configId && !props.fallbackModelIds.includes(configId)) {
emit('addFallback', configId)
}
}
</script>
<style scoped>
.models-body {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.models-hint {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--mc-bg-sunken);
border-radius: 10px;
border: 1px solid var(--mc-border-light);
flex-wrap: wrap;
}
.hint-chain { display: flex; align-items: center; gap: 6px; }
.hint-arrow { font-size: 11px; color: var(--mc-text-tertiary); }
.hint-node {
font-size: 11px;
font-weight: 600;
padding: 2px 9px;
border-radius: 99px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-tertiary);
}
.hint-node--wiki { border-color: var(--mc-primary); color: var(--mc-primary); background: var(--mc-primary-bg); }
.hint-node--step { border-color: var(--mc-border); color: var(--mc-text-secondary); }
.hint-desc { font-size: 11px; color: var(--mc-text-tertiary); }
.models-section { display: flex; flex-direction: column; gap: 8px; }
.models-section__title { font-size: 12px; font-weight: 600; color: var(--mc-text-primary); }
.models-section__hint { font-size: 11px; color: var(--mc-text-tertiary); margin-top: -4px; }
.section-badge {
font-size: 10px;
padding: 1px 7px;
border-radius: 99px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated);
color: var(--mc-text-tertiary);
font-weight: 500;
margin-left: 6px;
vertical-align: middle;
}
.section-badge--on { border-color: var(--mc-primary); color: var(--mc-primary); background: var(--mc-primary-bg); }
.section-clear {
margin-left: 8px;
font-size: 11px;
color: var(--mc-text-tertiary);
background: none;
border: none;
cursor: pointer;
padding: 0;
vertical-align: middle;
}
.section-clear:hover { color: var(--mc-danger, #e53e3e); text-decoration: underline; }
.step-grid { display: flex; flex-direction: column; gap: 6px; }
.step-row { display: grid; grid-template-columns: 160px 1fr; align-items: center; gap: 10px; }
.step-row__label { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--mc-text-secondary); font-weight: 500; }
.step-index {
width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
font-size: 10px;
font-weight: 700;
color: var(--mc-text-tertiary);
flex-shrink: 0;
}
.fallback-list { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.fallback-tag { display: flex; align-items: center; gap: 4px; padding: 3px 8px 3px 10px; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); }
.fallback-tag__remove { border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 14px; padding: 0 1px; line-height: 1; }
.fallback-tag__remove:hover { color: var(--mc-danger); }
/* Make ModelSelector stretch to full column width */
.wiki-model-selector { width: 100%; }
.wiki-model-selector :deep(.model-select-trigger) {
width: 100%;
max-width: 100%;
border-radius: 10px;
background: var(--mc-bg-elevated);
height: 38px;
}
.fallback-add-selector { width: 180px; }
.fallback-add-selector :deep(.model-select-trigger) {
border-style: dashed;
font-size: 12px;
height: 32px;
color: var(--mc-text-tertiary);
background: transparent;
}
</style>

View File

@ -0,0 +1,473 @@
<template>
<!-- Rules modal trigger card is rendered by parent; this component IS the modal content -->
<Teleport to="body">
<Transition name="cfg-modal">
<div v-if="open" class="cfg-modal-overlay" @click.self="emit('close')">
<div class="cfg-modal">
<!-- Header -->
<div class="cfg-modal__header">
<div class="cfg-modal__title-group">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
<span>处理规则</span>
<span class="cfg-modal__kb">{{ kbName }}</span>
</div>
<div class="cfg-modal__mode-switch">
<button :class="['mode-btn', { active: mode === 'guided' }]" @click="mode = 'guided'">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
向导
</button>
<button :class="['mode-btn', { active: mode === 'source' }]" @click="switchToSource">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
源码
</button>
</div>
<div class="cfg-modal__actions">
<button class="btn-cfg-reset" @click="resetRules">重置</button>
<button class="btn-cfg-save" :disabled="saving" @click="save">
{{ saving ? '保存中…' : '保存' }}
</button>
<button class="btn-cfg-close" @click="emit('close')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Body -->
<div class="cfg-modal__body">
<!-- Guided mode -->
<div v-if="mode === 'guided'" class="guided-wrap">
<div class="guided-intro">
选择启用的规则 AI 在生成页面时会严格遵循选中即生效不需要写配置文件
</div>
<div v-for="cat in categories" :key="cat.id" class="rule-cat">
<div class="rule-cat__header">
<span class="rule-cat__icon">{{ cat.icon }}</span>
<span class="rule-cat__title">{{ cat.title }}</span>
<span class="rule-cat__count">{{ cat.rules.filter(r => r.active).length }}/{{ cat.rules.length }}</span>
</div>
<div class="rule-grid">
<button
v-for="rule in cat.rules"
:key="rule.id"
:class="['rule-chip', { 'rule-chip--on': rule.active }]"
@click="rule.active = !rule.active"
>
<svg v-if="rule.active" class="chip-check" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else class="chip-plus" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ rule.label }}
</button>
</div>
<div v-if="cat.id === 'custom'" class="custom-rules-wrap">
<textarea
v-model="customExtra"
class="custom-rules-textarea"
placeholder="# 自定义规则&#10;&#10;- 用 Markdown 写额外规则…"
rows="5"
/>
</div>
</div>
<!-- Live preview pill -->
<div class="guided-preview">
<div class="guided-preview__header">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
生成预览
<button class="guided-preview__edit" @click="switchToSource">切换到源码编辑</button>
</div>
<pre class="guided-preview__content">{{ generatedContent || '(未选择任何规则)' }}</pre>
</div>
</div>
<!-- Source mode -->
<div v-else class="source-wrap">
<div class="source-bar">
<span class="source-lang">Markdown</span>
<span class="source-lines">{{ sourceContent.split('\n').length }} </span>
<button class="source-back" @click="mode = 'guided'"> 返回向导</button>
</div>
<textarea
ref="sourceEl"
v-model="sourceContent"
class="source-textarea"
spellcheck="false"
@keydown.tab.prevent="insertTab"
/>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, reactive } from 'vue'
const props = defineProps<{
open: boolean
modelValue: string // current raw config text
kbName?: string
saving?: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'save', content: string): void
}>()
// Mode
type Mode = 'guided' | 'source'
const mode = ref<Mode>('guided')
const sourceEl = ref<HTMLTextAreaElement | null>(null)
const sourceContent = ref('')
const customExtra = ref('')
// Rule IDs that are active by default (fresh / unrecognized config)
const DEFAULT_ACTIVE_IDS = new Set(['q1', 'q2', 'q3', 'q4', 'f1', 'f2', 'u1', 'u2', 'l1', 'l2'])
// Rule categories
interface Rule { id: string; label: string; text: string; active: boolean }
interface Category { id: string; icon: string; title: string; rules: Rule[] }
const categories = reactive<Category[]>([
{
id: 'quality',
icon: '✦',
title: '质量',
rules: [
{ id: 'q1', label: '宁少勿多,拒绝浅页', active: true,
text: '- 宁可生成更少但完整的页面,而非大量浅薄的条目' },
{ id: 'q2', label: '一页一概念', active: true,
text: '- 每个页面聚焦一个概念、实体或流程' },
{ id: 'q3', label: '至少 3 句实质内容', active: true,
text: '- 页面必须包含至少 3 句有实质内容的表述,否则不予创建' },
{ id: 'q4', label: '优先更新已有页面', active: true,
text: '- 若概念已存在于 Wiki更新而非重复创建' },
],
},
{
id: 'format',
icon: '❑',
title: '格式',
rules: [
{ id: 'f1', label: '顶部摘要段', active: true,
text: '- 每个页面顶部包含一段摘要1-2 句话概括核心含义)' },
{ id: 'f2', label: '使用 [[Wiki 链接]]', active: true,
text: '- 在内容中使用 [[页面标题]] 语法建立页面间的双向链接' },
{ id: 'f3', label: '清晰的 Markdown 结构', active: false,
text: '- 使用 ## 和 ### 标题组织内容结构' },
{ id: 'f4', label: '避免列表堆砌', active: false,
text: '- 优先用段落叙述而非大量无序列表' },
],
},
{
id: 'update',
icon: '↻',
title: '更新策略',
rules: [
{ id: 'u1', label: '合并而非替换', active: true,
text: '- 将新信息合并到已有页面,不覆盖现有内容' },
{ id: 'u2', label: '保留人工编辑', active: true,
text: '- 保留 last_updated_by = manual 的内容,不自动改写' },
{ id: 'u3', label: '标注矛盾信息', active: false,
text: '- 遇到与已有内容矛盾的新信息时,用 "Note:" 明确标注而非静默覆盖' },
],
},
{
id: 'language',
icon: '文',
title: '语言',
rules: [
{ id: 'l1', label: '与原材料语言一致', active: true,
text: '- 以与原始材料相同的语言编写 Wiki 页面' },
{ id: 'l2', label: '术语跨页保持一致', active: true,
text: '- 同一概念在所有页面中使用相同术语,避免同义词混用' },
],
},
{
id: 'custom',
icon: '+',
title: '自定义规则',
rules: [],
},
])
// Generate markdown from selected rules
const generatedContent = computed(() => {
const lines: string[] = ['# Wiki Processing Rules', '']
for (const cat of categories) {
if (cat.id === 'custom') continue
const active = cat.rules.filter(r => r.active)
if (!active.length) continue
lines.push(`## ${cat.title}`)
active.forEach(r => lines.push(r.text))
lines.push('')
}
if (customExtra.value.trim()) {
lines.push('## 自定义')
lines.push(customExtra.value.trim())
lines.push('')
}
return lines.join('\n').trim()
})
// Sync incoming value state
function applyDefaults() {
for (const cat of categories) {
for (const rule of cat.rules) {
rule.active = DEFAULT_ACTIVE_IDS.has(rule.id)
}
}
customExtra.value = ''
}
function parseIncoming(text: string) {
sourceContent.value = text
if (!text.trim()) {
applyDefaults()
return
}
// Match rule texts against saved content
let anyMatched = false
for (const cat of categories) {
for (const rule of cat.rules) {
const matched = text.includes(rule.text.trim())
if (matched) anyMatched = true
rule.active = matched
}
}
// Content exists but no rules recognized (e.g. header-only or legacy format) use defaults
if (!anyMatched) applyDefaults()
// Extract custom section if present
const customMatch = text.match(/## 自定义\n([\s\S]*?)(?=\n## |\s*$)/)
customExtra.value = customMatch ? customMatch[1].trim() : ''
}
watch(() => props.open, (v) => {
if (v) {
mode.value = 'guided' // always land on guided tab when re-opening
parseIncoming(props.modelValue || '')
}
})
// Mode switching
function switchToSource() {
sourceContent.value = generatedContent.value
mode.value = 'source'
nextTick(() => sourceEl.value?.focus())
}
function insertTab(e: KeyboardEvent) {
const el = e.target as HTMLTextAreaElement
const s = el.selectionStart
sourceContent.value = sourceContent.value.substring(0, s) + ' ' + sourceContent.value.substring(el.selectionEnd)
nextTick(() => { el.selectionStart = el.selectionEnd = s + 2 })
}
function resetRules() {
parseIncoming(props.modelValue || '')
mode.value = 'guided'
}
function save() {
const content = mode.value === 'guided' ? generatedContent.value : sourceContent.value
emit('save', content)
}
</script>
<style>
/* ── Modal shell (global — teleported) ── */
.cfg-modal-overlay {
position: fixed;
inset: 0;
z-index: 3100;
background: rgba(0,0,0,0.48);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.cfg-modal {
width: 100%;
max-width: 860px;
height: min(88vh, 760px);
background: var(--mc-bg-elevated);
border-radius: 18px;
box-shadow: 0 24px 64px rgba(0,0,0,0.28);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.cfg-modal__header {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 18px;
border-bottom: 1px solid var(--mc-border-light);
flex-shrink: 0;
}
.cfg-modal__title-group {
display: flex;
align-items: center;
gap: 7px;
font-size: 14px;
font-weight: 600;
color: var(--mc-text-primary);
flex: 1;
min-width: 0;
}
.cfg-modal__kb {
font-size: 11px;
font-weight: 500;
padding: 1px 8px;
background: var(--mc-primary-bg);
color: var(--mc-primary);
border-radius: 99px;
white-space: nowrap;
}
.cfg-modal__mode-switch {
display: flex;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
border-radius: 8px;
padding: 2px;
gap: 2px;
flex-shrink: 0;
}
.mode-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--mc-text-tertiary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.mode-btn.active { background: var(--mc-bg-elevated); color: var(--mc-text-primary); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.cfg-modal__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.btn-cfg-reset { padding: 5px 12px; border: 1px solid var(--mc-border); border-radius: 7px; background: transparent; color: var(--mc-text-secondary); font-size: 12px; cursor: pointer; }
.btn-cfg-reset:hover { background: var(--mc-bg-sunken); }
.btn-cfg-save { padding: 5px 16px; border: none; border-radius: 7px; background: var(--mc-primary); color: white; font-size: 12px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
.btn-cfg-save:hover { opacity: 0.88; }
.btn-cfg-save:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-cfg-close { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--mc-border-light); border-radius: 7px; background: transparent; color: var(--mc-text-tertiary); cursor: pointer; }
.btn-cfg-close:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
/* Body */
.cfg-modal__body { flex: 1; min-height: 0; overflow-y: auto; }
/* Guided mode */
.guided-wrap { padding: 18px 20px; display: flex; flex-direction: column; gap: 20px; }
.guided-intro { font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.6; padding: 10px 14px; background: var(--mc-bg-sunken); border-radius: 10px; border-left: 3px solid var(--mc-primary); }
.rule-cat {}
.rule-cat__header { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.rule-cat__icon { font-size: 13px; width: 20px; text-align: center; }
.rule-cat__title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
.rule-cat__count { margin-left: auto; font-size: 10px; color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); padding: 1px 6px; border-radius: 99px; }
.rule-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.rule-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 8px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.rule-chip:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
.rule-chip--on { border-color: var(--mc-primary); background: var(--mc-primary-bg); color: var(--mc-primary); font-weight: 500; }
.chip-check { color: var(--mc-primary); flex-shrink: 0; }
.chip-plus { color: var(--mc-text-tertiary); flex-shrink: 0; }
.rule-chip--on .chip-plus { color: var(--mc-primary); }
.custom-rules-wrap { margin-top: 8px; }
.custom-rules-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--mc-border-light);
border-radius: 10px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
line-height: 1.7;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.custom-rules-textarea:focus { border-color: var(--mc-primary); }
/* Preview */
.guided-preview { border: 1px solid var(--mc-border-light); border-radius: 10px; overflow: hidden; }
.guided-preview__header {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
background: var(--mc-bg-sunken);
font-size: 11px;
color: var(--mc-text-tertiary);
border-bottom: 1px solid var(--mc-border-light);
}
.guided-preview__edit { margin-left: auto; font-size: 11px; color: var(--mc-primary); background: none; border: none; cursor: pointer; padding: 0; }
.guided-preview__edit:hover { text-decoration: underline; }
.guided-preview__content {
margin: 0;
padding: 12px 14px;
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
line-height: 1.7;
color: var(--mc-text-secondary);
white-space: pre-wrap;
max-height: 220px;
overflow-y: auto;
background: var(--mc-bg-elevated);
}
/* Source mode */
.source-wrap { display: flex; flex-direction: column; height: 100%; }
.source-bar { display: flex; align-items: center; gap: 10px; padding: 6px 14px; background: var(--mc-bg-sunken); border-bottom: 1px solid var(--mc-border-light); flex-shrink: 0; }
.source-lang { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--mc-text-tertiary); }
.source-lines { font-size: 10px; color: var(--mc-text-tertiary); }
.source-back { margin-left: auto; font-size: 11px; color: var(--mc-primary); background: none; border: none; cursor: pointer; }
.source-textarea {
flex: 1;
min-height: 400px;
padding: 16px 18px;
border: none;
outline: none;
resize: none;
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 13px;
line-height: 1.8;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
}
/* Transitions */
.cfg-modal-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.cfg-modal-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.cfg-modal-enter-from, .cfg-modal-leave-to { opacity: 0; transform: scale(0.97); }
</style>

View File

@ -0,0 +1,133 @@
<template>
<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') }}
</span>
<button class="node-panel-close" @click="emit('close')"></button>
</div>
<div class="node-panel-title">{{ page.title }}</div>
<div class="node-panel-summary">{{ page.summary }}</div>
<div v-if="linkedPages.length > 0" class="node-panel-links">
<div class="links-label">{{ t('wiki.graph.linksTo') }} ({{ linkedPages.length }})</div>
<div class="links-list">
<button
v-for="link in linkedPages.slice(0, 8)"
:key="link.slug"
class="link-chip"
@click="emit('open-page', link.slug)"
>{{ link.title }}</button>
<span v-if="linkedPages.length > 8" class="link-more">+{{ linkedPages.length - 8 }}</span>
</div>
</div>
<button class="btn-open-page" @click="emit('open-page', page.slug)">
{{ t('wiki.graph.openPage') }}
</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { WikiPage } from '@/stores/useWikiStore'
const { t } = useI18n()
defineProps<{
page: WikiPage
linkedPages: WikiPage[]
}>()
const emit = defineEmits<{
(e: 'close'): void
(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>
.node-panel {
position: absolute;
right: 12px;
top: 12px;
width: 240px;
max-height: calc(100% - 24px);
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
overflow-y: auto;
}
.node-panel-header { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.node-type-badge {
font-size: 10px;
font-weight: 600;
color: white;
padding: 2px 8px;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.node-panel-close { border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 12px; flex-shrink: 0; }
.node-panel-close:hover { color: var(--mc-text-primary); }
.node-panel-title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); flex-shrink: 0; }
.node-panel-summary {
font-size: 12px;
color: var(--mc-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
flex-shrink: 0;
}
.node-panel-links { flex-shrink: 0; }
.links-label { font-size: 10px; font-weight: 600; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
.links-list { display: flex; flex-wrap: wrap; gap: 4px; }
.link-chip {
padding: 2px 8px;
font-size: 11px;
border: 1px solid var(--mc-border-light);
border-radius: 99px;
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
cursor: pointer;
transition: all 0.12s;
}
.link-chip:hover { border-color: var(--mc-primary); color: var(--mc-primary); background: var(--mc-primary-bg); }
.link-more { font-size: 11px; color: var(--mc-text-tertiary); padding: 2px 4px; }
.btn-open-page {
padding: 6px 12px;
border: 1px solid var(--mc-border-light);
border-radius: 8px;
background: var(--mc-bg-muted);
color: var(--mc-primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
text-align: center;
flex-shrink: 0;
}
.btn-open-page:hover { background: var(--mc-primary-bg); border-color: var(--mc-primary); }
</style>

View File

@ -0,0 +1,134 @@
<template>
<div class="graph-toolbar">
<div class="graph-stats">
<span class="stat-item">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="10" stroke-width="1.5"/>
</svg>
{{ nodeCount }} {{ t('wiki.graph.nodes') }}
</span>
<span class="stat-item">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ edgeCount }} {{ t('wiki.graph.edges') }}
</span>
<span v-if="orphanCount > 0" class="stat-item stat-warn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{{ orphanCount }} {{ t('wiki.graph.orphans') }}
</span>
</div>
<div class="graph-controls">
<label class="filter-label">
<input :checked="showOrphans" type="checkbox" @change="emit('update:showOrphans', ($event.target as HTMLInputElement).checked)" />
{{ t('wiki.graph.showOrphans') }}
</label>
<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) }}
</option>
</select>
<button class="btn-icon-sm" :title="t('wiki.graph.resetView')" @click="emit('reset')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<button
class="btn-icon-sm"
:title="isFullscreen ? t('wiki.graph.exitFullscreen') : t('wiki.graph.fullscreen')"
@click="emit('toggleFullscreen')"
>
<!-- Enter fullscreen icon -->
<svg v-if="!isFullscreen" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
<!-- Exit fullscreen icon -->
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps<{
nodeCount: number
edgeCount: number
orphanCount: number
showOrphans: boolean
typeFilter: string
availableTypes: string[]
isFullscreen: boolean
}>()
const emit = defineEmits<{
(e: 'update:showOrphans', val: boolean): void
(e: 'update:typeFilter', val: string): void
(e: 'reset'): void
(e: 'toggleFullscreen'): void
}>()
</script>
<style scoped>
.graph-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--mc-border-light);
gap: 12px;
flex-shrink: 0;
flex-wrap: wrap;
}
.graph-stats { display: flex; align-items: center; gap: 12px; }
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--mc-text-tertiary);
}
.stat-warn { color: var(--mc-danger, #f56c6c); }
.graph-controls { display: flex; align-items: center; gap: 8px; }
.filter-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--mc-text-secondary); cursor: pointer; }
.type-select {
padding: 3px 8px;
font-size: 11px;
border: 1px solid var(--mc-border-light);
border-radius: 7px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
cursor: pointer;
outline: none;
}
.btn-icon-sm {
width: 26px;
height: 26px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated);
border-radius: 7px;
cursor: pointer;
color: var(--mc-text-secondary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-icon-sm:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); }
</style>

View File

@ -1,69 +1,29 @@
<template>
<div class="graph-view">
<!-- Toolbar -->
<div class="graph-toolbar">
<div class="graph-stats">
<span class="stat-item">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="10" stroke-width="1.5"/></svg>
{{ nodes.length }} {{ t('wiki.graph.nodes') }}
</span>
<span class="stat-item">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ edges.length }} {{ t('wiki.graph.edges') }}
</span>
<span v-if="orphanCount > 0" class="stat-item stat-warn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ orphanCount }} {{ t('wiki.graph.orphans') }}
</span>
</div>
<div class="graph-controls">
<label class="filter-label">
<input v-model="showOrphans" type="checkbox" />
{{ t('wiki.graph.showOrphans') }}
</label>
<label class="filter-label">
<input v-model="selectedType" type="checkbox" value="" @change="typeFilter = ''" />
</label>
<select v-model="typeFilter" class="type-select">
<option value="">{{ t('wiki.graph.allTypes') }}</option>
<option v-for="type in availableTypes" :key="type" :value="type">
{{ t(`wiki.pageTypes.${type}`, type) }}
</option>
</select>
<button class="btn-icon-sm" :title="t('wiki.graph.resetView')" @click="resetChart">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
</div>
</div>
<div ref="graphViewEl" class="graph-view" :class="{ 'graph-view--fullscreen': isFullscreen }">
<!-- Toolbar sub-component -->
<WikiGraphToolbar
:node-count="nodes.length"
:edge-count="edges.length"
:orphan-count="orphanCount"
v-model:show-orphans="showOrphans"
v-model:type-filter="typeFilter"
:available-types="availableTypes"
:is-fullscreen="isFullscreen"
@reset="resetChart"
@toggle-fullscreen="toggleFullscreen"
/>
<!-- Chart container -->
<!-- ECharts canvas -->
<div ref="chartEl" class="graph-canvas" />
<!-- Hover tooltip / selected node panel -->
<div v-if="selectedNode" class="node-panel">
<div class="node-panel-header">
<span class="node-type-badge" :style="{ background: typeColor(selectedNode.pageType) }">
{{ t(`wiki.pageTypes.${selectedNode.pageType || 'other'}`, selectedNode.pageType || 'other') }}
</span>
<button class="node-panel-close" @click="selectedNode = null"></button>
</div>
<div class="node-panel-title">{{ selectedNode.title }}</div>
<div class="node-panel-summary">{{ selectedNode.summary }}</div>
<div class="node-panel-links" v-if="selectedNodeLinks.length > 0">
<div class="links-label">{{ t('wiki.graph.linksTo') }} ({{ selectedNodeLinks.length }})</div>
<div class="links-list">
<button
v-for="link in selectedNodeLinks.slice(0, 8)" :key="link.slug"
class="link-chip"
@click="emit('open-page', link.slug)"
>{{ link.title }}</button>
<span v-if="selectedNodeLinks.length > 8" class="link-more">+{{ selectedNodeLinks.length - 8 }}</span>
</div>
</div>
<button class="btn-open-page" @click="emit('open-page', selectedNode.slug)">
{{ t('wiki.graph.openPage') }}
</button>
</div>
<!-- Node detail panel sub-component -->
<WikiGraphNodePanel
v-if="selectedNode"
:page="selectedNode"
:linked-pages="selectedNodeLinks"
@close="selectedNode = null"
@open-page="emit('open-page', $event)"
/>
<!-- Empty state -->
<div v-if="nodes.length === 0" class="graph-empty">
@ -84,6 +44,8 @@ import { GraphChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { WikiPage } from '@/stores/useWikiStore'
import WikiGraphToolbar from './WikiGraphToolbar.vue'
import WikiGraphNodePanel from './WikiGraphNodePanel.vue'
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
@ -91,13 +53,14 @@ const { t } = useI18n()
const props = defineProps<{ pages: WikiPage[] }>()
const emit = defineEmits<{ (e: 'open-page', slug: string): void }>()
const graphViewEl = ref<HTMLDivElement | null>(null)
const chartEl = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null
const isFullscreen = ref(false)
const showOrphans = ref(true)
const typeFilter = ref('')
const selectedNode = ref<WikiPage | null>(null)
const selectedType = ref(false)
// Type color map
const TYPE_COLORS: Record<string, string> = {
@ -126,27 +89,72 @@ function parseLinks(outgoingLinks: string | null | undefined): string[] {
} catch { return [] }
}
// Canonical slug: strip hyphens + underscores, lowercase mirrors Java WikiPageService.canonicalSlug
function canonicalSlug(s: string): string {
return s.toLowerCase().replace(/-/g, '').replace(/_/g, '')
}
// Map canonical slug actual page slug (for edge resolution)
const canonicalToSlug = computed(() => {
const map = new Map<string, string>()
for (const p of props.pages) map.set(canonicalSlug(p.slug), p.slug)
return map
})
const slugToPage = computed(() => {
const map = new Map<string, WikiPage>()
for (const p of props.pages) map.set(p.slug, p)
return map
})
// Pages filtered by type
const filteredPages = computed(() => {
let ps = props.pages
if (typeFilter.value) ps = ps.filter(p => (p.pageType || 'other').toLowerCase() === typeFilter.value)
return ps
// Map page title (lowercased) slug.
// The backend's extractLinksAsJson runs toSlug() on [[link text]], which keeps Chinese
// characters as-is (e.g. [[]] ""). Page slugs, however, are
// pinyin (e.g. "bawei-sishijiu-yin"). This map bridges that gap.
const titleToSlug = computed(() => {
const map = new Map<string, string>()
for (const p of props.pages) {
map.set(p.title.toLowerCase(), p.slug)
// Also index the toSlug() equivalent: strip non-alphanum/non-CJK, lowercase
const titleSlug = p.title.toLowerCase().replace(/[^\p{Script=Han}a-z0-9\s-]/gu, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
if (titleSlug) map.set(titleSlug, p.slug)
}
return map
})
// Build edges from outgoing links
// Pages filtered by type
const filteredPages = computed(() => {
if (!typeFilter.value) return props.pages
return props.pages.filter(p => (p.pageType || 'other').toLowerCase() === typeFilter.value)
})
// Resolve a link token to a real page slug. Resolution order:
// 1. Direct slug match
// 2. Canonical slug match (handles pinyin segmentation differences)
// 3. Title match (for [[]] style links stored by extractLinksAsJson)
function resolveLink(link: string): string | null {
if (!link) return null
// 1. Direct slug match
if (slugToPage.value.has(link)) return link
// 2. Canonical slug match
const canon = canonicalSlug(link)
const bySlug = canonicalToSlug.value.get(canon)
if (bySlug) return bySlug
// 3. Title-based match (Chinese link text like "" pinyin slug)
const byTitle = titleToSlug.value.get(link.toLowerCase())
if (byTitle) return byTitle
return null
}
// Build edges from outgoing links with canonical resolution
const edges = computed(() => {
const filteredSet = new Set(filteredPages.value.map(p => p.slug))
const result: { source: string; target: string }[] = []
const slugSet = new Set(filteredPages.value.map(p => p.slug))
for (const page of filteredPages.value) {
for (const link of parseLinks(page.outgoingLinks)) {
if (slugSet.has(link) && link !== page.slug) {
result.push({ source: page.slug, target: link })
for (const rawLink of parseLinks(page.outgoingLinks)) {
const target = resolveLink(rawLink)
if (target && target !== page.slug && filteredSet.has(target)) {
result.push({ source: page.slug, target })
}
}
}
@ -163,7 +171,9 @@ const inDegree = computed(() => {
})
const orphanCount = computed(() =>
filteredPages.value.filter(p => (inDegree.value.get(p.slug) || 0) === 0 && parseLinks(p.outgoingLinks).length === 0).length
filteredPages.value.filter(
p => (inDegree.value.get(p.slug) || 0) === 0 && parseLinks(p.outgoingLinks).length === 0
).length
)
const nodes = computed(() => {
@ -184,30 +194,46 @@ const availableTypes = computed(() => {
const selectedNodeLinks = computed(() => {
if (!selectedNode.value) return []
return parseLinks(selectedNode.value.outgoingLinks)
.map(slug => slugToPage.value.get(slug))
.map(link => {
const slug = resolveLink(link)
return slug ? slugToPage.value.get(slug) : undefined
})
.filter(Boolean) as WikiPage[]
})
function buildOption() {
const nodeSet = new Set(nodes.value.map(p => p.slug))
const nodeList = nodes.value.map(p => {
const deg = (inDegree.value.get(p.slug) || 0) + parseLinks(p.outgoingLinks).length
const size = Math.max(10, Math.min(40, 10 + deg * 3))
const outDeg = parseLinks(p.outgoingLinks).filter(l => {
const resolved = resolveLink(l)
return resolved && resolved !== p.slug && nodeSet.has(resolved)
}).length
const deg = (inDegree.value.get(p.slug) || 0) + outDeg
const size = Math.max(10, Math.min(44, 10 + deg * 4))
return {
id: p.slug,
name: p.title,
symbolSize: size,
itemStyle: { color: typeColor(p.pageType) },
label: { show: size > 18, fontSize: 10, color: 'var(--mc-text-secondary)' },
_page: p,
// Show label only for well-connected nodes; position to the right to avoid overlap
label: {
show: size > 22,
position: 'right' as const,
fontSize: 10,
color: 'var(--mc-text-secondary)',
distance: 4,
},
// Do NOT embed Vue reactive proxies here ECharts normalizes data and strips them.
// Use slugToPage lookup in event handlers instead.
}
})
const edgeList = edges.value
.filter(e => nodes.value.some(n => n.slug === e.source) && nodes.value.some(n => n.slug === e.target))
.filter(e => nodeSet.has(e.source) && nodeSet.has(e.target))
.map(e => ({
source: e.source,
target: e.target,
lineStyle: { color: 'rgba(150,150,150,0.3)', width: 1 },
lineStyle: { color: 'rgba(150,150,150,0.28)', width: 1 },
}))
return {
@ -216,8 +242,19 @@ function buildOption() {
trigger: 'item',
formatter: (params: any) => {
if (params.dataType !== 'node') return ''
const p = params.data._page as WikiPage
return `<div style="max-width:220px"><strong>${p.title}</strong><br/><small style="color:#999">${t(`wiki.pageTypes.${p.pageType || 'other'}`, p.pageType || 'other')}</small><br/><span style="font-size:11px">${(p.summary || '').substring(0, 80)}${(p.summary || '').length > 80 ? '…' : ''}</span></div>`
// 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 summary = (page.summary || '').substring(0, 80)
const ellipsis = (page.summary || '').length > 80 ? '…' : ''
return [
`<div style="max-width:220px;word-break:break-all;white-space:normal">`,
`<strong style="display:block;margin-bottom:2px">${page.title}</strong>`,
`<small style="color:#999;display:block;margin-bottom:4px">${typeLabel}</small>`,
`<span style="font-size:11px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden">${summary}${ellipsis}</span>`,
`</div>`,
].join('')
},
},
series: [{
@ -227,38 +264,70 @@ function buildOption() {
links: edgeList,
roam: true,
force: {
repulsion: 200,
gravity: 0.08,
edgeLength: [60, 150],
friction: 0.6,
repulsion: 220,
gravity: 0.06,
edgeLength: [60, 180],
friction: 0.55,
},
emphasis: {
focus: 'adjacency',
lineStyle: { width: 2 },
},
lineStyle: { color: 'rgba(150,150,150,0.3)', curveness: 0.1 },
lineStyle: { color: 'rgba(150,150,150,0.28)', curveness: 0.08 },
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 6,
}],
}
}
function renderChart() {
// Use notMerge:true only for the initial render or explicit reset.
// Incremental updates use notMerge:false so ECharts matches nodes by id,
// keeping existing positions stable and only animating new nodes in.
function renderChart(fullReset = false) {
if (!chartEl.value) return
if (!chart) {
chart = echarts.init(chartEl.value, undefined, { renderer: 'canvas' })
chart.on('click', (params: any) => {
if (params.dataType === 'node' && params.data._page) {
selectedNode.value = params.data._page
if (params.dataType === 'node') {
// Look up page by slug (node id) don't rely on _page in ECharts data
const page = slugToPage.value.get(params.data.id)
if (page) selectedNode.value = page
}
})
fullReset = true // always full-reset on first init
}
chart.setOption(buildOption(), { notMerge: true })
chart.setOption(buildOption(), { notMerge: fullReset, lazyUpdate: true })
}
function resetChart() {
selectedNode.value = null
if (chart) chart.setOption(buildOption(), { notMerge: true })
renderChart(true)
}
async function toggleFullscreen() {
if (!document.fullscreenElement) {
await graphViewEl.value?.requestFullscreen()
} else {
await document.exitFullscreen()
}
}
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
// Let the DOM settle after fullscreen resize, then notify ECharts
nextTick(() => {
chart?.resize()
})
}
// Debounce incremental re-renders to avoid jitter when pages stream in rapidly
let renderTimer: ReturnType<typeof setTimeout> | null = null
function scheduleRender() {
if (renderTimer) clearTimeout(renderTimer)
renderTimer = setTimeout(() => {
renderTimer = null
renderChart(false)
}, 300)
}
const resizeObserver = new ResizeObserver(() => {
@ -267,24 +336,24 @@ const resizeObserver = new ResizeObserver(() => {
onMounted(async () => {
await nextTick()
renderChart()
renderChart(true)
if (chartEl.value) resizeObserver.observe(chartEl.value)
document.addEventListener('fullscreenchange', onFullscreenChange)
})
onBeforeUnmount(() => {
if (renderTimer) clearTimeout(renderTimer)
resizeObserver.disconnect()
document.removeEventListener('fullscreenchange', onFullscreenChange)
// Exit fullscreen if component is unmounted while in fullscreen mode
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
chart?.dispose()
chart = null
})
watch([nodes, edges], async () => {
await nextTick()
renderChart()
})
watch(() => props.pages.length, async () => {
await nextTick()
renderChart()
// Single watcher on nodes+edges; the page-length watcher is redundant and removed.
watch([nodes, edges], () => {
scheduleRender()
})
</script>
@ -297,53 +366,18 @@ watch(() => props.pages.length, async () => {
position: relative;
}
.graph-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--mc-border-light);
gap: 12px;
flex-shrink: 0;
flex-wrap: wrap;
/* Native fullscreen: fill the entire screen with proper background */
.graph-view:fullscreen,
.graph-view:-webkit-full-screen {
background: var(--mc-bg-base, #fff);
width: 100vw;
height: 100vh;
}
.graph-stats { display: flex; align-items: center; gap: 12px; }
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--mc-text-tertiary);
.dark .graph-view:fullscreen,
.dark .graph-view:-webkit-full-screen {
background: var(--mc-bg-base, #1a1a1a);
}
.stat-warn { color: var(--mc-danger, #f56c6c); }
.graph-controls { display: flex; align-items: center; gap: 8px; }
.filter-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--mc-text-secondary); cursor: pointer; }
.type-select {
padding: 3px 8px;
font-size: 11px;
border: 1px solid var(--mc-border-light);
border-radius: 7px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
cursor: pointer;
outline: none;
}
.btn-icon-sm {
width: 26px;
height: 26px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated);
border-radius: 7px;
cursor: pointer;
color: var(--mc-text-secondary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-icon-sm:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); }
.graph-canvas {
flex: 1;
@ -351,63 +385,6 @@ watch(() => props.pages.length, async () => {
width: 100%;
}
.node-panel {
position: absolute;
right: 12px;
top: 52px;
width: 240px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
}
.node-panel-header { display: flex; align-items: center; justify-content: space-between; }
.node-type-badge {
font-size: 10px;
font-weight: 600;
color: white;
padding: 2px 8px;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.node-panel-close { border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 12px; }
.node-panel-close:hover { color: var(--mc-text-primary); }
.node-panel-title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
.node-panel-summary { font-size: 12px; color: var(--mc-text-secondary); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.links-label { font-size: 10px; font-weight: 600; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.06em; }
.links-list { display: flex; flex-wrap: wrap; gap: 4px; }
.link-chip {
padding: 2px 8px;
font-size: 11px;
border: 1px solid var(--mc-border-light);
border-radius: 99px;
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
cursor: pointer;
transition: all 0.12s;
}
.link-chip:hover { border-color: var(--mc-primary); color: var(--mc-primary); background: var(--mc-primary-bg); }
.link-more { font-size: 11px; color: var(--mc-text-tertiary); padding: 2px 4px; }
.btn-open-page {
padding: 6px 12px;
border: 1px solid var(--mc-border-light);
border-radius: 8px;
background: var(--mc-bg-muted);
color: var(--mc-primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.btn-open-page:hover { background: var(--mc-primary-bg); border-color: var(--mc-primary); }
.graph-empty {
position: absolute;
inset: 0;

View File

@ -0,0 +1,338 @@
<template>
<div class="wmp-wrap" ref="triggerRef">
<!-- Trigger button -->
<button
class="wmp-trigger"
:class="{ 'wmp-trigger--set': !!modelValue, 'wmp-trigger--disabled': disabled }"
:disabled="disabled"
@click="toggle"
>
<span class="wmp-trigger__icon">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</span>
<span class="wmp-trigger__name">{{ selectedLabel }}</span>
<svg class="wmp-trigger__chevron" :class="{ open }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<!-- Popover (teleported to avoid overflow clipping) -->
<Teleport to="body">
<Transition name="wmp-fade">
<div v-if="open" class="wmp-backdrop" @click="open = false" />
</Transition>
<Transition name="wmp-pop">
<div v-if="open" ref="popRef" class="wmp-pop" :style="popStyle">
<!-- Search (only when enough options) -->
<div v-if="flatOptions.length > 6" class="wmp-search">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchEl"
v-model="query"
class="wmp-search__input"
:placeholder="t('wiki.configPanel.searchModel')"
@keydown.esc.stop="open = false"
/>
<button v-if="query" class="wmp-search__clear" @click="query = ''"></button>
</div>
<!-- List -->
<div class="wmp-list" ref="listEl">
<!-- Global default option (hidden when showDefault === false) -->
<div
v-if="showDefault !== false"
class="wmp-item wmp-item--default"
:class="{ 'wmp-item--active': !modelValue }"
@click="select('')"
>
<span class="wmp-item__name">{{ props.defaultLabel ?? t('wiki.configPanel.globalDefault') }}</span>
<svg v-if="!modelValue" class="wmp-item__check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<!-- Groups -->
<template v-for="group in filteredGroups" :key="group.providerName">
<div class="wmp-group-header">
<span class="wmp-group-header__label">{{ group.providerName }}</span>
<span v-if="group.isLocal" class="wmp-group-header__badge">Local</span>
</div>
<div
v-for="opt in group.options"
:key="opt.id"
class="wmp-item"
:class="{ 'wmp-item--active': modelValue === opt.id }"
@click="select(opt.id)"
>
<div class="wmp-item__info">
<span class="wmp-item__name">{{ opt.name }}</span>
<span v-if="opt.modelId" class="wmp-item__model-id">{{ opt.modelId }}</span>
</div>
<svg v-if="modelValue === opt.id" class="wmp-item__check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
</template>
<div v-if="filteredGroups.length === 0 && query" class="wmp-empty">
{{ t('wiki.configPanel.noModelMatch') }}
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
export interface ModelOption {
id: string // numeric config ID as string
name: string // display name
modelId?: string // underlying model identifier (e.g. "qwen-max")
providerName?: string // display name of the provider
providerId?: string // internal provider id (used for local detection)
isLocal?: boolean
/** false = provider API key not configured; such models are shown disabled */
available?: boolean
}
const props = defineProps<{
options: ModelOption[]
modelValue: string
disabled?: boolean
/** Override the "empty / default" label. Default: t('wiki.configPanel.globalDefault') */
defaultLabel?: string
/** Whether to show the "follow global default" option. Default: true */
showDefault?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: string): void
}>()
const open = ref(false)
const query = ref('')
const triggerRef = ref<HTMLElement | null>(null)
const popRef = ref<HTMLElement | null>(null)
const listEl = ref<HTMLElement | null>(null)
const searchEl = ref<HTMLInputElement | null>(null)
const popStyle = ref<CSSProperties>({})
// Available options only (providers with configured API keys)
const availableOptions = computed(() =>
props.options.filter(o => o.available !== false)
)
// Flat count for search threshold
const flatOptions = computed(() => availableOptions.value)
// Group available options by providerName
interface OptionGroup {
providerName: string
isLocal: boolean
options: ModelOption[]
}
const groups = computed<OptionGroup[]>(() => {
const map = new Map<string, OptionGroup>()
for (const opt of availableOptions.value) {
const key = opt.providerName || t('wiki.configPanel.otherProvider')
if (!map.has(key)) {
map.set(key, { providerName: key, isLocal: !!opt.isLocal, options: [] })
}
map.get(key)!.options.push(opt)
}
const arr = [...map.values()]
arr.sort((a, b) => Number(a.isLocal) - Number(b.isLocal))
return arr
})
const filteredGroups = computed<OptionGroup[]>(() => {
const q = query.value.trim().toLowerCase()
if (!q) return groups.value
const result: OptionGroup[] = []
for (const g of groups.value) {
if (g.providerName.toLowerCase().includes(q)) {
result.push(g)
continue
}
const matched = g.options.filter(
o => o.name.toLowerCase().includes(q) || (o.modelId || '').toLowerCase().includes(q)
)
if (matched.length > 0) result.push({ ...g, options: matched })
}
return result
})
const selectedLabel = computed(() => {
if (!props.modelValue) {
if (props.showDefault === false) return t('wiki.configPanel.selectModel')
return props.defaultLabel ?? t('wiki.configPanel.globalDefault')
}
// Search all options (not just available) so a previously-saved selection still shows its name
const opt = props.options.find(o => o.id === props.modelValue)
return opt ? opt.name : props.modelValue
})
function position() {
const el = triggerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
popStyle.value = {
position: 'fixed',
top: `${rect.bottom + 6}px`,
left: `${rect.left}px`,
minWidth: `${Math.max(rect.width, 260)}px`,
}
}
function toggle() {
if (open.value) { open.value = false; return }
position()
open.value = true
}
function select(id: string) {
open.value = false
query.value = ''
emit('update:modelValue', id)
}
watch(open, async (v) => {
if (v) {
query.value = ''
await nextTick()
searchEl.value?.focus()
await nextTick()
if (props.modelValue) {
listEl.value?.querySelector('.wmp-item--active')?.scrollIntoView({ block: 'nearest' })
}
}
})
</script>
<style scoped>
.wmp-wrap { position: relative; display: inline-block; width: 100%; }
.wmp-trigger {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
padding: 8px 12px;
border: 1px solid var(--mc-border);
border-radius: 10px;
background: var(--mc-bg-elevated);
color: var(--mc-text-secondary);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
text-align: left;
}
.wmp-trigger:hover:not(.wmp-trigger--disabled) { border-color: var(--mc-primary); background: var(--mc-bg-muted); }
.wmp-trigger--set { color: var(--mc-text-primary); }
.wmp-trigger--disabled { opacity: 0.5; cursor: not-allowed; }
.wmp-trigger__icon { color: var(--mc-text-tertiary); flex-shrink: 0; }
.wmp-trigger__name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wmp-trigger__chevron { flex-shrink: 0; color: var(--mc-text-tertiary); transition: transform 0.18s; }
.wmp-trigger__chevron.open { transform: rotate(180deg); }
</style>
<style>
/* Teleported elements — must be global */
.wmp-backdrop {
position: fixed;
inset: 0;
z-index: 4000;
}
.wmp-pop {
z-index: 4001;
max-width: 380px;
max-height: 400px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
padding: 6px;
box-shadow: 0 8px 32px rgba(0,0,0,0.14);
display: flex;
flex-direction: column;
overflow: hidden;
}
.wmp-search {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 10px 8px;
border-bottom: 1px solid var(--mc-border-light);
flex-shrink: 0;
color: var(--mc-text-tertiary);
}
.wmp-search__input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mc-text-primary);
font-size: 13px;
}
.wmp-search__input::placeholder { color: var(--mc-text-tertiary); }
.wmp-search__clear { border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 11px; padding: 0; }
.wmp-search__clear:hover { color: var(--mc-text-primary); }
.wmp-list { overflow-y: auto; flex: 1; }
.wmp-group-header {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 10px 3px;
font-size: 10px;
font-weight: 700;
color: var(--mc-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
user-select: none;
}
.wmp-group-header__badge {
padding: 1px 5px;
border-radius: 4px;
font-size: 9px;
font-weight: 700;
background: rgba(52,199,89,0.12);
color: #34c759;
}
.wmp-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.1s;
}
.wmp-item:hover { background: var(--mc-bg-sunken); }
.wmp-item--active { background: var(--mc-primary-bg) !important; }
.wmp-item--default { border-bottom: 1px solid var(--mc-border-light); margin-bottom: 4px; border-radius: 8px 8px 0 0; }
.wmp-item__info { display: flex; flex-direction: column; gap: 1px; overflow: hidden; }
.wmp-item__name { font-size: 13px; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wmp-item__model-id { font-size: 10px; color: var(--mc-text-tertiary); font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wmp-item__check { flex-shrink: 0; color: var(--mc-primary); }
.wmp-empty { padding: 16px 10px; text-align: center; font-size: 13px; color: var(--mc-text-tertiary); }
/* Transitions */
.wmp-fade-enter-active, .wmp-fade-leave-active { transition: opacity 0.15s; }
.wmp-fade-enter-from, .wmp-fade-leave-to { opacity: 0; }
.wmp-pop-enter-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.wmp-pop-leave-active { transition: opacity 0.1s ease, transform 0.1s ease; }
.wmp-pop-enter-from { opacity: 0; transform: translateY(-6px); }
.wmp-pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>

View File

@ -1,46 +1,121 @@
<template>
<div class="search-preview">
<h4 class="preview-title">{{ t('wiki.configPanel.searchPreview') }}</h4>
<div class="preview-input-row">
<input
v-model="query"
type="text"
class="preview-input"
:placeholder="t('wiki.configPanel.searchPreviewPlaceholder')"
@keyup.enter="runSearch"
/>
<button class="btn-secondary" @click="runSearch" :disabled="searching || !query.trim()">
{{ searching ? '...' : t('wiki.configPanel.searchPreviewRun') }}
</button>
</div>
<Teleport to="body">
<Transition name="cfg-modal">
<div v-if="open" class="cfg-modal-overlay" @click.self="emit('close')">
<div class="cfg-modal" style="max-width:700px">
<div v-if="results.length > 0" class="preview-results">
<div v-for="r in results" :key="r.slug" class="preview-result-item">
<div class="result-header">
<span class="result-slug">[[{{ r.slug }}]]</span>
<span class="result-title">{{ r.title }}</span>
</div>
<div v-if="r.snippet" class="result-snippet">"{{ r.snippet }}"</div>
<div class="result-meta">
<span v-if="r.matchedBy?.length" class="result-matched">
{{ t('wiki.configPanel.searchPreviewRun') }}: {{ r.matchedBy.join(', ') }}
</span>
<span v-if="r.reason" class="result-reason">· {{ r.reason }}</span>
<!-- Header -->
<div class="cfg-modal__header">
<div class="cfg-modal__title-group">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<span>{{ t('wiki.configPanel.searchPreview') }}</span>
<span v-if="kbName" class="cfg-modal__kb">{{ kbName }}</span>
</div>
<div class="cfg-modal__actions">
<button class="btn-cfg-close" @click="emit('close')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Body -->
<div class="sp-body">
<!-- Input row -->
<div class="sp-input-row">
<input
ref="inputEl"
v-model="query"
type="text"
class="sp-input"
:placeholder="t('wiki.configPanel.searchPreviewPlaceholder')"
@keyup.enter="runSearch"
/>
<button class="sp-btn" @click="runSearch" :disabled="searching || !query.trim()">
<svg v-if="searching" class="sp-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
{{ searching ? t('wiki.configPanel.searching') : t('wiki.configPanel.searchPreviewRun') }}
</button>
</div>
<!-- Mode chips -->
<div class="sp-modes">
<button
v-for="m in modes"
:key="m.value"
:class="['sp-mode-chip', { active: searchMode === m.value }]"
@click="searchMode = m.value"
>{{ m.label }}</button>
</div>
<!-- Results -->
<div v-if="results.length > 0" class="sp-results">
<div class="sp-results-header">
<span>找到 {{ results.length }} 条结果</span>
<button class="sp-clear" @click="results = []">清除</button>
</div>
<div v-for="(r, idx) in results" :key="r.slug" class="sp-item">
<div class="sp-item__rank">{{ idx + 1 }}</div>
<div class="sp-item__body">
<div class="sp-item__head">
<span class="sp-item__title">{{ r.title }}</span>
<span class="sp-item__slug">[[{{ r.slug }}]]</span>
<span v-if="r.score != null" class="sp-item__score">{{ (r.score * 100).toFixed(0) }}%</span>
</div>
<div v-if="r.snippet" class="sp-item__snippet">{{ r.snippet }}</div>
<div v-if="r.matchedBy?.length || r.reason" class="sp-item__meta">
<span v-if="r.matchedBy?.length" class="sp-item__matched">
{{ r.matchedBy.join(' · ') }}
</span>
<span v-if="r.reason" class="sp-item__reason">{{ r.reason }}</span>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<div v-else-if="searched && !searching" class="sp-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
<p>无匹配结果</p>
</div>
<!-- Hint (before first search) -->
<div v-else-if="!searched" class="sp-hint">
输入问题或关键词测试知识库的检索效果
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { wikiApi } from '@/api/index'
const { t } = useI18n()
const props = defineProps<{
open: boolean
kbId: number
kbName?: string
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
interface SearchResult {
@ -52,18 +127,36 @@ interface SearchResult {
score: number
}
const modes = [
{ value: 'hybrid', label: '混合' },
{ value: 'semantic', label: '语义' },
{ value: 'keyword', label: '关键词' },
]
const query = ref('')
const searchMode = ref('hybrid')
const results = ref<SearchResult[]>([])
const searching = ref(false)
const searched = ref(false)
const inputEl = ref<HTMLInputElement | null>(null)
watch(() => props.open, (v) => {
if (v) {
results.value = []
searched.value = false
nextTick(() => inputEl.value?.focus())
}
})
async function runSearch() {
if (!query.value.trim() || !props.kbId) return
searching.value = true
searched.value = true
try {
const res: any = await wikiApi.searchPreview(props.kbId, {
query: query.value.trim(),
mode: 'hybrid',
topK: 5,
mode: searchMode.value,
topK: 8,
})
results.value = res.data || res || []
} catch (e) {
@ -76,52 +169,156 @@ async function runSearch() {
</script>
<style scoped>
.search-preview {
.sp-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: var(--mc-bg-sunken);
border-radius: 10px;
border: 1px solid var(--mc-border-light);
gap: 14px;
}
.preview-title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.preview-input-row { display: flex; gap: 8px; }
.preview-input {
.sp-input-row { display: flex; gap: 8px; }
.sp-input {
flex: 1;
padding: 8px 12px;
padding: 10px 14px;
border: 1px solid var(--mc-border);
border-radius: 8px;
font-size: 13px;
border-radius: 10px;
font-size: 14px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
outline: none;
transition: border-color 0.15s;
}
.preview-input:focus { border-color: var(--mc-primary); }
.sp-input:focus { border-color: var(--mc-primary); }
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.sp-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border: none;
border-radius: 10px;
background: var(--mc-primary);
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.sp-btn:hover { opacity: 0.88; }
.sp-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sp-spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.preview-results {
.sp-modes { display: flex; gap: 6px; }
.sp-mode-chip {
padding: 4px 12px;
border-radius: 99px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-tertiary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.sp-mode-chip.active { border-color: var(--mc-primary); color: var(--mc-primary); background: var(--mc-primary-bg); font-weight: 500; }
.sp-mode-chip:not(.active):hover { border-color: var(--mc-border); color: var(--mc-text-secondary); background: var(--mc-bg-sunken); }
.sp-results { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--mc-border-light); border-radius: 12px; overflow: hidden; }
.sp-results-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: var(--mc-bg-sunken);
font-size: 11px;
color: var(--mc-text-tertiary);
border-bottom: 1px solid var(--mc-border-light);
}
.sp-clear { background: none; border: none; cursor: pointer; font-size: 11px; color: var(--mc-text-tertiary); padding: 0; }
.sp-clear:hover { color: var(--mc-danger, #e53); }
.sp-item {
display: flex;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--mc-border-light);
transition: background 0.1s;
}
.sp-item:last-child { border-bottom: none; }
.sp-item:hover { background: var(--mc-bg-muted); }
.sp-item__rank {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
font-size: 10px;
font-weight: 700;
color: var(--mc-text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.sp-item__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.sp-item__head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.sp-item__title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
.sp-item__slug { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--mc-primary); opacity: 0.8; }
.sp-item__score {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: var(--mc-primary);
background: var(--mc-primary-bg);
padding: 1px 7px;
border-radius: 99px;
flex-shrink: 0;
}
.sp-item__snippet {
font-size: 13px;
color: var(--mc-text-secondary);
line-height: 1.6;
word-break: break-word;
}
.sp-item__meta { display: flex; flex-wrap: wrap; gap: 6px; }
.sp-item__matched {
font-size: 11px;
color: var(--mc-text-tertiary);
background: var(--mc-bg-sunken);
padding: 1px 7px;
border-radius: 6px;
}
.sp-item__reason { font-size: 11px; color: var(--mc-text-tertiary); font-style: italic; }
/* Empty / hint */
.sp-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
max-height: 320px;
overflow-y: auto;
padding: 48px 0;
color: var(--mc-text-tertiary);
}
.sp-empty p { font-size: 13px; margin: 0; }
.preview-result-item {
padding: 10px 12px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
border-radius: 8px;
.sp-hint {
padding: 40px 0;
text-align: center;
font-size: 13px;
color: var(--mc-text-tertiary);
line-height: 1.6;
}
.result-header { display: flex; gap: 8px; align-items: center; margin-bottom: 4px; }
.result-slug { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--mc-primary); }
.result-title { font-size: 13px; font-weight: 500; color: var(--mc-text-primary); }
.result-snippet { font-size: 12px; color: var(--mc-text-secondary); font-style: italic; line-height: 1.5; margin-bottom: 4px; max-height: 60px; overflow: hidden; }
.result-meta { display: flex; gap: 6px; font-size: 11px; color: var(--mc-text-tertiary); }
</style>

View File

@ -96,8 +96,15 @@
</button>
</div>
<!-- Raw material filter banner -->
<div v-if="store.selectedRawId" class="filter-banner">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 3H2l8 9.46V19l4 2V12.46L22 3z"/></svg>
{{ t('wiki.filteredByRaw') }}
<button class="filter-clear-btn" @click="store.clearRawFilter(store.currentKB!.id)"></button>
</div>
<!-- Grouped page list -->
<div class="page-list" v-if="!pageSearch">
<div class="page-list" v-if="!pageSearch" ref="pageListEl" @scroll="onPageListScroll">
<div v-for="group in groupedPages" :key="group.type" class="page-group">
<button
class="group-header"
@ -132,13 +139,13 @@
</div>
</div>
</div>
<!-- Load more within group -->
<!-- Load more within group (visible when more items exist) -->
<button
v-if="groupPageLimit[group.type] < group.pages.length"
v-if="(groupPageLimit[group.type] || PAGE_STEP) < group.pages.length"
class="load-more-btn"
@click.stop="loadMoreGroup(group.type)"
>
{{ t('wiki.loadMore', { n: Math.min(PAGE_STEP, group.pages.length - groupPageLimit[group.type]) }) }}
{{ t('wiki.loadMore', { n: Math.min(PAGE_STEP, group.pages.length - (groupPageLimit[group.type] || PAGE_STEP)) }) }}
</button>
</div>
</div>
@ -211,6 +218,10 @@
</div>
</div>
<div v-if="activeTab === 'graph'" class="tab-content tab-content--graph">
<WikiGraphView :pages="store.pages" @open-page="openPage" />
</div>
<div v-if="activeTab === 'config'" class="tab-content tab-content--config">
<WikiConfig />
</div>
@ -242,16 +253,18 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import RawMaterialPanel from './components/RawMaterialPanel.vue'
import WikiPageViewer from './components/WikiPageViewer.vue'
import WikiConfig from './components/WikiConfig.vue'
import WikiGraphView from './components/WikiGraphView.vue'
const { t } = useI18n()
const store = useWikiStore()
const pageListEl = ref<HTMLElement | null>(null)
// KB health stats
interface KBStats {
@ -310,23 +323,12 @@ function paginatedGroupPages(group: { type: string; pages: any[] }) {
return group.pages.slice(0, limit)
}
// Type label mapping (matches backend WikiProcessingService route output)
const TYPE_LABELS: Record<string, string> = {
concept: 'Concepts',
person: 'People',
place: 'Places',
event: 'Events',
technology: 'Technology',
organization: 'Organizations',
product: 'Products',
term: 'Terms',
process: 'Processes',
other: 'Other',
}
function formatGroupLabel(type: string): string {
if (!type) return TYPE_LABELS.other
return TYPE_LABELS[type.toLowerCase()] || (type.charAt(0).toUpperCase() + type.slice(1))
if (!type) return t('wiki.pageTypes.other')
const key = `wiki.pageTypes.${type.toLowerCase()}`
const translated = t(key)
// If i18n key not found it returns the key itself; fall back to capitalised type
return translated === key ? (type.charAt(0).toUpperCase() + type.slice(1)) : translated
}
// Type sort order
@ -406,6 +408,7 @@ async function handleBatchDelete() {
const tabs = computed(() => [
{ key: 'raw', label: t('wiki.rawMaterials') },
{ key: 'pages', label: t('wiki.pages') },
{ key: 'graph', label: t('wiki.graph.tab') },
{ key: 'config', label: t('wiki.config') },
])
@ -427,6 +430,23 @@ async function handleCreateKB() {
newKBDesc.value = ''
}
// Infinite scroll: when the page-list container scrolls near the bottom,
// auto-load more items for the last non-fully-expanded group.
function onPageListScroll() {
const el = pageListEl.value
if (!el) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 60) {
// Find the first group that still has hidden pages and load more
for (const group of groupedPages.value) {
const limit = groupPageLimit[group.type] || PAGE_STEP
if (limit < group.pages.length) {
loadMoreGroup(group.type)
break
}
}
}
}
onMounted(() => {
store.fetchKnowledgeBases()
})
@ -638,6 +658,31 @@ onMounted(() => {
.empty-hint { font-size: 13px; color: var(--mc-text-tertiary); padding: 12px 4px; }
.filter-banner {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--mc-primary-bg);
border: 1px solid rgba(217,109,70,0.2);
border-radius: 8px;
font-size: 11px;
color: var(--mc-primary);
font-weight: 500;
}
.filter-clear-btn {
margin-left: auto;
border: none;
background: none;
cursor: pointer;
color: var(--mc-primary);
font-size: 11px;
padding: 0 2px;
opacity: 0.7;
line-height: 1;
}
.filter-clear-btn:hover { opacity: 1; }
/* Content area */
.wiki-content { flex: 1; overflow: hidden; min-width: 0; padding: 16px; display: flex; flex-direction: column; min-height: 0; }
.wiki-content-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
@ -647,6 +692,7 @@ onMounted(() => {
.tab-btn.active { color: var(--mc-primary); background: var(--mc-bg-elevated); box-shadow: 0 1px 4px rgba(0,0,0,0.08); font-weight: 600; }
.tab-content { flex: 1; min-height: 0; overflow-y: auto; padding-right: 2px; }
.tab-content--config { overflow: hidden; padding-right: 0; }
.tab-content--graph { overflow: hidden; padding: 0; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 200px; color: var(--mc-text-tertiary); text-align: center; }
.empty-state p { font-size: 14px; }