mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
feat(wiki): config UI overhaul — model strategy, search preview modal, graph fullscreen
This commit is contained in:
parent
4f67e31887
commit
27c4c3e5e2
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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 更新页面内容(手动编辑的页面不覆盖内容,仅追加来源)
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
{config}
|
||||
|
||||
{document_map_section}
|
||||
|
||||
## 已有 Wiki 页面索引(用于建立 [[链接]])
|
||||
|
||||
{existing_pages}
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '自动执行',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
291
mateclaw-ui/src/views/Wiki/components/WikiConfigModels.vue
Normal file
291
mateclaw-ui/src/views/Wiki/components/WikiConfigModels.vue
Normal 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>
|
||||
473
mateclaw-ui/src/views/Wiki/components/WikiConfigRules.vue
Normal file
473
mateclaw-ui/src/views/Wiki/components/WikiConfigRules.vue
Normal 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="# 自定义规则 - 用 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>
|
||||
133
mateclaw-ui/src/views/Wiki/components/WikiGraphNodePanel.vue
Normal file
133
mateclaw-ui/src/views/Wiki/components/WikiGraphNodePanel.vue
Normal 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>
|
||||
134
mateclaw-ui/src/views/Wiki/components/WikiGraphToolbar.vue
Normal file
134
mateclaw-ui/src/views/Wiki/components/WikiGraphToolbar.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
338
mateclaw-ui/src/views/Wiki/components/WikiModelPicker.vue
Normal file
338
mateclaw-ui/src/views/Wiki/components/WikiModelPicker.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user