diff --git a/packages/http-routes/src/routes/web/ai-memories.test.ts b/packages/http-routes/src/routes/web/ai-memories.test.ts index 77647deb..263e0d35 100644 --- a/packages/http-routes/src/routes/web/ai-memories.test.ts +++ b/packages/http-routes/src/routes/web/ai-memories.test.ts @@ -48,6 +48,18 @@ describe('AI memory routes', () => { assert.equal(globalResponse.json().content, '最近默认按 90 天') assert.equal(globalResponse.json().sourceType, 'user') + const selfResponse = await fixture.app.inject({ + method: 'POST', + url: '/_web/ai/memories', + payload: { + scopeType: 'self', + scopeId: null, + content: '用户目前在上海工作', + }, + }) + assert.equal(selfResponse.statusCode, 200) + assert.equal(selfResponse.json().scopeType, 'self') + await fixture.app.inject({ method: 'POST', url: '/_web/ai/memories', @@ -61,7 +73,16 @@ describe('AI memory routes', () => { const all = await fixture.app.inject({ method: 'GET', url: '/_web/ai/memories' }) assert.equal(all.statusCode, 200) - assert.equal(all.json().length, 3) + assert.equal(all.json().length, 4) + + const self = await fixture.app.inject({ + method: 'GET', + url: '/_web/ai/memories?scopeType=self', + }) + assert.deepEqual( + self.json().map((entry: { content: string }) => entry.content), + ['用户目前在上海工作'] + ) const filtered = await fixture.app.inject({ method: 'GET', diff --git a/packages/http-routes/src/routes/web/ai-memories.ts b/packages/http-routes/src/routes/web/ai-memories.ts index c4436b62..8c3050df 100644 --- a/packages/http-routes/src/routes/web/ai-memories.ts +++ b/packages/http-routes/src/routes/web/ai-memories.ts @@ -11,8 +11,8 @@ interface MemoryScopeInput { function parseScope(input: MemoryScopeInput): AIMemoryScope { const scopeType = input.scopeType - if (scopeType !== 'global' && scopeType !== 'contact' && scopeType !== 'group') { - throw new Error('scopeType must be global, contact, or group') + if (scopeType !== 'global' && scopeType !== 'self' && scopeType !== 'contact' && scopeType !== 'group') { + throw new Error('scopeType must be global, self, contact, or group') } return { diff --git a/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts b/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts index de49cb55..ed5473b3 100644 --- a/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts +++ b/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts @@ -43,6 +43,8 @@ describe('cross-chat agent prompt', () => { assert.match(prompt, /不得复用历史消息中的旧实体/) assert.match(prompt, /当前稳定 ID.*自动注入/) assert.match(prompt, /需要更多.*memory_read/) + assert.match(prompt, /关于用户本人.*scope_type=self/) + assert.match(prompt, /不会自动注入/) assert.match(prompt, /交集、并集/) assert.match(prompt, /不要仅因为消息中出现了 @联系人就机械调用/) assert.match(prompt, /roster_only/) @@ -107,6 +109,8 @@ describe('cross-chat agent prompt', () => { assert.match(prompt, /never stale entities from earlier messages/) assert.match(prompt, /current stable IDs.*automatically injected/) assert.match(prompt, /need more.*memory_read/i) + assert.match(prompt, /facts about the user.*scope_type=self/i) + assert.match(prompt, /not automatically injected/i) assert.match(prompt, /private-chat frequency ranking/) assert.match(prompt, /never substitute keyword hits/i) assert.match(prompt, /coverage\.complete=true/) diff --git a/packages/node-runtime/src/ai/__tests__/memory.test.ts b/packages/node-runtime/src/ai/__tests__/memory.test.ts index 7792c7ac..3285e872 100644 --- a/packages/node-runtime/src/ai/__tests__/memory.test.ts +++ b/packages/node-runtime/src/ai/__tests__/memory.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, it } from 'node:test' +import Database from 'better-sqlite3' import type { AIMemoryEntry, AIMemoryScope } from '@openchatlab/shared-types' import { AI_MEMORY_CONTENT_MAX_CHARS, AIMemoryService, buildEntityMemoryPrompt } from '../memory' @@ -27,8 +28,40 @@ function cleanup(dir: string): void { } } +function createVersionOneDatabase(dir: string): void { + const dbPath = join(dir, 'memory.db') + const db = sqliteNativeBinding ? new Database(dbPath, { nativeBinding: sqliteNativeBinding }) : new Database(dbPath) + db.exec(` + CREATE TABLE ai_memory ( + id TEXT PRIMARY KEY, + scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'contact', 'group')), + scope_id TEXT, + content TEXT NOT NULL, + source_type TEXT NOT NULL CHECK (source_type IN ('user', 'ai')), + source_ai_chat_id TEXT, + source_message_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK ( + (scope_type = 'global' AND scope_id IS NULL) OR + (scope_type IN ('contact', 'group') AND scope_id IS NOT NULL) + ) + ); + CREATE INDEX idx_ai_memory_scope_updated + ON ai_memory(scope_type, scope_id, updated_at DESC, id ASC); + INSERT INTO ai_memory ( + id, scope_type, scope_id, content, source_type, + source_ai_chat_id, source_message_id, created_at, updated_at + ) VALUES + ('legacy-global', 'global', NULL, '先给结论', 'user', NULL, NULL, 1, 1), + ('legacy-contact', 'contact', 'contact-a', '大学同学', 'ai', 'chat-a', NULL, 2, 2); + PRAGMA user_version = 1; + `) + db.close() +} + describe('AIMemoryService', () => { - it('persists global, contact, and group memories across reopen', () => { + it('persists global, self, contact, and group memories across reopen', () => { const dir = createTempDir() try { let id = 0 @@ -43,6 +76,12 @@ describe('AIMemoryService', () => { sourceType: 'user', sourceAIChatId: 'chat-1', }) + first.create({ + scopeType: 'self', + scopeId: null, + content: '用户目前在上海工作', + sourceType: 'user', + }) first.create({ scopeType: 'contact', scopeId: 'contact:qq:10001', @@ -63,18 +102,47 @@ describe('AIMemoryService', () => { reopened.list().map((entry) => [entry.id, entry.scopeType, entry.scopeId, entry.sourceType]), [ ['memory-1', 'global', null, 'user'], - ['memory-2', 'contact', 'contact:qq:10001', 'user'], - ['memory-3', 'group', 'session-group-1', 'ai'], + ['memory-2', 'self', null, 'user'], + ['memory-3', 'contact', 'contact:qq:10001', 'user'], + ['memory-4', 'group', 'session-group-1', 'ai'], ] ) assert.equal(reopened.get('memory-1')?.sourceAIChatId, 'chat-1') - assert.equal(reopened.getSchemaVersion(), 1) + assert.equal(reopened.getSchemaVersion(), 2) reopened.close() } finally { cleanup(dir) } }) + it('migrates version 1 without losing existing memories and then accepts self memories', () => { + const dir = createTempDir() + try { + createVersionOneDatabase(dir) + const service = createService(dir, { now: () => 3, idFactory: () => 'self-memory' }) + + assert.equal(service.getSchemaVersion(), 2) + assert.deepEqual( + service.list().map((entry) => [entry.id, entry.scopeType, entry.scopeId, entry.content]), + [ + ['legacy-contact', 'contact', 'contact-a', '大学同学'], + ['legacy-global', 'global', null, '先给结论'], + ] + ) + + service.create({ + scopeType: 'self', + scopeId: null, + content: '用户正在维护 ChatLab', + sourceType: 'user', + }) + assert.equal(service.list({ scopeType: 'self', scopeId: null })[0]?.content, '用户正在维护 ChatLab') + service.close() + } finally { + cleanup(dir) + } + }) + it('updates by stable id, keeps scope immutable, and sorts deterministically', () => { const dir = createTempDir() try { @@ -166,6 +234,10 @@ describe('AIMemoryService', () => { () => service.create({ scopeType: 'global', scopeId: 'unexpected', content: 'x', sourceType: 'user' }), /scopeId/i ) + assert.throws( + () => service.create({ scopeType: 'self', scopeId: 'unexpected', content: 'x', sourceType: 'user' }), + /scopeId/i + ) assert.throws( () => service.create({ scopeType: 'contact', scopeId: null, content: 'x', sourceType: 'user' }), /scopeId/i diff --git a/packages/node-runtime/src/ai/cross-chat-agent.ts b/packages/node-runtime/src/ai/cross-chat-agent.ts index adeb7764..e7b7fb02 100644 --- a/packages/node-runtime/src/ai/cross-chat-agent.ts +++ b/packages/node-runtime/src/ai/cross-chat-agent.ts @@ -260,6 +260,8 @@ export function buildCrossChatSystemPrompt( 长期记忆规则: - 只保存跨 AI 对话仍有长期价值的用户偏好、身份背景、明确纠正,或已有聊天证据支持且会影响后续调查方向的稳定结论。不要保存本轮临时要求、普通统计、聊天原文、工具过程、普通回答或未经证据支持的猜测。 +- 全局用户偏好使用 scope_type=global。关于用户本人的稳定身份、背景、长期目标或生活状态使用 scope_type=self;与某个特定联系人的关系仍属于该 contact 作用域。 +- 关于用户本人的 self 记忆不会自动注入。只有当前问题依赖这类背景且现有对话信息不足时,才调用 memory_read(scope_type=self) 按需读取;写入前也先读取同一作用域,避免重复。 - 本轮选择的联系人和群聊记忆已按当前稳定 ID 自动注入;优先使用这些记忆,不得复用历史消息中旧实体的记忆。当前实体没有已注入记忆、需要更多记忆或实体由文本临时解析得到时,再调用 memory_read。 - 联系人和群聊记忆必须使用稳定 contactKey 或 group sessionId。写入前先调用 memory_read 读取同一作用域,已有相同含义时不要重复创建。 - 用户明确提供、要求记住或纠正的内容使用 source_type=user;你根据聊天数据形成的结论使用 source_type=ai,并写清必要的观察时间和不确定性。 @@ -310,6 +312,8 @@ Tool and conclusion rules: Long-term memory rules: - Save only preferences, identity/background facts, explicit corrections, or evidence-backed stable conclusions that remain useful across AI conversations. Never save temporary instructions, ordinary statistics, transcripts, tool traces, ordinary answers, or unsupported guesses. +- Use scope_type=global for global user preferences. Store stable facts about the user's identity, background, long-term goals, or life situation with scope_type=self; a relationship with one specific contact still belongs to that contact scope. +- Self memory is not automatically injected. Call memory_read(scope_type=self) on demand only when the current question depends on that background and the conversation does not already provide it; read the same scope before writing to avoid duplicates. - Memories keyed by the current stable IDs are automatically injected for the selected contacts and groups. Prefer those memories and never reuse memories for stale entities from history. If the current entity has no injected memory, you need more entries, or the entity was resolved from plain text during this turn, call memory_read. - Entity memory must use a stable contactKey or group sessionId. Call memory_read for the same scope before writing, and do not create a duplicate with the same meaning. - Use source_type=user only for facts the user explicitly provides, asks to remember, or corrects. Use source_type=ai for conclusions derived from chat data, including the observation period and uncertainty. diff --git a/packages/node-runtime/src/ai/memory.ts b/packages/node-runtime/src/ai/memory.ts index 34e8f9f5..32e419dd 100644 --- a/packages/node-runtime/src/ai/memory.ts +++ b/packages/node-runtime/src/ai/memory.ts @@ -12,7 +12,7 @@ import type { import { appLogger } from '../logging/app-logger' export const AI_MEMORY_CONTENT_MAX_CHARS = 2_000 -const AI_MEMORY_SCHEMA_VERSION = 1 +const AI_MEMORY_SCHEMA_VERSION = 2 const AI_MEMORY_PROMPT_MAX_ENTRIES = 20 const AI_MEMORY_PROMPT_MAX_CONTENT_CHARS = 4_000 @@ -179,7 +179,7 @@ export class AIMemoryService { } const normalized = normalizeScope(scope) - if (normalized.scopeType === 'global') { + if (normalized.scopeType === 'global' || normalized.scopeType === 'self') { return db .prepare( `SELECT id, @@ -192,10 +192,10 @@ export class AIMemoryService { created_at AS createdAt, updated_at AS updatedAt FROM ai_memory - WHERE scope_type = 'global' AND scope_id IS NULL + WHERE scope_type = ? AND scope_id IS NULL ORDER BY updated_at DESC, id ASC` ) - .all() as AIMemoryEntry[] + .all(normalized.scopeType) as AIMemoryEntry[] } return db @@ -304,8 +304,9 @@ export class AIMemoryService { if (!scope) return db.prepare('DELETE FROM ai_memory').run().changes const normalized = normalizeScope(scope) - if (normalized.scopeType === 'global') { - return db.prepare("DELETE FROM ai_memory WHERE scope_type = 'global' AND scope_id IS NULL").run().changes + if (normalized.scopeType === 'global' || normalized.scopeType === 'self') { + return db.prepare('DELETE FROM ai_memory WHERE scope_type = ? AND scope_id IS NULL').run(normalized.scopeType) + .changes } return db .prepare('DELETE FROM ai_memory WHERE scope_type = ? AND scope_id = ?') @@ -333,26 +334,7 @@ export class AIMemoryService { : new Database(this.dbPath) db.pragma('journal_mode = WAL') db.pragma('busy_timeout = 5000') - db.exec(` - CREATE TABLE IF NOT EXISTS ai_memory ( - id TEXT PRIMARY KEY, - scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'contact', 'group')), - scope_id TEXT, - content TEXT NOT NULL, - source_type TEXT NOT NULL CHECK (source_type IN ('user', 'ai')), - source_ai_chat_id TEXT, - source_message_id TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - CHECK ( - (scope_type = 'global' AND scope_id IS NULL) OR - (scope_type IN ('contact', 'group') AND scope_id IS NOT NULL) - ) - ); - CREATE INDEX IF NOT EXISTS idx_ai_memory_scope_updated - ON ai_memory(scope_type, scope_id, updated_at DESC, id ASC); - `) - db.pragma(`user_version = ${AI_MEMORY_SCHEMA_VERSION}`) + migrateMemorySchema(db) this.db = db appLogger.info('ai-memory', 'AI memory database initialized', { schemaVersion: AI_MEMORY_SCHEMA_VERSION }) return db @@ -376,13 +358,13 @@ export class AIMemoryService { function normalizeScope(scope: AIMemoryScope): AIMemoryScope { const scopeType = scope.scopeType - if (scopeType !== 'global' && scopeType !== 'contact' && scopeType !== 'group') { + if (scopeType !== 'global' && scopeType !== 'self' && scopeType !== 'contact' && scopeType !== 'group') { throw new Error(`Unsupported AI memory scope type: ${String(scopeType)}`) } const scopeId = normalizeOptionalId(scope.scopeId) - if (scopeType === 'global') { - if (scopeId !== null) throw new Error('Global AI memory scopeId must be null') + if (scopeType === 'global' || scopeType === 'self') { + if (scopeId !== null) throw new Error(`${scopeType} AI memory scopeId must be null`) return { scopeType, scopeId: null } } if (!scopeId) throw new Error(`${scopeType} AI memory scopeId is required`) @@ -409,3 +391,60 @@ function normalizeOptionalId(value: string | null | undefined): string | null { const normalized = value.trim() return normalized || null } + +function migrateMemorySchema(db: Database.Database): void { + const version = db.pragma('user_version', { simple: true }) as number + if (version > AI_MEMORY_SCHEMA_VERSION) { + throw new Error(`Unsupported AI memory schema version: ${version}`) + } + if (version === AI_MEMORY_SCHEMA_VERSION) return + + const migrate = db.transaction(() => { + if (version === 1) { + db.exec('ALTER TABLE ai_memory RENAME TO ai_memory_v1') + } + + db.exec(` + CREATE TABLE ai_memory ( + id TEXT PRIMARY KEY, + scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'self', 'contact', 'group')), + scope_id TEXT, + content TEXT NOT NULL, + source_type TEXT NOT NULL CHECK (source_type IN ('user', 'ai')), + source_ai_chat_id TEXT, + source_message_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK ( + (scope_type IN ('global', 'self') AND scope_id IS NULL) OR + (scope_type IN ('contact', 'group') AND scope_id IS NOT NULL) + ) + ); + `) + + if (version === 1) { + db.exec(` + INSERT INTO ai_memory ( + id, scope_type, scope_id, content, source_type, + source_ai_chat_id, source_message_id, created_at, updated_at + ) + SELECT id, scope_type, scope_id, content, source_type, + source_ai_chat_id, source_message_id, created_at, updated_at + FROM ai_memory_v1; + DROP TABLE ai_memory_v1; + `) + } + + db.exec(` + CREATE INDEX idx_ai_memory_scope_updated + ON ai_memory(scope_type, scope_id, updated_at DESC, id ASC); + `) + db.pragma(`user_version = ${AI_MEMORY_SCHEMA_VERSION}`) + }) + + migrate() + appLogger.info('ai-memory', 'AI memory database schema migrated', { + fromVersion: version, + toVersion: AI_MEMORY_SCHEMA_VERSION, + }) +} diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 423e3b23..15f4a6b9 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -136,7 +136,7 @@ export type AIEntityRef = sessionType: 'private' | 'group' } -export type AIMemoryScopeType = 'global' | 'contact' | 'group' +export type AIMemoryScopeType = 'global' | 'self' | 'contact' | 'group' export type AIMemorySourceType = 'user' | 'ai' export interface AIMemoryScope { diff --git a/packages/tools/src/definitions/memory-tools.test.ts b/packages/tools/src/definitions/memory-tools.test.ts index b30bde3e..58fccd83 100644 --- a/packages/tools/src/definitions/memory-tools.test.ts +++ b/packages/tools/src/definitions/memory-tools.test.ts @@ -195,6 +195,36 @@ describe('global memory tools', () => { assert.equal((result.data as AIMemoryEntry).sourceType, 'user') }) + it('reads and writes self memory on demand without an entity id', async () => { + const context = createContext() + const written = await memoryWriteTool.handler( + { + scope_type: 'self', + content: '用户目前在上海工作', + source_type: 'user', + }, + context + ) + + assert.deepEqual( + { + scopeType: (written.data as AIMemoryEntry).scopeType, + scopeId: (written.data as AIMemoryEntry).scopeId, + }, + { scopeType: 'self', scopeId: null } + ) + const read = await memoryReadTool.handler({ scope_type: 'self' }, context) + assert.deepEqual( + (read.data as { entries: AIMemoryEntry[] }).entries.map((entry) => entry.content), + ['用户目前在上海工作'] + ) + + await assert.rejects( + async () => memoryReadTool.handler({ scope_type: 'self', scope_id: 'owner' }, context), + /does not accept scope_id/i + ) + }) + it('requires resolvable stable ids for entity scopes', async () => { const context = createContext() await assert.rejects( diff --git a/packages/tools/src/definitions/memory-tools.ts b/packages/tools/src/definitions/memory-tools.ts index 5416af63..aa847de4 100644 --- a/packages/tools/src/definitions/memory-tools.ts +++ b/packages/tools/src/definitions/memory-tools.ts @@ -5,10 +5,10 @@ import type { CrossChatToolExecutionContext, JsonSchema, ToolDefinition, ToolRes const readSchema: JsonSchema = { type: 'object', properties: { - scope_type: { type: 'string', enum: ['global', 'contact', 'group'] }, + scope_type: { type: 'string', enum: ['global', 'self', 'contact', 'group'] }, scope_id: { type: 'string', - description: 'Stable contactKey or group sessionId. Omit for global memory.', + description: 'Stable contactKey or group sessionId. Omit for global and self memory.', }, limit: { type: 'number', minimum: 1, maximum: 50, default: 20 }, }, @@ -19,10 +19,10 @@ const writeSchema: JsonSchema = { type: 'object', properties: { id: { type: 'string', description: 'Stable memory ID. Provide it to update an existing memory.' }, - scope_type: { type: 'string', enum: ['global', 'contact', 'group'] }, + scope_type: { type: 'string', enum: ['global', 'self', 'contact', 'group'] }, scope_id: { type: 'string', - description: 'Stable contactKey or group sessionId. Omit for global memory.', + description: 'Stable contactKey or group sessionId. Omit for global and self memory.', }, content: { type: 'string', description: 'One durable fact or preference, without chat transcripts.' }, source_type: { @@ -45,7 +45,7 @@ const forgetSchema: JsonSchema = { export const memoryReadTool: ToolDefinition = { name: 'memory_read', description: - 'Read durable global preferences or memories for one resolved contact/group. AI-derived memories are leads and must be re-verified against current chat evidence.', + 'Read durable global preferences, facts about the user, or memories for one resolved contact/group. Self memory is read on demand and is never injected automatically. AI-derived memories are leads and must be re-verified against current chat evidence.', inputSchema: readSchema, handler: memoryReadHandler, } @@ -137,12 +137,12 @@ async function memoryForgetHandler( function parseScope(params: Record): AIMemoryScope { const scopeType = params.scope_type - if (scopeType !== 'global' && scopeType !== 'contact' && scopeType !== 'group') { - throw new Error('scope_type must be global, contact, or group') + if (scopeType !== 'global' && scopeType !== 'self' && scopeType !== 'contact' && scopeType !== 'group') { + throw new Error('scope_type must be global, self, contact, or group') } const scopeId = optionalString(params.scope_id) - if (scopeType === 'global') { - if (scopeId) throw new Error('Global memory does not accept scope_id') + if (scopeType === 'global' || scopeType === 'self') { + if (scopeId) throw new Error(`${scopeType} memory does not accept scope_id`) return { scopeType, scopeId: null } } if (!scopeId) throw new Error(`${scopeType} memory requires a stable scope_id`) @@ -177,7 +177,7 @@ async function assertResolvableEntityScope( scope: AIMemoryScope, context: CrossChatToolExecutionContext ): Promise { - if (scope.scopeType === 'global') return + if (scope.scopeType === 'global' || scope.scopeType === 'self') return if (context.entityRefs?.length) { const matchesCurrentSelection = context.entityRefs.some((ref) => diff --git a/src/components/AIChat/memory/AIMemoryManagerModal.vue b/src/components/AIChat/memory/AIMemoryManagerModal.vue index 589515d3..9332efd4 100644 --- a/src/components/AIChat/memory/AIMemoryManagerModal.vue +++ b/src/components/AIChat/memory/AIMemoryManagerModal.vue @@ -8,7 +8,7 @@ import { useToast } from '@/composables/useToast' import { usePromptStore } from '@/stores/prompt' import { useSessionStore } from '@/stores/session' -type MemoryView = 'preferences' | 'entities' +type MemoryView = 'preferences' | 'self' | 'entities' const props = defineProps<{ open: boolean }>() const emit = defineEmits<{ 'update:open': [value: boolean] }>() @@ -31,11 +31,16 @@ const editingContent = ref('') const viewItems = computed(() => [ { label: t('ai.global.memory.preferences'), value: 'preferences' }, + { label: t('ai.global.memory.self'), value: 'self' }, { label: t('ai.global.memory.entities'), value: 'entities' }, ]) const visibleEntries = computed(() => entries.value.filter((entry) => - activeView.value === 'preferences' ? entry.scopeType === 'global' : entry.scopeType !== 'global' + activeView.value === 'preferences' + ? entry.scopeType === 'global' + : activeView.value === 'self' + ? entry.scopeType === 'self' + : entry.scopeType === 'contact' || entry.scopeType === 'group' ) ) const proactiveMemory = computed({ @@ -137,10 +142,13 @@ async function clearCurrentView(): Promise { if (currentEntries.length === 0 || !confirm(t('ai.global.memory.confirmClearView'))) return loading.value = true try { - if (activeView.value === 'preferences') { - await aiService.clearAIMemories({ scopeType: 'global', scopeId: null }) - } else { + if (activeView.value === 'entities') { await Promise.all(currentEntries.map((entry) => aiService.deleteAIMemory(entry.id))) + } else { + await aiService.clearAIMemories({ + scopeType: activeView.value === 'preferences' ? 'global' : 'self', + scopeId: null, + }) } await loadMemories() toast.success(t('ai.global.memory.toast.cleared')) @@ -232,7 +240,10 @@ watch( :key="entry.id" class="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800" > -
+
-import { computed, ref, watch } from 'vue' +import { computed } from 'vue' import { useI18n } from 'vue-i18n' -import type { AIMemoryEntry, ContactItem } from '@openchatlab/shared-types' -import { useAIService } from '@/services' +import type { ContactItem } from '@openchatlab/shared-types' const props = defineProps<{ selectedKey: string | null @@ -19,11 +18,7 @@ const emit = defineEmits<{ unmarkFriend: [] }>() -const { t, locale } = useI18n() -const aiService = useAIService() -const memories = ref([]) -const isMemoryLoading = ref(false) -const memoryLoadFailed = ref(false) +const { t } = useI18n() const visibleAliases = computed(() => { const contact = props.contact @@ -39,45 +34,6 @@ 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 { - 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 } -)