diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 42622f1f..d38b0dd1 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -221,6 +221,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ aiContext: { aiDataDir, aiChatManager, + aiMemoryService, assistantManager, skillManagerCore, llmConfigStore, diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index 0cebdf85..83c6c79a 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -24,6 +24,7 @@ import type { PathProvider } from '@openchatlab/core' import type { DatabaseManager, AIChatManager, + AIMemoryService, AssistantManager, SkillManagerCore, LLMConfigStore, @@ -59,6 +60,7 @@ import { getServerAiLogger } from '../../../ai/logger' export interface AiContextOptions { aiDataDir: string aiChatManager: AIChatManager + aiMemoryService: AIMemoryService assistantManager: AssistantManager skillManagerCore: SkillManagerCore llmConfigStore: LLMConfigStore @@ -177,6 +179,7 @@ export function registerWebRoutes( ...(ai && { aiDataDir: ai.aiDataDir, aiChatManager: ai.aiChatManager, + aiMemoryService: ai.aiMemoryService, assistantManager: ai.assistantManager, skillManagerCore: ai.skillManagerCore, llmConfigStore: ai.llmConfigStore, diff --git a/apps/desktop/main/internal-api/server.ts b/apps/desktop/main/internal-api/server.ts index 707f90d7..2b765bb2 100644 --- a/apps/desktop/main/internal-api/server.ts +++ b/apps/desktop/main/internal-api/server.ts @@ -193,6 +193,7 @@ export async function startInternalServer( streamImport: electronStreamImport, aiDataDir, aiChatManager: getAIChatManager(), + aiMemoryService: newAIMemoryService, assistantManager: getAssistantManager(), skillManagerCore: getSkillManager(), llmConfigStore, diff --git a/packages/http-routes/src/context/ai.ts b/packages/http-routes/src/context/ai.ts index 8ea0619b..b9dd7df7 100644 --- a/packages/http-routes/src/context/ai.ts +++ b/packages/http-routes/src/context/ai.ts @@ -2,6 +2,7 @@ import type { AuthProfile } from '@openchatlab/config' import type { AgentStreamChunk, AIChatManager, + AIMemoryService, AssistantManager, CustomModelStore, CustomProviderStore, @@ -60,6 +61,7 @@ export interface AgentStreamRequest { export interface AiRouteContext { aiDataDir?: string aiChatManager?: AIChatManager + aiMemoryService?: AIMemoryService assistantManager?: AssistantManager skillManagerCore?: SkillManagerCore llmConfigStore?: LLMConfigStore diff --git a/packages/http-routes/src/routes/web/ai-memories.test.ts b/packages/http-routes/src/routes/web/ai-memories.test.ts new file mode 100644 index 00000000..77647deb --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-memories.test.ts @@ -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() + } + }) +}) diff --git a/packages/http-routes/src/routes/web/ai-memories.ts b/packages/http-routes/src/routes/web/ai-memories.ts new file mode 100644 index 00000000..c4436b62 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-memories.ts @@ -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 + +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) + } + }) +} diff --git a/packages/http-routes/src/routes/web/register-ai.test.ts b/packages/http-routes/src/routes/web/register-ai.test.ts index ec602d17..0b55d298 100644 --- a/packages/http-routes/src/routes/web/register-ai.test.ts +++ b/packages/http-routes/src/routes/web/register-ai.test.ts @@ -4,6 +4,7 @@ import Fastify from 'fastify' import type { PathProvider } from '@openchatlab/core' import type { AIChatManager, + AIMemoryService, AssistantManager, CustomModelStore, CustomProviderStore, @@ -28,6 +29,7 @@ function completeContext(): AiRoutesContext { ...baseContext(), aiDataDir: '/tmp/chatlab-ai-route-test', aiChatManager: {} as AIChatManager, + aiMemoryService: {} as AIMemoryService, assistantManager: {} as AssistantManager, skillManagerCore: {} as SkillManagerCore, llmConfigStore: {} as LLMConfigStore, @@ -42,6 +44,7 @@ test('registerAiRoutes identifies each missing required dependency', () => { const requiredDependencies = [ 'aiDataDir', 'aiChatManager', + 'aiMemoryService', 'assistantManager', 'skillManagerCore', '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' }) 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' }) assert.equal(semanticStatus.statusCode, 200) assert.deepEqual(semanticStatus.json(), { status: null }) diff --git a/packages/http-routes/src/routes/web/register-ai.ts b/packages/http-routes/src/routes/web/register-ai.ts index ab1ef9b1..4801eeb7 100644 --- a/packages/http-routes/src/routes/web/register-ai.ts +++ b/packages/http-routes/src/routes/web/register-ai.ts @@ -9,6 +9,7 @@ import { registerAiChatTopicRoutes } from './ai-chat-topics' import { registerAiLlmStreamRoutes } from './ai-llm-stream' import { registerAiLlmRoutes } from './ai-llm' import { registerAiLogRoutes } from './ai-logs' +import { registerAiMemoryRoutes } from './ai-memories' import { registerSemanticIndexRoutes } from './ai-semantic-index' import { registerAiSkillRoutes } from './ai-skills' import { registerAiSummaryRoutes } from './ai-summaries' @@ -26,6 +27,7 @@ export interface AiRouteOptions { const REQUIRED_AI_DEPENDENCIES = [ 'aiDataDir', 'aiChatManager', + 'aiMemoryService', 'assistantManager', 'skillManagerCore', 'llmConfigStore', @@ -52,6 +54,7 @@ export function registerAiRoutes(server: FastifyInstance, ctx: AiRoutesContext, registerAiAgentStreamRoutes(server, ctx) registerAiToolRoutes(server, ctx) registerAiChatRoutes(server, ctx) + registerAiMemoryRoutes(server, ctx) registerAiChatTopicRoutes(server, ctx) registerAiSummaryRoutes(server, ctx) registerAiLogRoutes(server, ctx) diff --git a/src/services/ai/fetch.ts b/src/services/ai/fetch.ts index 18e3c95e..60fb1205 100644 --- a/src/services/ai/fetch.ts +++ b/src/services/ai/fetch.ts @@ -20,6 +20,10 @@ import type { ToolCatalogEntry, ToolExecuteResult, DesensitizeRule, + AIMemoryEntry, + AIMemoryScope, + CreateUserAIMemoryInput, + ClearAIMemoriesInput, } from './types' import { get, post, put, del, fetchWithAuth } from '../utils/http' @@ -59,6 +63,30 @@ export class FetchAIAdapter implements AIAdapter { return del(`/ai/chats/${aiChatId}`) } + // ===== 长期记忆 ===== + async getAIMemories(scope?: AIMemoryScope): Promise { + if (!scope) return get('/ai/memories') + const params = new URLSearchParams({ scopeType: scope.scopeType }) + if (scope.scopeId) params.set('scopeId', scope.scopeId) + return get(`/ai/memories?${params.toString()}`) + } + + async createAIMemory(input: CreateUserAIMemoryInput): Promise { + return post('/ai/memories', input) + } + + async updateAIMemory(id: string, content: string): Promise { + return put(`/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 { return get(`/ai/chats/${aiChatId}/messages`) diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts index 8cf89099..cc8cb449 100644 --- a/src/services/ai/types.ts +++ b/src/services/ai/types.ts @@ -1,6 +1,6 @@ import type { TimeFilter } from '@/types/base' 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' export interface AIChat { @@ -14,7 +14,10 @@ export interface AIChat { 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 = | { type: 'text'; text: string; processDurationMs?: number } @@ -129,6 +132,13 @@ export interface AIAdapter { updateAIChatTitle(aiChatId: string, title: string): Promise deleteAIChat(aiChatId: string): Promise + // ===== 长期记忆 ===== + getAIMemories(scope?: AIMemoryScope): Promise + createAIMemory(input: CreateUserAIMemoryInput): Promise + updateAIMemory(id: string, content: string): Promise + deleteAIMemory(id: string): Promise<{ success: boolean }> + clearAIMemories(input: ClearAIMemoriesInput): Promise<{ success: boolean; cleared: number }> + // ===== 消息 ===== getMessages(aiChatId: string): Promise addMessage(