diff --git a/packages/kit/src/storage/localStorageStrategy.ts b/packages/kit/src/storage/localStorageStrategy.ts index 66c9929fc..d52bb90ba 100644 --- a/packages/kit/src/storage/localStorageStrategy.ts +++ b/packages/kit/src/storage/localStorageStrategy.ts @@ -76,11 +76,16 @@ export class LocalStorageStrategy implements ConversationStorageStrategy { } deleteConversation(conversationId: string) { - const conversations = getConversations(this.storageKey) - const index = conversations.findIndex((item) => item.id === conversationId) - if (index !== -1) { - conversations.splice(index, 1) + try { + const conversations = getConversations(this.storageKey) + const index = conversations.findIndex((item) => item.id === conversationId) + if (index !== -1) { + conversations.splice(index, 1) + } + localStorage.setItem(this.storageKey, JSON.stringify(conversations)) + } catch (error) { + console.error('删除会话失败:', error) + throw error } - localStorage.setItem(this.storageKey, JSON.stringify(conversations)) } } diff --git a/packages/kit/src/storage/storageStrategies.test.ts b/packages/kit/src/storage/storageStrategies.test.ts new file mode 100644 index 000000000..18312ca94 --- /dev/null +++ b/packages/kit/src/storage/storageStrategies.test.ts @@ -0,0 +1,144 @@ +import 'fake-indexeddb/auto' +import { reactive } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ChatMessage } from '../types' +import type { ConversationInfo } from '../vue/conversation/types' +import { indexedDBStorageStrategyFactory, localStorageStrategyFactory } from './factories' + +const createLocalStorage = () => { + const values = new Map() + const storage: Storage = { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } + return { storage, values } +} + +const conversation = (id: string, updatedAt: number): ConversationInfo => ({ + id, + title: `Conversation ${id}`, + createdAt: updatedAt - 1, + updatedAt, + metadata: { source: 'test' }, +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('localStorageStrategyFactory', () => { + it('persists conversation metadata and restores legacy messages through the configured key', async () => { + const { storage, values } = createLocalStorage() + vi.stubGlobal('localStorage', storage) + const strategy = localStorageStrategyFactory({ key: 'conversation-test' }) + const legacyMessage = { + role: 'assistant', + content: 'stale', + renderContent: [ + { type: 'collapsible-text', content: 'thinking ' }, + { type: 'collapsible-text', content: 'done' }, + { type: 'markdown', content: 'Hello ' }, + { type: 'text', content: 'world' }, + ], + } as ChatMessage + + await strategy.saveConversation(conversation('one', 10)) + await strategy.saveConversation({ ...conversation('one', 20), title: 'Updated title' }) + await strategy.saveMessages('one', [legacyMessage]) + + expect(await strategy.loadConversations()).toEqual([ + { + id: 'one', + title: 'Updated title', + createdAt: 19, + updatedAt: 20, + metadata: { source: 'test' }, + }, + ]) + expect(await strategy.loadMessages('one')).toEqual([ + { + role: 'assistant', + content: 'Hello world', + reasoning_content: 'thinking done', + }, + ]) + expect(JSON.parse(values.get('conversation-test') ?? '[]')).toHaveLength(1) + + await strategy.deleteConversation?.('one') + + expect(await strategy.loadConversations()).toEqual([]) + expect(await strategy.loadMessages('one')).toEqual([]) + }) + + it('returns empty data and reports delete failure when persisted JSON is corrupted', async () => { + const { storage, values } = createLocalStorage() + values.set('corrupted', '{not-json') + vi.stubGlobal('localStorage', storage) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const strategy = localStorageStrategyFactory({ key: 'corrupted' }) + + expect(await strategy.loadConversations()).toEqual([]) + expect(await strategy.loadMessages('missing')).toEqual([]) + expect(() => strategy.deleteConversation?.('missing')).toThrow(SyntaxError) + }) + + it('propagates deletion writes that localStorage rejects', () => { + const { storage, values } = createLocalStorage() + values.set('conversation-test', JSON.stringify([conversation('one', 10)])) + storage.setItem = () => { + throw new Error('storage quota exceeded') + } + vi.stubGlobal('localStorage', storage) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const strategy = localStorageStrategyFactory({ key: 'conversation-test' }) + + expect(() => strategy.deleteConversation?.('one')).toThrow('storage quota exceeded') + }) +}) + +describe('indexedDBStorageStrategyFactory', () => { + it('creates, updates, sorts, restores, and deletes conversations', async () => { + const strategy = indexedDBStorageStrategyFactory({ + dbName: `tiny-robot-conversations-${crypto.randomUUID()}`, + dbVersion: 1, + }) + const first = reactive(conversation('first', 10)) as ConversationInfo + const second = reactive(conversation('second', 20)) as ConversationInfo + const messages = reactive([ + { + role: 'assistant', + content: '', + renderContent: [ + { type: 'collapsible-text', content: 'reason' }, + { type: 'markdown', content: 'answer' }, + ], + }, + ]) as ChatMessage[] + + await strategy.saveConversation(first) + await strategy.saveConversation(second) + await strategy.saveConversation({ ...first, title: 'First updated', updatedAt: 30 }) + await strategy.saveMessages('first', messages) + + expect(await strategy.loadConversations()).toEqual([ + { ...conversation('first', 10), title: 'First updated', updatedAt: 30 }, + conversation('second', 20), + ]) + expect(await strategy.loadMessages('first')).toEqual([ + { role: 'assistant', content: 'answer', reasoning_content: 'reason' }, + ]) + expect(await strategy.loadMessages('missing')).toEqual([]) + + await strategy.deleteConversation?.('first') + + expect(await strategy.loadConversations()).toEqual([conversation('second', 20)]) + expect(await strategy.loadMessages('first')).toEqual([]) + }) +}) diff --git a/packages/kit/src/storage/utils.test.ts b/packages/kit/src/storage/utils.test.ts new file mode 100644 index 000000000..a7b5a597b --- /dev/null +++ b/packages/kit/src/storage/utils.test.ts @@ -0,0 +1,89 @@ +import { reactive } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import type { ChatMessage } from '../types' +import { transformMessages, unwrapProxy } from './utils' + +describe('unwrapProxy', () => { + it('creates a serializable graph while preserving supported values and shared references', () => { + const shared = { value: 1 } + const source: Record = { + nested: shared, + list: [shared], + date: new Date('2026-09-08T00:00:00.000Z'), + regexp: /robot/gi, + buffer: new Uint8Array([1, 2, 3]).buffer, + blob: new Blob(['hello'], { type: 'text/plain' }), + callback: () => 'ignored', + token: Symbol('ignored'), + } + Object.defineProperty(source, 'computed', { enumerable: true, get: () => 'ignored' }) + source.self = source + + const result = unwrapProxy(reactive(source)) + + expect(result).not.toBe(source) + expect(result.nested).toEqual({ value: 1 }) + expect(result.nested).toBe((result.list as unknown[])[0]) + expect(result.date).toBe(source.date) + expect(result.regexp).toBe(source.regexp) + expect(result.buffer).toBe(source.buffer) + expect(result.blob).toBe(source.blob) + expect(result.self).toBe(result) + expect(result).not.toHaveProperty('callback') + expect(result).not.toHaveProperty('token') + expect(result).not.toHaveProperty('computed') + }) + + it('passes through null, undefined, and primitive values', () => { + expect(unwrapProxy(null)).toBeNull() + expect(unwrapProxy(undefined)).toBeUndefined() + expect(unwrapProxy('text')).toBe('text') + expect(unwrapProxy(42)).toBe(42) + }) + + it('falls back to an empty container when an object cannot be inspected', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const inaccessible = new Proxy( + {}, + { + ownKeys() { + throw new Error('cannot inspect') + }, + }, + ) + + expect(unwrapProxy(inaccessible)).toEqual({}) + }) +}) + +describe('transformMessages', () => { + it('preserves modern message content', () => { + const message: ChatMessage = { role: 'user', content: 'already modern' } + + expect(transformMessages([message])).toEqual([{ role: 'user', content: 'already modern' }]) + }) + + it('combines legacy reasoning and text segments and removes renderContent', () => { + const message = { + role: 'assistant', + content: 'old', + metadata: { id: 'message-1' }, + renderContent: [ + { type: 'collapsible-text', content: 'reason ' }, + { type: 'collapsible-text', content: 'complete' }, + { type: 'markdown', content: 'new ' }, + { type: 'text', content: 'answer' }, + { type: 'image', content: 'ignored' }, + ], + } as ChatMessage + + expect(transformMessages([message])).toEqual([ + { + role: 'assistant', + content: 'new answer', + reasoning_content: 'reason complete', + metadata: { id: 'message-1' }, + }, + ]) + }) +}) diff --git a/packages/kit/src/vue/conversation/useConversation.lifecycle.test.ts b/packages/kit/src/vue/conversation/useConversation.lifecycle.test.ts new file mode 100644 index 000000000..ac3071c1c --- /dev/null +++ b/packages/kit/src/vue/conversation/useConversation.lifecycle.test.ts @@ -0,0 +1,429 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ChatMessage } from '../../types' +import type { ConversationStorageStrategy } from '../../storage' +import type { ConversationInfo } from './types' +import type { ChatCompletion, ResponseProvider } from '../message/types' +import { useConversation } from './useConversation' + +const completion = (content = 'assistant reply'): ChatCompletion => ({ + id: 'conversation-completion', + object: 'chat.completion', + created: 1, + model: 'mock', + system_fingerprint: null, + choices: [ + { + index: 0, + message: { role: 'assistant', content }, + delta: undefined, + logprobs: null, + finish_reason: 'stop', + }, + ], +}) + +const responseProvider: ResponseProvider = async () => completion() + +const info = (id: string, title = id, updatedAt = 2): ConversationInfo => ({ + id, + title, + createdAt: 1, + updatedAt, +}) + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +const createMemoryStorage = (initialConversations: ConversationInfo[] = []) => { + const conversations = new Map(initialConversations.map((conversation) => [conversation.id, clone(conversation)])) + const messages = new Map() + const storage: ConversationStorageStrategy = { + loadConversations: () => [...conversations.values()].map((conversation) => clone(conversation)), + loadMessages: (id) => clone(messages.get(id) ?? []), + saveConversation: (conversation) => { + conversations.set(conversation.id, clone(conversation)) + }, + saveMessages: (id, nextMessages) => { + messages.set(id, clone(nextMessages)) + }, + deleteConversation: (id) => { + conversations.delete(id) + messages.delete(id) + }, + } + + return { storage, conversations, messages } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useConversation lifecycle', () => { + it('restores conversation metadata and lazily loads messages when switched', async () => { + const persisted = info('stored', 'Stored conversation') + const { storage, messages } = createMemoryStorage([persisted]) + messages.set('stored', [{ role: 'user', content: 'restored message' }]) + let loaded: ConversationInfo[] | undefined + const conversation = useConversation({ + storage, + useMessageOptions: { responseProvider }, + onLoad: (items) => { + loaded = items + }, + }) + + await vi.waitFor(() => expect(conversation.conversations.value).toEqual([persisted])) + const active = await conversation.switchConversation('stored') + + expect(loaded).toEqual([persisted]) + expect(conversation.activeConversationId.value).toBe('stored') + expect(active?.engine.messages.value).toEqual([{ role: 'user', content: 'restored message' }]) + await expect(conversation.switchConversation('missing')).resolves.toBeNull() + await expect(conversation.switchConversation('stored')).resolves.toBe(active) + }) + + it('merges an async storage load without overwriting conversations created in memory', async () => { + let resolveLoad!: (items: ConversationInfo[]) => void + const delayedLoad = new Promise((resolve) => { + resolveLoad = resolve + }) + let loaded: ConversationInfo[] | undefined + const storage: ConversationStorageStrategy = { + loadConversations: () => delayedLoad, + loadMessages: () => [], + saveConversation: () => undefined, + saveMessages: () => undefined, + } + const conversation = useConversation({ + storage, + useMessageOptions: { responseProvider }, + onLoad: (items) => { + loaded = items + }, + }) + conversation.createConversation({ id: 'shared', title: 'Memory title' }) + + resolveLoad([info('shared', 'Stored title'), info('remote', 'Remote title')]) + + await vi.waitFor(() => expect(conversation.conversations.value).toHaveLength(2)) + expect(conversation.conversations.value.map(({ id, title }) => ({ id, title }))).toEqual([ + { id: 'shared', title: 'Memory title' }, + { id: 'remote', title: 'Remote title' }, + ]) + expect(loaded?.map(({ id, title }) => ({ id, title }))).toEqual([ + { id: 'shared', title: 'Memory title' }, + { id: 'remote', title: 'Remote title' }, + ]) + }) + + it('persists consumer-visible create, message, title, and delete operations', async () => { + vi.spyOn(Date, 'now').mockReturnValueOnce(100).mockReturnValue(200) + const { storage, conversations, messages } = createMemoryStorage() + const api = useConversation({ storage, useMessageOptions: { responseProvider } }) + + const created = api.createConversation({ id: 'one', title: 'Draft', metadata: { source: 'user' } }) + await api.sendMessage('hello') + await api.saveMessages() + api.updateConversationTitle('one', 'Published') + + expect(created.id).toBe('one') + expect(api.activeConversation.value?.title).toBe('Published') + expect(conversations.get('one')).toMatchObject({ + id: 'one', + title: 'Published', + updatedAt: 200, + metadata: { source: 'user' }, + }) + expect(messages.get('one')).toMatchObject([ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'assistant reply' }, + ]) + + await api.deleteConversation('one') + + expect(api.conversations.value).toEqual([]) + expect(api.activeConversation.value).toBeNull() + expect(conversations.has('one')).toBe(false) + expect(messages.has('one')).toBe(false) + }) + + it('does not restore a deleted conversation when an asynchronous title save finishes late', async () => { + const conversations = new Map() + let releaseTitleSave!: () => void + const titleSavePending = new Promise((resolve) => { + releaseTitleSave = resolve + }) + let markTitleSaveStarted!: () => void + const titleSaveStarted = new Promise((resolve) => { + markTitleSaveStarted = resolve + }) + let markTitleSaveFinished!: () => void + const titleSaveFinished = new Promise((resolve) => { + markTitleSaveFinished = resolve + }) + const storage: ConversationStorageStrategy = { + loadConversations: () => [], + loadMessages: () => [], + saveConversation: async (nextConversation) => { + const snapshot = clone(nextConversation) + if (snapshot.title === 'Updated') { + markTitleSaveStarted() + await titleSavePending + } + conversations.set(snapshot.id, snapshot) + if (snapshot.title === 'Updated') { + markTitleSaveFinished() + } + }, + saveMessages: () => undefined, + deleteConversation: (id) => { + conversations.delete(id) + }, + } + const api = useConversation({ storage, useMessageOptions: { responseProvider } }) + api.createConversation({ id: 'one', title: 'Draft' }) + await api.saveMessages('one') + + api.updateConversationTitle('one', 'Updated') + await titleSaveStarted + const deletion = api.deleteConversation('one') + await Promise.race([deletion, new Promise((resolve) => setTimeout(resolve, 0))]) + + releaseTitleSave() + await titleSaveFinished + await deletion + + expect(conversations.has('one')).toBe(false) + }) + + it('waits for asynchronous persistence deletion before resolving', async () => { + let releaseDelete!: () => void + let markDeleteStarted!: () => void + const deleteStarted = new Promise((resolve) => { + markDeleteStarted = resolve + }) + const deletePending = new Promise((resolve) => { + releaseDelete = resolve + }) + const storage: ConversationStorageStrategy = { + loadConversations: () => [info('one')], + loadMessages: () => [], + saveConversation: () => undefined, + saveMessages: () => undefined, + deleteConversation: () => { + markDeleteStarted() + return deletePending + }, + } + const api = useConversation({ storage, useMessageOptions: { responseProvider } }) + await vi.waitFor(() => expect(api.conversations.value).toHaveLength(1)) + await api.switchConversation('one') + + let resolved = false + const deletion = api.deleteConversation('one').then(() => { + resolved = true + }) + await deleteStarted + + await expect( + Promise.race([ + deletion.then(() => 'resolved'), + new Promise((resolve) => queueMicrotask(() => resolve('pending'))), + ]), + ).resolves.toBe('pending') + expect(resolved).toBe(false) + expect(api.activeConversationId.value).toBeNull() + expect(api.activeConversation.value).toBeNull() + + releaseDelete() + await deletion + expect(resolved).toBe(true) + }) + + it('finishes an in-flight save before deleting persisted data', async () => { + const conversations = new Map() + const messages = new Map() + let markSaveStarted!: () => void + const saveStarted = new Promise((resolve) => { + markSaveStarted = resolve + }) + let releaseSave!: () => void + const savePending = new Promise((resolve) => { + releaseSave = resolve + }) + const storage: ConversationStorageStrategy = { + loadConversations: () => [], + loadMessages: () => [], + saveConversation: async (conversation) => { + markSaveStarted() + await savePending + conversations.set(conversation.id, clone(conversation)) + }, + saveMessages: (id, nextMessages) => { + messages.set(id, clone(nextMessages)) + }, + deleteConversation: (id) => { + conversations.delete(id) + messages.delete(id) + }, + } + const api = useConversation({ storage, useMessageOptions: { responseProvider } }) + api.createConversation({ id: 'one' }) + await saveStarted + + let deletionResolved = false + const deletion = api.deleteConversation('one').then(() => { + deletionResolved = true + }) + await Promise.resolve() + await Promise.resolve() + + expect(deletionResolved).toBe(false) + expect(api.activeConversationId.value).toBeNull() + + releaseSave() + await deletion + expect(conversations.has('one')).toBe(false) + expect(messages.has('one')).toBe(false) + }) + + it('keeps later saves serialized when a conversation id is recreated during deletion', async () => { + const conversations = new Map() + const messages = new Map() + let releaseOldSave!: () => void + const oldSavePending = new Promise((resolve) => { + releaseOldSave = resolve + }) + let markOldSaveStarted!: () => void + const oldSaveStarted = new Promise((resolve) => { + markOldSaveStarted = resolve + }) + let releaseRecreatedSave!: () => void + const recreatedSavePending = new Promise((resolve) => { + releaseRecreatedSave = resolve + }) + let markRecreatedSaveStarted!: () => void + const recreatedSaveStarted = new Promise((resolve) => { + markRecreatedSaveStarted = resolve + }) + let markRecreatedSaveFinished!: () => void + const recreatedSaveFinished = new Promise((resolve) => { + markRecreatedSaveFinished = resolve + }) + const storage: ConversationStorageStrategy = { + loadConversations: () => [], + loadMessages: () => [], + saveConversation: (nextConversation) => { + conversations.set(nextConversation.id, clone(nextConversation)) + }, + saveMessages: async (id, nextMessages) => { + const snapshot = clone(nextMessages) + if (snapshot[0]?.content === 'old') { + markOldSaveStarted() + await oldSavePending + } else if (snapshot.length === 1 && snapshot[0]?.content === 'recreated') { + markRecreatedSaveStarted() + await recreatedSavePending + } + messages.set(id, snapshot) + if (snapshot.length === 1 && snapshot[0]?.content === 'recreated') { + markRecreatedSaveFinished() + } + }, + deleteConversation: (id) => { + conversations.delete(id) + messages.delete(id) + }, + } + const api = useConversation({ storage, useMessageOptions: { responseProvider } }) + api.createConversation({ + id: 'one', + useMessageOptions: { initialMessages: [{ role: 'user', content: 'old' }] }, + }) + await oldSaveStarted + + const deletion = api.deleteConversation('one') + await vi.waitFor(() => expect(api.conversations.value).toEqual([])) + const recreated = api.createConversation({ + id: 'one', + useMessageOptions: { initialMessages: [{ role: 'user', content: 'recreated' }] }, + }) + releaseOldSave() + await recreatedSaveStarted + await deletion + + recreated.engine.messages.value.push({ role: 'user', content: 'latest' }) + const latestSave = api.saveMessages('one') + await Promise.race([latestSave, new Promise((resolve) => setTimeout(resolve, 0))]) + releaseRecreatedSave() + await recreatedSaveFinished + await latestSave + + expect(conversations.get('one')).toMatchObject({ id: 'one' }) + expect(messages.get('one')).toEqual([ + { role: 'user', content: 'recreated' }, + { role: 'user', content: 'latest' }, + ]) + }) + + it('keeps active state cleared when persisted deletion fails', async () => { + const storage: ConversationStorageStrategy = { + loadConversations: () => [info('one')], + loadMessages: () => [], + saveConversation: () => undefined, + saveMessages: () => undefined, + deleteConversation: async () => { + throw new Error('delete failed') + }, + } + const api = useConversation({ storage, useMessageOptions: { responseProvider } }) + await vi.waitFor(() => expect(api.conversations.value).toHaveLength(1)) + await api.switchConversation('one') + + await expect(api.deleteConversation('one')).rejects.toThrow('delete failed') + + expect(api.conversations.value).toEqual([]) + expect(api.activeConversationId.value).toBeNull() + expect(api.activeConversation.value).toBeNull() + }) + + it('clears all runtime and persisted conversations', async () => { + const { storage, conversations } = createMemoryStorage() + const api = useConversation({ storage, useMessageOptions: { responseProvider } }) + api.createConversation({ id: 'one' }) + await api.saveMessages('one') + api.createConversation({ id: 'two' }) + await api.saveMessages('two') + + api.clear() + + expect(api.conversations.value).toEqual([]) + expect(api.activeConversationId.value).toBeNull() + expect(api.activeConversation.value).toBeNull() + expect(conversations.size).toBe(0) + }) + + it('keeps configured initial messages when persisted message loading fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const storage: ConversationStorageStrategy = { + loadConversations: () => [info('stored')], + loadMessages: async () => { + throw new Error('storage unavailable') + }, + saveConversation: () => undefined, + saveMessages: () => undefined, + } + const api = useConversation({ + storage, + useMessageOptions: { + initialMessages: [{ role: 'system', content: 'fallback context' }], + responseProvider, + }, + }) + await vi.waitFor(() => expect(api.conversations.value).toHaveLength(1)) + + const active = await api.switchConversation('stored') + + expect(active?.engine.messages.value).toEqual([{ role: 'system', content: 'fallback context' }]) + }) +}) diff --git a/packages/kit/src/vue/conversation/useConversation.ts b/packages/kit/src/vue/conversation/useConversation.ts index 516cd44be..a130c2865 100644 --- a/packages/kit/src/vue/conversation/useConversation.ts +++ b/packages/kit/src/vue/conversation/useConversation.ts @@ -25,10 +25,25 @@ export const useConversation = (options: UseConversationOptions): UseConversatio const watchers = new Map() /** - * Serialize message saves per conversation so an older async write cannot - * complete after a newer paused-turn save and overwrite it. + * Serialize persistence per conversation so saves and deletion cannot + * complete out of order. */ - const messageSaveQueues = new Map>() + const persistenceQueues = new Map>() + + const enqueuePersistence = (id: string, operation: () => Promise): Promise => { + const previousOperation = persistenceQueues.get(id) + const currentOperation = previousOperation ? previousOperation.catch(() => undefined).then(operation) : operation() + persistenceQueues.set(id, currentOperation) + + const clearCompletedOperation = () => { + if (persistenceQueues.get(id) === currentOperation) { + persistenceQueues.delete(id) + } + } + void currentOperation.then(clearCompletedOperation, clearCompletedOperation) + + return currentOperation + } /** * Currently active conversation id. @@ -61,22 +76,16 @@ export const useConversation = (options: UseConversationOptions): UseConversatio * @param id - 会话 ID,如果不提供则使用当前活跃会话 */ const saveConversationMessages = (id: string, messages: ChatMessage[]): Promise => { - const previousSave = messageSaveQueues.get(id) ?? Promise.resolve() - const currentSave = previousSave - .catch(() => undefined) - .then(async () => { - if (!storage?.saveMessages) return - - const conversation = conversations.value.find((item) => item.id === id) - if (!conversation) return - - conversation.updatedAt = Date.now() - await storage.saveConversation?.(conversation) - await storage.saveMessages(id, messages) - }) + return enqueuePersistence(id, async () => { + if (!storage?.saveMessages) return - messageSaveQueues.set(id, currentSave) - return currentSave + const conversation = conversations.value.find((item) => item.id === id) + if (!conversation) return + + conversation.updatedAt = Date.now() + await storage.saveConversation?.(conversation) + await storage.saveMessages(id, messages) + }) } const saveMessages = async (id?: string): Promise => { @@ -312,13 +321,20 @@ export const useConversation = (options: UseConversationOptions): UseConversatio conversations.value.splice(idx, 1) - storage?.deleteConversation?.(id) - - // If deleting the active conversation, switch to new conversation + // Clear active state before waiting for persistence so consumers never + // observe an active id whose conversation has already been removed. if (activeConversationId.value === id) { activeConversationId.value = null clearInactiveEngines() } + + // Serialize deletion with both pending and later saves for this id. A new + // conversation with the same id can then persist only after deletion. + const deletion = enqueuePersistence(id, async () => { + await storage?.deleteConversation?.(id) + }) + + await deletion } /** @@ -350,7 +366,11 @@ export const useConversation = (options: UseConversationOptions): UseConversatio info.title = title info.updatedAt = Date.now() - storage?.saveConversation?.(info) + void enqueuePersistence(id, async () => { + await storage?.saveConversation?.(info) + }).catch((error) => { + console.error('[useConversation] update title failed:', error) + }) } /** diff --git a/packages/kit/src/vue/conversation/useThrottleFn.test.ts b/packages/kit/src/vue/conversation/useThrottleFn.test.ts new file mode 100644 index 000000000..386f0ab83 --- /dev/null +++ b/packages/kit/src/vue/conversation/useThrottleFn.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { throttleFilter, useThrottleFn } from './useThrottleFn' + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('useThrottleFn', () => { + it('runs immediately on the leading edge and resolves the trailing call with its result', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const calls: string[] = [] + const throttled = useThrottleFn( + (value: string) => { + calls.push(value) + return value.toUpperCase() + }, + 100, + true, + ) + + await expect(throttled('first')).resolves.toBe('FIRST') + const trailing = throttled('second') + expect(calls).toEqual(['first']) + + await vi.advanceTimersByTimeAsync(100) + + await expect(trailing).resolves.toBe('SECOND') + expect(calls).toEqual(['first', 'second']) + }) + + it('uses a reactive delay and executes every call when delay is zero', async () => { + const delay = ref(100) + const calls: number[] = [] + const throttled = useThrottleFn((value: number) => { + calls.push(value) + return value * 2 + }, delay) + + delay.value = 0 + + await expect(throttled(2)).resolves.toBe(4) + await expect(throttled(3)).resolves.toBe(6) + expect(calls).toEqual([2, 3]) + }) + + it('preserves the caller context and arguments', async () => { + const receiver = { + prefix: 'robot', + run: useThrottleFn(function (this: { prefix: string }, suffix: string) { + return `${this.prefix}-${suffix}` + }, 0), + } + + await expect(receiver.run('kit')).resolves.toBe('robot-kit') + }) + + it('rejects a cancelled trailing call when rejectOnCancel is enabled', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const throttled = useThrottleFn((value: string) => value, 100, true, true, true) + + await throttled('leading') + const cancelled = throttled('cancelled') + const rejection = expect(cancelled).rejects.toBeUndefined() + const latest = throttled('latest') + + await rejection + await vi.advanceTimersByTimeAsync(100) + await expect(latest).resolves.toBe('latest') + }) + + it('supports the options overload with a non-leading trailing invocation', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const calls: string[] = [] + const filter = throttleFilter({ delay: 100, leading: false, trailing: true }) + const invoke = (value: string) => filter(() => calls.push(value), { fn: () => value, args: [], thisArg: undefined }) + + const cancelled = Promise.resolve(invoke('first')) + vi.setSystemTime(50) + const trailing = Promise.resolve(invoke('second')) + + await cancelled + expect(calls).toEqual([]) + + await vi.advanceTimersByTimeAsync(50) + await trailing + + expect(calls).toEqual(['second']) + }) +}) diff --git a/packages/kit/src/vue/message/useMessage.lifecycle.test.ts b/packages/kit/src/vue/message/useMessage.lifecycle.test.ts new file mode 100644 index 000000000..7cfbab933 --- /dev/null +++ b/packages/kit/src/vue/message/useMessage.lifecycle.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest' +import type { ChatMessage } from '../../types' +import type { ChatCompletion, MessageRequestBody, ResponseProvider } from './types' +import { useMessage } from './useMessage' + +const completion = (content: string): ChatCompletion => ({ + id: `completion-${content}`, + object: 'chat.completion', + created: 1, + model: 'mock', + system_fingerprint: null, + choices: [ + { + index: 0, + message: { role: 'assistant', content }, + delta: undefined, + logprobs: null, + finish_reason: 'stop', + }, + ], +}) + +describe('useMessage lifecycle', () => { + it('exposes reactive lifecycle context and plugin-produced messages to consumers', async () => { + let requestBody: MessageRequestBody | undefined + let turnId: string | null = null + let requestTurnId: string | null = null + let turnStartedWith: string | undefined + let initializedWith: string | undefined + let turnEndedAs: string | undefined + let finalizedAs: string | undefined + const responseProvider: ResponseProvider = async (body) => { + requestBody = structuredClone(body) + return completion('answer') + } + const engine = useMessage({ + responseProvider, + plugins: [ + { + name: 'lifecycle', + onInit: ({ initialMessages }) => { + initializedWith = initialMessages[0]?.content + }, + onTurnStart: (context) => { + turnId = context.turnId + turnStartedWith = context.currentTurn[0]?.content + context.setCustomContext({ traceId: 'trace-1' }) + }, + onBeforeRequest: (context) => { + requestTurnId = context.turnId + context.requestBody.traceId = context.customContext.traceId + }, + onCompletionChunk: ({ currentMessage }) => { + currentMessage.metadata = { observedByPlugin: true } + }, + onAfterRequest: ({ appendMessage }) => { + appendMessage({ role: 'system', content: 'audit complete' }) + }, + onTurnEnd: (context) => { + turnEndedAs = context.requestState + }, + onFinally: (context) => { + finalizedAs = context.requestState + }, + }, + ], + }) + + await engine.sendMessage('question') + + expect(turnId).not.toBeNull() + expect(requestTurnId).toBe(turnId) + expect(initializedWith).toBeUndefined() + expect(turnStartedWith).toBe('question') + expect(requestBody).toMatchObject({ + traceId: 'trace-1', + messages: [{ role: 'user', content: 'question' }], + }) + expect(engine.messages.value).toMatchObject([ + { role: 'user', content: 'question' }, + { role: 'assistant', content: 'answer', metadata: { observedByPlugin: true } }, + { role: 'system', content: 'audit complete' }, + ]) + expect(turnEndedAs).toBe('completed') + expect(finalizedAs).toBe('completed') + }) + + it('evaluates reactive disabled predicates for each turn', async () => { + let disabled = true + const bodies: MessageRequestBody[] = [] + const engine = useMessage({ + responseProvider: async (body) => { + bodies.push(structuredClone(body)) + return completion('ok') + }, + plugins: [ + { + disabled: () => disabled, + onBeforeRequest: ({ requestBody }) => { + requestBody.pluginEnabled = true + }, + }, + ], + }) + + await engine.sendMessage('first') + disabled = false + await engine.sendMessage('second') + + expect(bodies.map((body) => body.pluginEnabled)).toEqual([undefined, true]) + }) + + it('runs generic plugin commands and exposes appended messages', async () => { + const engine = useMessage({ + responseProvider: async () => completion('unused'), + plugins: [ + { + commands: { + append: (payload, { appendMessage }) => { + appendMessage({ role: 'system', content: String(payload) }) + return 'appended' + }, + }, + }, + ], + }) + + await expect(engine.dispatchCommand('append', 'manual context')).resolves.toBe('appended') + expect(engine.messages.value).toEqual([{ role: 'system', content: 'manual context' }]) + }) + + it('lets the global completion hook replace default streaming merge behavior', async () => { + const responseProvider: ResponseProvider = async function* () { + yield { + ...completion(''), + choices: [ + { + index: 0, + message: undefined, + delta: { role: 'assistant', content: 'one' }, + logprobs: null, + finish_reason: null, + }, + ], + } + yield { + ...completion(''), + choices: [ + { + index: 0, + message: undefined, + delta: { content: 'two' }, + logprobs: null, + finish_reason: 'stop', + }, + ], + } + } + const engine = useMessage({ + responseProvider, + onCompletionChunk: ({ choice, currentMessage }) => { + const content = choice.delta?.content ?? '' + currentMessage.content += `[${content}]` + }, + }) + + await engine.sendMessage('stream') + + expect(engine.messages.value.at(-1)).toMatchObject({ + role: 'assistant', + content: '[one][two]', + }) + }) + + it('applies request field inclusion and exclusion without mutating displayed messages', async () => { + let requestMessages: Partial[] = [] + const engine = useMessage({ + responseProvider: async (body) => { + requestMessages = structuredClone(body.messages) + return completion('ok') + }, + requestMessageFields: ['role', 'content', 'metadata', 'state'], + requestMessageFieldsExclude: ['state'], + }) + const message: ChatMessage = { + role: 'user', + content: 'raw message', + metadata: { id: 'message-1' }, + state: { selected: true }, + extra: 'display-only', + } + + await engine.send(message) + + expect(requestMessages).toEqual([{ role: 'user', content: 'raw message', metadata: { id: 'message-1' } }]) + expect(engine.messages.value[0]).toMatchObject({ + state: { selected: true }, + extra: 'display-only', + }) + }) + + it('exposes provider failures through error and finally hooks', async () => { + const providerError = new Error('provider failed') + let observedError: unknown + let finalState: string | undefined + const engine = useMessage({ + responseProvider: async () => { + throw providerError + }, + plugins: [ + { + onError: ({ error }) => { + observedError = error + }, + onFinally: ({ requestState }) => { + finalState = requestState + }, + }, + ], + }) + + await engine.sendMessage('fail') + + expect(observedError).toBe(providerError) + expect(finalState).toBe('error') + expect(engine.requestState.value).toBe('error') + expect(engine.isProcessing.value).toBe(false) + }) + + it('aborts an active provider and publishes the aborted lifecycle state', async () => { + let markStarted!: () => void + const started = new Promise((resolve) => { + markStarted = resolve + }) + let abortHookState: string | undefined + const responseProvider: ResponseProvider = (_body, signal) => + new Promise((_resolve, reject) => { + markStarted() + signal.addEventListener( + 'abort', + () => { + const error = new Error('cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true }, + ) + }) + const engine = useMessage({ + responseProvider, + plugins: [ + { + onTurnAbort: ({ requestState }) => { + abortHookState = requestState + }, + }, + ], + }) + + const turn = engine.sendMessage('wait') + await started + await engine.abortRequest() + await turn + + expect(abortHookState).toBe('processing') + expect(engine.requestState.value).toBe('aborted') + expect(engine.isProcessing.value).toBe(false) + expect(engine.canStartTurn.value).toBe(true) + }) +})