From a9036a10301cfd51c5328cac11bbf0a48fd6e1a6 Mon Sep 17 00:00:00 2001 From: Kirjava Date: Mon, 29 Jul 2024 14:02:15 +0100 Subject: [PATCH] feat: use artifact id in urls, store metadata in history (#15) --- .../header/OpenStackBlitz.client.tsx | 24 +++---- packages/bolt/app/lib/persistence/db.ts | 72 ++++++++++++++++++- .../app/lib/persistence/useChatHistory.ts | 53 ++++++++++---- packages/bolt/app/lib/stores/workbench.ts | 20 +++++- 4 files changed, 134 insertions(+), 35 deletions(-) diff --git a/packages/bolt/app/components/header/OpenStackBlitz.client.tsx b/packages/bolt/app/components/header/OpenStackBlitz.client.tsx index 25deb06..a1c4c0b 100644 --- a/packages/bolt/app/components/header/OpenStackBlitz.client.tsx +++ b/packages/bolt/app/components/header/OpenStackBlitz.client.tsx @@ -5,12 +5,6 @@ import type { FileMap } from '~/lib/stores/files'; import { workbenchStore, type ArtifactState } from '~/lib/stores/workbench'; import { WORK_DIR } from '~/utils/constants'; import { memo, useCallback, useEffect, useState } from 'react'; -import type { ActionState } from '~/lib/runtime/action-runner'; - -// return false if some file-writing actions haven't completed -const fileActionsComplete = (actions: Record) => { - return !Object.values(actions).some((action) => action.type === 'file' && action.status !== 'complete'); -}; // extract relative path and content from file, wrapped in array for flatMap use const extractContent = ([file, value]: [string, FileMap[string]]) => { @@ -31,19 +25,17 @@ const extractContent = ([file, value]: [string, FileMap[string]]) => { }; // subscribe to changes in first artifact's runner actions -const useFirstArtifact = (): [boolean, ArtifactState] => { +const useFirstArtifact = (): [boolean, ArtifactState | undefined] => { const [hasLoaded, setHasLoaded] = useState(false); - const artifacts = useStore(workbenchStore.artifacts); - const firstArtifact = artifacts[workbenchStore.artifactList[0]]; - const handleActionChange = useCallback( - (actions: Record) => setHasLoaded(fileActionsComplete(actions)), - [firstArtifact], - ); + // react to artifact changes + useStore(workbenchStore.artifacts); + + const { firstArtifact } = workbenchStore; useEffect(() => { if (firstArtifact) { - return firstArtifact.runner.actions.subscribe(handleActionChange); + return firstArtifact.runner.actions.subscribe((_) => setHasLoaded(workbenchStore.filesCount > 0)); } return undefined; @@ -56,6 +48,10 @@ export const OpenStackBlitz = memo(() => { const [artifactLoaded, artifact] = useFirstArtifact(); const handleClick = useCallback(() => { + if (!artifact) { + return; + } + // extract relative path and content from files map const workbenchFiles = workbenchStore.files.get(); const files = Object.fromEntries(Object.entries(workbenchFiles).flatMap(extractContent)); diff --git a/packages/bolt/app/lib/persistence/db.ts b/packages/bolt/app/lib/persistence/db.ts index 1ead1dd..e304dc1 100644 --- a/packages/bolt/app/lib/persistence/db.ts +++ b/packages/bolt/app/lib/persistence/db.ts @@ -1,5 +1,5 @@ -import type { ChatHistory } from './useChatHistory'; import type { Message } from 'ai'; +import type { ChatHistory } from './useChatHistory'; import { createScopedLogger } from '~/utils/logger'; const logger = createScopedLogger('ChatHistory'); @@ -15,6 +15,7 @@ export async function openDatabase(): Promise { if (!db.objectStoreNames.contains('chats')) { const store = db.createObjectStore('chats', { keyPath: 'id' }); store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); } }; @@ -29,7 +30,13 @@ export async function openDatabase(): Promise { }); } -export async function setMessages(db: IDBDatabase, id: string, messages: Message[]): Promise { +export async function setMessages( + db: IDBDatabase, + id: string, + messages: Message[], + urlId?: string, + description?: string, +): Promise { return new Promise((resolve, reject) => { const transaction = db.transaction('chats', 'readwrite'); const store = transaction.objectStore('chats'); @@ -37,6 +44,8 @@ export async function setMessages(db: IDBDatabase, id: string, messages: Message const request = store.put({ id, messages, + urlId, + description, }); request.onsuccess = () => resolve(); @@ -45,6 +54,22 @@ export async function setMessages(db: IDBDatabase, id: string, messages: Message } export async function getMessages(db: IDBDatabase, id: string): Promise { + return (await getMessagesById(db, id)) || (await getMessagesByUrlId(db, id)); +} + +export async function getMessagesByUrlId(db: IDBDatabase, id: string): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const index = store.index('urlId'); + const request = index.get(id); + + request.onsuccess = () => resolve(request.result as ChatHistory); + request.onerror = () => reject(request.error); + }); +} + +export async function getMessagesById(db: IDBDatabase, id: string): Promise { return new Promise((resolve, reject) => { const transaction = db.transaction('chats', 'readonly'); const store = transaction.objectStore('chats'); @@ -55,7 +80,7 @@ export async function getMessages(db: IDBDatabase, id: string): Promise { +export async function getNextId(db: IDBDatabase): Promise { return new Promise((resolve, reject) => { const transaction = db.transaction('chats', 'readonly'); const store = transaction.objectStore('chats'); @@ -65,3 +90,44 @@ export async function getNextID(db: IDBDatabase): Promise { request.onerror = () => reject(request.error); }); } + +export async function getUrlId(db: IDBDatabase, id: string): Promise { + const idList = await getUrlIds(db); + + if (!idList.includes(id)) { + return id; + } else { + let i = 2; + + while (idList.includes(`${id}-${i}`)) { + i++; + } + + return `${id}-${i}`; + } +} + +async function getUrlIds(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const idList: string[] = []; + + const request = store.openCursor(); + + request.onsuccess = (event: Event) => { + const cursor = (event.target as IDBRequest).result; + + if (cursor) { + idList.push(cursor.value.urlId); + cursor.continue(); + } else { + resolve(idList); + } + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} diff --git a/packages/bolt/app/lib/persistence/useChatHistory.ts b/packages/bolt/app/lib/persistence/useChatHistory.ts index 98671e3..cd6895f 100644 --- a/packages/bolt/app/lib/persistence/useChatHistory.ts +++ b/packages/bolt/app/lib/persistence/useChatHistory.ts @@ -1,12 +1,14 @@ import { useNavigate, useLoaderData } from '@remix-run/react'; import { useState, useEffect } from 'react'; import type { Message } from 'ai'; -import { openDatabase, setMessages, getMessages, getNextID } from './db'; +import { openDatabase, setMessages, getMessages, getNextId, getUrlId } from './db'; import { toast } from 'react-toastify'; +import { workbenchStore } from '~/lib/stores/workbench'; export interface ChatHistory { id: string; - displayName?: string; + urlId?: string; + description?: string; messages: Message[]; } @@ -21,6 +23,8 @@ export function useChatHistory() { const [initialMessages, setInitialMessages] = useState([]); const [ready, setReady] = useState(false); const [entryId, setEntryId] = useState(); + const [urlId, setUrlId] = useState(); + const [description, setDescription] = useState(); useEffect(() => { if (!db) { @@ -58,29 +62,48 @@ export function useChatHistory() { return; } + const { firstArtifact } = workbenchStore; + + if (!urlId && firstArtifact?.id) { + const urlId = await getUrlId(db, firstArtifact.id); + + navigateChat(urlId); + setUrlId(urlId); + } + + if (!description && firstArtifact?.title) { + setDescription(firstArtifact?.title); + } + if (initialMessages.length === 0) { if (!entryId) { - const nextId = await getNextID(db); + const nextId = await getNextId(db); - await setMessages(db, nextId, messages); + await setMessages(db, nextId, messages, urlId, description); setEntryId(nextId); - /** - * FIXME: Using the intended navigate function causes a rerender for that breaks the app. - * - * `navigate(`/chat/${nextId}`, { replace: true });` - */ - const url = new URL(window.location.href); - url.pathname = `/chat/${nextId}`; - - window.history.replaceState({}, '', url); + if (!urlId) { + navigateChat(nextId); + } } else { - await setMessages(db, entryId, messages); + await setMessages(db, entryId, messages, urlId, description); } } else { - await setMessages(db, chatId as string, messages); + await setMessages(db, chatId as string, messages, urlId, description); } }, }; } + +function navigateChat(nextId: string) { + /** + * FIXME: Using the intended navigate function causes a rerender for that breaks the app. + * + * `navigate(`/chat/${nextId}`, { replace: true });` + */ + const url = new URL(window.location.href); + url.pathname = `/chat/${nextId}`; + + window.history.replaceState({}, '', url); +} diff --git a/packages/bolt/app/lib/stores/workbench.ts b/packages/bolt/app/lib/stores/workbench.ts index 41cd86a..0fd72fb 100644 --- a/packages/bolt/app/lib/stores/workbench.ts +++ b/packages/bolt/app/lib/stores/workbench.ts @@ -11,6 +11,7 @@ import { PreviewsStore } from './previews'; import { TerminalStore } from './terminal'; export interface ArtifactState { + id: string; title: string; closed: boolean; runner: ActionRunner; @@ -27,10 +28,11 @@ export class WorkbenchStore { #terminalStore = new TerminalStore(webcontainer); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); + showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); modifiedFiles = new Set(); - artifactList: string[] = []; + artifactIdList: string[] = []; constructor() { if (import.meta.hot) { @@ -56,6 +58,14 @@ export class WorkbenchStore { return this.#editorStore.selectedFile; } + get firstArtifact(): ArtifactState | undefined { + return this.#getArtifact(this.artifactIdList[0]); + } + + get filesCount(): number { + return this.#filesStore.filesCount; + } + get showTerminal() { return this.#terminalStore.showTerminal; } @@ -200,15 +210,19 @@ export class WorkbenchStore { // TODO: what do we wanna do and how do we wanna recover from this? } - addArtifact({ messageId, title }: ArtifactCallbackData) { + addArtifact({ messageId, title, id }: ArtifactCallbackData) { const artifact = this.#getArtifact(messageId); if (artifact) { - this.artifactList.push(messageId); return; } + if (!this.artifactIdList.includes(messageId)) { + this.artifactIdList.push(messageId); + } + this.artifacts.setKey(messageId, { + id, title, closed: false, runner: new ActionRunner(webcontainer),