mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): link memories to source messages
This commit is contained in:
1 parent
cc240c85f5
commit
f4017b0adf
34 files changed
+1278
-72
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -319,6 +319,7 @@ export {
|
||||
runCrossChatAgent,
|
||||
} from './ai'
|
||||
export type { CrossChatAgentLogger, RunCrossChatAgentOptions } from './ai'
|
||||
export { linkAIMemorySources, listAIMemoriesWithSourceStatus, resolveAIMemorySourceStatus } from './ai'
|
||||
export type {
|
||||
AnalysisPlanIntent,
|
||||
AnalysisPlanner,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メモリを削除しました",
|
||||
|
||||
@@ -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": "记忆已删除",
|
||||
|
||||
@@ -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": "記憶已刪除",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user