mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): hot cache panel — view, regenerate, reset from KB detail
This commit is contained in:
parent
2ae8758cd1
commit
af5670f2f7
@ -653,3 +653,24 @@ export const featureFlagApi = {
|
||||
update: (flagKey: string, data: FeatureFlagUpdate) =>
|
||||
http.put(`/feature-flags/${flagKey}`, data),
|
||||
}
|
||||
|
||||
export interface WikiHotCache {
|
||||
id: number
|
||||
kbId: number
|
||||
content: string | null
|
||||
contentHash: string | null
|
||||
lastUpdated: string | null
|
||||
updateReason: string | null
|
||||
rebuildCount: number
|
||||
lastRebuildStartedAt: string | null
|
||||
lastRebuildDurationMs: number | null
|
||||
lastRebuildError: string | null
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export const hotCacheApi = {
|
||||
get: (kbId: number) => http.get<WikiHotCache | null>(`/wiki/hot-cache/${kbId}`),
|
||||
regenerate: (kbId: number) => http.post(`/wiki/hot-cache/${kbId}/regenerate`),
|
||||
reset: (kbId: number) => http.delete(`/wiki/hot-cache/${kbId}`),
|
||||
}
|
||||
|
||||
@ -1489,6 +1489,27 @@ export default {
|
||||
rawMaterials: 'Raw Materials',
|
||||
pages: 'Wiki Pages',
|
||||
config: 'Config',
|
||||
hotCache: {
|
||||
tab: 'Hot Cache',
|
||||
title: 'Recent Activity Snapshot (Hot Cache)',
|
||||
desc: 'A rolling markdown digest of what has been happening in this knowledge base, injected into the agent system prompt so chat does not have to re-search the obvious. Rebuilt asynchronously by the listener with a 5-minute debounce.',
|
||||
empty: 'No snapshot yet — one will be built after the bound agent finishes its next conversation.',
|
||||
contentEmpty: '(snapshot body is empty)',
|
||||
regenerate: 'Rebuild now',
|
||||
regenerating: 'Rebuilding…',
|
||||
regenerateQueued: 'Rebuild queued; refreshing in a few seconds',
|
||||
regenerateFailed: 'Failed to schedule rebuild',
|
||||
reset: 'Clear snapshot',
|
||||
resetConfirm: 'Clear the current snapshot? The next agent conversation will regenerate it.',
|
||||
resetDone: 'Cleared',
|
||||
resetFailed: 'Clear failed',
|
||||
lastUpdated: 'Last updated',
|
||||
updateReason: 'Trigger reason',
|
||||
rebuildCount: 'Rebuilds',
|
||||
lastDuration: 'Last duration',
|
||||
lastError: 'Last error',
|
||||
footer: 'Toggling wiki.hot_cache.enabled off short-circuits the injection path. The "Rebuild now" button bypasses the debounce window for easier debugging.',
|
||||
},
|
||||
searchPages: 'Search pages...',
|
||||
dropFiles: 'Drop files here or click to upload',
|
||||
dropToUpload: 'Release to upload',
|
||||
|
||||
@ -1501,6 +1501,27 @@ export default {
|
||||
rawMaterials: '原始材料',
|
||||
pages: 'Wiki 页面',
|
||||
config: '处理配置',
|
||||
hotCache: {
|
||||
tab: '近况快照',
|
||||
title: '近况快照(Hot Cache)',
|
||||
desc: '该知识库最近发生了什么的滚动 markdown 摘要,会被自动注入到 Agent 的系统提示,让对话不必每次都重新检索。事件触发后台异步重建,5 分钟去抖。',
|
||||
empty: '尚未生成快照——绑定的 Agent 完成一次对话后会自动构建。',
|
||||
contentEmpty: '(快照内容为空)',
|
||||
regenerate: '立即重建',
|
||||
regenerating: '重建中…',
|
||||
regenerateQueued: '已加入重建队列,几秒后刷新',
|
||||
regenerateFailed: '重建调度失败',
|
||||
reset: '清除快照',
|
||||
resetConfirm: '确认清除当前快照?下次 Agent 对话会重新生成。',
|
||||
resetDone: '已清除',
|
||||
resetFailed: '清除失败',
|
||||
lastUpdated: '上次更新',
|
||||
updateReason: '触发原因',
|
||||
rebuildCount: '累计重建次数',
|
||||
lastDuration: '上次耗时',
|
||||
lastError: '最近一次错误',
|
||||
footer: '关闭 wiki.hot_cache.enabled 后注入路径短路;该面板的"立即重建"会绕过去抖,方便调试。',
|
||||
},
|
||||
searchPages: '搜索页面...',
|
||||
dropFiles: '拖拽文件到此处或点击上传',
|
||||
dropToUpload: '松开即开始上传',
|
||||
|
||||
289
mateclaw-ui/src/views/Wiki/components/HotCachePanel.vue
Normal file
289
mateclaw-ui/src/views/Wiki/components/HotCachePanel.vue
Normal file
@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="hot-cache-panel">
|
||||
<header class="panel-header">
|
||||
<div class="header-text">
|
||||
<h3 class="panel-title">{{ t('wiki.hotCache.title') }}</h3>
|
||||
<p class="panel-desc">{{ t('wiki.hotCache.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
:disabled="loading || !cache"
|
||||
@click="onReset"
|
||||
>
|
||||
{{ t('wiki.hotCache.reset') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="regenerating"
|
||||
@click="onRegenerate"
|
||||
>
|
||||
<span v-if="regenerating">{{ t('wiki.hotCache.regenerating') }}</span>
|
||||
<span v-else>{{ t('wiki.hotCache.regenerate') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="state-row">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="state-row state-row--error">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span>{{ error }}</span>
|
||||
<button class="btn-secondary" @click="load">{{ t('common.retry', 'Retry') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!cache" class="state-row state-row--empty">
|
||||
<span>{{ t('wiki.hotCache.empty') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="cache-body">
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ t('wiki.hotCache.lastUpdated') }}</span>
|
||||
<span class="meta-value">{{ formatTimestamp(cache.lastUpdated) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ t('wiki.hotCache.updateReason') }}</span>
|
||||
<span class="meta-value">{{ cache.updateReason || '—' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ t('wiki.hotCache.rebuildCount') }}</span>
|
||||
<span class="meta-value">{{ cache.rebuildCount ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="meta-item" v-if="cache.lastRebuildDurationMs">
|
||||
<span class="meta-label">{{ t('wiki.hotCache.lastDuration') }}</span>
|
||||
<span class="meta-value">{{ formatDuration(cache.lastRebuildDurationMs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cache.lastRebuildError" class="error-banner">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<div>
|
||||
<div class="error-title">{{ t('wiki.hotCache.lastError') }}</div>
|
||||
<div class="error-detail">{{ cache.lastRebuildError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="cache-content">{{ cache.content || t('wiki.hotCache.contentEmpty') }}</pre>
|
||||
</div>
|
||||
|
||||
<p class="footer-note">{{ t('wiki.hotCache.footer') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElIcon, ElMessage } from 'element-plus'
|
||||
import { Loading, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { hotCacheApi, type WikiHotCache } from '@/api/index'
|
||||
import { useWikiStore } from '@/stores/useWikiStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useWikiStore()
|
||||
|
||||
const cache = ref<WikiHotCache | null>(null)
|
||||
const loading = ref(false)
|
||||
const regenerating = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
if (!store.currentKB) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const resp: any = await hotCacheApi.get(store.currentKB.id)
|
||||
cache.value = resp?.data ?? null
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate() {
|
||||
if (!store.currentKB) return
|
||||
regenerating.value = true
|
||||
try {
|
||||
await hotCacheApi.regenerate(store.currentKB.id)
|
||||
ElMessage.success(t('wiki.hotCache.regenerateQueued'))
|
||||
// Background rebuild — poll once after a short delay so the operator sees
|
||||
// the row update without manual refresh. The LLM call typically returns
|
||||
// within 5-15s; if the user hits "regenerate" again, a fresh poll fires.
|
||||
setTimeout(load, 4000)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message ?? t('wiki.hotCache.regenerateFailed'))
|
||||
} finally {
|
||||
regenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onReset() {
|
||||
if (!store.currentKB) return
|
||||
if (!confirm(t('wiki.hotCache.resetConfirm'))) return
|
||||
try {
|
||||
await hotCacheApi.reset(store.currentKB.id)
|
||||
ElMessage.success(t('wiki.hotCache.resetDone'))
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message ?? t('wiki.hotCache.resetFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string | null): string {
|
||||
if (!iso) return t('common.never', 'Never')
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
watch(() => store.currentKB?.id, () => {
|
||||
cache.value = null
|
||||
if (store.currentKB) load()
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hot-cache-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-text { flex: 1; min-width: 240px; }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.panel-desc {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--mc-primary);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-secondary {
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-primary);
|
||||
border-color: var(--mc-border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--mc-bg-sunken); }
|
||||
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.state-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
background: var(--mc-bg-muted);
|
||||
border-radius: 10px;
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.state-row--error {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
.state-row--empty {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cache-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.meta-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-color-danger-light-7);
|
||||
font-size: 13px;
|
||||
}
|
||||
.error-title { font-weight: 600; margin-bottom: 2px; }
|
||||
.error-detail { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; word-break: break-word; }
|
||||
|
||||
.cache-content {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
font-family: var(--mc-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--mc-text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@ -282,6 +282,10 @@
|
||||
<div v-if="activeTab === 'config'" class="tab-content tab-content--config">
|
||||
<WikiConfig />
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'hotCache'" class="tab-content tab-content--hot-cache">
|
||||
<HotCachePanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -318,6 +322,7 @@ import RawMaterialPanel from './components/RawMaterialPanel.vue'
|
||||
import WikiPageViewer from './components/WikiPageViewer.vue'
|
||||
import WikiConfig from './components/WikiConfig.vue'
|
||||
import WikiGraphView from './components/WikiGraphView.vue'
|
||||
import HotCachePanel from './components/HotCachePanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useWikiStore()
|
||||
@ -507,6 +512,7 @@ const tabs = computed(() => [
|
||||
{ key: 'pages', label: t('wiki.pages') },
|
||||
{ key: 'graph', label: t('wiki.graph.tab') },
|
||||
{ key: 'config', label: t('wiki.config') },
|
||||
{ key: 'hotCache', label: t('wiki.hotCache.tab') },
|
||||
])
|
||||
|
||||
async function selectKB(id: number) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user