feat(ai): expose global memory management api

This commit is contained in:
digua committed 2026-08-24 22:35:47 +08:00
1 parent ed8ab84b4b
commit 2938c42f9f
10 files changed
+375 -2

No files matched your search

+1
View File
@@ -221,6 +221,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
aiContext: { aiContext: {
aiDataDir, aiDataDir,
aiChatManager, aiChatManager,
aiMemoryService,
assistantManager, assistantManager,
skillManagerCore, skillManagerCore,
llmConfigStore, llmConfigStore,
+3
View File
@@ -24,6 +24,7 @@ import type { PathProvider } from '@openchatlab/core'
import type { import type {
DatabaseManager, DatabaseManager,
AIChatManager, AIChatManager,
AIMemoryService,
AssistantManager, AssistantManager,
SkillManagerCore, SkillManagerCore,
LLMConfigStore, LLMConfigStore,
@@ -59,6 +60,7 @@ import { getServerAiLogger } from '../../../ai/logger'
export interface AiContextOptions { export interface AiContextOptions {
aiDataDir: string aiDataDir: string
aiChatManager: AIChatManager aiChatManager: AIChatManager
aiMemoryService: AIMemoryService
assistantManager: AssistantManager assistantManager: AssistantManager
skillManagerCore: SkillManagerCore skillManagerCore: SkillManagerCore
llmConfigStore: LLMConfigStore llmConfigStore: LLMConfigStore
@@ -177,6 +179,7 @@ export function registerWebRoutes(
...(ai && { ...(ai && {
aiDataDir: ai.aiDataDir, aiDataDir: ai.aiDataDir,
aiChatManager: ai.aiChatManager, aiChatManager: ai.aiChatManager,
aiMemoryService: ai.aiMemoryService,
assistantManager: ai.assistantManager, assistantManager: ai.assistantManager,
skillManagerCore: ai.skillManagerCore, skillManagerCore: ai.skillManagerCore,
llmConfigStore: ai.llmConfigStore, llmConfigStore: ai.llmConfigStore,
+1
View File
@@ -193,6 +193,7 @@ export async function startInternalServer(
streamImport: electronStreamImport, streamImport: electronStreamImport,
aiDataDir, aiDataDir,
aiChatManager: getAIChatManager(), aiChatManager: getAIChatManager(),
aiMemoryService: newAIMemoryService,
assistantManager: getAssistantManager(), assistantManager: getAssistantManager(),
skillManagerCore: getSkillManager(), skillManagerCore: getSkillManager(),
llmConfigStore, llmConfigStore,
+2
View File
@@ -2,6 +2,7 @@ import type { AuthProfile } from '@openchatlab/config'
import type { import type {
AgentStreamChunk, AgentStreamChunk,
AIChatManager, AIChatManager,
AIMemoryService,
AssistantManager, AssistantManager,
CustomModelStore, CustomModelStore,
CustomProviderStore, CustomProviderStore,
@@ -60,6 +61,7 @@ export interface AgentStreamRequest {
export interface AiRouteContext { export interface AiRouteContext {
aiDataDir?: string aiDataDir?: string
aiChatManager?: AIChatManager aiChatManager?: AIChatManager
aiMemoryService?: AIMemoryService
assistantManager?: AssistantManager assistantManager?: AssistantManager
skillManagerCore?: SkillManagerCore skillManagerCore?: SkillManagerCore
llmConfigStore?: LLMConfigStore llmConfigStore?: LLMConfigStore
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it } from 'node:test'
import Fastify from 'fastify'
import { AIMemoryService } from '@openchatlab/node-runtime'
import { registerAiMemoryRoutes } from './ai-memories'
const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING
function createFixture() {
const dir = mkdtempSync(join(tmpdir(), 'chatlab-ai-memory-routes-'))
const service = new AIMemoryService(dir, { nativeBinding: sqliteNativeBinding })
const app = Fastify()
registerAiMemoryRoutes(app, { aiMemoryService: service })
return {
app,
service,
async close() {
await app.close()
service.close()
try {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
} catch {
// Windows can hold SQLite WAL handles briefly after close; temp cleanup is best-effort.
}
},
}
}
describe('AI memory routes', () => {
it('creates user memories and lists exact scopes', async () => {
const fixture = createFixture()
try {
const globalResponse = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories',
payload: {
scopeType: 'global',
scopeId: null,
content: ' 最近默认按 90 天 ',
sourceType: 'ai',
},
})
assert.equal(globalResponse.statusCode, 200)
assert.equal(globalResponse.json().content, '最近默认按 90 天')
assert.equal(globalResponse.json().sourceType, 'user')
await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories',
payload: { scopeType: 'contact', scopeId: 'contact-a', content: '大学同学' },
})
await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories',
payload: { scopeType: 'contact', scopeId: 'contact-b', content: '前同事' },
})
const all = await fixture.app.inject({ method: 'GET', url: '/_web/ai/memories' })
assert.equal(all.statusCode, 200)
assert.equal(all.json().length, 3)
const filtered = await fixture.app.inject({
method: 'GET',
url: '/_web/ai/memories?scopeType=contact&scopeId=contact-a',
})
assert.equal(filtered.statusCode, 200)
assert.deepEqual(
filtered.json().map((entry: { content: string }) => entry.content),
['大学同学']
)
} finally {
await fixture.close()
}
})
it('updates, deletes, and clears memories without letting clients spoof the source', async () => {
const fixture = createFixture()
try {
const created = fixture.service.create({
scopeType: 'group',
scopeId: 'group-a',
content: '旧结论',
sourceType: 'ai',
sourceAIChatId: 'chat-a',
})
fixture.service.create({
scopeType: 'group',
scopeId: 'group-b',
content: '另一个群',
sourceType: 'user',
})
const updated = await fixture.app.inject({
method: 'PUT',
url: `/_web/ai/memories/${created.id}`,
payload: { content: '用户确认后的结论', sourceType: 'ai' },
})
assert.equal(updated.statusCode, 200)
assert.deepEqual(
{
scopeType: updated.json().scopeType,
scopeId: updated.json().scopeId,
content: updated.json().content,
sourceType: updated.json().sourceType,
sourceAIChatId: updated.json().sourceAIChatId,
},
{
scopeType: 'group',
scopeId: 'group-a',
content: '用户确认后的结论',
sourceType: 'user',
sourceAIChatId: null,
}
)
const cleared = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/clear',
payload: { scopeType: 'group', scopeId: 'group-a' },
})
assert.equal(cleared.statusCode, 200)
assert.deepEqual(cleared.json(), { success: true, cleared: 1 })
assert.deepEqual(
fixture.service.list().map((entry) => entry.scopeId),
['group-b']
)
const remainingId = fixture.service.list()[0]?.id
assert.ok(remainingId)
const deleted = await fixture.app.inject({ method: 'DELETE', url: `/_web/ai/memories/${remainingId}` })
assert.equal(deleted.statusCode, 200)
assert.deepEqual(deleted.json(), { success: true })
const missing = await fixture.app.inject({ method: 'DELETE', url: '/_web/ai/memories/missing' })
assert.equal(missing.statusCode, 404)
} finally {
await fixture.close()
}
})
it('requires explicit scope or all confirmation before clearing', async () => {
const fixture = createFixture()
try {
fixture.service.create({ scopeType: 'global', scopeId: null, content: '先给结论', sourceType: 'user' })
fixture.service.create({
scopeType: 'contact',
scopeId: 'contact-a',
content: '大学同学',
sourceType: 'user',
})
const implicit = await fixture.app.inject({ method: 'POST', url: '/_web/ai/memories/clear', payload: {} })
assert.equal(implicit.statusCode, 400)
assert.equal(fixture.service.list().length, 2)
const ambiguous = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/clear',
payload: { all: true, scopeType: 'global' },
})
assert.equal(ambiguous.statusCode, 400)
assert.equal(fixture.service.list().length, 2)
const explicit = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/clear',
payload: { all: true },
})
assert.equal(explicit.statusCode, 200)
assert.deepEqual(explicit.json(), { success: true, cleared: 2 })
assert.deepEqual(fixture.service.list(), [])
} finally {
await fixture.close()
}
})
it('rejects invalid scope and content instead of changing stored memories', async () => {
const fixture = createFixture()
try {
const invalidScope = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories',
payload: { scopeType: 'contact', content: 'missing id' },
})
assert.equal(invalidScope.statusCode, 400)
const invalidQuery = await fixture.app.inject({
method: 'GET',
url: '/_web/ai/memories?scopeId=contact-a',
})
assert.equal(invalidQuery.statusCode, 400)
const invalidContent = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories',
payload: { scopeType: 'global', content: ' ' },
})
assert.equal(invalidContent.statusCode, 400)
assert.deepEqual(fixture.service.list(), [])
} finally {
await fixture.close()
}
})
})
@@ -0,0 +1,111 @@
import type { FastifyInstance, FastifyReply } from 'fastify'
import type { AIMemoryScope, AIMemoryScopeType } from '@openchatlab/shared-types'
import type { AiRouteContext } from '../../context/ai'
type AiMemoryRouteContext = Pick<AiRouteContext, 'aiMemoryService'>
interface MemoryScopeInput {
scopeType?: string
scopeId?: string | null
}
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')
}
return {
scopeType: scopeType as AIMemoryScopeType,
scopeId: typeof input.scopeId === 'string' ? input.scopeId : null,
}
}
function sendBadRequest(reply: FastifyReply, error: unknown) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) })
}
export function registerAiMemoryRoutes(server: FastifyInstance, ctx: AiMemoryRouteContext): void {
const memoryService = ctx.aiMemoryService
if (!memoryService) return
server.get<{
Querystring: MemoryScopeInput
}>('/_web/ai/memories', async (request, reply) => {
try {
const { scopeType, scopeId } = request.query
if (!scopeType) {
if (scopeId != null) throw new Error('scopeType is required when scopeId is provided')
return memoryService.list()
}
return memoryService.list(parseScope({ scopeType, scopeId }))
} catch (error) {
return sendBadRequest(reply, error)
}
})
server.post<{
Body: MemoryScopeInput & { content?: string; sourceType?: string }
}>('/_web/ai/memories', async (request, reply) => {
try {
const scope = parseScope(request.body ?? {})
return memoryService.create({
...scope,
content: request.body?.content ?? '',
sourceType: 'user',
sourceAIChatId: null,
sourceMessageId: null,
})
} catch (error) {
return sendBadRequest(reply, error)
}
})
server.put<{
Params: { id: string }
Body: { content?: string; sourceType?: string }
}>('/_web/ai/memories/:id', async (request, reply) => {
if (!memoryService.get(request.params.id)) {
return reply.code(404).send({ error: 'AI memory not found' })
}
try {
return memoryService.update(request.params.id, {
content: request.body?.content ?? '',
sourceType: 'user',
sourceAIChatId: null,
sourceMessageId: null,
})
} catch (error) {
return sendBadRequest(reply, error)
}
})
server.delete<{
Params: { id: string }
}>('/_web/ai/memories/:id', async (request, reply) => {
if (!memoryService.forget(request.params.id)) {
return reply.code(404).send({ error: 'AI memory not found' })
}
return { success: true }
})
server.post<{
Body: MemoryScopeInput & { all?: boolean }
}>('/_web/ai/memories/clear', async (request, reply) => {
const { all, scopeType, scopeId } = request.body ?? {}
if (all === true) {
if (scopeType || scopeId != null) {
return reply.code(400).send({ error: 'all cannot be combined with a scope' })
}
return { success: true, cleared: memoryService.clear() }
}
if (!scopeType) {
return reply.code(400).send({ error: 'A scope or all: true is required' })
}
try {
return { success: true, cleared: memoryService.clear(parseScope({ scopeType, scopeId })) }
} catch (error) {
return sendBadRequest(reply, error)
}
})
}
@@ -4,6 +4,7 @@ import Fastify from 'fastify'
import type { PathProvider } from '@openchatlab/core' import type { PathProvider } from '@openchatlab/core'
import type { import type {
AIChatManager, AIChatManager,
AIMemoryService,
AssistantManager, AssistantManager,
CustomModelStore, CustomModelStore,
CustomProviderStore, CustomProviderStore,
@@ -28,6 +29,7 @@ function completeContext(): AiRoutesContext {
...baseContext(), ...baseContext(),
aiDataDir: '/tmp/chatlab-ai-route-test', aiDataDir: '/tmp/chatlab-ai-route-test',
aiChatManager: {} as AIChatManager, aiChatManager: {} as AIChatManager,
aiMemoryService: {} as AIMemoryService,
assistantManager: {} as AssistantManager, assistantManager: {} as AssistantManager,
skillManagerCore: {} as SkillManagerCore, skillManagerCore: {} as SkillManagerCore,
llmConfigStore: {} as LLMConfigStore, llmConfigStore: {} as LLMConfigStore,
@@ -42,6 +44,7 @@ test('registerAiRoutes identifies each missing required dependency', () => {
const requiredDependencies = [ const requiredDependencies = [
'aiDataDir', 'aiDataDir',
'aiChatManager', 'aiChatManager',
'aiMemoryService',
'assistantManager', 'assistantManager',
'skillManagerCore', 'skillManagerCore',
'llmConfigStore', 'llmConfigStore',
@@ -72,6 +75,9 @@ test('registerAiRoutes keeps static and graceful fallback routes without AI mana
const assistants = await app.inject({ method: 'GET', url: '/_web/ai/assistants' }) const assistants = await app.inject({ method: 'GET', url: '/_web/ai/assistants' })
assert.equal(assistants.statusCode, 404) assert.equal(assistants.statusCode, 404)
const memories = await app.inject({ method: 'GET', url: '/_web/ai/memories' })
assert.equal(memories.statusCode, 404)
const semanticStatus = await app.inject({ method: 'GET', url: '/_web/ai/semantic-index/status' }) const semanticStatus = await app.inject({ method: 'GET', url: '/_web/ai/semantic-index/status' })
assert.equal(semanticStatus.statusCode, 200) assert.equal(semanticStatus.statusCode, 200)
assert.deepEqual(semanticStatus.json(), { status: null }) assert.deepEqual(semanticStatus.json(), { status: null })
@@ -9,6 +9,7 @@ import { registerAiChatTopicRoutes } from './ai-chat-topics'
import { registerAiLlmStreamRoutes } from './ai-llm-stream' import { registerAiLlmStreamRoutes } from './ai-llm-stream'
import { registerAiLlmRoutes } from './ai-llm' import { registerAiLlmRoutes } from './ai-llm'
import { registerAiLogRoutes } from './ai-logs' import { registerAiLogRoutes } from './ai-logs'
import { registerAiMemoryRoutes } from './ai-memories'
import { registerSemanticIndexRoutes } from './ai-semantic-index' import { registerSemanticIndexRoutes } from './ai-semantic-index'
import { registerAiSkillRoutes } from './ai-skills' import { registerAiSkillRoutes } from './ai-skills'
import { registerAiSummaryRoutes } from './ai-summaries' import { registerAiSummaryRoutes } from './ai-summaries'
@@ -26,6 +27,7 @@ export interface AiRouteOptions {
const REQUIRED_AI_DEPENDENCIES = [ const REQUIRED_AI_DEPENDENCIES = [
'aiDataDir', 'aiDataDir',
'aiChatManager', 'aiChatManager',
'aiMemoryService',
'assistantManager', 'assistantManager',
'skillManagerCore', 'skillManagerCore',
'llmConfigStore', 'llmConfigStore',
@@ -52,6 +54,7 @@ export function registerAiRoutes(server: FastifyInstance, ctx: AiRoutesContext,
registerAiAgentStreamRoutes(server, ctx) registerAiAgentStreamRoutes(server, ctx)
registerAiToolRoutes(server, ctx) registerAiToolRoutes(server, ctx)
registerAiChatRoutes(server, ctx) registerAiChatRoutes(server, ctx)
registerAiMemoryRoutes(server, ctx)
registerAiChatTopicRoutes(server, ctx) registerAiChatTopicRoutes(server, ctx)
registerAiSummaryRoutes(server, ctx) registerAiSummaryRoutes(server, ctx)
registerAiLogRoutes(server, ctx) registerAiLogRoutes(server, ctx)
+28
View File
@@ -20,6 +20,10 @@ import type {
ToolCatalogEntry, ToolCatalogEntry,
ToolExecuteResult, ToolExecuteResult,
DesensitizeRule, DesensitizeRule,
AIMemoryEntry,
AIMemoryScope,
CreateUserAIMemoryInput,
ClearAIMemoriesInput,
} from './types' } from './types'
import { get, post, put, del, fetchWithAuth } from '../utils/http' import { get, post, put, del, fetchWithAuth } from '../utils/http'
@@ -59,6 +63,30 @@ export class FetchAIAdapter implements AIAdapter {
return del<boolean>(`/ai/chats/${aiChatId}`) return del<boolean>(`/ai/chats/${aiChatId}`)
} }
// ===== 长期记忆 =====
async getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryEntry[]> {
if (!scope) return get<AIMemoryEntry[]>('/ai/memories')
const params = new URLSearchParams({ scopeType: scope.scopeType })
if (scope.scopeId) params.set('scopeId', scope.scopeId)
return get<AIMemoryEntry[]>(`/ai/memories?${params.toString()}`)
}
async createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryEntry> {
return post<AIMemoryEntry>('/ai/memories', input)
}
async updateAIMemory(id: string, content: string): Promise<AIMemoryEntry> {
return put<AIMemoryEntry>(`/ai/memories/${id}`, { content })
}
async deleteAIMemory(id: string): Promise<{ success: boolean }> {
return del<{ success: boolean }>(`/ai/memories/${id}`)
}
async clearAIMemories(input: ClearAIMemoriesInput): Promise<{ success: boolean; cleared: number }> {
return post<{ success: boolean; cleared: number }>('/ai/memories/clear', input)
}
// ===== 消息 ===== // ===== 消息 =====
async getMessages(aiChatId: string): Promise<AIMessage[]> { async getMessages(aiChatId: string): Promise<AIMessage[]> {
return get<AIMessage[]>(`/ai/chats/${aiChatId}/messages`) return get<AIMessage[]>(`/ai/chats/${aiChatId}/messages`)
+12 -2
View File
@@ -1,6 +1,6 @@
import type { TimeFilter } from '@/types/base' import type { TimeFilter } from '@/types/base'
import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core' import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core'
import type { AIEntityRef, CrossChatEvidencePayload } from '@openchatlab/shared-types' import type { AIEntityRef, AIMemoryEntry, AIMemoryScope, CrossChatEvidencePayload } from '@openchatlab/shared-types'
import type { PlanContentBlock } from './planBlocks' import type { PlanContentBlock } from './planBlocks'
export interface AIChat { export interface AIChat {
@@ -14,7 +14,10 @@ export interface AIChat {
updatedAt: number updatedAt: number
} }
export type { AIEntityRef } from '@openchatlab/shared-types' export type { AIEntityRef, AIMemoryEntry, AIMemoryScope } from '@openchatlab/shared-types'
export type CreateUserAIMemoryInput = AIMemoryScope & { content: string }
export type ClearAIMemoriesInput = AIMemoryScope | { all: true }
export type ContentBlock = export type ContentBlock =
| { type: 'text'; text: string; processDurationMs?: number } | { type: 'text'; text: string; processDurationMs?: number }
@@ -129,6 +132,13 @@ export interface AIAdapter {
updateAIChatTitle(aiChatId: string, title: string): Promise<boolean> updateAIChatTitle(aiChatId: string, title: string): Promise<boolean>
deleteAIChat(aiChatId: string): Promise<boolean> deleteAIChat(aiChatId: string): Promise<boolean>
// ===== 长期记忆 =====
getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryEntry[]>
createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryEntry>
updateAIMemory(id: string, content: string): Promise<AIMemoryEntry>
deleteAIMemory(id: string): Promise<{ success: boolean }>
clearAIMemories(input: ClearAIMemoriesInput): Promise<{ success: boolean; cleared: number }>
// ===== 消息 ===== // ===== 消息 =====
getMessages(aiChatId: string): Promise<AIMessage[]> getMessages(aiChatId: string): Promise<AIMessage[]>
addMessage( addMessage(