Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions packages/kit/src/storage/localStorageStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
localStorage.setItem(this.storageKey, JSON.stringify(conversations))
}
}
144 changes: 144 additions & 0 deletions packages/kit/src/storage/storageStrategies.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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([])
})
})
89 changes: 89 additions & 0 deletions packages/kit/src/storage/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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' },
},
])
})
})
Loading
Loading