feat(contacts): show AI memories in contact details

This commit is contained in:
digua committed 2026-08-24 22:35:47 +08:00
1 parent db582d852e
commit 2a4ab0d2ae
5 files changed
+102 -3

No files matched your search

+2
View File
@@ -57,6 +57,8 @@
"scoreHelp": "The score is used to sort contacts. A higher score means stronger interaction signals.",
"lastInteraction": "Last interaction",
"sources": "Source chats",
"memories": "Memory",
"memoryLoadFailed": "Failed to load memory",
"sourceType": {
"private": "Private",
"group": "Group"
+2
View File
@@ -57,6 +57,8 @@
"scoreHelp": "総合スコアは連絡先の並び順に使われます。スコアが高いほど、やり取りのシグナルが強いことを示します。",
"lastInteraction": "最終インタラクション",
"sources": "元チャット",
"memories": "メモリ",
"memoryLoadFailed": "メモリを読み込めませんでした",
"sourceType": {
"private": "個人",
"group": "グループ"
+2
View File
@@ -57,6 +57,8 @@
"scoreHelp": "综合分用于联系人排序。分数越高,代表互动信号越强。",
"lastInteraction": "最近互动",
"sources": "来源会话",
"memories": "记忆",
"memoryLoadFailed": "加载记忆失败",
"sourceType": {
"private": "私聊",
"group": "群聊"
+2
View File
@@ -57,6 +57,8 @@
"scoreHelp": "綜合分用於聯絡人排序。分數越高,代表互動訊號越強。",
"lastInteraction": "最近互動",
"sources": "來源會話",
"memories": "記憶",
"memoryLoadFailed": "載入記憶失敗",
"sourceType": {
"private": "私聊",
"group": "群聊"
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ContactItem } from '@openchatlab/shared-types'
import type { AIMemoryEntry, ContactItem } from '@openchatlab/shared-types'
import { useAIService } from '@/services'
const props = defineProps<{
selectedKey: string | null
@@ -18,7 +19,11 @@ const emit = defineEmits<{
unmarkFriend: []
}>()
const { t } = useI18n()
const { t, locale } = useI18n()
const aiService = useAIService()
const memories = ref<AIMemoryEntry[]>([])
const isMemoryLoading = ref(false)
const memoryLoadFailed = ref(false)
const visibleAliases = computed(() => {
const contact = props.contact
@@ -34,6 +39,45 @@ const showFriendActions = computed(() => canMarkFriend.value || canUnmarkFriend.
function avatarText(contact: ContactItem): string {
return contact.displayName.trim().slice(0, 1).toUpperCase() || '?'
}
function formatMemoryUpdatedAt(timestamp: number): string {
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(timestamp)
}
async function loadContactMemories(key: string): Promise<void> {
memories.value = []
memoryLoadFailed.value = false
isMemoryLoading.value = true
try {
const loaded = await aiService.getAIMemories({ scopeType: 'contact', scopeId: key })
if (props.selectedKey === key) memories.value = loaded
} catch {
if (props.selectedKey === key) memoryLoadFailed.value = true
} finally {
if (props.selectedKey === key) isMemoryLoading.value = false
}
}
function retryLoadMemories(): void {
if (props.selectedKey) void loadContactMemories(props.selectedKey)
}
watch(
() => props.selectedKey,
(key) => {
if (key) void loadContactMemories(key)
else {
memories.value = []
memoryLoadFailed.value = false
isMemoryLoading.value = false
}
},
{ immediate: true }
)
</script>
<template>
@@ -180,6 +224,53 @@ function avatarText(contact: ContactItem): string {
</div>
</div>
</section>
<section class="border-t border-gray-100 px-5 py-4 dark:border-white/5">
<div class="mb-3 flex items-center justify-between gap-3">
<h3
class="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
<UIcon name="i-lucide-brain" class="h-3.5 w-3.5" />
{{ t('contacts.detail.memories') }}
</h3>
<span v-if="!isMemoryLoading && !memoryLoadFailed" class="text-[10px] text-gray-400 dark:text-gray-500">
{{ t('ai.global.memory.count', { count: memories.length }) }}
</span>
</div>
<div v-if="isMemoryLoading" class="flex items-center gap-2 py-3 text-xs text-gray-400 dark:text-gray-500">
<UIcon name="i-lucide-loader-2" class="h-3.5 w-3.5 animate-spin" />
<span>{{ t('common.loading') }}</span>
</div>
<div
v-else-if="memoryLoadFailed"
class="flex items-center justify-between gap-3 rounded-xl bg-gray-50 px-3.5 py-3 dark:bg-white/5"
>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('contacts.detail.memoryLoadFailed') }}</span>
<UButton color="neutral" variant="ghost" size="xs" @click="retryLoadMemories">
{{ t('common.retry') }}
</UButton>
</div>
<p v-else-if="memories.length === 0" class="py-2 text-xs leading-5 text-gray-400 dark:text-gray-500">
{{ t('ai.global.memory.empty') }}
</p>
<div v-else class="space-y-2">
<article
v-for="memory in memories"
:key="memory.id"
class="rounded-xl bg-gray-50 px-3.5 py-3 dark:bg-white/5"
>
<p class="whitespace-pre-wrap break-words text-xs leading-5 text-gray-700 dark:text-gray-300">
{{ memory.content }}
</p>
<div class="mt-2 flex items-center gap-1.5 text-[10px] text-gray-400 dark:text-gray-500">
<span>{{ t(`ai.global.memory.source.${memory.sourceType}`) }}</span>
<span aria-hidden="true">·</span>
<span>{{ formatMemoryUpdatedAt(memory.updatedAt) }}</span>
</div>
</article>
</div>
</section>
</div>
</aside>
</Transition>