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), maxToolResultTokens: resolveCrossChatToolResultTokenBudget(contextWindow),
memoryService: options.memoryService, memoryService: options.memoryService,
aiChatId, aiChatId,
reportMemoryChange: (memoryId) => onEvent({ type: 'memory_change', memoryId }),
}) })
await runCrossChatAgent({ await runCrossChatAgent({
userMessage, userMessage,
@@ -23,6 +23,7 @@ export function createCliCrossChatTools(options: {
maxToolResultTokens: number maxToolResultTokens: number
memoryService: AIMemoryService memoryService: AIMemoryService
aiChatId: string aiChatId: string
reportMemoryChange?: (memoryId: string) => void
}): AgentTool<any, any>[] { }): AgentTool<any, any>[] {
const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = { const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = {
locale: options.locale, locale: options.locale,
@@ -30,6 +31,7 @@ export function createCliCrossChatTools(options: {
analysisService: options.analysisService, analysisService: options.analysisService,
memoryService: options.memoryService, memoryService: options.memoryService,
aiChatId: options.aiChatId, aiChatId: options.aiChatId,
reportMemoryChange: options.reportMemoryChange,
maxToolResultTokens: options.maxToolResultTokens, maxToolResultTokens: options.maxToolResultTokens,
countTokens, countTokens,
preprocessMessagesBySession: (sessionId, messages) => preprocessMessagesBySession: (sessionId, messages) =>
@@ -155,6 +155,7 @@ export function createElectronRunAgentStream(
maxToolResultTokens: resolveCrossChatToolResultTokenBudget(resolvedContextWindow), maxToolResultTokens: resolveCrossChatToolResultTokenBudget(resolvedContextWindow),
memoryService: crossChatOptions.memoryService, memoryService: crossChatOptions.memoryService,
aiChatId, aiChatId,
reportMemoryChange: (memoryId) => onEvent({ type: 'memory_change', memoryId }),
}) })
await runCrossChatAgent({ await runCrossChatAgent({
userMessage, userMessage,
@@ -23,6 +23,7 @@ export function createElectronCrossChatTools(options: {
maxToolResultTokens: number maxToolResultTokens: number
memoryService: AIMemoryService memoryService: AIMemoryService
aiChatId: string aiChatId: string
reportMemoryChange?: (memoryId: string) => void
}): AgentTool<any, any>[] { }): AgentTool<any, any>[] {
const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = { const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = {
locale: options.locale, locale: options.locale,
@@ -30,6 +31,7 @@ export function createElectronCrossChatTools(options: {
analysisService: options.analysisService, analysisService: options.analysisService,
memoryService: options.memoryService, memoryService: options.memoryService,
aiChatId: options.aiChatId, aiChatId: options.aiChatId,
reportMemoryChange: options.reportMemoryChange,
maxToolResultTokens: options.maxToolResultTokens, maxToolResultTokens: options.maxToolResultTokens,
countTokens, countTokens,
preprocessMessagesBySession: (sessionId, messages) => preprocessMessagesBySession: (sessionId, messages) =>
@@ -1,20 +1,28 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { randomUUID } from 'node:crypto'
import type { AgentStreamRequest, AiRouteContext } from '../../context/ai' import type { AgentStreamRequest, AiRouteContext } from '../../context/ai'
import { chatTopicWorkCoordinator } from '@openchatlab/node-runtime' import { chatTopicWorkCoordinator } from '@openchatlab/node-runtime'
import type { MemoryProvenanceCoordinator } from './memory-provenance-coordinator'
const activeAgentAborts = new Map<string, AbortController>() 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 if (!ctx.runAgentStream) return
const runAgentStream = ctx.runAgentStream const runAgentStream = ctx.runAgentStream
server.post<{ Body: AgentStreamRequest }>('/_web/ai/agent/stream', async (request, reply) => { 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() const abortController = new AbortController()
activeAgentAborts.set(requestId, 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, { reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -50,6 +58,11 @@ export function registerAiAgentStreamRoutes(server: FastifyInstance, ctx: AiAgen
request.body, request.body,
(chunk) => { (chunk) => {
if (chunk.type === 'done') emittedDone = true 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) safeSendSSE(chunk.type, chunk)
}, },
abortController.signal abortController.signal
@@ -58,6 +71,7 @@ export function registerAiAgentStreamRoutes(server: FastifyInstance, ctx: AiAgen
const msg = error instanceof Error ? error.message : String(error) const msg = error instanceof Error ? error.message : String(error)
safeSendSSE('error', { type: 'error', error: { name: 'ServerError', message: msg } }) safeSendSSE('error', { type: 'error', error: { name: 'ServerError', message: msg } })
} finally { } finally {
memoryProvenanceCoordinator?.complete(requestId)
releaseInteractiveWork() releaseInteractiveWork()
if (!emittedDone) safeSendSSE('done', { type: 'done', isFinished: true }) if (!emittedDone) safeSendSSE('done', { type: 'done', isFinished: true })
activeAgentAborts.delete(requestId) activeAgentAborts.delete(requestId)
@@ -4,23 +4,29 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { describe, it } from 'node:test' import { describe, it } from 'node:test'
import Fastify from 'fastify' import Fastify from 'fastify'
import { AIMemoryService } from '@openchatlab/node-runtime' import { AIChatManager, AIMemoryService } from '@openchatlab/node-runtime'
import { registerAiMemoryRoutes } from './ai-memories' import { registerAiMemoryRoutes } from './ai-memories'
import { MemoryProvenanceCoordinator } from './memory-provenance-coordinator'
const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING
function createFixture() { function createFixture() {
const dir = mkdtempSync(join(tmpdir(), 'chatlab-ai-memory-routes-')) const dir = mkdtempSync(join(tmpdir(), 'chatlab-ai-memory-routes-'))
const service = new AIMemoryService(dir, { nativeBinding: sqliteNativeBinding }) const service = new AIMemoryService(dir, { nativeBinding: sqliteNativeBinding })
const aiChatManager = new AIChatManager(dir, { nativeBinding: sqliteNativeBinding })
const memoryProvenanceCoordinator = new MemoryProvenanceCoordinator()
const app = Fastify() const app = Fastify()
registerAiMemoryRoutes(app, { aiMemoryService: service }) registerAiMemoryRoutes(app, { aiMemoryService: service, aiChatManager }, memoryProvenanceCoordinator)
return { return {
app, app,
service, service,
aiChatManager,
memoryProvenanceCoordinator,
async close() { async close() {
await app.close() await app.close()
service.close() service.close()
aiChatManager.close()
try { try {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
} catch { } 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 () => { it('rejects invalid scope and content instead of changing stored memories', async () => {
const fixture = createFixture() const fixture = createFixture()
try { try {
@@ -1,8 +1,14 @@
import type { FastifyInstance, FastifyReply } from 'fastify' 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 { AiRouteContext } from '../../context/ai'
import type { MemoryProvenanceCoordinator } from './memory-provenance-coordinator'
type AiMemoryRouteContext = Pick<AiRouteContext, 'aiMemoryService'> type AiMemoryRouteContext = Pick<AiRouteContext, 'aiMemoryService' | 'aiChatManager'>
interface MemoryScopeInput { interface MemoryScopeInput {
scopeType?: string 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) }) 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 const memoryService = ctx.aiMemoryService
if (!memoryService) return 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<{ server.get<{
Querystring: MemoryScopeInput Querystring: MemoryScopeInput
@@ -36,9 +55,45 @@ export function registerAiMemoryRoutes(server: FastifyInstance, ctx: AiMemoryRou
const { scopeType, scopeId } = request.query const { scopeType, scopeId } = request.query
if (!scopeType) { if (!scopeType) {
if (scopeId != null) throw new Error('scopeType is required when scopeId is provided') 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) { } catch (error) {
return sendBadRequest(reply, error) return sendBadRequest(reply, error)
} }
@@ -49,13 +104,15 @@ export function registerAiMemoryRoutes(server: FastifyInstance, ctx: AiMemoryRou
}>('/_web/ai/memories', async (request, reply) => { }>('/_web/ai/memories', async (request, reply) => {
try { try {
const scope = parseScope(request.body ?? {}) const scope = parseScope(request.body ?? {})
return memoryService.create({ return withSourceStatus(
...scope, memoryService.create({
content: request.body?.content ?? '', ...scope,
sourceType: 'user', content: request.body?.content ?? '',
sourceAIChatId: null, sourceType: 'user',
sourceMessageId: null, sourceAIChatId: null,
}) sourceMessageId: null,
})
)
} catch (error) { } catch (error) {
return sendBadRequest(reply, 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' }) return reply.code(404).send({ error: 'AI memory not found' })
} }
try { try {
return memoryService.update(request.params.id, { return withSourceStatus(
content: request.body?.content ?? '', memoryService.update(request.params.id, {
sourceType: 'user', content: request.body?.content ?? '',
sourceAIChatId: null, sourceType: 'user',
sourceMessageId: null, sourceAIChatId: null,
}) sourceMessageId: null,
})
)
} catch (error) { } catch (error) {
return sendBadRequest(reply, 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 { registerAiSkillRoutes } from './ai-skills'
import { registerAiSummaryRoutes } from './ai-summaries' import { registerAiSummaryRoutes } from './ai-summaries'
import { registerAiToolRoutes } from './ai-tools' import { registerAiToolRoutes } from './ai-tools'
import { MemoryProvenanceCoordinator } from './memory-provenance-coordinator'
export type AiRoutesContext = AiRouteContext & export type AiRoutesContext = AiRouteContext &
Pick<RuntimeRouteContext, 'dbManager' | 'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding'> & 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. */ /** Register all AI-related Web routes while preserving optional-feature behavior. */
export function registerAiRoutes(server: FastifyInstance, ctx: AiRoutesContext, options?: AiRouteOptions): void { export function registerAiRoutes(server: FastifyInstance, ctx: AiRoutesContext, options?: AiRouteOptions): void {
if (options?.requireAi) assertRequiredAiContext(ctx) if (options?.requireAi) assertRequiredAiContext(ctx)
const memoryProvenanceCoordinator = new MemoryProvenanceCoordinator()
registerAiAssistantRoutes(server, ctx) registerAiAssistantRoutes(server, ctx)
registerAiSkillRoutes(server, ctx) registerAiSkillRoutes(server, ctx)
registerAiLlmRoutes(server, ctx) registerAiLlmRoutes(server, ctx)
registerAiLlmStreamRoutes(server, ctx) registerAiLlmStreamRoutes(server, ctx)
registerAiAgentStreamRoutes(server, ctx) registerAiAgentStreamRoutes(server, ctx, memoryProvenanceCoordinator)
registerAiToolRoutes(server, ctx) registerAiToolRoutes(server, ctx)
registerAiChatRoutes(server, ctx) registerAiChatRoutes(server, ctx)
registerAiMemoryRoutes(server, ctx) registerAiMemoryRoutes(server, ctx, memoryProvenanceCoordinator)
registerAiChatTopicRoutes(server, ctx) registerAiChatTopicRoutes(server, ctx)
registerAiSummaryRoutes(server, ctx) registerAiSummaryRoutes(server, ctx)
registerAiLogRoutes(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_start'
| 'tool_update' | 'tool_update'
| 'tool_result' | 'tool_result'
| 'memory_change'
| 'status' | 'status'
| 'compression_done' | 'compression_done'
| 'route' | 'route'
@@ -56,6 +57,8 @@ export interface AgentStreamChunk {
toolProgress?: ToolProgress toolProgress?: ToolProgress
toolResult?: unknown toolResult?: unknown
toolIsError?: boolean toolIsError?: boolean
memoryId?: string
provenanceToken?: string
error?: unknown error?: unknown
isFinished?: boolean isFinished?: boolean
usage?: TokenUsage usage?: TokenUsage
+5
View File
@@ -811,6 +811,11 @@ export class AIChatManager {
return this.getActivePathRows(aiChatId).map((row) => this.parseMessageRow(row)) 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 { deleteMessage(messageId: string): boolean {
const db = this.getDb() const db = this.getDb()
const result = db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId) const result = db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId)
+1
View File
@@ -49,6 +49,7 @@ export type {
} from './chats' } from './chats'
export { AI_MEMORY_CONTENT_MAX_CHARS, AIMemoryService, buildGlobalMemoryPrompt } from './memory' export { AI_MEMORY_CONTENT_MAX_CHARS, AIMemoryService, buildGlobalMemoryPrompt } from './memory'
export type { AIMemoryServiceOptions, CreateAIMemoryInput, UpdateAIMemoryInput } from './memory' export type { AIMemoryServiceOptions, CreateAIMemoryInput, UpdateAIMemoryInput } from './memory'
export { linkAIMemorySources, listAIMemoriesWithSourceStatus, resolveAIMemorySourceStatus } from './memory-provenance'
// Tokenizer // Tokenizer
export { countTokens, countMessagesTokens, initTokenizer } from './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 { forget(id: string): boolean {
return this.runWrite('forget', () => this.getDb().prepare('DELETE FROM ai_memory WHERE id = ?').run(id).changes > 0) 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, runCrossChatAgent,
} from './ai' } from './ai'
export type { CrossChatAgentLogger, RunCrossChatAgentOptions } from './ai' export type { CrossChatAgentLogger, RunCrossChatAgentOptions } from './ai'
export { linkAIMemorySources, listAIMemoriesWithSourceStatus, resolveAIMemorySourceStatus } from './ai'
export type { export type {
AnalysisPlanIntent, AnalysisPlanIntent,
AnalysisPlanner, AnalysisPlanner,
+18
View File
@@ -138,6 +138,7 @@ export type AIEntityRef =
export type AIMemoryScopeType = 'global' | 'self' | 'contact' | 'group' export type AIMemoryScopeType = 'global' | 'self' | 'contact' | 'group'
export type AIMemorySourceType = 'user' | 'ai' export type AIMemorySourceType = 'user' | 'ai'
export type AIMemorySourceStatus = 'none' | 'conversation' | 'message' | 'unavailable'
export interface AIMemoryScope { export interface AIMemoryScope {
scopeType: AIMemoryScopeType scopeType: AIMemoryScopeType
@@ -154,6 +155,23 @@ export interface AIMemoryEntry extends AIMemoryScope {
updatedAt: number 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 { export {
CHATLAB_FORMAT_VERSION, CHATLAB_FORMAT_VERSION,
CHATLAB_SUPPORTED_FORMAT_VERSIONS, CHATLAB_SUPPORTED_FORMAT_VERSIONS,
@@ -116,7 +116,11 @@ function entityResolution(refs: AIEntityRef[]): CrossChatEntityResolution {
} }
function createContext( function createContext(
options: { entityRefs?: AIEntityRef[]; resolvedEntityRefs?: AIEntityRef[] } = {} options: {
entityRefs?: AIEntityRef[]
resolvedEntityRefs?: AIEntityRef[]
reportMemoryChange?: (memoryId: string) => void
} = {}
): CrossChatToolExecutionContext { ): CrossChatToolExecutionContext {
const analysisService = { const analysisService = {
resolveEntities: async (refs: AIEntityRef[]) => entityResolution(refs), resolveEntities: async (refs: AIEntityRef[]) => entityResolution(refs),
@@ -128,6 +132,7 @@ function createContext(
analysisService, analysisService,
memoryService: new FakeMemoryService(), memoryService: new FakeMemoryService(),
aiChatId: 'global-chat-1', aiChatId: 'global-chat-1',
reportMemoryChange: options.reportMemoryChange,
preprocessMessagesBySession: (_sessionId, messages) => messages, preprocessMessagesBySession: (_sessionId, messages) => messages,
preprocessSummariesBySession: (_sessionId, summaries) => summaries, preprocessSummariesBySession: (_sessionId, summaries) => summaries,
preprocessModelLabel: (value) => value, preprocessModelLabel: (value) => value,
@@ -157,7 +162,8 @@ describe('global memory tool registry', () => {
describe('global memory tools', () => { describe('global memory tools', () => {
it('writes user memory with runtime-owned source metadata and updates only the same scope', async () => { 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( const created = await memoryWriteTool.handler(
{ {
scope_type: 'contact', scope_type: 'contact',
@@ -182,6 +188,7 @@ describe('global memory tools', () => {
context context
) )
assert.equal((updated.data as AIMemoryEntry).content, '用户纠正为高中同学') assert.equal((updated.data as AIMemoryEntry).content, '用户纠正为高中同学')
assert.deepEqual(changedMemoryIds, [createdEntry.id, createdEntry.id])
await assert.rejects( await assert.rejects(
async () => async () =>
@@ -128,6 +128,7 @@ async function memoryWriteHandler(
}) })
} }
context.reportMemoryChange?.(entry.id)
return { content: JSON.stringify(entry), data: entry } return { content: JSON.stringify(entry), data: entry }
} }
+2
View File
@@ -438,6 +438,8 @@ export interface CrossChatToolExecutionContext {
analysisService: CrossChatAnalysisToolService analysisService: CrossChatAnalysisToolService
memoryService: AIMemoryToolService memoryService: AIMemoryToolService
aiChatId: string aiChatId: string
/** Reports a successful memory create/update without exposing it to the model context. */
reportMemoryChange?: (memoryId: string) => void
preprocessMessagesBySession: ( preprocessMessagesBySession: (
sessionId: string, sessionId: string,
messages: CrossChatMessageSource[] messages: CrossChatMessageSource[]
+9 -6
View File
@@ -366,11 +366,14 @@ onMounted(async () => {
// 同步原生 modal 在异步锁屏就绪前保持底层文档 inert;chunk 加载失败时不会自动放行。 // 同步原生 modal 在异步锁屏就绪前保持底层文档 inert;chunk 加载失败时不会自动放行。
syncBootstrapMask(bootstrapDialogRef.value) syncBootstrapMask(bootstrapDialogRef.value)
window.addEventListener('keydown', handleGlobalKeydown) window.addEventListener('keydown', handleGlobalKeydown)
const platform = navigator.platform.toLowerCase() if (IS_ELECTRON) {
if (platform.includes('win')) { document.documentElement.classList.add('platform-electron')
document.documentElement.classList.add('platform-windows') const platform = navigator.platform.toLowerCase()
} else if (platform.includes('linux')) { if (platform.includes('win')) {
document.documentElement.classList.add('platform-linux') document.documentElement.classList.add('platform-windows')
} else if (platform.includes('linux')) {
document.documentElement.classList.add('platform-linux')
}
} }
if (IS_ELECTRON) { if (IS_ELECTRON) {
@@ -417,7 +420,7 @@ onUnmounted(() => {
</template> </template>
<template v-else> <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 class="relative flex h-screen w-full overflow-hidden bg-page-bg dark:bg-page-dark">
<!-- 运行时就绪后先在启动屏下挂载真实界面,利用动画时间完成布局和页面预热。 --> <!-- 运行时就绪后先在启动屏下挂载真实界面,利用动画时间完成布局和页面预热。 -->
<div <div
+5 -1
View File
@@ -67,11 +67,15 @@
background-color: var(--color-gray-600); background-color: var(--color-gray-600);
} }
/* 标题栏安全区域高度 - 页面顶部 padding 需要避开此区域 /* 页面顶部偏移:CLI Web 用作视觉留白,Electron 同时用于避开标题栏
* macOS: 红绿灯在左侧由 Sidebar 处理,右侧内容区无遮挡,使用较小值 * macOS: 红绿灯在左侧由 Sidebar 处理,右侧内容区无遮挡,使用较小值
* Windows: 跟随 Window Controls Overlay 提供的系统高度 * Windows: 跟随 Window Controls Overlay 提供的系统高度
* Linux: 自定义窗口控制按钮固定占据 32px 高度 */ * Linux: 自定义窗口控制按钮固定占据 32px 高度 */
:root { :root {
--titlebar-area-height: 1rem;
}
.platform-electron {
--titlebar-area-height: 1rem; /* 16px - macOS */ --titlebar-area-height: 1rem; /* 16px - macOS */
} }
+7 -1
View File
@@ -44,6 +44,7 @@ const props = defineProps<{
contentBlocks?: ContentBlock[] contentBlocks?: ContentBlock[]
/** 是否显示截屏按钮(仅 AI 回复) */ /** 是否显示截屏按钮(仅 AI 回复) */
showCaptureButton?: boolean showCaptureButton?: boolean
highlighted?: boolean
editable?: boolean editable?: boolean
/** Live tool status for the streaming process header. */ /** Live tool status for the streaming process header. */
activeTool?: { activeTool?: {
@@ -786,7 +787,12 @@ async function handleCopyMarkdown() {
<template> <template>
<div <div
class="flex items-start gap-3" 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 <div
@@ -1,15 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' 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 { useAIService, useDataService } from '@/services'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
type MemoryView = 'preferences' | 'self' | 'entities' 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 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 { t, locale } = useI18n()
const toast = useToast() const toast = useToast()
@@ -18,12 +30,23 @@ const dataService = useDataService()
const sessionStore = useSessionStore() const sessionStore = useSessionStore()
const activeView = ref<MemoryView>('preferences') const activeView = ref<MemoryView>('preferences')
const entries = ref<AIMemoryEntry[]>([]) const entries = ref<AIMemoryManagementEntry[]>([])
const contactNames = ref(new Map<string, string>()) const contactNames = ref(new Map<string, string>())
const loading = ref(false) const loading = ref(false)
const savingId = ref<string | null>(null) const savingId = ref<string | null>(null)
const editingId = ref<string | null>(null) const editingId = ref<string | null>(null)
const editingContent = ref('') 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(() => [ const viewItems = computed(() => [
{ label: t('ai.global.memory.preferences'), value: 'preferences' }, { label: t('ai.global.memory.preferences'), value: 'preferences' },
@@ -39,11 +62,41 @@ const visibleEntries = computed(() =>
: entry.scopeType === 'contact' || entry.scopeType === 'group' : 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 { function close(): void {
emit('update:open', false) emit('update:open', false)
} }
function startEditing(entry: AIMemoryEntry): void { function startEditing(entry: AIMemoryManagementEntry): void {
editingId.value = entry.id editingId.value = entry.id
editingContent.value = entry.content editingContent.value = entry.content
} }
@@ -53,6 +106,31 @@ function stopEditing(): void {
editingContent.value = '' 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 { function formatUpdatedAt(timestamp: number): string {
return new Intl.DateTimeFormat(locale.value, { return new Intl.DateTimeFormat(locale.value, {
year: 'numeric', year: 'numeric',
@@ -63,13 +141,22 @@ function formatUpdatedAt(timestamp: number): string {
}).format(timestamp) }).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.scopeId) return ''
if (entry.scopeType === 'contact') return contactNames.value.get(entry.scopeId) ?? entry.scopeId if (entry.scopeType === 'contact') return contactNames.value.get(entry.scopeId) ?? entry.scopeId
return sessionStore.sessions.find((session) => session.id === entry.scopeId)?.name ?? 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 = [ const keys = [
...new Set(memories.filter((entry) => entry.scopeType === 'contact').map((entry) => entry.scopeId)), ...new Set(memories.filter((entry) => entry.scopeType === 'contact').map((entry) => entry.scopeId)),
].filter((key): key is string => Boolean(key)) ].filter((key): key is string => Boolean(key))
@@ -85,6 +172,68 @@ async function loadContactNames(memories: AIMemoryEntry[]): Promise<void> {
contactNames.value = names 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> { async function loadMemories(): Promise<void> {
loading.value = true loading.value = true
try { 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 if (!editingContent.value.trim()) return
savingId.value = entry.id savingId.value = entry.id
try { 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 if (!confirm(t('ai.global.memory.confirmDelete'))) return
savingId.value = entry.id savingId.value = entry.id
try { try {
@@ -171,10 +350,37 @@ watch(
() => props.open, () => props.open,
(open) => { (open) => {
if (open) void loadMemories() if (open) void loadMemories()
else stopEditing() else {
stopEditing()
stopCreating()
}
}, },
{ immediate: true } { 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> </script>
<template> <template>
@@ -199,9 +405,95 @@ watch(
<div class="flex min-h-0 flex-1 flex-col px-5 py-4"> <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"> <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" /> <UTabs v-model="activeView" :items="viewItems" :content="false" size="sm" class="min-w-max" />
<span class="text-xs text-gray-400"> <div class="flex items-center gap-2">
{{ t('ai.global.memory.count', { count: visibleEntries.length }) }} <span class="text-xs text-gray-400">
</span> {{ 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>
<div class="min-h-0 flex-1 overflow-y-auto pr-1"> <div class="min-h-0 flex-1 overflow-y-auto pr-1">
@@ -253,10 +545,14 @@ watch(
{{ entry.content }} {{ entry.content }}
</p> </p>
<div class="mt-2 flex items-center justify-between gap-3"> <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>{{ t(`ai.global.memory.source.${entry.sourceType}`) }}</span>
<span aria-hidden="true">·</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>
<div class="flex shrink-0 items-center gap-0.5"> <div class="flex shrink-0 items-center gap-0.5">
<UButton <UButton
@@ -278,6 +574,28 @@ watch(
/> />
</div> </div>
</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> </template>
</article> </article>
</div> </div>
+3 -1
View File
@@ -4,6 +4,8 @@
* 包含标题、描述、可选头像/图标,以及默认 slot 用于额外内容 * 包含标题、描述、可选头像/图标,以及默认 slot 用于额外内容
*/ */
import { IS_ELECTRON } from '@/utils/platform'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
title: string title: string
@@ -31,7 +33,7 @@ const props = withDefaults(
> >
<!-- 拖拽区域 - 覆盖顶部安全区域(平台自适应) <!-- 拖拽区域 - 覆盖顶部安全区域(平台自适应)
macOS: 16px padding + 16px = 32px | Windows/Linux: 32px padding + 16px = 48px --> 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"> <div class="flex items-center justify-between">
+12
View File
@@ -287,10 +287,20 @@
"entities": "Contacts and groups", "entities": "Contacts and groups",
"count": "{count} items", "count": "{count} items",
"empty": "No memories yet. You can ask AI to remember something in a conversation.", "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": { "source": {
"user": "User confirmed", "user": "User confirmed",
"ai": "AI generated" "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", "clearCurrent": "Clear current category",
"clearAll": "Clear all", "clearAll": "Clear all",
"confirmDelete": "Delete this memory?", "confirmDelete": "Delete this memory?",
@@ -298,6 +308,8 @@
"confirmClearAll": "Clear all long-term memories? This cannot be undone.", "confirmClearAll": "Clear all long-term memories? This cannot be undone.",
"toast": { "toast": {
"loadFailed": "Failed to load memories", "loadFailed": "Failed to load memories",
"created": "Memory added",
"createFailed": "Failed to add memory",
"saved": "Memory updated", "saved": "Memory updated",
"saveFailed": "Failed to update memory", "saveFailed": "Failed to update memory",
"deleted": "Memory deleted", "deleted": "Memory deleted",
+12
View File
@@ -287,10 +287,20 @@
"entities": "連絡先とグループ", "entities": "連絡先とグループ",
"count": "{count} 件", "count": "{count} 件",
"empty": "メモリはまだありません。会話で AI に覚えるよう依頼できます。", "empty": "メモリはまだありません。会話で AI に覚えるよう依頼できます。",
"add": "追加",
"createTitle": "メモリを追加",
"scopeLabel": "保存先: {scope}",
"selectEntity": "連絡先またはグループを選択",
"contentPlaceholder": "長期的に有効な設定または事実を入力",
"source": { "source": {
"user": "ユーザー確認済み", "user": "ユーザー確認済み",
"ai": "AI が生成" "ai": "AI が生成"
}, },
"createdAt": "作成: {time}",
"updatedAt": "更新: {time}",
"viewSource": "元の会話を表示",
"sourceUnavailable": "参照元を利用できません",
"aiSourceWarning": "過去の分析メモです。使用前にチャットの根拠を再確認してください",
"clearCurrent": "現在の分類を消去", "clearCurrent": "現在の分類を消去",
"clearAll": "すべて消去", "clearAll": "すべて消去",
"confirmDelete": "このメモリを削除しますか?", "confirmDelete": "このメモリを削除しますか?",
@@ -298,6 +308,8 @@
"confirmClearAll": "長期メモリをすべて消去しますか?この操作は元に戻せません。", "confirmClearAll": "長期メモリをすべて消去しますか?この操作は元に戻せません。",
"toast": { "toast": {
"loadFailed": "メモリを読み込めませんでした", "loadFailed": "メモリを読み込めませんでした",
"created": "メモリを追加しました",
"createFailed": "メモリを追加できませんでした",
"saved": "メモリを更新しました", "saved": "メモリを更新しました",
"saveFailed": "メモリを更新できませんでした", "saveFailed": "メモリを更新できませんでした",
"deleted": "メモリを削除しました", "deleted": "メモリを削除しました",
+12
View File
@@ -287,10 +287,20 @@
"entities": "联系人和群聊", "entities": "联系人和群聊",
"count": "{count} 条", "count": "{count} 条",
"empty": "暂无记忆,你可以在对话中让 AI 记住。", "empty": "暂无记忆,你可以在对话中让 AI 记住。",
"add": "新增",
"createTitle": "新增记忆",
"scopeLabel": "保存到:{scope}",
"selectEntity": "请选择联系人或群聊",
"contentPlaceholder": "输入一条长期有效的偏好或事实",
"source": { "source": {
"user": "用户确认", "user": "用户确认",
"ai": "AI 形成" "ai": "AI 形成"
}, },
"createdAt": "创建于 {time}",
"updatedAt": "更新于 {time}",
"viewSource": "查看来源对话",
"sourceUnavailable": "来源已不可用",
"aiSourceWarning": "历史分析线索,使用时需重新核验聊天证据",
"clearCurrent": "清空当前分类", "clearCurrent": "清空当前分类",
"clearAll": "清空全部", "clearAll": "清空全部",
"confirmDelete": "确定删除这条记忆吗?", "confirmDelete": "确定删除这条记忆吗?",
@@ -298,6 +308,8 @@
"confirmClearAll": "确定清空全部长期记忆吗?此操作无法撤销。", "confirmClearAll": "确定清空全部长期记忆吗?此操作无法撤销。",
"toast": { "toast": {
"loadFailed": "加载记忆失败", "loadFailed": "加载记忆失败",
"created": "记忆已添加",
"createFailed": "添加记忆失败",
"saved": "记忆已更新", "saved": "记忆已更新",
"saveFailed": "更新记忆失败", "saveFailed": "更新记忆失败",
"deleted": "记忆已删除", "deleted": "记忆已删除",
+12
View File
@@ -287,10 +287,20 @@
"entities": "聯絡人和群聊", "entities": "聯絡人和群聊",
"count": "{count} 條", "count": "{count} 條",
"empty": "暫無記憶,你可以在對話中讓 AI 記住。", "empty": "暫無記憶,你可以在對話中讓 AI 記住。",
"add": "新增",
"createTitle": "新增記憶",
"scopeLabel": "儲存到:{scope}",
"selectEntity": "請選擇聯絡人或群聊",
"contentPlaceholder": "輸入一條長期有效的偏好或事實",
"source": { "source": {
"user": "使用者確認", "user": "使用者確認",
"ai": "AI 形成" "ai": "AI 形成"
}, },
"createdAt": "建立於 {time}",
"updatedAt": "更新於 {time}",
"viewSource": "查看來源對話",
"sourceUnavailable": "來源已無法使用",
"aiSourceWarning": "歷史分析線索,使用時需重新核驗聊天證據",
"clearCurrent": "清空目前分類", "clearCurrent": "清空目前分類",
"clearAll": "清空全部", "clearAll": "清空全部",
"confirmDelete": "確定刪除這條記憶嗎?", "confirmDelete": "確定刪除這條記憶嗎?",
@@ -298,6 +308,8 @@
"confirmClearAll": "確定清空全部長期記憶嗎?此操作無法復原。", "confirmClearAll": "確定清空全部長期記憶嗎?此操作無法復原。",
"toast": { "toast": {
"loadFailed": "載入記憶失敗", "loadFailed": "載入記憶失敗",
"created": "記憶已新增",
"createFailed": "新增記憶失敗",
"saved": "記憶已更新", "saved": "記憶已更新",
"saveFailed": "更新記憶失敗", "saveFailed": "更新記憶失敗",
"deleted": "記憶已刪除", "deleted": "記憶已刪除",
+45 -3
View File
@@ -39,12 +39,14 @@ const chatInputRef = ref<{ clearDraft: () => void } | null>(null)
let isUnmounted = false let isUnmounted = false
let conversationSelectionRequestId = 0 let conversationSelectionRequestId = 0
let contextEstimateRequestId = 0 let contextEstimateRequestId = 0
let sourceHighlightTimer: ReturnType<typeof setTimeout> | null = null
function isGlobalPageActive(): boolean { function isGlobalPageActive(): boolean {
return !isUnmounted && route.name === 'global-ai' return !isUnmounted && route.name === 'global-ai'
} }
const estimatedContextTokens = ref(0) const estimatedContextTokens = ref(0)
const showMemoryManager = ref(false) const showMemoryManager = ref(false)
const highlightedSourceMessageId = ref<string | null>(null)
const pairs = computed(() => groupMessagesToQAPairs(messages.value)) const pairs = computed(() => groupMessagesToQAPairs(messages.value))
const { messagesContainer, showScrollToBottom, handleScrollToBottom, scrollToBottom } = useChatScroll( 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 currentRequest = ++conversationSelectionRequestId
const loaded = await aiChatStore.loadAIChat(chatKey, aiChatId) const loaded = await aiChatStore.loadAIChat(chatKey, aiChatId)
if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return
@@ -95,7 +97,44 @@ async function selectConversation(aiChatId: string): Promise<void> {
await syncAIChatIdToRoute(aiChatId) await syncAIChatIdToRoute(aiChatId)
if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return
await nextTick() 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 { function startNewConversation(): void {
@@ -227,6 +266,7 @@ onUnmounted(() => {
isUnmounted = true isUnmounted = true
conversationSelectionRequestId++ conversationSelectionRequestId++
contextEstimateRequestId++ contextEstimateRequestId++
if (sourceHighlightTimer) clearTimeout(sourceHighlightTimer)
}) })
</script> </script>
@@ -319,6 +359,7 @@ onUnmounted(() => {
:content="pair.user.content" :content="pair.user.content"
:timestamp="pair.user.timestamp" :timestamp="pair.user.timestamp"
:entity-refs="pair.user.entityRefs" :entity-refs="pair.user.entityRefs"
:highlighted="highlightedSourceMessageId === pair.user.id"
/> />
<ChatMessage <ChatMessage
v-if=" v-if="
@@ -334,6 +375,7 @@ onUnmounted(() => {
:content-blocks="pair.assistant.contentBlocks" :content-blocks="pair.assistant.contentBlocks"
:show-capture-button="!pair.assistant.isStreaming" :show-capture-button="!pair.assistant.isStreaming"
:active-tool="pair.assistant.isStreaming ? state.currentToolStatus : null" :active-tool="pair.assistant.isStreaming ? state.currentToolStatus : null"
:highlighted="highlightedSourceMessageId === pair.assistant.id"
/> />
<AIThinkingIndicator <AIThinkingIndicator
v-else-if="pair.assistant?.isStreaming" v-else-if="pair.assistant?.isStreaming"
@@ -384,6 +426,6 @@ onUnmounted(() => {
</section> </section>
</div> </div>
<AIMemoryManagerModal v-model:open="showMemoryManager" /> <AIMemoryManagerModal v-model:open="showMemoryManager" @view-source="handleViewMemorySource" />
</div> </div>
</template> </template>
+3
View File
@@ -17,6 +17,7 @@ export interface AgentStreamChunk {
| 'tool_start' | 'tool_start'
| 'tool_update' | 'tool_update'
| 'tool_result' | 'tool_result'
| 'memory_change'
| 'status' | 'status'
| 'compression_done' | 'compression_done'
| 'route' | 'route'
@@ -35,6 +36,8 @@ export interface AgentStreamChunk {
toolProgress?: ToolProgress toolProgress?: ToolProgress
toolResult?: unknown toolResult?: unknown
toolIsError?: boolean toolIsError?: boolean
memoryId?: string
provenanceToken?: string
error?: unknown error?: unknown
isFinished?: boolean isFinished?: boolean
usage?: TokenUsage usage?: TokenUsage
+13 -8
View File
@@ -20,11 +20,12 @@ import type {
ToolCatalogEntry, ToolCatalogEntry,
ToolExecuteResult, ToolExecuteResult,
DesensitizeRule, DesensitizeRule,
AIMemoryEntry, AIMemoryManagementEntry,
AIMemoryScope, AIMemoryScope,
CreateUserAIMemoryInput, CreateUserAIMemoryInput,
ClearAIMemoriesInput, ClearAIMemoriesInput,
} from './types' } from './types'
import type { LinkAIMemorySourcesInput, LinkAIMemorySourcesResult } from '@openchatlab/shared-types'
import { get, post, put, del, fetchWithAuth } from '../utils/http' import { get, post, put, del, fetchWithAuth } from '../utils/http'
const NOT_AVAILABLE_WEB = 'This feature is not available in web mode' 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[]> { async getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryManagementEntry[]> {
if (!scope) return get<AIMemoryEntry[]>('/ai/memories') if (!scope) return get<AIMemoryManagementEntry[]>('/ai/memories')
const params = new URLSearchParams({ scopeType: scope.scopeType }) const params = new URLSearchParams({ scopeType: scope.scopeType })
if (scope.scopeId) params.set('scopeId', scope.scopeId) 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> { async createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryManagementEntry> {
return post<AIMemoryEntry>('/ai/memories', input) return post<AIMemoryManagementEntry>('/ai/memories', input)
} }
async updateAIMemory(id: string, content: string): Promise<AIMemoryEntry> { async updateAIMemory(id: string, content: string): Promise<AIMemoryManagementEntry> {
return put<AIMemoryEntry>(`/ai/memories/${id}`, { content }) return put<AIMemoryManagementEntry>(`/ai/memories/${id}`, { content })
} }
async deleteAIMemory(id: string): Promise<{ success: boolean }> { 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) 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[]> { async getMessages(aiChatId: string): Promise<AIMessage[]> {
return get<AIMessage[]>(`/ai/chats/${aiChatId}/messages`) return get<AIMessage[]>(`/ai/chats/${aiChatId}/messages`)
+13 -5
View File
@@ -1,6 +1,13 @@
import type { TimeFilter } from '@/types/base' import type { TimeFilter } from '@/types/base'
import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core' import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core'
import type { AIEntityRef, AIMemoryEntry, AIMemoryScope, CrossChatEvidencePayload } from '@openchatlab/shared-types' import type {
AIEntityRef,
AIMemoryManagementEntry,
AIMemoryScope,
CrossChatEvidencePayload,
LinkAIMemorySourcesInput,
LinkAIMemorySourcesResult,
} from '@openchatlab/shared-types'
import type { PlanContentBlock } from './planBlocks' import type { PlanContentBlock } from './planBlocks'
export interface AIChat { export interface AIChat {
@@ -14,7 +21,7 @@ export interface AIChat {
updatedAt: number 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 CreateUserAIMemoryInput = AIMemoryScope & { content: string }
export type ClearAIMemoriesInput = AIMemoryScope | { all: true } export type ClearAIMemoriesInput = AIMemoryScope | { all: true }
@@ -133,11 +140,12 @@ export interface AIAdapter {
deleteAIChat(aiChatId: string): Promise<boolean> deleteAIChat(aiChatId: string): Promise<boolean>
// ===== 长期记忆 ===== // ===== 长期记忆 =====
getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryEntry[]> getAIMemories(scope?: AIMemoryScope): Promise<AIMemoryManagementEntry[]>
createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryEntry> createAIMemory(input: CreateUserAIMemoryInput): Promise<AIMemoryManagementEntry>
updateAIMemory(id: string, content: string): Promise<AIMemoryEntry> updateAIMemory(id: string, content: string): Promise<AIMemoryManagementEntry>
deleteAIMemory(id: string): Promise<{ success: boolean }> deleteAIMemory(id: string): Promise<{ success: boolean }>
clearAIMemories(input: ClearAIMemoriesInput): Promise<{ success: boolean; cleared: number }> clearAIMemories(input: ClearAIMemoriesInput): Promise<{ success: boolean; cleared: number }>
linkAIMemorySources(input: LinkAIMemorySourcesInput): Promise<LinkAIMemorySourcesResult>
// ===== 消息 ===== // ===== 消息 =====
getMessages(aiChatId: string): Promise<AIMessage[]> getMessages(aiChatId: string): Promise<AIMessage[]>
+42 -5
View File
@@ -50,6 +50,7 @@ import {
} from './aiChatContentBlocks' } from './aiChatContentBlocks'
import { createToolLifecycleTracker, type ToolStatus } from './aiToolLifecycle' import { createToolLifecycleTracker, type ToolStatus } from './aiToolLifecycle'
import { applyQueuedStreamTextDeltas, createAIStreamTextBatcher } from './aiChatStreamBatcher' import { applyQueuedStreamTextDeltas, createAIStreamTextBatcher } from './aiChatStreamBatcher'
import { reportError } from '@/services/log-report'
export type { ToolStatus } from './aiToolLifecycle' export type { ToolStatus } from './aiToolLifecycle'
@@ -1041,6 +1042,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
// 在 try 外部声明,以便 catch 块能正确引用当前轮次的用户消息 // 在 try 外部声明,以便 catch 块能正确引用当前轮次的用户消息
let currentUserMessage: ChatMessage | undefined let currentUserMessage: ChatMessage | undefined
let lastDoneUsage: TokenUsage | undefined let lastDoneUsage: TokenUsage | undefined
const changedMemoryIds = new Set<string>()
let memoryProvenanceToken: string | null = null
targetBuffer.assistantId = state.selectedAssistantId targetBuffer.assistantId = state.selectedAssistantId
targetBuffer.loaded = true targetBuffer.loaded = true
@@ -1318,6 +1321,11 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
} }
break break
case 'memory_change':
if (chunk.memoryId) changedMemoryIds.add(chunk.memoryId)
if (chunk.provenanceToken) memoryProvenanceToken = chunk.provenanceToken
break
case 'plan': case 'plan':
if (chunk.plan) { if (chunk.plan) {
appendPlanToBlocks(chunk.plan) appendPlanToBlocks(chunk.plan)
@@ -1433,7 +1441,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
resolvedAIChatId, resolvedAIChatId,
userMessage, userMessage,
targetBuffer.messages[aiMessageIndex], targetBuffer.messages[aiMessageIndex],
lastDoneUsage lastDoneUsage,
[...changedMemoryIds],
memoryProvenanceToken
) )
if (!savedMessages) return { success: false, reason: 'error' } if (!savedMessages) return { success: false, reason: 'error' }
Object.assign(userMessage, savedMessages.userMessage) Object.assign(userMessage, savedMessages.userMessage)
@@ -1457,7 +1467,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
resolvedAIChatId, resolvedAIChatId,
userMessage, userMessage,
targetBuffer.messages[aiMessageIndex], targetBuffer.messages[aiMessageIndex],
lastDoneUsage lastDoneUsage,
[...changedMemoryIds],
memoryProvenanceToken
) )
if (!savedMessages) return { success: false, reason: 'error' } if (!savedMessages) return { success: false, reason: 'error' }
Object.assign(userMessage, savedMessages.userMessage) Object.assign(userMessage, savedMessages.userMessage)
@@ -1471,7 +1483,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
resolvedAIChatId, resolvedAIChatId,
userMessage, userMessage,
targetBuffer.messages[aiMessageIndex], targetBuffer.messages[aiMessageIndex],
lastDoneUsage lastDoneUsage,
[...changedMemoryIds],
memoryProvenanceToken
) )
if (!savedMessages) return { success: false, reason: 'error' } if (!savedMessages) return { success: false, reason: 'error' }
Object.assign(userMessage, savedMessages.userMessage) Object.assign(userMessage, savedMessages.userMessage)
@@ -1505,7 +1519,14 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
// 优先使用当前轮次的用户消息,避免多轮对话取到第一条历史消息 // 优先使用当前轮次的用户消息,避免多轮对话取到第一条历史消息
const userMsg = currentUserMessage || targetBuffer.messages.findLast((m) => m.role === 'user') const userMsg = currentUserMessage || targetBuffer.messages.findLast((m) => m.role === 'user')
if (userMsg) { 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, aiChatId: string | null,
userMsg: ChatMessage, userMsg: ChatMessage,
aiMsg: ChatMessage, aiMsg: ChatMessage,
tokenUsage?: TokenUsage tokenUsage?: TokenUsage,
changedMemoryIds: string[] = [],
memoryProvenanceToken: string | null = null
): Promise<{ userMessage: ChatMessage; assistantMessage: ChatMessage } | null> { ): Promise<{ userMessage: ChatMessage; assistantMessage: ChatMessage } | null> {
try { try {
if (!aiChatId) { if (!aiChatId) {
@@ -1539,6 +1562,20 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
{ content: userMsg.content, entityRefs: userMsg.entityRefs }, { content: userMsg.content, entityRefs: userMsg.entityRefs },
{ content: aiMsg.content, contentBlocks: serializableContentBlocks, tokenUsage } { 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 { return {
userMessage: toRuntimeMessage(savedUserMessage), userMessage: toRuntimeMessage(savedUserMessage),
assistantMessage: toRuntimeMessage(savedAssistantMessage), assistantMessage: toRuntimeMessage(savedAssistantMessage),