feat(ui): batch 2 — Facts Tab + Contradiction Inbox + Trust Bar

This commit is contained in:
matevip 2026-04-21 15:28:16 +08:00
parent 0a776f96fd
commit e42444f50a
5 changed files with 283 additions and 2 deletions

View File

@ -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"…',

View File

@ -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: '输入主题,如"项目架构"、"用户偏好"…',

View 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>

View 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>

View File

@ -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 () => {