mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): batch 2 — Facts Tab + Contradiction Inbox + Trust Bar
This commit is contained in:
parent
0a776f96fd
commit
e42444f50a
@ -1832,6 +1832,24 @@ export default {
|
||||
confirmed: 'Confirmed',
|
||||
saved: 'Saved to MEMORY.md',
|
||||
},
|
||||
facts: {
|
||||
searchPlaceholder: 'Search facts…',
|
||||
empty: 'No facts yet (enable fact projection)',
|
||||
trust: 'Trust',
|
||||
used: 'Used {n} times',
|
||||
helpful: 'Helpful',
|
||||
unhelpful: 'Not helpful',
|
||||
forget: 'Forget',
|
||||
forgotten: 'Forgotten',
|
||||
resolved: 'Resolved',
|
||||
contradictions: '{count} contradictions pending',
|
||||
resolve: {
|
||||
KEEP_A: 'Keep A',
|
||||
KEEP_B: 'Keep B',
|
||||
MERGE: 'Merge',
|
||||
IGNORE: 'Ignore',
|
||||
},
|
||||
},
|
||||
focused: {
|
||||
desc: 'Re-organize memories around a specific topic, prioritizing related information.',
|
||||
placeholder: 'Enter a topic, e.g. "architecture decisions"…',
|
||||
|
||||
@ -1842,6 +1842,24 @@ export default {
|
||||
confirmed: '已确认',
|
||||
saved: '已保存到 MEMORY.md',
|
||||
},
|
||||
facts: {
|
||||
searchPlaceholder: '搜索事实…',
|
||||
empty: '暂无事实数据(需启用事实投影)',
|
||||
trust: '信任度',
|
||||
used: '使用 {n} 次',
|
||||
helpful: '有用',
|
||||
unhelpful: '没用',
|
||||
forget: '遗忘',
|
||||
forgotten: '已遗忘',
|
||||
resolved: '已解决',
|
||||
contradictions: '{count} 条矛盾待处理',
|
||||
resolve: {
|
||||
KEEP_A: '保留 A',
|
||||
KEEP_B: '保留 B',
|
||||
MERGE: '合并',
|
||||
IGNORE: '忽略',
|
||||
},
|
||||
},
|
||||
focused: {
|
||||
desc: '围绕一个主题让智能体重新整理记忆,优先提炼相关信息。',
|
||||
placeholder: '输入主题,如"项目架构"、"用户偏好"…',
|
||||
|
||||
206
mateclaw-ui/src/views/Memory/components/FactList.vue
Normal file
206
mateclaw-ui/src/views/Memory/components/FactList.vue
Normal file
@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="fact-list">
|
||||
<!-- Search -->
|
||||
<div class="fact-search">
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="fact-search-input"
|
||||
:placeholder="t('memory.facts.searchPlaceholder')"
|
||||
@keydown.enter="search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contradiction badge -->
|
||||
<div v-if="contradictions.length > 0" class="contradiction-bar" @click="showContradictions = !showContradictions">
|
||||
<span class="contradiction-dot" />
|
||||
<span>{{ t('memory.facts.contradictions', { count: contradictions.length }) }}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline :points="showContradictions ? '18 15 12 9 6 15' : '6 9 12 15 18 9'" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Contradiction inbox -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="showContradictions && contradictions.length > 0" class="contradiction-inbox">
|
||||
<div v-for="c in contradictions" :key="c.id" class="contradiction-item">
|
||||
<p class="contradiction-desc">{{ c.description }}</p>
|
||||
<div class="contradiction-actions">
|
||||
<button v-for="r in ['KEEP_A', 'KEEP_B', 'MERGE', 'IGNORE']" :key="r"
|
||||
class="resolve-btn" @click="resolve(c.id, r)">{{ t('memory.facts.resolve.' + r) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="fact-loading">
|
||||
<div class="skeleton-card" v-for="i in 3" :key="i"><div class="skeleton-line" /><div class="skeleton-line short" /></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="facts.length === 0" class="fact-empty">
|
||||
<div class="empty-icon">📦</div>
|
||||
<p>{{ t('memory.facts.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Fact cards -->
|
||||
<div v-else class="fact-cards">
|
||||
<div v-for="fact in facts" :key="fact.id" class="fact-card">
|
||||
<div class="fact-top">
|
||||
<span class="fact-subject">{{ fact.subject }}</span>
|
||||
<span class="fact-category">{{ fact.category }}</span>
|
||||
</div>
|
||||
<div class="fact-triple">
|
||||
<span class="fact-predicate">{{ fact.predicate }}</span>
|
||||
<span class="fact-object">{{ fact.objectValue }}</span>
|
||||
</div>
|
||||
<div class="fact-bottom">
|
||||
<FactTrustBar :trust="fact.trust" />
|
||||
<span class="fact-use-count" v-if="fact.useCount > 0">{{ t('memory.facts.used', { n: fact.useCount }) }}</span>
|
||||
<div class="fact-actions">
|
||||
<button class="action-btn helpful" @click="feedback(fact.id, 'HELPFUL')" :title="t('memory.facts.helpful')">👍</button>
|
||||
<button class="action-btn unhelpful" @click="feedback(fact.id, 'UNHELPFUL')" :title="t('memory.facts.unhelpful')">👎</button>
|
||||
<button class="action-btn forget" @click="forget(fact.id)" :title="t('memory.facts.forget')">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '@/api'
|
||||
import FactTrustBar from './FactTrustBar.vue'
|
||||
|
||||
const props = defineProps<{ agentId: number }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const facts = ref<any[]>([])
|
||||
const contradictions = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const showContradictions = ref(false)
|
||||
|
||||
watch(() => props.agentId, () => { keyword.value = ''; loadFacts(); loadContradictions() }, { immediate: true })
|
||||
|
||||
async function loadFacts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (keyword.value.trim()) params.keyword = keyword.value.trim()
|
||||
const res = await http.get(`/memory/${props.agentId}/facts`, { params })
|
||||
facts.value = res.data || []
|
||||
} catch { facts.value = [] }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadContradictions() {
|
||||
try {
|
||||
const res = await http.get(`/memory/${props.agentId}/facts/contradictions`)
|
||||
contradictions.value = res.data || []
|
||||
} catch { contradictions.value = [] }
|
||||
}
|
||||
|
||||
function search() { loadFacts() }
|
||||
|
||||
async function feedback(factId: number, kind: string) {
|
||||
try {
|
||||
await http.post(`/memory/${props.agentId}/facts/${factId}/feedback`, { kind })
|
||||
ElMessage.success(kind === 'HELPFUL' ? '👍' : '👎')
|
||||
loadFacts()
|
||||
} catch (e: any) { ElMessage.error(e.message || 'Failed') }
|
||||
}
|
||||
|
||||
async function forget(factId: number) {
|
||||
try {
|
||||
await http.post(`/memory/${props.agentId}/facts/${factId}/forget`)
|
||||
ElMessage.success(t('memory.facts.forgotten'))
|
||||
loadFacts()
|
||||
} catch (e: any) { ElMessage.error(e.message || 'Failed') }
|
||||
}
|
||||
|
||||
async function resolve(contradictionId: number, resolution: string) {
|
||||
try {
|
||||
await http.post(`/memory/${props.agentId}/facts/contradictions/${contradictionId}/resolve`, { resolution })
|
||||
ElMessage.success(t('memory.facts.resolved'))
|
||||
loadContradictions()
|
||||
} catch (e: any) { ElMessage.error(e.message || 'Failed') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fact-search { padding: 0 0 12px; }
|
||||
.fact-search-input {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 10px;
|
||||
background: var(--mc-bg-sunken); font-size: 13px; color: var(--mc-text-primary);
|
||||
outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.fact-search-input:focus { border-color: var(--mc-primary); }
|
||||
.fact-search-input::placeholder { color: var(--mc-text-tertiary); }
|
||||
|
||||
/* Contradiction bar */
|
||||
.contradiction-bar {
|
||||
display: flex; align-items: center; gap: 6px; padding: 8px 12px; margin-bottom: 12px;
|
||||
border-radius: 10px; background: rgba(255,59,48,0.08); cursor: pointer;
|
||||
font-size: 12px; font-weight: 500; color: #ff3b30; transition: background 0.15s;
|
||||
}
|
||||
.contradiction-bar:hover { background: rgba(255,59,48,0.12); }
|
||||
.contradiction-dot { width: 8px; height: 8px; border-radius: 50%; background: #ff3b30; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
|
||||
.contradiction-inbox { margin-bottom: 12px; }
|
||||
.contradiction-item {
|
||||
padding: 10px 12px; margin-bottom: 6px; border-radius: 10px;
|
||||
background: var(--mc-bg-sunken); border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.contradiction-desc { margin: 0 0 8px; font-size: 12px; color: var(--mc-text-secondary); line-height: 1.4; }
|
||||
.contradiction-actions { display: flex; gap: 4px; }
|
||||
.resolve-btn {
|
||||
padding: 3px 8px; border: 1px solid var(--mc-border); border-radius: 6px;
|
||||
background: transparent; font-size: 11px; color: var(--mc-text-secondary);
|
||||
cursor: pointer; transition: all 0.12s;
|
||||
}
|
||||
.resolve-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
|
||||
/* Fact cards */
|
||||
.fact-cards { display: flex; flex-direction: column; gap: 8px; }
|
||||
.fact-card {
|
||||
padding: 12px 14px; border-radius: 12px; background: var(--mc-bg-sunken);
|
||||
border: 1px solid transparent; transition: border-color 0.12s;
|
||||
}
|
||||
.fact-card:hover { border-color: var(--mc-border); }
|
||||
.fact-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.fact-subject { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.fact-category {
|
||||
font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: var(--mc-text-tertiary); padding: 2px 6px; border-radius: 4px; background: var(--mc-bg-elevated);
|
||||
}
|
||||
.fact-triple { margin-top: 4px; font-size: 12px; }
|
||||
.fact-predicate { color: var(--mc-text-tertiary); margin-right: 4px; }
|
||||
.fact-object { color: var(--mc-text-secondary); }
|
||||
.fact-bottom { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.fact-use-count { font-size: 10px; color: var(--mc-text-tertiary); }
|
||||
.fact-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
|
||||
.fact-card:hover .fact-actions { opacity: 1; }
|
||||
.action-btn {
|
||||
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
|
||||
border: none; border-radius: 6px; background: transparent; font-size: 13px;
|
||||
cursor: pointer; transition: background 0.12s;
|
||||
}
|
||||
.action-btn:hover { background: var(--mc-bg-elevated); }
|
||||
.action-btn.forget:hover { background: rgba(255,59,48,0.1); }
|
||||
|
||||
/* States */
|
||||
.fact-loading { padding: 8px 0; }
|
||||
.skeleton-card { padding: 12px; margin-bottom: 6px; }
|
||||
.skeleton-line { height: 10px; border-radius: 4px; background: var(--mc-border-light); margin-bottom: 8px; }
|
||||
.skeleton-line.short { width: 60%; }
|
||||
.fact-empty { display: flex; flex-direction: column; align-items: center; padding: 40px 0; color: var(--mc-text-tertiary); }
|
||||
.empty-icon { font-size: 28px; margin-bottom: 8px; }
|
||||
|
||||
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.2s ease; }
|
||||
.slide-down-enter-from, .slide-down-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||
</style>
|
||||
30
mateclaw-ui/src/views/Memory/components/FactTrustBar.vue
Normal file
30
mateclaw-ui/src/views/Memory/components/FactTrustBar.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="trust-bar" :title="`${t('memory.facts.trust')}: ${(trust * 100).toFixed(0)}%`">
|
||||
<div class="trust-bar__fill" :style="{ width: (trust * 100) + '%' }" :class="trustLevel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{ trust: number }>()
|
||||
const trustLevel = computed(() => {
|
||||
if (props.trust >= 0.7) return 'high'
|
||||
if (props.trust >= 0.4) return 'mid'
|
||||
return 'low'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trust-bar {
|
||||
width: 48px; height: 4px; border-radius: 2px;
|
||||
background: var(--mc-border-light); overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.trust-bar__fill {
|
||||
height: 100%; border-radius: 2px; transition: width 0.3s ease;
|
||||
}
|
||||
.trust-bar__fill.high { background: #34c759; }
|
||||
.trust-bar__fill.mid { background: #ff9f0a; }
|
||||
.trust-bar__fill.low { background: #ff3b30; }
|
||||
</style>
|
||||
@ -99,8 +99,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Detail panel -->
|
||||
<!-- Right: Content panel -->
|
||||
<div class="memory-detail">
|
||||
<!-- Facts tab -->
|
||||
<template v-if="activeTab === 'facts' && selectedAgentId">
|
||||
<FactList :agent-id="selectedAgentId" />
|
||||
</template>
|
||||
|
||||
<!-- Timeline tab detail -->
|
||||
<template v-else-if="activeTab === 'timeline'">
|
||||
<!-- Inline dream input (replaces dialog) -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="dreamInputOpen" class="dream-input-area">
|
||||
@ -158,6 +165,7 @@
|
||||
<p>{{ t('memory.desc') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</template><!-- /timeline tab -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -172,6 +180,7 @@ import { http } from '@/api'
|
||||
import { useAgentStore } from '@/stores/useAgentStore'
|
||||
import { useMemoryStore, type DreamReportItem } from '@/stores/useMemoryStore'
|
||||
import MorningCard from './components/MorningCard.vue'
|
||||
import FactList from './components/FactList.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const agentStore = useAgentStore()
|
||||
@ -193,7 +202,7 @@ const dreamRunning = ref(false)
|
||||
const tabs = computed(() => [
|
||||
{ key: 'timeline', label: t('memory.tabTimeline'), disabled: false },
|
||||
{ key: 'memory', label: t('memory.tabMemory'), disabled: true },
|
||||
{ key: 'facts', label: t('memory.tabFacts'), disabled: true },
|
||||
{ key: 'facts', label: t('memory.tabFacts'), disabled: false },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user