mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 10:14:35 +08:00
feat(ai): add self memory management
This commit is contained in:
1 parent
2a4ab0d2ae
commit
85bf46b168
19 files changed
+249
-163
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<CrossChatToolExecutionContext> = {
|
||||
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<string, unknown>): 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<void> {
|
||||
if (scope.scopeType === 'global') return
|
||||
if (scope.scopeType === 'global' || scope.scopeType === 'self') return
|
||||
|
||||
if (context.entityRefs?.length) {
|
||||
const matchesCurrentSelection = context.entityRefs.some((ref) =>
|
||||
|
||||
@@ -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<void> {
|
||||
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"
|
||||
>
|
||||
<div v-if="entry.scopeType !== 'global'" class="mb-2 flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<div
|
||||
v-if="entry.scopeType === 'contact' || entry.scopeType === 'group'"
|
||||
class="mb-2 flex items-center gap-1.5 text-xs text-gray-500"
|
||||
>
|
||||
<UIcon
|
||||
:name="entry.scopeType === 'contact' ? 'i-lucide-user-round' : 'i-lucide-users-round'"
|
||||
class="h-3.5 w-3.5"
|
||||
|
||||
@@ -282,14 +282,15 @@
|
||||
"memory": {
|
||||
"button": "Memory",
|
||||
"title": "Long-term memory",
|
||||
"description": "Manage preferences and relationship context that AI can use across conversations.",
|
||||
"description": "Manage preferences, personal context, and relationship memories that AI can use across conversations.",
|
||||
"preferences": "Preferences",
|
||||
"self": "About me",
|
||||
"entities": "Contacts and groups",
|
||||
"count": "{count} items",
|
||||
"empty": "No memories yet. You can ask AI to remember something in a conversation.",
|
||||
"proactive": {
|
||||
"title": "Allow proactive AI memory",
|
||||
"description": "Let AI save long-term preferences and relationship insights. Explicit requests to remember still work when this is off."
|
||||
"description": "Let AI save long-term preferences, personal context, and relationship insights. Explicit requests to remember still work when this is off."
|
||||
},
|
||||
"source": {
|
||||
"user": "User confirmed",
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
"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"
|
||||
|
||||
@@ -282,14 +282,15 @@
|
||||
"memory": {
|
||||
"button": "メモリ",
|
||||
"title": "長期メモリ",
|
||||
"description": "会話をまたいで AI が利用する設定や関係性の背景を管理します。",
|
||||
"description": "会話をまたいで AI が利用する設定、本人の背景、関係性のメモリを管理します。",
|
||||
"preferences": "設定",
|
||||
"self": "自分について",
|
||||
"entities": "連絡先とグループ",
|
||||
"count": "{count} 件",
|
||||
"empty": "メモリはまだありません。会話で AI に覚えるよう依頼できます。",
|
||||
"proactive": {
|
||||
"title": "AI による自発的な記憶を許可",
|
||||
"description": "長期的に役立つ設定や関係性の知見を AI が保存できます。オフでも、明示的に記憶を依頼した内容は保存されます。"
|
||||
"description": "長期的に役立つ設定、本人の背景、関係性の知見を AI が保存できます。オフでも、明示的に記憶を依頼した内容は保存されます。"
|
||||
},
|
||||
"source": {
|
||||
"user": "ユーザー確認済み",
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
"scoreHelp": "総合スコアは連絡先の並び順に使われます。スコアが高いほど、やり取りのシグナルが強いことを示します。",
|
||||
"lastInteraction": "最終インタラクション",
|
||||
"sources": "元チャット",
|
||||
"memories": "メモリ",
|
||||
"memoryLoadFailed": "メモリを読み込めませんでした",
|
||||
"sourceType": {
|
||||
"private": "個人",
|
||||
"group": "グループ"
|
||||
|
||||
@@ -282,14 +282,15 @@
|
||||
"memory": {
|
||||
"button": "记忆",
|
||||
"title": "长期记忆",
|
||||
"description": "管理 AI 在不同对话间持续使用的偏好和关系背景。",
|
||||
"description": "管理 AI 在不同对话间持续使用的偏好、个人背景和关系记忆。",
|
||||
"preferences": "偏好",
|
||||
"self": "关于我",
|
||||
"entities": "联系人和群聊",
|
||||
"count": "{count} 条",
|
||||
"empty": "暂无记忆,你可以在对话中让 AI 记住。",
|
||||
"proactive": {
|
||||
"title": "允许 AI 主动记忆",
|
||||
"description": "允许 AI 保存有长期价值的偏好和关系结论;关闭后仍会保存你明确要求记住的内容。"
|
||||
"description": "允许 AI 保存有长期价值的偏好、个人背景和关系结论;关闭后仍会保存你明确要求记住的内容。"
|
||||
},
|
||||
"source": {
|
||||
"user": "用户确认",
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
"scoreHelp": "综合分用于联系人排序。分数越高,代表互动信号越强。",
|
||||
"lastInteraction": "最近互动",
|
||||
"sources": "来源会话",
|
||||
"memories": "记忆",
|
||||
"memoryLoadFailed": "加载记忆失败",
|
||||
"sourceType": {
|
||||
"private": "私聊",
|
||||
"group": "群聊"
|
||||
|
||||
@@ -282,14 +282,15 @@
|
||||
"memory": {
|
||||
"button": "記憶",
|
||||
"title": "長期記憶",
|
||||
"description": "管理 AI 在不同對話間持續使用的偏好與關係背景。",
|
||||
"description": "管理 AI 在不同對話間持續使用的偏好、個人背景與關係記憶。",
|
||||
"preferences": "偏好",
|
||||
"self": "關於我",
|
||||
"entities": "聯絡人和群聊",
|
||||
"count": "{count} 條",
|
||||
"empty": "暫無記憶,你可以在對話中讓 AI 記住。",
|
||||
"proactive": {
|
||||
"title": "允許 AI 主動記憶",
|
||||
"description": "允許 AI 儲存有長期價值的偏好與關係結論;關閉後仍會儲存你明確要求記住的內容。"
|
||||
"description": "允許 AI 儲存有長期價值的偏好、個人背景與關係結論;關閉後仍會儲存你明確要求記住的內容。"
|
||||
},
|
||||
"source": {
|
||||
"user": "使用者確認",
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
"scoreHelp": "綜合分用於聯絡人排序。分數越高,代表互動訊號越強。",
|
||||
"lastInteraction": "最近互動",
|
||||
"sources": "來源會話",
|
||||
"memories": "記憶",
|
||||
"memoryLoadFailed": "載入記憶失敗",
|
||||
"sourceType": {
|
||||
"private": "私聊",
|
||||
"group": "群聊"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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<AIMemoryEntry[]>([])
|
||||
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<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>
|
||||
@@ -224,53 +180,6 @@ watch(
|
||||
</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>
|
||||
|
||||
Reference in new issue
Block a user