feat: added conversation history feature

This commit is contained in:
Xiaohan-Tian
2026-06-03 22:56:05 -07:00
parent 5c6e1d69c5
commit a3a0f2f4ad
19 changed files with 1271 additions and 90 deletions
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGConversationStorage } from './KGConversationStorage';
class MockFileSystemWritableFileStream {
public data = '';
async write(content: string) { this.data = content; }
async close() {}
}
class MockFileSystemFileHandle {
kind = 'file' as const;
constructor(public name: string, private _content: string = '') {}
async getFile() {
return { text: () => Promise.resolve(this._content) };
}
async createWritable() {
const stream = new MockFileSystemWritableFileStream();
const origClose = stream.close.bind(stream);
stream.close = async () => {
this._content = stream.data;
await origClose();
};
return stream;
}
}
class MockFileSystemDirectoryHandle {
kind = 'directory' as const;
private entries = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
constructor(public name: string) {}
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
let entry = this.entries.get(name);
if (!entry || entry.kind !== 'directory') {
if (options?.create) {
entry = new MockFileSystemDirectoryHandle(name);
this.entries.set(name, entry);
} else {
throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
}
}
return entry as MockFileSystemDirectoryHandle;
}
async getFileHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemFileHandle> {
let entry = this.entries.get(name);
if (!entry || entry.kind !== 'file') {
if (options?.create) {
entry = new MockFileSystemFileHandle(name);
this.entries.set(name, entry);
} else {
throw new DOMException(`File "${name}" not found`, 'NotFoundError');
}
}
return entry as MockFileSystemFileHandle;
}
async removeEntry(name: string): Promise<void> {
this.entries.delete(name);
}
async *values(): AsyncIterableIterator<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
for (const entry of this.entries.values()) {
yield entry;
}
}
}
const mockRoot = new MockFileSystemDirectoryHandle('root');
vi.stubGlobal('navigator', {
...navigator,
storage: {
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
persist: vi.fn(() => Promise.resolve(true)),
},
});
describe('KGConversationStorage', () => {
let storage: KGConversationStorage;
beforeEach(async () => {
;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
const entries = (mockRoot as unknown as { entries: Map<string, unknown> }).entries;
entries.clear();
storage = KGConversationStorage.getInstance();
await storage.initialize();
});
it('saves, lists, and loads project-scoped conversations sorted by last turn time', async () => {
await storage.saveConversation('Song A', {
version: 1,
conversationId: 'conv_1',
continuationState: {
messages: [{ id: 'm1', role: 'user', content: 'first', timestamp: 1 }],
todos: [],
},
fullHistory: {
messages: [{ id: 'm1', role: 'user', content: 'first', timestamp: 1 }],
},
displayTranscript: [{ id: 'display_1', role: 'user', content: 'first' }],
}, {
conversationId: 'conv_1',
title: 'First',
createdAt: 1,
updatedAt: 1,
lastTurnAt: 1,
messageCount: 1,
preview: 'first',
});
await storage.saveConversation('Song A', {
version: 1,
conversationId: 'conv_2',
continuationState: {
messages: [{ id: 'm2', role: 'user', content: 'second', timestamp: 2 }],
todos: [],
},
fullHistory: {
messages: [{ id: 'm2', role: 'user', content: 'second', timestamp: 2 }],
},
displayTranscript: [{ id: 'display_2', role: 'user', content: 'second' }],
}, {
conversationId: 'conv_2',
title: 'Second',
createdAt: 2,
updatedAt: 2,
lastTurnAt: 2,
messageCount: 1,
preview: 'second',
});
const listed = await storage.listConversations('Song A');
expect(listed.map(item => item.conversationId)).toEqual(['conv_2', 'conv_1']);
const loaded = await storage.loadConversation('Song A', 'conv_1');
expect(loaded?.document.conversationId).toBe('conv_1');
expect(loaded?.document.displayTranscript).toEqual([
{ id: 'display_1', role: 'user', content: 'first' },
]);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
import type { SavedConversationDocument, SavedConversationMeta } from '../../types/conversationTypes';
export class KGConversationStorage {
private static _instance: KGConversationStorage;
private rootDirHandle: FileSystemDirectoryHandle | null = null;
private projectsDirHandle: FileSystemDirectoryHandle | null = null;
private _initialized = false;
private constructor() {}
public static getInstance(): KGConversationStorage {
if (!KGConversationStorage._instance) {
KGConversationStorage._instance = new KGConversationStorage();
}
return KGConversationStorage._instance;
}
public async initialize(): Promise<void> {
if (this._initialized) return;
if (!navigator.storage?.getDirectory) {
throw new Error(
'OPFS is unavailable. K.G.Studio requires a secure context (HTTPS or localhost). ' +
'Access via https:// or use localhost/127.0.0.1 instead of an IP address.',
);
}
this.rootDirHandle = await navigator.storage.getDirectory();
this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
OPFS_CONSTANTS.ROOT_DIR,
{ create: true },
);
this._initialized = true;
}
public async saveConversation(
projectName: string,
document: SavedConversationDocument,
meta: SavedConversationMeta,
): Promise<void> {
this.ensureInitialized();
const conversationDir = await this.getConversationDir(projectName, document.conversationId, true);
await this.writeFile(
conversationDir,
OPFS_CONSTANTS.CONVERSATION_FILE,
JSON.stringify(document, null, 2),
);
await this.writeFile(
conversationDir,
OPFS_CONSTANTS.METADATA_FILE,
JSON.stringify(meta, null, 2),
);
}
public async loadConversation(
projectName: string,
conversationId: string,
): Promise<{ document: SavedConversationDocument; meta: SavedConversationMeta } | null> {
this.ensureInitialized();
try {
const conversationDir = await this.getConversationDir(projectName, conversationId, false);
const [documentRaw, metaRaw] = await Promise.all([
this.readFile(conversationDir, OPFS_CONSTANTS.CONVERSATION_FILE),
this.readFile(conversationDir, OPFS_CONSTANTS.METADATA_FILE),
]);
return {
document: JSON.parse(documentRaw) as SavedConversationDocument,
meta: JSON.parse(metaRaw) as SavedConversationMeta,
};
} catch (error) {
console.error(`Error loading conversation "${conversationId}" for project "${projectName}":`, error);
return null;
}
}
public async listConversations(projectName: string): Promise<SavedConversationMeta[]> {
this.ensureInitialized();
let conversationsDir: FileSystemDirectoryHandle;
try {
conversationsDir = await this.getConversationsDir(projectName, false);
} catch {
return [];
}
const metas: SavedConversationMeta[] = [];
for await (const entry of conversationsDir.values()) {
if (entry.kind !== 'directory') {
continue;
}
try {
const conversationDir = await conversationsDir.getDirectoryHandle(entry.name);
const metaRaw = await this.readFile(conversationDir, OPFS_CONSTANTS.METADATA_FILE);
metas.push(JSON.parse(metaRaw) as SavedConversationMeta);
} catch {
// Skip malformed entries.
}
}
return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt);
}
public async deleteConversation(projectName: string, conversationId: string): Promise<void> {
this.ensureInitialized();
const conversationsDir = await this.getConversationsDir(projectName, false);
await conversationsDir.removeEntry(conversationId, { recursive: true });
}
private ensureInitialized(): void {
if (!this._initialized || !this.projectsDirHandle) {
throw new Error('KGConversationStorage not initialized. Call initialize() first.');
}
}
private async getConversationsDir(
projectName: string,
create: boolean,
): Promise<FileSystemDirectoryHandle> {
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create });
return projectDir.getDirectoryHandle(OPFS_CONSTANTS.CONVERSATIONS_DIR, { create });
}
private async getConversationDir(
projectName: string,
conversationId: string,
create: boolean,
): Promise<FileSystemDirectoryHandle> {
const conversationsDir = await this.getConversationsDir(projectName, create);
return conversationsDir.getDirectoryHandle(conversationId, { create });
}
private async writeFile(
dirHandle: FileSystemDirectoryHandle,
fileName: string,
content: string,
): Promise<void> {
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
}
private async readFile(
dirHandle: FileSystemDirectoryHandle,
fileName: string,
): Promise<string> {
const fileHandle = await dirHandle.getFileHandle(fileName);
const file = await fileHandle.getFile();
return file.text();
}
}
+77 -4
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
import { KGConversationStorage } from './KGConversationStorage';
import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
@@ -10,16 +11,23 @@ import { KGTrack } from '../track/KGTrack';
// --- OPFS mock infrastructure ---
class MockFileSystemWritableFileStream {
public data = '';
async write(content: string) { this.data = content; }
public data: string | ArrayBuffer = '';
async write(content: string | ArrayBuffer) { this.data = content; }
async close() {}
}
class MockFileSystemFileHandle {
kind = 'file' as const;
constructor(public name: string, private _content: string = '') {}
constructor(public name: string, private _content: string | ArrayBuffer = '') {}
async getFile() {
return { text: () => Promise.resolve(this._content) };
return {
text: () => Promise.resolve(typeof this._content === 'string' ? this._content : new TextDecoder().decode(this._content)),
arrayBuffer: () => Promise.resolve(
typeof this._content === 'string'
? new TextEncoder().encode(this._content).buffer
: this._content
),
};
}
async createWritable() {
const stream = new MockFileSystemWritableFileStream();
@@ -93,6 +101,7 @@ vi.stubGlobal('navigator', {
describe('KGProjectStorage', () => {
let storage: KGProjectStorage;
let conversationStorage: KGConversationStorage;
beforeEach(async () => {
// Reset singleton and mock filesystem
@@ -103,6 +112,9 @@ describe('KGProjectStorage', () => {
storage = KGProjectStorage.getInstance();
await storage.initialize();
;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
conversationStorage = KGConversationStorage.getInstance();
await conversationStorage.initialize();
});
function createTestProject(name = 'Test Project'): KGProject {
@@ -337,4 +349,65 @@ describe('KGProjectStorage', () => {
const loaded = await storage.load('New Name');
expect(loaded!.getName()).toBe('New Name');
});
it('copies conversation history when saving under a new project name', async () => {
await storage.save('Source Song', createTestProject('Source Song'));
await conversationStorage.saveConversation('Source Song', {
version: 1,
conversationId: 'conv_1',
continuationState: {
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
todos: [],
},
fullHistory: {
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
},
displayTranscript: [{ id: 'display_1', role: 'user', content: 'hello' }],
}, {
conversationId: 'conv_1',
title: 'hello',
createdAt: 1,
updatedAt: 1,
lastTurnAt: 1,
messageCount: 1,
preview: 'hello',
});
await storage.saveAs('Source Song', 'Copied Song', createTestProject('Copied Song'));
const loadedConversation = await conversationStorage.loadConversation('Copied Song', 'conv_1');
expect(loadedConversation?.document.conversationId).toBe('conv_1');
});
it('includes conversation history in project bundle export and import', async () => {
await storage.save('Bundle Song', createTestProject('Bundle Song'));
await conversationStorage.saveConversation('Bundle Song', {
version: 1,
conversationId: 'conv_bundle',
continuationState: {
messages: [{ id: 'm1', role: 'user', content: 'bundle', timestamp: 1 }],
todos: [],
},
fullHistory: {
messages: [{ id: 'm1', role: 'user', content: 'bundle', timestamp: 1 }],
},
displayTranscript: [{ id: 'display_1', role: 'user', content: 'bundle' }],
}, {
conversationId: 'conv_bundle',
title: 'bundle',
createdAt: 1,
updatedAt: 1,
lastTurnAt: 1,
messageCount: 1,
preview: 'bundle',
});
const bundle = await storage.exportAsZip('Bundle Song');
const importedName = await storage.importFromZip(bundle);
const loadedConversation = await conversationStorage.loadConversation(importedName, 'conv_bundle');
expect(loadedConversation?.document.displayTranscript).toEqual([
{ id: 'display_1', role: 'user', content: 'bundle' },
]);
});
});
+50 -25
View File
@@ -232,8 +232,8 @@ export class KGProjectStorage {
project.setName(targetName);
await this.save(targetName, project, false);
// Copy media files
await this.copyMediaFiles(sourceName, targetName);
// Copy project-scoped auxiliary artifacts such as media and conversation history
await this.copyProjectArtifacts(sourceName, targetName);
}
/**
@@ -346,8 +346,8 @@ export class KGProjectStorage {
project.setName(newName);
await this.save(newName, project, false);
// Copy media files from old to new location
await this.copyMediaFiles(oldName, newName);
// Copy project-scoped auxiliary artifacts such as media and conversation history
await this.copyProjectArtifacts(oldName, newName);
// Delete old location
await this.delete(oldName);
@@ -366,7 +366,7 @@ export class KGProjectStorage {
// Migrate media files only if the old folder exists
if (await this.exists(oldName)) {
await this.copyMediaFiles(oldName, newName);
await this.copyProjectArtifacts(oldName, newName);
await this.delete(oldName);
}
}
@@ -381,7 +381,7 @@ export class KGProjectStorage {
await this.save(targetName, data, false);
if (await this.exists(sourceName)) {
await this.copyMediaFiles(sourceName, targetName);
await this.copyProjectArtifacts(sourceName, targetName);
}
}
@@ -414,7 +414,7 @@ export class KGProjectStorage {
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
zip.file(entryPath, file.arrayBuffer());
zip.file(entryPath, new Uint8Array(await this.readBlobLikeAsArrayBuffer(file)));
} else {
const subDir = entry as FileSystemDirectoryHandle;
await this.addDirectoryToZip(zip, subDir, entryPath);
@@ -551,40 +551,53 @@ export class KGProjectStorage {
// --- Media migration ---
/**
* Copy all files from projects/<fromName>/media/ to projects/<toName>/media/.
* If the source media directory doesn't exist, returns without error.
*/
private async copyMediaFiles(fromName: string, toName: string): Promise<void> {
private async copyProjectArtifacts(fromName: string, toName: string): Promise<void> {
try {
const fromDir = await this.projectsDirHandle!.getDirectoryHandle(fromName);
let fromMedia: FileSystemDirectoryHandle;
try {
fromMedia = await fromDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
} catch {
// No media directory in source — nothing to copy
return;
}
const toDir = await this.projectsDirHandle!.getDirectoryHandle(toName);
const toMedia = await toDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
for await (const entry of fromMedia.values()) {
for await (const entry of fromDir.values()) {
if (entry.name === OPFS_CONSTANTS.PROJECT_FILE || entry.name === OPFS_CONSTANTS.METADATA_FILE) {
continue;
}
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
const newHandle = await toMedia.getFileHandle(entry.name, { create: true });
const newHandle = await toDir.getFileHandle(entry.name, { create: true });
const writable = await newHandle.createWritable();
await writable.write(await file.arrayBuffer());
await writable.close();
} else {
await this.copyDirectoryContents(
entry as FileSystemDirectoryHandle,
await toDir.getDirectoryHandle(entry.name, { create: true }),
);
}
}
} catch (error) {
console.error(`Error copying media files from "${fromName}" to "${toName}":`, error);
console.error(`Error copying project artifacts from "${fromName}" to "${toName}":`, error);
throw error;
}
}
private async copyDirectoryContents(
fromDir: FileSystemDirectoryHandle,
toDir: FileSystemDirectoryHandle,
): Promise<void> {
for await (const entry of fromDir.values()) {
if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
const newHandle = await toDir.getFileHandle(entry.name, { create: true });
const writable = await newHandle.createWritable();
await writable.write(await this.readBlobLikeAsArrayBuffer(file));
await writable.close();
} else {
const newSubDir = await toDir.getDirectoryHandle(entry.name, { create: true });
await this.copyDirectoryContents(entry as FileSystemDirectoryHandle, newSubDir);
}
}
}
// --- File I/O helpers ---
private async writeFile(
@@ -618,4 +631,16 @@ export class KGProjectStorage {
return { name, createdAt: 0, updatedAt: 0 };
}
}
private async readBlobLikeAsArrayBuffer(
file: { arrayBuffer?: () => Promise<ArrayBuffer>; text?: () => Promise<string> },
): Promise<ArrayBuffer> {
if (typeof file.arrayBuffer === 'function') {
return file.arrayBuffer();
}
if (typeof file.text === 'function') {
return new TextEncoder().encode(await file.text()).buffer;
}
throw new Error('Unsupported file object: expected arrayBuffer() or text().');
}
}