feat(ai): link memories to source messages

This commit is contained in:
digua committed 2026-08-26 22:23:04 +08:00
1 parent cc240c85f5
commit f4017b0adf
34 files changed
+1278 -72

No files matched your search

+1
View File
@@ -160,6 +160,7 @@ export function createCliRunAgentStream(
maxToolResultTokens: resolveCrossChatToolResultTokenBudget(contextWindow),
memoryService: options.memoryService,
aiChatId,
reportMemoryChange: (memoryId) => onEvent({ type: 'memory_change', memoryId }),
})
await runCrossChatAgent({
userMessage,
@@ -23,6 +23,7 @@ export function createCliCrossChatTools(options: {
maxToolResultTokens: number
memoryService: AIMemoryService
aiChatId: string
reportMemoryChange?: (memoryId: string) => void
}): AgentTool<any, any>[] {
const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = {
locale: options.locale,
@@ -30,6 +31,7 @@ export function createCliCrossChatTools(options: {
analysisService: options.analysisService,
memoryService: options.memoryService,
aiChatId: options.aiChatId,
reportMemoryChange: options.reportMemoryChange,
maxToolResultTokens: options.maxToolResultTokens,
countTokens,
preprocessMessagesBySession: (sessionId, messages) =>
@@ -155,6 +155,7 @@ export function createElectronRunAgentStream(
maxToolResultTokens: resolveCrossChatToolResultTokenBudget(resolvedContextWindow),
memoryService: crossChatOptions.memoryService,
aiChatId,
reportMemoryChange: (memoryId) => onEvent({ type: 'memory_change', memoryId }),
})
await runCrossChatAgent({
userMessage,
@@ -23,6 +23,7 @@ export function createElectronCrossChatTools(options: {
maxToolResultTokens: number
memoryService: AIMemoryService
aiChatId: string
reportMemoryChange?: (memoryId: string) => void
}): AgentTool<any, any>[] {
const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = {
locale: options.locale,
@@ -30,6 +31,7 @@ export function createElectronCrossChatTools(options: {
analysisService: options.analysisService,
memoryService: options.memoryService,
aiChatId: options.aiChatId,
reportMemoryChange: options.reportMemoryChange,
maxToolResultTokens: options.maxToolResultTokens,
countTokens,
preprocessMessagesBySession: (sessionId, messages) =>
@@ -1,20 +1,28 @@
import type { FastifyInstance } from 'fastify'
import { randomUUID } from 'node:crypto'
import type { AgentStreamRequest, AiRouteContext } from '../../context/ai'
import { chatTopicWorkCoordinator } from '@openchatlab/node-runtime'
import type { MemoryProvenanceCoordinator } from './memory-provenance-coordinator'
const activeAgentAborts = new Map<string, AbortController>()
type AiAgentStreamRouteContext = Pick<AiRouteContext, 'runAgentStream'>
type AiAgentStreamRouteContext = Pick<AiRouteContext, 'runAgentStream' | 'aiChatManager'>
export function registerAiAgentStreamRoutes(server: FastifyInstance, ctx: AiAgentStreamRouteContext): void {
export function registerAiAgentStreamRoutes(
server: FastifyInstance,
ctx: AiAgentStreamRouteContext,
memoryProvenanceCoordinator?: MemoryProvenanceCoordinator
): void {
if (!ctx.runAgentStream) return
const runAgentStream = ctx.runAgentStream
server.post<{ Body: AgentStreamRequest }>('/_web/ai/agent/stream', async (request, reply) => {
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const requestId = `req_${randomUUID()}`
const abortController = new AbortController()
activeAgentAborts.set(requestId, abortController)
const expectedParentMessageId = ctx.aiChatManager?.getAIChat(request.body.aiChatId)?.activeMessageId ?? null
memoryProvenanceCoordinator?.begin(requestId, request.body.aiChatId, expectedParentMessageId)
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -50,6 +58,11 @@ export function registerAiAgentStreamRoutes(server: FastifyInstance, ctx: AiAgen
request.body,
(chunk) => {
if (chunk.type === 'done') emittedDone = true
if (chunk.type === 'memory_change' && chunk.memoryId) {
memoryProvenanceCoordinator?.record(requestId, chunk.memoryId)
safeSendSSE(chunk.type, { ...chunk, provenanceToken: requestId })
return
}
safeSendSSE(chunk.type, chunk)
},
abortController.signal
@@ -58,6 +71,7 @@ export function registerAiAgentStreamRoutes(server: FastifyInstance, ctx: AiAgen
const msg = error instanceof Error ? error.message : String(error)
safeSendSSE('error', { type: 'error', error: { name: 'ServerError', message: msg } })
} finally {
memoryProvenanceCoordinator?.complete(requestId)
releaseInteractiveWork()
if (!emittedDone) safeSendSSE('done', { type: 'done', isFinished: true })
activeAgentAborts.delete(requestId)
@@ -4,23 +4,29 @@ 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 { AIChatManager, AIMemoryService } from '@openchatlab/node-runtime'
import { registerAiMemoryRoutes } from './ai-memories'
import { MemoryProvenanceCoordinator } from './memory-provenance-coordinator'
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 aiChatManager = new AIChatManager(dir, { nativeBinding: sqliteNativeBinding })
const memoryProvenanceCoordinator = new MemoryProvenanceCoordinator()
const app = Fastify()
registerAiMemoryRoutes(app, { aiMemoryService: service })
registerAiMemoryRoutes(app, { aiMemoryService: service, aiChatManager }, memoryProvenanceCoordinator)
return {
app,
service,
aiChatManager,
memoryProvenanceCoordinator,
async close() {
await app.close()
service.close()
aiChatManager.close()
try {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
} catch {
@@ -199,6 +205,147 @@ describe('AI memory routes', () => {
}
})
it('links only memories and persisted message roles owned by the requested global conversation', async () => {
const fixture = createFixture()
try {
const chat = fixture.aiChatManager.createGlobalAIChat('source', 'general_cn')
const otherChat = fixture.aiChatManager.createGlobalAIChat('other', 'general_cn')
const userMessage = fixture.aiChatManager.addMessage(chat.id, 'user', '请记住')
const assistantMessage = fixture.aiChatManager.addMessage(chat.id, 'assistant', '已经记住')
const memory = fixture.service.create({
scopeType: 'global',
scopeId: null,
content: '先给结论',
sourceType: 'user',
sourceAIChatId: chat.id,
})
const provenanceToken = 'test-turn'
fixture.memoryProvenanceCoordinator.begin(provenanceToken, chat.id, userMessage.parentId ?? null)
fixture.memoryProvenanceCoordinator.record(provenanceToken, memory.id)
fixture.memoryProvenanceCoordinator.complete(provenanceToken)
const linked = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/link-sources',
payload: {
provenanceToken,
aiChatId: chat.id,
userMessageId: userMessage.id,
assistantMessageId: assistantMessage.id,
memoryIds: [memory.id],
},
})
assert.equal(linked.statusCode, 200)
assert.deepEqual(linked.json(), { linkedMemoryIds: [memory.id], skippedMemoryIds: [] })
const listed = await fixture.app.inject({ method: 'GET', url: '/_web/ai/memories' })
assert.equal(listed.json()[0].sourceStatus, 'message')
assert.equal(listed.json()[0].sourceMessageId, userMessage.id)
const forged = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/link-sources',
payload: {
provenanceToken,
aiChatId: otherChat.id,
userMessageId: userMessage.id,
assistantMessageId: assistantMessage.id,
memoryIds: [memory.id],
},
})
assert.equal(forged.statusCode, 400)
assert.equal(fixture.service.get(memory.id)?.sourceMessageId, userMessage.id)
} finally {
await fixture.close()
}
})
it('binds source backfill to one completed agent turn and its exact memory change set', async () => {
const fixture = createFixture()
try {
const chat = fixture.aiChatManager.createGlobalAIChat('source', 'general_cn')
const previousUser = fixture.aiChatManager.addMessage(chat.id, 'user', 'previous')
const previousAssistant = fixture.aiChatManager.addMessage(chat.id, 'assistant', 'previous answer')
const memory = fixture.service.create({
scopeType: 'global',
scopeId: null,
content: 'changed this turn',
sourceType: 'user',
sourceAIChatId: chat.id,
})
const untrackedMemory = fixture.service.create({
scopeType: 'global',
scopeId: null,
content: 'not changed this turn',
sourceType: 'user',
sourceAIChatId: chat.id,
})
const provenanceToken = 'bound-turn'
fixture.memoryProvenanceCoordinator.begin(provenanceToken, chat.id, previousAssistant.id)
fixture.memoryProvenanceCoordinator.record(provenanceToken, memory.id)
fixture.memoryProvenanceCoordinator.complete(provenanceToken)
const currentUser = fixture.aiChatManager.addMessage(chat.id, 'user', 'current')
const currentAssistant = fixture.aiChatManager.addMessage(chat.id, 'assistant', 'current answer')
const wrongChangeSet = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/link-sources',
payload: {
provenanceToken,
aiChatId: chat.id,
userMessageId: currentUser.id,
assistantMessageId: currentAssistant.id,
memoryIds: [memory.id, untrackedMemory.id],
},
})
assert.equal(wrongChangeSet.statusCode, 400)
assert.equal(fixture.service.get(memory.id)?.sourceMessageId, null)
const oldMessages = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/link-sources',
payload: {
provenanceToken,
aiChatId: chat.id,
userMessageId: previousUser.id,
assistantMessageId: previousAssistant.id,
memoryIds: [memory.id],
},
})
assert.equal(oldMessages.statusCode, 400)
assert.equal(fixture.service.get(memory.id)?.sourceMessageId, null)
const linked = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/link-sources',
payload: {
provenanceToken,
aiChatId: chat.id,
userMessageId: currentUser.id,
assistantMessageId: currentAssistant.id,
memoryIds: [memory.id],
},
})
assert.equal(linked.statusCode, 200)
assert.equal(fixture.service.get(memory.id)?.sourceMessageId, currentUser.id)
const replay = await fixture.app.inject({
method: 'POST',
url: '/_web/ai/memories/link-sources',
payload: {
provenanceToken,
aiChatId: chat.id,
userMessageId: currentUser.id,
assistantMessageId: currentAssistant.id,
memoryIds: [memory.id],
},
})
assert.equal(replay.statusCode, 400)
} finally {
await fixture.close()
}
})
it('rejects invalid scope and content instead of changing stored memories', async () => {
const fixture = createFixture()
try {
@@ -1,8 +1,14 @@
import type { FastifyInstance, FastifyReply } from 'fastify'
import type { AIMemoryScope, AIMemoryScopeType } from '@openchatlab/shared-types'
import type { AIMemoryScope, AIMemoryScopeType, LinkAIMemorySourcesInput } from '@openchatlab/shared-types'
import {
linkAIMemorySources,
listAIMemoriesWithSourceStatus,
resolveAIMemorySourceStatus,
} from '@openchatlab/node-runtime'
import type { AiRouteContext } from '../../context/ai'
import type { MemoryProvenanceCoordinator } from './memory-provenance-coordinator'
type AiMemoryRouteContext = Pick<AiRouteContext, 'aiMemoryService'>
type AiMemoryRouteContext = Pick<AiRouteContext, 'aiMemoryService' | 'aiChatManager'>
interface MemoryScopeInput {
scopeType?: string
@@ -25,9 +31,22 @@ 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 {
export function registerAiMemoryRoutes(
server: FastifyInstance,
ctx: AiMemoryRouteContext,
memoryProvenanceCoordinator?: MemoryProvenanceCoordinator
): void {
const memoryService = ctx.aiMemoryService
if (!memoryService) return
const aiChatManager = ctx.aiChatManager
const withSourceStatus = (entry: ReturnType<typeof memoryService.create>) => ({
...entry,
sourceStatus: aiChatManager
? resolveAIMemorySourceStatus(entry, aiChatManager)
: entry.sourceAIChatId
? ('unavailable' as const)
: ('none' as const),
})
server.get<{
Querystring: MemoryScopeInput
@@ -36,9 +55,45 @@ export function registerAiMemoryRoutes(server: FastifyInstance, ctx: AiMemoryRou
const { scopeType, scopeId } = request.query
if (!scopeType) {
if (scopeId != null) throw new Error('scopeType is required when scopeId is provided')
return memoryService.list()
return aiChatManager
? listAIMemoriesWithSourceStatus(memoryService, aiChatManager)
: memoryService.list().map((entry) => ({
...entry,
sourceStatus: entry.sourceAIChatId ? ('unavailable' as const) : ('none' as const),
}))
}
return memoryService.list(parseScope({ scopeType, scopeId }))
const scope = parseScope({ scopeType, scopeId })
return aiChatManager
? listAIMemoriesWithSourceStatus(memoryService, aiChatManager, scope)
: memoryService.list(scope).map((entry) => ({
...entry,
sourceStatus: entry.sourceAIChatId ? ('unavailable' as const) : ('none' as const),
}))
} catch (error) {
return sendBadRequest(reply, error)
}
})
server.post<{
Body: Partial<LinkAIMemorySourcesInput>
}>('/_web/ai/memories/link-sources', async (request, reply) => {
if (!aiChatManager) {
return reply.code(503).send({ error: 'AI conversation storage is unavailable' })
}
try {
const body = request.body ?? {}
const input = {
provenanceToken: body.provenanceToken ?? '',
aiChatId: body.aiChatId ?? '',
userMessageId: body.userMessageId ?? '',
assistantMessageId: body.assistantMessageId ?? '',
memoryIds: body.memoryIds ?? [],
}
if (!memoryProvenanceCoordinator) throw new Error('Memory provenance coordination is unavailable')
const provenance = memoryProvenanceCoordinator.validate(input.provenanceToken, input.aiChatId, input.memoryIds)
const result = linkAIMemorySources(memoryService, aiChatManager, input, provenance)
memoryProvenanceCoordinator.consume(input.provenanceToken)
return result
} catch (error) {
return sendBadRequest(reply, error)
}
@@ -49,13 +104,15 @@ export function registerAiMemoryRoutes(server: FastifyInstance, ctx: AiMemoryRou
}>('/_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,
})
return withSourceStatus(
memoryService.create({
...scope,
content: request.body?.content ?? '',
sourceType: 'user',
sourceAIChatId: null,
sourceMessageId: null,
})
)
} catch (error) {
return sendBadRequest(reply, error)
}
@@ -69,12 +126,14 @@ export function registerAiMemoryRoutes(server: FastifyInstance, ctx: AiMemoryRou
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,
})
return withSourceStatus(
memoryService.update(request.params.id, {
content: request.body?.content ?? '',
sourceType: 'user',
sourceAIChatId: null,
sourceMessageId: null,
})
)
} catch (error) {
return sendBadRequest(reply, error)
}
@@ -0,0 +1,66 @@
interface PendingMemoryProvenance {
aiChatId: string
expectedParentMessageId: string | null
memoryIds: Set<string>
completed: boolean
expiresAt: number
}
const DEFAULT_TTL_MS = 10 * 60 * 1000
export class MemoryProvenanceCoordinator {
private readonly pending = new Map<string, PendingMemoryProvenance>()
constructor(private readonly ttlMs = DEFAULT_TTL_MS) {}
begin(token: string, aiChatId: string, expectedParentMessageId: string | null): void {
this.cleanupExpired()
this.pending.set(token, {
aiChatId,
expectedParentMessageId,
memoryIds: new Set(),
completed: false,
expiresAt: Date.now() + this.ttlMs,
})
}
record(token: string, memoryId: string): void {
const changeSet = this.pending.get(token)
if (!changeSet || changeSet.completed || Date.now() > changeSet.expiresAt) return
changeSet.memoryIds.add(memoryId)
}
complete(token: string): void {
const changeSet = this.pending.get(token)
if (!changeSet) return
changeSet.completed = true
}
validate(token: string, aiChatId: string, memoryIds: string[]): { expectedParentMessageId: string | null } {
this.cleanupExpired()
const changeSet = this.pending.get(token)
if (!changeSet || !changeSet.completed || changeSet.aiChatId !== aiChatId) {
throw new Error('Memory provenance token is invalid or expired')
}
const requestedIds = new Set(memoryIds)
if (
requestedIds.size !== changeSet.memoryIds.size ||
[...requestedIds].some((memoryId) => !changeSet.memoryIds.has(memoryId))
) {
throw new Error('Memory IDs do not match the agent turn change set')
}
return { expectedParentMessageId: changeSet.expectedParentMessageId }
}
consume(token: string): void {
this.pending.delete(token)
}
private cleanupExpired(): void {
const now = Date.now()
for (const [token, changeSet] of this.pending) {
if (now > changeSet.expiresAt) this.pending.delete(token)
}
}
}
@@ -14,6 +14,7 @@ import { registerSemanticIndexRoutes } from './ai-semantic-index'
import { registerAiSkillRoutes } from './ai-skills'
import { registerAiSummaryRoutes } from './ai-summaries'
import { registerAiToolRoutes } from './ai-tools'
import { MemoryProvenanceCoordinator } from './memory-provenance-coordinator'
export type AiRoutesContext = AiRouteContext &
Pick<RuntimeRouteContext, 'dbManager' | 'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding'> &
@@ -46,15 +47,16 @@ function assertRequiredAiContext(ctx: AiRouteContext): void {
/** Register all AI-related Web routes while preserving optional-feature behavior. */
export function registerAiRoutes(server: FastifyInstance, ctx: AiRoutesContext, options?: AiRouteOptions): void {
if (options?.requireAi) assertRequiredAiContext(ctx)
const memoryProvenanceCoordinator = new MemoryProvenanceCoordinator()
registerAiAssistantRoutes(server, ctx)
registerAiSkillRoutes(server, ctx)
registerAiLlmRoutes(server, ctx)
registerAiLlmStreamRoutes(server, ctx)
registerAiAgentStreamRoutes(server, ctx)
registerAiAgentStreamRoutes(server, ctx, memoryProvenanceCoordinator)
registerAiToolRoutes(server, ctx)
registerAiChatRoutes(server, ctx)
registerAiMemoryRoutes(server, ctx)
registerAiMemoryRoutes(server, ctx, memoryProvenanceCoordinator)
registerAiChatTopicRoutes(server, ctx)
registerAiSummaryRoutes(server, ctx)
registerAiLogRoutes(server, ctx)
@@ -0,0 +1,262 @@
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 { AIChatManager } from '../chats'
import { AIMemoryService } from '../memory'
import { linkAIMemorySources, listAIMemoriesWithSourceStatus, resolveAIMemorySourceStatus } from '../memory-provenance'
const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING
function createFixture() {
const dir = mkdtempSync(join(tmpdir(), 'chatlab-ai-memory-provenance-'))
const memoryService = new AIMemoryService(dir, { nativeBinding: sqliteNativeBinding, now: () => 1234 })
const aiChatManager = new AIChatManager(dir, { nativeBinding: sqliteNativeBinding })
return {
dir,
memoryService,
aiChatManager,
close() {
memoryService.close()
aiChatManager.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 provenance', () => {
it('links user and AI memories to the matching persisted messages without changing update time', () => {
const fixture = createFixture()
try {
const chat = fixture.aiChatManager.createGlobalAIChat('source', 'general_cn')
const userMessage = fixture.aiChatManager.addMessage(chat.id, 'user', '请记住我喜欢先看结论')
const assistantMessage = fixture.aiChatManager.addMessage(chat.id, 'assistant', '已经记住')
const userMemory = fixture.memoryService.create({
scopeType: 'global',
scopeId: null,
content: '回答时先给结论',
sourceType: 'user',
sourceAIChatId: chat.id,
})
const aiMemory = fixture.memoryService.create({
scopeType: 'self',
scopeId: null,
content: '用户可能在准备搬家',
sourceType: 'ai',
sourceAIChatId: chat.id,
})
const result = linkAIMemorySources(fixture.memoryService, fixture.aiChatManager, {
provenanceToken: 'test-turn',
aiChatId: chat.id,
userMessageId: userMessage.id,
assistantMessageId: assistantMessage.id,
memoryIds: [userMemory.id, aiMemory.id, userMemory.id],
})
assert.deepEqual(result, { linkedMemoryIds: [userMemory.id, aiMemory.id], skippedMemoryIds: [] })
assert.equal(fixture.memoryService.get(userMemory.id)?.sourceMessageId, userMessage.id)
assert.equal(fixture.memoryService.get(aiMemory.id)?.sourceMessageId, assistantMessage.id)
assert.equal(fixture.memoryService.get(userMemory.id)?.updatedAt, 1234)
assert.equal(fixture.memoryService.get(aiMemory.id)?.updatedAt, 1234)
assert.deepEqual(
linkAIMemorySources(fixture.memoryService, fixture.aiChatManager, {
provenanceToken: 'test-turn',
aiChatId: chat.id,
userMessageId: userMessage.id,
assistantMessageId: assistantMessage.id,
memoryIds: [userMemory.id, aiMemory.id],
}),
{ linkedMemoryIds: [userMemory.id, aiMemory.id], skippedMemoryIds: [] }
)
} finally {
fixture.close()
}
})
it('rejects cross-conversation memories and invalid source message roles before writing anything', () => {
const fixture = createFixture()
try {
const chatA = fixture.aiChatManager.createGlobalAIChat('a', 'general_cn')
const chatB = fixture.aiChatManager.createGlobalAIChat('b', 'general_cn')
const userA = fixture.aiChatManager.addMessage(chatA.id, 'user', 'a user')
const assistantA = fixture.aiChatManager.addMessage(chatA.id, 'assistant', 'a assistant')
const memoryA = fixture.memoryService.create({
scopeType: 'global',
scopeId: null,
content: 'memory a',
sourceType: 'user',
sourceAIChatId: chatA.id,
})
const memoryB = fixture.memoryService.create({
scopeType: 'global',
scopeId: null,
content: 'memory b',
sourceType: 'user',
sourceAIChatId: chatB.id,
})
assert.throws(
() =>
linkAIMemorySources(fixture.memoryService, fixture.aiChatManager, {
provenanceToken: 'test-turn',
aiChatId: chatA.id,
userMessageId: userA.id,
assistantMessageId: assistantA.id,
memoryIds: [memoryA.id, memoryB.id],
}),
/does not belong/i
)
assert.equal(fixture.memoryService.get(memoryA.id)?.sourceMessageId, null)
assert.equal(fixture.memoryService.get(memoryB.id)?.sourceMessageId, null)
assert.throws(
() =>
linkAIMemorySources(fixture.memoryService, fixture.aiChatManager, {
provenanceToken: 'test-turn',
aiChatId: chatA.id,
userMessageId: assistantA.id,
assistantMessageId: userA.id,
memoryIds: [memoryA.id],
}),
/source message/i
)
assert.equal(fixture.memoryService.get(memoryA.id)?.sourceMessageId, null)
} finally {
fixture.close()
}
})
it('treats a memory deleted before backfill as an idempotent no-op', () => {
const fixture = createFixture()
try {
const chat = fixture.aiChatManager.createGlobalAIChat('source', 'general_cn')
const userMessage = fixture.aiChatManager.addMessage(chat.id, 'user', 'remember')
const assistantMessage = fixture.aiChatManager.addMessage(chat.id, 'assistant', 'done')
assert.deepEqual(
linkAIMemorySources(fixture.memoryService, fixture.aiChatManager, {
provenanceToken: 'test-turn',
aiChatId: chat.id,
userMessageId: userMessage.id,
assistantMessageId: assistantMessage.id,
memoryIds: ['already-deleted'],
}),
{ linkedMemoryIds: [], skippedMemoryIds: ['already-deleted'] }
)
} finally {
fixture.close()
}
})
it('rejects non-parent messages and batch conflicts without partially linking memories', () => {
const fixture = createFixture()
try {
const chat = fixture.aiChatManager.createGlobalAIChat('source', 'general_cn')
const firstUser = fixture.aiChatManager.addMessage(chat.id, 'user', 'first')
const firstAssistant = fixture.aiChatManager.addMessage(chat.id, 'assistant', 'first answer')
const conflictingMemory = fixture.memoryService.create({
scopeType: 'global',
scopeId: null,
content: 'already linked',
sourceType: 'user',
sourceAIChatId: chat.id,
sourceMessageId: firstUser.id,
})
const secondUser = fixture.aiChatManager.addMessage(chat.id, 'user', 'second')
const secondAssistant = fixture.aiChatManager.addMessage(chat.id, 'assistant', 'second answer')
const unlinkedMemory = fixture.memoryService.create({
scopeType: 'global',
scopeId: null,
content: 'must remain unlinked',
sourceType: 'user',
sourceAIChatId: chat.id,
})
assert.throws(
() =>
linkAIMemorySources(fixture.memoryService, fixture.aiChatManager, {
provenanceToken: 'test-turn',
aiChatId: chat.id,
userMessageId: firstUser.id,
assistantMessageId: secondAssistant.id,
memoryIds: [unlinkedMemory.id],
}),
/directly reply/i
)
assert.throws(
() =>
linkAIMemorySources(fixture.memoryService, fixture.aiChatManager, {
provenanceToken: 'test-turn',
aiChatId: chat.id,
userMessageId: secondUser.id,
assistantMessageId: secondAssistant.id,
memoryIds: [unlinkedMemory.id, conflictingMemory.id],
}),
/already linked/i
)
assert.equal(fixture.memoryService.get(unlinkedMemory.id)?.sourceMessageId, null)
assert.equal(fixture.memoryService.get(conflictingMemory.id)?.sourceMessageId, firstUser.id)
assert.equal(firstAssistant.parentId, firstUser.id)
} finally {
fixture.close()
}
})
it('reports conversation, message, and unavailable source states without deleting the memory', () => {
const fixture = createFixture()
try {
const chat = fixture.aiChatManager.createGlobalAIChat('source', 'general_cn')
const message = fixture.aiChatManager.addMessage(chat.id, 'user', 'remember')
const none = fixture.memoryService.create({
scopeType: 'global',
scopeId: null,
content: 'manual',
sourceType: 'user',
})
const conversation = fixture.memoryService.create({
scopeType: 'global',
scopeId: null,
content: 'conversation only',
sourceType: 'user',
sourceAIChatId: chat.id,
})
const linked = fixture.memoryService.create({
scopeType: 'global',
scopeId: null,
content: 'linked',
sourceType: 'user',
sourceAIChatId: chat.id,
sourceMessageId: message.id,
})
assert.equal(resolveAIMemorySourceStatus(none, fixture.aiChatManager), 'none')
assert.equal(resolveAIMemorySourceStatus(conversation, fixture.aiChatManager), 'conversation')
assert.equal(resolveAIMemorySourceStatus(linked, fixture.aiChatManager), 'message')
fixture.aiChatManager.deleteMessage(message.id)
assert.equal(resolveAIMemorySourceStatus(linked, fixture.aiChatManager), 'unavailable')
fixture.aiChatManager.deleteAIChat(chat.id)
assert.equal(resolveAIMemorySourceStatus(conversation, fixture.aiChatManager), 'unavailable')
assert.equal(fixture.memoryService.list().length, 3)
assert.deepEqual(
Object.fromEntries(
listAIMemoriesWithSourceStatus(fixture.memoryService, fixture.aiChatManager).map((entry) => [
entry.content,
entry.sourceStatus,
])
),
{ manual: 'none', 'conversation only': 'unavailable', linked: 'unavailable' }
)
} finally {
fixture.close()
}
})
})
@@ -38,6 +38,7 @@ export interface AgentStreamChunk {
| 'tool_start'
| 'tool_update'
| 'tool_result'
| 'memory_change'
| 'status'
| 'compression_done'
| 'route'
@@ -56,6 +57,8 @@ export interface AgentStreamChunk {
toolProgress?: ToolProgress
toolResult?: unknown
toolIsError?: boolean
memoryId?: string
provenanceToken?: string
error?: unknown
isFinished?: boolean
usage?: TokenUsage
+5
View File
@@ -811,6 +811,11 @@ export class AIChatManager {
return this.getActivePathRows(aiChatId).map((row) => this.parseMessageRow(row))
}
getMessage(messageId: string): AIMessage | null {
const row = this.getMessageRow(messageId)
return row ? this.parseMessageRow(row) : null
}
deleteMessage(messageId: string): boolean {
const db = this.getDb()
const result = db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId)
+1
View File
@@ -49,6 +49,7 @@ export type {
} from './chats'
export { AI_MEMORY_CONTENT_MAX_CHARS, AIMemoryService, buildGlobalMemoryPrompt } from './memory'
export type { AIMemoryServiceOptions, CreateAIMemoryInput, UpdateAIMemoryInput } from './memory'
export { linkAIMemorySources, listAIMemoriesWithSourceStatus, resolveAIMemorySourceStatus } from './memory-provenance'
// Tokenizer
export { countTokens, countMessagesTokens, initTokenizer } from './tokenizer'
@@ -0,0 +1,113 @@
import type {
AIMemoryEntry,
AIMemoryManagementEntry,
AIMemoryScope,
AIMemorySourceStatus,
LinkAIMemorySourcesInput,
LinkAIMemorySourcesResult,
} from '@openchatlab/shared-types'
import type { AIChatManager, AIMessage } from './chats'
import type { AIMemoryService } from './memory'
export function listAIMemoriesWithSourceStatus(
memoryService: AIMemoryService,
aiChatManager: AIChatManager,
scope?: AIMemoryScope
): AIMemoryManagementEntry[] {
return memoryService.list(scope).map((entry) => ({
...entry,
sourceStatus: resolveAIMemorySourceStatus(entry, aiChatManager),
}))
}
export function linkAIMemorySources(
memoryService: AIMemoryService,
aiChatManager: AIChatManager,
input: LinkAIMemorySourcesInput,
options?: { expectedParentMessageId?: string | null }
): LinkAIMemorySourcesResult {
const aiChatId = requireId(input.aiChatId, 'aiChatId')
const conversation = aiChatManager.getAIChat(aiChatId)
if (!conversation || conversation.kind !== 'global') {
throw new Error('Global AI conversation not found')
}
const userMessage = requireSourceMessage(aiChatManager, input.userMessageId, aiChatId, 'user')
const assistantMessage = requireSourceMessage(aiChatManager, input.assistantMessageId, aiChatId, 'assistant')
if (assistantMessage.parentId !== userMessage.id) {
throw new Error('Assistant source message must directly reply to the user source message')
}
if (
options &&
options.expectedParentMessageId !== undefined &&
userMessage.parentId !== options.expectedParentMessageId
) {
throw new Error('User source message does not belong to the requested agent turn')
}
const memoryIds = normalizeMemoryIds(input.memoryIds)
const memories = memoryIds.map((id) => ({ id, entry: memoryService.get(id) }))
for (const { entry } of memories) {
if (entry && entry.sourceAIChatId !== aiChatId) {
throw new Error('AI memory does not belong to the requested conversation')
}
if (entry?.sourceMessageId) {
const expectedSourceMessageId = entry.sourceType === 'user' ? userMessage.id : assistantMessage.id
if (entry.sourceMessageId !== expectedSourceMessageId) {
throw new Error('AI memory is already linked to another source message')
}
}
}
const skippedMemoryIds: string[] = []
const links: Array<{ id: string; sourceAIChatId: string; sourceMessageId: string }> = []
for (const { id, entry } of memories) {
if (!entry) {
skippedMemoryIds.push(id)
continue
}
const sourceMessage = entry.sourceType === 'user' ? userMessage : assistantMessage
links.push({ id, sourceAIChatId: aiChatId, sourceMessageId: sourceMessage.id })
}
const linkedMemoryIds = memoryService.linkSourceMessages(links)
return { linkedMemoryIds, skippedMemoryIds }
}
export function resolveAIMemorySourceStatus(entry: AIMemoryEntry, aiChatManager: AIChatManager): AIMemorySourceStatus {
if (!entry.sourceAIChatId) return 'none'
const conversation = aiChatManager.getAIChat(entry.sourceAIChatId)
if (!conversation || conversation.kind !== 'global') return 'unavailable'
if (!entry.sourceMessageId) return 'conversation'
const message = aiChatManager.getMessage(entry.sourceMessageId)
const expectedRole = entry.sourceType === 'user' ? 'user' : 'assistant'
return message?.aiChatId === entry.sourceAIChatId && message.role === expectedRole ? 'message' : 'unavailable'
}
function requireSourceMessage(
aiChatManager: AIChatManager,
messageId: string,
aiChatId: string,
role: AIMessage['role']
): AIMessage {
const normalizedMessageId = requireId(messageId, `${role}MessageId`)
const message = aiChatManager.getMessage(normalizedMessageId)
if (!message || message.aiChatId !== aiChatId || message.role !== role) {
throw new Error(`${role} source message does not belong to the requested conversation`)
}
return message
}
function normalizeMemoryIds(memoryIds: string[]): string[] {
if (!Array.isArray(memoryIds)) throw new Error('memoryIds must be an array')
return [...new Set(memoryIds.map((id) => requireId(id, 'memoryId')))]
}
function requireId(value: string, field: string): string {
const normalized = typeof value === 'string' ? value.trim() : ''
if (!normalized) throw new Error(`${field} is required`)
return normalized
}
+23
View File
@@ -371,6 +371,29 @@ export class AIMemoryService {
})
}
linkSourceMessages(links: Array<{ id: string; sourceAIChatId: string; sourceMessageId: string }>): string[] {
if (links.length === 0) return []
return this.runWrite('link sources', () => {
const db = this.getDb()
const update = db.prepare(
`UPDATE ai_memory
SET source_message_id = ?
WHERE id = ?
AND source_ai_chat_id = ?
AND (source_message_id IS NULL OR source_message_id = ?)`
)
return db.transaction(() => {
const linkedIds: string[] = []
for (const link of links) {
const result = update.run(link.sourceMessageId, link.id, link.sourceAIChatId, link.sourceMessageId)
if (result.changes === 0) throw new Error('AI memory source changed before it could be linked')
linkedIds.push(link.id)
}
return linkedIds
})()
})
}
forget(id: string): boolean {
return this.runWrite('forget', () => this.getDb().prepare('DELETE FROM ai_memory WHERE id = ?').run(id).changes > 0)
}
+1
View File
@@ -319,6 +319,7 @@ export {
runCrossChatAgent,
} from './ai'
export type { CrossChatAgentLogger, RunCrossChatAgentOptions } from './ai'
export { linkAIMemorySources, listAIMemoriesWithSourceStatus, resolveAIMemorySourceStatus } from './ai'
export type {
AnalysisPlanIntent,
AnalysisPlanner,
+18
View File
@@ -138,6 +138,7 @@ export type AIEntityRef =
export type AIMemoryScopeType = 'global' | 'self' | 'contact' | 'group'
export type AIMemorySourceType = 'user' | 'ai'
export type AIMemorySourceStatus = 'none' | 'conversation' | 'message' | 'unavailable'
export interface AIMemoryScope {
scopeType: AIMemoryScopeType
@@ -154,6 +155,23 @@ export interface AIMemoryEntry extends AIMemoryScope {
updatedAt: number
}
export interface AIMemoryManagementEntry extends AIMemoryEntry {
sourceStatus: AIMemorySourceStatus
}
export interface LinkAIMemorySourcesInput {
provenanceToken: string
aiChatId: string
userMessageId: string
assistantMessageId: string
memoryIds: string[]
}
export interface LinkAIMemorySourcesResult {
linkedMemoryIds: string[]
skippedMemoryIds: string[]
}
export {
CHATLAB_FORMAT_VERSION,
CHATLAB_SUPPORTED_FORMAT_VERSIONS,
@@ -116,7 +116,11 @@ function entityResolution(refs: AIEntityRef[]): CrossChatEntityResolution {
}
function createContext(
options: { entityRefs?: AIEntityRef[]; resolvedEntityRefs?: AIEntityRef[] } = {}
options: {
entityRefs?: AIEntityRef[]
resolvedEntityRefs?: AIEntityRef[]
reportMemoryChange?: (memoryId: string) => void
} = {}
): CrossChatToolExecutionContext {
const analysisService = {
resolveEntities: async (refs: AIEntityRef[]) => entityResolution(refs),
@@ -128,6 +132,7 @@ function createContext(
analysisService,
memoryService: new FakeMemoryService(),
aiChatId: 'global-chat-1',
reportMemoryChange: options.reportMemoryChange,
preprocessMessagesBySession: (_sessionId, messages) => messages,
preprocessSummariesBySession: (_sessionId, summaries) => summaries,
preprocessModelLabel: (value) => value,
@@ -157,7 +162,8 @@ describe('global memory tool registry', () => {
describe('global memory tools', () => {
it('writes user memory with runtime-owned source metadata and updates only the same scope', async () => {
const context = createContext()
const changedMemoryIds: string[] = []
const context = createContext({ reportMemoryChange: (memoryId) => changedMemoryIds.push(memoryId) })
const created = await memoryWriteTool.handler(
{
scope_type: 'contact',
@@ -182,6 +188,7 @@ describe('global memory tools', () => {
context
)
assert.equal((updated.data as AIMemoryEntry).content, '用户纠正为高中同学')
assert.deepEqual(changedMemoryIds, [createdEntry.id, createdEntry.id])
await assert.rejects(
async () =>
@@ -128,6 +128,7 @@ async function memoryWriteHandler(
})
}
context.reportMemoryChange?.(entry.id)
return { content: JSON.stringify(entry), data: entry }
}
+2
View File
@@ -438,6 +438,8 @@ export interface CrossChatToolExecutionContext {
analysisService: CrossChatAnalysisToolService
memoryService: AIMemoryToolService
aiChatId: string
/** Reports a successful memory create/update without exposing it to the model context. */
reportMemoryChange?: (memoryId: string) => void
preprocessMessagesBySession: (
sessionId: string,
messages: CrossChatMessageSource[]
+9 -6
View File
@@ -366,11 +366,14 @@ onMounted(async () => {
// 同步原生 modal 在异步锁屏就绪前保持底层文档 inert;chunk 加载失败时不会自动放行。
syncBootstrapMask(bootstrapDialogRef.value)
window.addEventListener('keydown', handleGlobalKeydown)
const platform = navigator.platform.toLowerCase()
if (platform.includes('win')) {
document.documentElement.classList.add('platform-windows')
} else if (platform.includes('linux')) {
document.documentElement.classList.add('platform-linux')
if (IS_ELECTRON) {
document.documentElement.classList.add('platform-electron')
const platform = navigator.platform.toLowerCase()
if (platform.includes('win')) {
document.documentElement.classList.add('platform-windows')
} else if (platform.includes('linux')) {
document.documentElement.classList.add('platform-linux')
}
}
if (IS_ELECTRON) {
@@ -417,7 +420,7 @@ onUnmounted(() => {
</template>
<template v-else>
<!-- 自定义标题栏 - 拖拽区域 + 窗口控制按钮 -->
<TitleBar />
<TitleBar v-if="IS_ELECTRON" />
<div class="relative flex h-screen w-full overflow-hidden bg-page-bg dark:bg-page-dark">
<!-- 运行时就绪后先在启动屏下挂载真实界面,利用动画时间完成布局和页面预热。 -->
<div
+5 -1
View File
@@ -67,11 +67,15 @@
background-color: var(--color-gray-600);
}
/* 标题栏安全区域高度 - 页面顶部 padding 需要避开此区域
/* 页面顶部偏移:CLI Web 用作视觉留白,Electron 同时用于避开标题栏
* macOS: 红绿灯在左侧由 Sidebar 处理,右侧内容区无遮挡,使用较小值
* Windows: 跟随 Window Controls Overlay 提供的系统高度
* Linux: 自定义窗口控制按钮固定占据 32px 高度 */
:root {
--titlebar-area-height: 1rem;
}
.platform-electron {
--titlebar-area-height: 1rem; /* 16px - macOS */
}
+7 -1
View File
@@ -44,6 +44,7 @@ const props = defineProps<{
contentBlocks?: ContentBlock[]
/** 是否显示截屏按钮(仅 AI 回复) */
showCaptureButton?: boolean
highlighted?: boolean
editable?: boolean
/** Live tool status for the streaming process header. */
activeTool?: {
@@ -786,7 +787,12 @@ async function handleCopyMarkdown() {
<template>
<div
class="flex items-start gap-3"
:class="[isUser && !isEditing ? 'flex-row-reverse' : '', isSummary ? 'justify-center' : '']"
:class="[
isUser && !isEditing ? 'flex-row-reverse' : '',
isSummary ? 'justify-center' : '',
highlighted ? 'rounded-xl ring-2 ring-primary-400/70 ring-offset-4 dark:ring-offset-gray-950' : '',
]"
:data-ai-message-id="messageId"
>
<!-- 消息内容 -->
<div
@@ -1,15 +1,27 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AIMemoryEntry } from '@/services/ai/types'
import type { ContactListItem, ContactsResponse } from '@openchatlab/shared-types'
import type { AIMemoryManagementEntry } from '@/services/ai/types'
import { useAIService, useDataService } from '@/services'
import { useToast } from '@/composables/useToast'
import { useSessionStore } from '@/stores/session'
type MemoryView = 'preferences' | 'self' | 'entities'
type EntityScopeType = 'contact' | 'group'
interface MemoryEntityOption {
scopeType: EntityScopeType
scopeId: string
label: string
detail: string
}
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
const emit = defineEmits<{
'update:open': [value: boolean]
'view-source': [payload: { aiChatId: string; messageId: string | null }]
}>()
const { t, locale } = useI18n()
const toast = useToast()
@@ -18,12 +30,23 @@ const dataService = useDataService()
const sessionStore = useSessionStore()
const activeView = ref<MemoryView>('preferences')
const entries = ref<AIMemoryEntry[]>([])
const entries = ref<AIMemoryManagementEntry[]>([])
const contactNames = ref(new Map<string, string>())
const loading = ref(false)
const savingId = ref<string | null>(null)
const editingId = ref<string | null>(null)
const editingContent = ref('')
const creating = ref(false)
const createContent = ref('')
const createEntityType = ref<EntityScopeType>('contact')
const selectedEntity = ref<MemoryEntityOption | null>(null)
const entitySearch = ref('')
const contactOptions = ref<MemoryEntityOption[]>([])
const loadingEntities = ref(false)
let entitySearchTimer: ReturnType<typeof setTimeout> | null = null
let entityPollTimer: ReturnType<typeof setTimeout> | null = null
let entitySearchRequestId = 0
const CONTACT_POLL_INTERVAL_MS = 1500
const viewItems = computed(() => [
{ label: t('ai.global.memory.preferences'), value: 'preferences' },
@@ -39,11 +62,41 @@ const visibleEntries = computed(() =>
: entry.scopeType === 'contact' || entry.scopeType === 'group'
)
)
const entityTypeItems = computed(() => [
{ label: t('ai.global.entityPicker.contacts'), value: 'contact' },
{ label: t('ai.global.entityPicker.groups'), value: 'group' },
])
const groupOptions = computed<MemoryEntityOption[]>(() => {
const query = entitySearch.value.trim().toLocaleLowerCase()
return sessionStore.sessions
.filter((session) => session.type === 'group')
.filter((session) => !query || `${session.name} ${session.platform}`.toLocaleLowerCase().includes(query))
.map((session) => ({
scopeType: 'group',
scopeId: session.id,
label: session.name,
detail: session.platform,
}))
})
const createEntityOptions = computed(() =>
createEntityType.value === 'contact' ? contactOptions.value : groupOptions.value
)
const createScopeLabel = computed(() => {
if (activeView.value === 'preferences') return t('ai.global.memory.preferences')
if (activeView.value === 'self') return t('ai.global.memory.self')
return selectedEntity.value?.label ?? t('ai.global.memory.selectEntity')
})
const canCreate = computed(
() =>
createContent.value.trim().length > 0 &&
createContent.value.trim().length <= 2000 &&
(activeView.value !== 'entities' || selectedEntity.value !== null)
)
function close(): void {
emit('update:open', false)
}
function startEditing(entry: AIMemoryEntry): void {
function startEditing(entry: AIMemoryManagementEntry): void {
editingId.value = entry.id
editingContent.value = entry.content
}
@@ -53,6 +106,31 @@ function stopEditing(): void {
editingContent.value = ''
}
function startCreating(): void {
stopEditing()
creating.value = true
createContent.value = ''
selectedEntity.value = null
entitySearch.value = ''
if (activeView.value === 'entities' && createEntityType.value === 'contact') {
void loadContactOptions('')
}
}
function stopCreating(): void {
creating.value = false
createContent.value = ''
selectedEntity.value = null
entitySearch.value = ''
entitySearchRequestId++
loadingEntities.value = false
if (entitySearchTimer) {
clearTimeout(entitySearchTimer)
entitySearchTimer = null
}
stopContactPolling()
}
function formatUpdatedAt(timestamp: number): string {
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric',
@@ -63,13 +141,22 @@ function formatUpdatedAt(timestamp: number): string {
}).format(timestamp)
}
function getEntityLabel(entry: AIMemoryEntry): string {
function viewSource(entry: AIMemoryManagementEntry): void {
if (!entry.sourceAIChatId || (entry.sourceStatus !== 'conversation' && entry.sourceStatus !== 'message')) return
emit('update:open', false)
emit('view-source', {
aiChatId: entry.sourceAIChatId,
messageId: entry.sourceStatus === 'message' ? entry.sourceMessageId : null,
})
}
function getEntityLabel(entry: AIMemoryManagementEntry): string {
if (!entry.scopeId) return ''
if (entry.scopeType === 'contact') return contactNames.value.get(entry.scopeId) ?? entry.scopeId
return sessionStore.sessions.find((session) => session.id === entry.scopeId)?.name ?? entry.scopeId
}
async function loadContactNames(memories: AIMemoryEntry[]): Promise<void> {
async function loadContactNames(memories: AIMemoryManagementEntry[]): Promise<void> {
const keys = [
...new Set(memories.filter((entry) => entry.scopeType === 'contact').map((entry) => entry.scopeId)),
].filter((key): key is string => Boolean(key))
@@ -85,6 +172,68 @@ async function loadContactNames(memories: AIMemoryEntry[]): Promise<void> {
contactNames.value = names
}
async function loadContactOptions(query: string): Promise<void> {
const requestId = ++entitySearchRequestId
loadingEntities.value = true
try {
const options = {
acceptStale: true,
timeRangePreset: 'all',
page: 1,
pageSize: 100,
query: query.trim() || undefined,
} as const
const responses = await Promise.all([
dataService.getContacts({ ...options, pool: 'friend' }),
dataService.getContacts({ ...options, pool: 'non_friend' }),
])
if (requestId !== entitySearchRequestId) return
const contactsByKey = new Map<string, ContactListItem>()
responses.flatMap((response) => response.contacts).forEach((contact) => contactsByKey.set(contact.key, contact))
contactOptions.value = [...contactsByKey.values()].map((contact) => ({
scopeType: 'contact',
scopeId: contact.key,
label: contact.displayName,
detail: contact.platformId,
}))
scheduleContactPolling(responses, query)
} catch (error) {
if (requestId !== entitySearchRequestId) return
stopContactPolling()
contactOptions.value = []
toast.fail(t('ai.global.entityPicker.loadFailed'), { description: String(error) })
} finally {
if (requestId === entitySearchRequestId) loadingEntities.value = false
}
}
function stopContactPolling(): void {
if (!entityPollTimer) return
clearTimeout(entityPollTimer)
entityPollTimer = null
}
function scheduleContactPolling(responses: ContactsResponse[], query: string): void {
stopContactPolling()
if (!creating.value || activeView.value !== 'entities' || createEntityType.value !== 'contact') return
if (!responses.some((response) => response.task?.status === 'running')) return
entityPollTimer = setTimeout(() => {
entityPollTimer = null
void loadContactOptions(query)
}, CONTACT_POLL_INTERVAL_MS)
}
function scheduleContactSearch(query: string): void {
if (entitySearchTimer) clearTimeout(entitySearchTimer)
stopContactPolling()
entitySearchRequestId++
entitySearchTimer = setTimeout(() => {
entitySearchTimer = null
void loadContactOptions(query)
}, 200)
}
async function loadMemories(): Promise<void> {
loading.value = true
try {
@@ -98,7 +247,7 @@ async function loadMemories(): Promise<void> {
}
}
async function saveEntry(entry: AIMemoryEntry): Promise<void> {
async function saveEntry(entry: AIMemoryManagementEntry): Promise<void> {
if (!editingContent.value.trim()) return
savingId.value = entry.id
try {
@@ -113,7 +262,37 @@ async function saveEntry(entry: AIMemoryEntry): Promise<void> {
}
}
async function deleteEntry(entry: AIMemoryEntry): Promise<void> {
async function createEntry(): Promise<void> {
if (!canCreate.value) return
savingId.value = 'new'
try {
const scope =
activeView.value === 'preferences'
? { scopeType: 'global' as const, scopeId: null }
: activeView.value === 'self'
? { scopeType: 'self' as const, scopeId: null }
: {
scopeType: selectedEntity.value!.scopeType,
scopeId: selectedEntity.value!.scopeId,
}
const created = await aiService.createAIMemory({
...scope,
content: createContent.value,
})
entries.value = [created, ...entries.value]
if (created.scopeType === 'contact' && created.scopeId && selectedEntity.value) {
contactNames.value = new Map(contactNames.value).set(created.scopeId, selectedEntity.value.label)
}
stopCreating()
toast.success(t('ai.global.memory.toast.created'))
} catch (error) {
toast.fail(t('ai.global.memory.toast.createFailed'), { description: String(error) })
} finally {
savingId.value = null
}
}
async function deleteEntry(entry: AIMemoryManagementEntry): Promise<void> {
if (!confirm(t('ai.global.memory.confirmDelete'))) return
savingId.value = entry.id
try {
@@ -171,10 +350,37 @@ watch(
() => props.open,
(open) => {
if (open) void loadMemories()
else stopEditing()
else {
stopEditing()
stopCreating()
}
},
{ immediate: true }
)
watch(activeView, () => stopCreating())
watch(createEntityType, (scopeType) => {
stopContactPolling()
entitySearchRequestId++
loadingEntities.value = false
selectedEntity.value = null
entitySearch.value = ''
if (creating.value && activeView.value === 'entities' && scopeType === 'contact') void loadContactOptions('')
})
watch(entitySearch, (query) => {
selectedEntity.value = null
if (creating.value && activeView.value === 'entities' && createEntityType.value === 'contact') {
scheduleContactSearch(query)
}
})
onBeforeUnmount(() => {
entitySearchRequestId++
if (entitySearchTimer) clearTimeout(entitySearchTimer)
stopContactPolling()
})
</script>
<template>
@@ -199,9 +405,95 @@ watch(
<div class="flex min-h-0 flex-1 flex-col px-5 py-4">
<div class="mb-3 flex items-center justify-between gap-3">
<UTabs v-model="activeView" :items="viewItems" :content="false" size="sm" class="min-w-max" />
<span class="text-xs text-gray-400">
{{ t('ai.global.memory.count', { count: visibleEntries.length }) }}
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">
{{ t('ai.global.memory.count', { count: visibleEntries.length }) }}
</span>
<UButton
icon="i-lucide-plus"
color="primary"
variant="soft"
size="xs"
:disabled="loading"
@click="creating ? stopCreating() : startCreating()"
>
{{ t('ai.global.memory.add') }}
</UButton>
</div>
</div>
<div
v-if="creating"
class="mb-3 rounded-xl border border-primary-200 bg-primary-50/40 p-3 dark:border-primary-900/70 dark:bg-primary-950/20"
>
<div class="mb-2 flex items-center justify-between gap-3">
<span class="text-xs font-medium text-gray-700 dark:text-gray-200">
{{ t('ai.global.memory.createTitle') }}
</span>
<span class="truncate text-[11px] text-gray-400">
{{ t('ai.global.memory.scopeLabel', { scope: createScopeLabel }) }}
</span>
</div>
<div v-if="activeView === 'entities'" class="mb-3 space-y-2">
<UTabs v-model="createEntityType" :items="entityTypeItems" :content="false" size="xs" />
<UInput
v-model="entitySearch"
icon="i-lucide-search"
size="sm"
:placeholder="t('ai.global.entityPicker.search')"
/>
<div
class="max-h-36 space-y-1 overflow-y-auto rounded-lg border border-gray-200 p-1 dark:border-gray-800"
>
<div v-if="loadingEntities" class="flex h-20 items-center justify-center">
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin text-gray-400" />
</div>
<template v-else>
<button
v-for="option in createEntityOptions"
:key="`${option.scopeType}:${option.scopeId}`"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left text-xs transition-colors"
:class="
selectedEntity?.scopeId === option.scopeId && selectedEntity.scopeType === option.scopeType
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-200'
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
"
@click="selectedEntity = option"
>
<span class="truncate">{{ option.label }}</span>
<span class="shrink-0 text-[10px] text-gray-400">{{ option.detail }}</span>
</button>
</template>
<p
v-if="!loadingEntities && createEntityOptions.length === 0"
class="flex h-20 items-center justify-center text-xs text-gray-400"
>
{{ t('ai.global.entityPicker.empty') }}
</p>
</div>
</div>
<UTextarea
v-model="createContent"
:rows="3"
:maxlength="2000"
autoresize
class="w-full"
:placeholder="t('ai.global.memory.contentPlaceholder')"
/>
<div class="mt-2 flex items-center justify-between gap-3">
<span class="text-[11px] text-gray-400">{{ createContent.trim().length }} / 2000</span>
<div class="flex items-center gap-2">
<UButton color="neutral" variant="ghost" size="xs" @click="stopCreating">
{{ t('common.cancel') }}
</UButton>
<UButton size="xs" :loading="savingId === 'new'" :disabled="!canCreate" @click="createEntry">
{{ t('common.save') }}
</UButton>
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
@@ -253,10 +545,14 @@ watch(
{{ entry.content }}
</p>
<div class="mt-2 flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2 text-[11px] text-gray-400">
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-gray-400">
<span>{{ t(`ai.global.memory.source.${entry.sourceType}`) }}</span>
<span aria-hidden="true">·</span>
<span class="truncate">{{ formatUpdatedAt(entry.updatedAt) }}</span>
<span>{{ t('ai.global.memory.createdAt', { time: formatUpdatedAt(entry.createdAt) }) }}</span>
<template v-if="entry.updatedAt !== entry.createdAt">
<span aria-hidden="true">·</span>
<span>{{ t('ai.global.memory.updatedAt', { time: formatUpdatedAt(entry.updatedAt) }) }}</span>
</template>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<UButton
@@ -278,6 +574,28 @@ watch(
/>
</div>
</div>
<div
v-if="entry.sourceType === 'ai' || entry.sourceStatus !== 'none'"
class="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-gray-100 pt-2 text-[11px] dark:border-gray-800"
>
<span v-if="entry.sourceType === 'ai'" class="text-amber-600 dark:text-amber-400">
{{ t('ai.global.memory.aiSourceWarning') }}
</span>
<span v-if="entry.sourceStatus === 'unavailable'" class="ml-auto text-gray-400 dark:text-gray-500">
{{ t('ai.global.memory.sourceUnavailable') }}
</span>
<UButton
v-else-if="entry.sourceStatus === 'conversation' || entry.sourceStatus === 'message'"
class="ml-auto"
icon="i-lucide-external-link"
color="neutral"
variant="ghost"
size="xs"
@click="viewSource(entry)"
>
{{ t('ai.global.memory.viewSource') }}
</UButton>
</div>
</template>
</article>
</div>
+3 -1
View File
@@ -4,6 +4,8 @@
* 包含标题、描述、可选头像/图标,以及默认 slot 用于额外内容
*/
import { IS_ELECTRON } from '@/utils/platform'
const props = withDefaults(
defineProps<{
title: string
@@ -31,7 +33,7 @@ const props = withDefaults(
>
<!-- 拖拽区域 - 覆盖顶部安全区域(平台自适应)
macOS: 16px padding + 16px = 32px | Windows/Linux: 32px padding + 16px = 48px -->
<div class="titlebar-drag-cover" />
<div v-if="IS_ELECTRON" class="titlebar-drag-cover" />
<!-- 标题区域 -->
<div class="flex items-center justify-between">
+12
View File
@@ -287,10 +287,20 @@
"entities": "Contacts and groups",
"count": "{count} items",
"empty": "No memories yet. You can ask AI to remember something in a conversation.",
"add": "Add",
"createTitle": "Add memory",
"scopeLabel": "Save to: {scope}",
"selectEntity": "Select a contact or group",
"contentPlaceholder": "Enter one durable preference or fact",
"source": {
"user": "User confirmed",
"ai": "AI generated"
},
"createdAt": "Created {time}",
"updatedAt": "Updated {time}",
"viewSource": "View source conversation",
"sourceUnavailable": "Source is no longer available",
"aiSourceWarning": "Historical analysis lead; recheck the chat evidence before use",
"clearCurrent": "Clear current category",
"clearAll": "Clear all",
"confirmDelete": "Delete this memory?",
@@ -298,6 +308,8 @@
"confirmClearAll": "Clear all long-term memories? This cannot be undone.",
"toast": {
"loadFailed": "Failed to load memories",
"created": "Memory added",
"createFailed": "Failed to add memory",
"saved": "Memory updated",
"saveFailed": "Failed to update memory",
"deleted": "Memory deleted",
+12
View File
@@ -287,10 +287,20 @@
"entities": "連絡先とグループ",
"count": "{count} 件",
"empty": "メモリはまだありません。会話で AI に覚えるよう依頼できます。",
"add": "追加",
"createTitle": "メモリを追加",
"scopeLabel": "保存先: {scope}",
"selectEntity": "連絡先またはグループを選択",
"contentPlaceholder": "長期的に有効な設定または事実を入力",
"source": {
"user": "ユーザー確認済み",
"ai": "AI が生成"
},
"createdAt": "作成: {time}",
"updatedAt": "更新: {time}",
"viewSource": "元の会話を表示",
"sourceUnavailable": "参照元を利用できません",
"aiSourceWarning": "過去の分析メモです。使用前にチャットの根拠を再確認してください",
"clearCurrent": "現在の分類を消去",
"clearAll": "すべて消去",
"confirmDelete": "このメモリを削除しますか?",
@@ -298,6 +308,8 @@
"confirmClearAll": "長期メモリをすべて消去しますか?この操作は元に戻せません。",
"toast": {
"loadFailed": "メモリを読み込めませんでした",
"created": "メモリを追加しました",
"createFailed": "メモリを追加できませんでした",
"saved": "メモリを更新しました",
"saveFailed": "メモリを更新できませんでした",
"deleted": "メモリを削除しました",
+12
View File
@@ -287,10 +287,20 @@
"entities": "联系人和群聊",
"count": "{count} 条",
"empty": "暂无记忆,你可以在对话中让 AI 记住。",
"add": "新增",
"createTitle": "新增记忆",
"scopeLabel": "保存到:{scope}",
"selectEntity": "请选择联系人或群聊",
"contentPlaceholder": "输入一条长期有效的偏好或事实",
"source": {
"user": "用户确认",
"ai": "AI 形成"
},
"createdAt": "创建于 {time}",
"updatedAt": "更新于 {time}",
"viewSource": "查看来源对话",
"sourceUnavailable": "来源已不可用",
"aiSourceWarning": "历史分析线索,使用时需重新核验聊天证据",
"clearCurrent": "清空当前分类",
"clearAll": "清空全部",
"confirmDelete": "确定删除这条记忆吗?",
@@ -298,6 +308,8 @@
"confirmClearAll": "确定清空全部长期记忆吗?此操作无法撤销。",
"toast": {
"loadFailed": "加载记忆失败",
"created": "记忆已添加",
"createFailed": "添加记忆失败",
"saved": "记忆已更新",
"saveFailed": "更新记忆失败",
"deleted": "记忆已删除",
+12
View File
@@ -287,10 +287,20 @@
"entities": "聯絡人和群聊",
"count": "{count} 條",
"empty": "暫無記憶,你可以在對話中讓 AI 記住。",
"add": "新增",
"createTitle": "新增記憶",
"scopeLabel": "儲存到:{scope}",
"selectEntity": "請選擇聯絡人或群聊",
"contentPlaceholder": "輸入一條長期有效的偏好或事實",
"source": {
"user": "使用者確認",
"ai": "AI 形成"
},
"createdAt": "建立於 {time}",
"updatedAt": "更新於 {time}",
"viewSource": "查看來源對話",
"sourceUnavailable": "來源已無法使用",
"aiSourceWarning": "歷史分析線索,使用時需重新核驗聊天證據",
"clearCurrent": "清空目前分類",
"clearAll": "清空全部",
"confirmDelete": "確定刪除這條記憶嗎?",
@@ -298,6 +308,8 @@
"confirmClearAll": "確定清空全部長期記憶嗎?此操作無法復原。",
"toast": {
"loadFailed": "載入記憶失敗",
"created": "記憶已新增",
"createFailed": "新增記憶失敗",
"saved": "記憶已更新",
"saveFailed": "更新記憶失敗",
"deleted": "記憶已刪除",
+45 -3
View File
@@ -39,12 +39,14 @@ const chatInputRef = ref<{ clearDraft: () => void } | null>(null)
let isUnmounted = false
let conversationSelectionRequestId = 0
let contextEstimateRequestId = 0
let sourceHighlightTimer: ReturnType<typeof setTimeout> | null = null
function isGlobalPageActive(): boolean {
return !isUnmounted && route.name === 'global-ai'
}
const estimatedContextTokens = ref(0)
const showMemoryManager = ref(false)
const highlightedSourceMessageId = ref<string | null>(null)
const pairs = computed(() => groupMessagesToQAPairs(messages.value))
const { messagesContainer, showScrollToBottom, handleScrollToBottom, scrollToBottom } = useChatScroll(
@@ -82,7 +84,7 @@ async function loadConversations(): Promise<void> {
}
}
async function selectConversation(aiChatId: string): Promise<void> {
async function selectConversation(aiChatId: string, sourceMessageId: string | null = null): Promise<void> {
const currentRequest = ++conversationSelectionRequestId
const loaded = await aiChatStore.loadAIChat(chatKey, aiChatId)
if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return
@@ -95,7 +97,44 @@ async function selectConversation(aiChatId: string): Promise<void> {
await syncAIChatIdToRoute(aiChatId)
if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return
await nextTick()
scrollToBottom(true)
if (sourceMessageId) {
await revealSourceMessage(sourceMessageId)
} else {
highlightedSourceMessageId.value = null
scrollToBottom(true)
}
}
async function revealSourceMessage(messageId: string): Promise<void> {
const targetExists = pairs.value.some(
(pair) => pair.user?.id === messageId || pair.assistant?.id === messageId || pair.standalone?.id === messageId
)
if (!targetExists) {
toast.warn(t('ai.global.memory.sourceUnavailable'))
return
}
while (
hasOlderPairs.value &&
!visiblePairs.value.some(
(pair) => pair.user?.id === messageId || pair.assistant?.id === messageId || pair.standalone?.id === messageId
)
) {
await loadOlderPairs()
}
highlightedSourceMessageId.value = messageId
await nextTick()
const element = messagesContainer.value?.querySelector<HTMLElement>(`[data-ai-message-id="${CSS.escape(messageId)}"]`)
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
if (sourceHighlightTimer) clearTimeout(sourceHighlightTimer)
sourceHighlightTimer = setTimeout(() => {
if (highlightedSourceMessageId.value === messageId) highlightedSourceMessageId.value = null
}, 4000)
}
async function handleViewMemorySource(payload: { aiChatId: string; messageId: string | null }): Promise<void> {
await selectConversation(payload.aiChatId, payload.messageId)
}
function startNewConversation(): void {
@@ -227,6 +266,7 @@ onUnmounted(() => {
isUnmounted = true
conversationSelectionRequestId++
contextEstimateRequestId++
if (sourceHighlightTimer) clearTimeout(sourceHighlightTimer)
})
</script>
@@ -319,6 +359,7 @@ onUnmounted(() => {
:content="pair.user.content"
:timestamp="pair.user.timestamp"
:entity-refs="pair.user.entityRefs"
:highlighted="highlightedSourceMessageId === pair.user.id"
/>
<ChatMessage
v-if="
@@ -334,6 +375,7 @@ onUnmounted(() => {
:content-blocks="pair.assistant.contentBlocks"
:show-capture-button="!pair.assistant.isStreaming"
:active-tool="pair.assistant.isStreaming ? state.currentToolStatus : null"
:highlighted="highlightedSourceMessageId === pair.assistant.id"
/>
<AIThinkingIndicator
v-else-if="pair.assistant?.isStreaming"
@@ -384,6 +426,6 @@ onUnmounted(() => {
</section>
</div>
<AIMemoryManagerModal v-model:open="showMemoryManager" />
<AIMemoryManagerModal v-model:open="showMemoryManager" @view-source="handleViewMemorySource" />
</div>
</template>
+3
View File
@@ -17,6 +17,7 @@ export interface AgentStreamChunk {
| 'tool_start'
| 'tool_update'
| 'tool_result'
| 'memory_change'
| 'status'
| 'compression_done'
| 'route'
@@ -35,6 +36,8 @@ export interface AgentStreamChunk {
toolProgress?: ToolProgress
toolResult?: unknown
toolIsError?: boolean
memoryId?: string
provenanceToken?: string
error?: unknown
isFinished?: boolean
usage?: TokenUsage
+13 -8
View File
@@ -20,11 +20,12 @@ import type {
ToolCatalogEntry,
ToolExecuteResult,
DesensitizeRule,
AIMemoryEntry,
AIMemoryManagementEntry,
AIMemoryScope,
CreateUserAIMemoryInput,
ClearAIMemoriesInput,
} from './types'
import type { LinkAIMemorySourcesInput, LinkAIMemorySourcesResult } from '@openchatlab/shared-types'
import { get, post, put, del, fetchWithAuth } from '../utils/http'
const NOT_AVAILABLE_WEB = 'This feature is not available in web mode'
@@ -64,19 +65,19 @@ export class FetchAIAdapter implements AIAdapter {
}
// ===== 长期记忆 =====
async getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryEntry[]> {
if (!scope) return get<AIMemoryEntry[]>('/ai/memories')
async getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryManagementEntry[]> {
if (!scope) return get<AIMemoryManagementEntry[]>('/ai/memories')
const params = new URLSearchParams({ scopeType: scope.scopeType })
if (scope.scopeId) params.set('scopeId', scope.scopeId)
return get<AIMemoryEntry[]>(`/ai/memories?${params.toString()}`)
return get<AIMemoryManagementEntry[]>(`/ai/memories?${params.toString()}`)
}
async createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryEntry> {
return post<AIMemoryEntry>('/ai/memories', input)
async createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryManagementEntry> {
return post<AIMemoryManagementEntry>('/ai/memories', input)
}
async updateAIMemory(id: string, content: string): Promise<AIMemoryEntry> {
return put<AIMemoryEntry>(`/ai/memories/${id}`, { content })
async updateAIMemory(id: string, content: string): Promise<AIMemoryManagementEntry> {
return put<AIMemoryManagementEntry>(`/ai/memories/${id}`, { content })
}
async deleteAIMemory(id: string): Promise<{ success: boolean }> {
@@ -87,6 +88,10 @@ export class FetchAIAdapter implements AIAdapter {
return post<{ success: boolean; cleared: number }>('/ai/memories/clear', input)
}
async linkAIMemorySources(input: LinkAIMemorySourcesInput): Promise<LinkAIMemorySourcesResult> {
return post<LinkAIMemorySourcesResult>('/ai/memories/link-sources', input)
}
// ===== 消息 =====
async getMessages(aiChatId: string): Promise<AIMessage[]> {
return get<AIMessage[]>(`/ai/chats/${aiChatId}/messages`)
+13 -5
View File
@@ -1,6 +1,13 @@
import type { TimeFilter } from '@/types/base'
import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core'
import type { AIEntityRef, AIMemoryEntry, AIMemoryScope, CrossChatEvidencePayload } from '@openchatlab/shared-types'
import type {
AIEntityRef,
AIMemoryManagementEntry,
AIMemoryScope,
CrossChatEvidencePayload,
LinkAIMemorySourcesInput,
LinkAIMemorySourcesResult,
} from '@openchatlab/shared-types'
import type { PlanContentBlock } from './planBlocks'
export interface AIChat {
@@ -14,7 +21,7 @@ export interface AIChat {
updatedAt: number
}
export type { AIEntityRef, AIMemoryEntry, AIMemoryScope } from '@openchatlab/shared-types'
export type { AIEntityRef, AIMemoryManagementEntry, AIMemoryScope } from '@openchatlab/shared-types'
export type CreateUserAIMemoryInput = AIMemoryScope & { content: string }
export type ClearAIMemoriesInput = AIMemoryScope | { all: true }
@@ -133,11 +140,12 @@ export interface AIAdapter {
deleteAIChat(aiChatId: string): Promise<boolean>
// ===== 长期记忆 =====
getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryEntry[]>
createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryEntry>
updateAIMemory(id: string, content: string): Promise<AIMemoryEntry>
getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryManagementEntry[]>
createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryManagementEntry>
updateAIMemory(id: string, content: string): Promise<AIMemoryManagementEntry>
deleteAIMemory(id: string): Promise<{ success: boolean }>
clearAIMemories(input: ClearAIMemoriesInput): Promise<{ success: boolean; cleared: number }>
linkAIMemorySources(input: LinkAIMemorySourcesInput): Promise<LinkAIMemorySourcesResult>
// ===== 消息 =====
getMessages(aiChatId: string): Promise<AIMessage[]>
+42 -5
View File
@@ -50,6 +50,7 @@ import {
} from './aiChatContentBlocks'
import { createToolLifecycleTracker, type ToolStatus } from './aiToolLifecycle'
import { applyQueuedStreamTextDeltas, createAIStreamTextBatcher } from './aiChatStreamBatcher'
import { reportError } from '@/services/log-report'
export type { ToolStatus } from './aiToolLifecycle'
@@ -1041,6 +1042,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
// 在 try 外部声明,以便 catch 块能正确引用当前轮次的用户消息
let currentUserMessage: ChatMessage | undefined
let lastDoneUsage: TokenUsage | undefined
const changedMemoryIds = new Set<string>()
let memoryProvenanceToken: string | null = null
targetBuffer.assistantId = state.selectedAssistantId
targetBuffer.loaded = true
@@ -1318,6 +1321,11 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
break
case 'memory_change':
if (chunk.memoryId) changedMemoryIds.add(chunk.memoryId)
if (chunk.provenanceToken) memoryProvenanceToken = chunk.provenanceToken
break
case 'plan':
if (chunk.plan) {
appendPlanToBlocks(chunk.plan)
@@ -1433,7 +1441,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
resolvedAIChatId,
userMessage,
targetBuffer.messages[aiMessageIndex],
lastDoneUsage
lastDoneUsage,
[...changedMemoryIds],
memoryProvenanceToken
)
if (!savedMessages) return { success: false, reason: 'error' }
Object.assign(userMessage, savedMessages.userMessage)
@@ -1457,7 +1467,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
resolvedAIChatId,
userMessage,
targetBuffer.messages[aiMessageIndex],
lastDoneUsage
lastDoneUsage,
[...changedMemoryIds],
memoryProvenanceToken
)
if (!savedMessages) return { success: false, reason: 'error' }
Object.assign(userMessage, savedMessages.userMessage)
@@ -1471,7 +1483,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
resolvedAIChatId,
userMessage,
targetBuffer.messages[aiMessageIndex],
lastDoneUsage
lastDoneUsage,
[...changedMemoryIds],
memoryProvenanceToken
)
if (!savedMessages) return { success: false, reason: 'error' }
Object.assign(userMessage, savedMessages.userMessage)
@@ -1505,7 +1519,14 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
// 优先使用当前轮次的用户消息,避免多轮对话取到第一条历史消息
const userMsg = currentUserMessage || targetBuffer.messages.findLast((m) => m.role === 'user')
if (userMsg) {
await saveAIChatMessages(resolvedAIChatId, userMsg, lastMessage, lastDoneUsage)
await saveAIChatMessages(
resolvedAIChatId,
userMsg,
lastMessage,
lastDoneUsage,
[...changedMemoryIds],
memoryProvenanceToken
)
}
}
@@ -1525,7 +1546,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
aiChatId: string | null,
userMsg: ChatMessage,
aiMsg: ChatMessage,
tokenUsage?: TokenUsage
tokenUsage?: TokenUsage,
changedMemoryIds: string[] = [],
memoryProvenanceToken: string | null = null
): Promise<{ userMessage: ChatMessage; assistantMessage: ChatMessage } | null> {
try {
if (!aiChatId) {
@@ -1539,6 +1562,20 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
{ content: userMsg.content, entityRefs: userMsg.entityRefs },
{ content: aiMsg.content, contentBlocks: serializableContentBlocks, tokenUsage }
)
if (changedMemoryIds.length > 0) {
try {
if (!memoryProvenanceToken) throw new Error('Memory provenance token is missing')
await useAIService().linkAIMemorySources({
provenanceToken: memoryProvenanceToken,
aiChatId,
userMessageId: savedUserMessage.id,
assistantMessageId: savedAssistantMessage.id,
memoryIds: changedMemoryIds,
})
} catch (error) {
reportError('AI memory source linking failed', error instanceof Error ? error.stack : undefined)
}
}
return {
userMessage: toRuntimeMessage(savedUserMessage),
assistantMessage: toRuntimeMessage(savedAssistantMessage),