feat: use artifact id in urls, store metadata in history (#15)

This commit is contained in:
Kirjava 2024-07-29 14:02:15 +01:00 committed by GitHub
parent 4eb54949cf
commit a9036a1030
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 134 additions and 35 deletions

View File

@ -5,12 +5,6 @@ import type { FileMap } from '~/lib/stores/files';
import { workbenchStore, type ArtifactState } from '~/lib/stores/workbench'; import { workbenchStore, type ArtifactState } from '~/lib/stores/workbench';
import { WORK_DIR } from '~/utils/constants'; import { WORK_DIR } from '~/utils/constants';
import { memo, useCallback, useEffect, useState } from 'react'; 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<string, ActionState>) => {
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 // extract relative path and content from file, wrapped in array for flatMap use
const extractContent = ([file, value]: [string, FileMap[string]]) => { 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 // subscribe to changes in first artifact's runner actions
const useFirstArtifact = (): [boolean, ArtifactState] => { const useFirstArtifact = (): [boolean, ArtifactState | undefined] => {
const [hasLoaded, setHasLoaded] = useState(false); const [hasLoaded, setHasLoaded] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
const firstArtifact = artifacts[workbenchStore.artifactList[0]];
const handleActionChange = useCallback( // react to artifact changes
(actions: Record<string, ActionState>) => setHasLoaded(fileActionsComplete(actions)), useStore(workbenchStore.artifacts);
[firstArtifact],
); const { firstArtifact } = workbenchStore;
useEffect(() => { useEffect(() => {
if (firstArtifact) { if (firstArtifact) {
return firstArtifact.runner.actions.subscribe(handleActionChange); return firstArtifact.runner.actions.subscribe((_) => setHasLoaded(workbenchStore.filesCount > 0));
} }
return undefined; return undefined;
@ -56,6 +48,10 @@ export const OpenStackBlitz = memo(() => {
const [artifactLoaded, artifact] = useFirstArtifact(); const [artifactLoaded, artifact] = useFirstArtifact();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (!artifact) {
return;
}
// extract relative path and content from files map // extract relative path and content from files map
const workbenchFiles = workbenchStore.files.get(); const workbenchFiles = workbenchStore.files.get();
const files = Object.fromEntries(Object.entries(workbenchFiles).flatMap(extractContent)); const files = Object.fromEntries(Object.entries(workbenchFiles).flatMap(extractContent));

View File

@ -1,5 +1,5 @@
import type { ChatHistory } from './useChatHistory';
import type { Message } from 'ai'; import type { Message } from 'ai';
import type { ChatHistory } from './useChatHistory';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('ChatHistory'); const logger = createScopedLogger('ChatHistory');
@ -15,6 +15,7 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
if (!db.objectStoreNames.contains('chats')) { if (!db.objectStoreNames.contains('chats')) {
const store = db.createObjectStore('chats', { keyPath: 'id' }); const store = db.createObjectStore('chats', { keyPath: 'id' });
store.createIndex('id', 'id', { unique: true }); store.createIndex('id', 'id', { unique: true });
store.createIndex('urlId', 'urlId', { unique: true });
} }
}; };
@ -29,7 +30,13 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
}); });
} }
export async function setMessages(db: IDBDatabase, id: string, messages: Message[]): Promise<void> { export async function setMessages(
db: IDBDatabase,
id: string,
messages: Message[],
urlId?: string,
description?: string,
): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readwrite'); const transaction = db.transaction('chats', 'readwrite');
const store = transaction.objectStore('chats'); const store = transaction.objectStore('chats');
@ -37,6 +44,8 @@ export async function setMessages(db: IDBDatabase, id: string, messages: Message
const request = store.put({ const request = store.put({
id, id,
messages, messages,
urlId,
description,
}); });
request.onsuccess = () => resolve(); 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<ChatHistory> { export async function getMessages(db: IDBDatabase, id: string): Promise<ChatHistory> {
return (await getMessagesById(db, id)) || (await getMessagesByUrlId(db, id));
}
export async function getMessagesByUrlId(db: IDBDatabase, id: string): Promise<ChatHistory> {
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<ChatHistory> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readonly'); const transaction = db.transaction('chats', 'readonly');
const store = transaction.objectStore('chats'); const store = transaction.objectStore('chats');
@ -55,7 +80,7 @@ export async function getMessages(db: IDBDatabase, id: string): Promise<ChatHist
}); });
} }
export async function getNextID(db: IDBDatabase): Promise<string> { export async function getNextId(db: IDBDatabase): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readonly'); const transaction = db.transaction('chats', 'readonly');
const store = transaction.objectStore('chats'); const store = transaction.objectStore('chats');
@ -65,3 +90,44 @@ export async function getNextID(db: IDBDatabase): Promise<string> {
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
} }
export async function getUrlId(db: IDBDatabase, id: string): Promise<string> {
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<string[]> {
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<IDBCursorWithValue>).result;
if (cursor) {
idList.push(cursor.value.urlId);
cursor.continue();
} else {
resolve(idList);
}
};
request.onerror = () => {
reject(request.error);
};
});
}

View File

@ -1,12 +1,14 @@
import { useNavigate, useLoaderData } from '@remix-run/react'; import { useNavigate, useLoaderData } from '@remix-run/react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Message } from 'ai'; 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 { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
export interface ChatHistory { export interface ChatHistory {
id: string; id: string;
displayName?: string; urlId?: string;
description?: string;
messages: Message[]; messages: Message[];
} }
@ -21,6 +23,8 @@ export function useChatHistory() {
const [initialMessages, setInitialMessages] = useState<Message[]>([]); const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [ready, setReady] = useState<boolean>(false); const [ready, setReady] = useState<boolean>(false);
const [entryId, setEntryId] = useState<string | undefined>(); const [entryId, setEntryId] = useState<string | undefined>();
const [urlId, setUrlId] = useState<string | undefined>();
const [description, setDescription] = useState<string | undefined>();
useEffect(() => { useEffect(() => {
if (!db) { if (!db) {
@ -58,14 +62,41 @@ export function useChatHistory() {
return; 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 (initialMessages.length === 0) {
if (!entryId) { 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); setEntryId(nextId);
if (!urlId) {
navigateChat(nextId);
}
} else {
await setMessages(db, entryId, messages, urlId, description);
}
} else {
await setMessages(db, chatId as string, messages, urlId, description);
}
},
};
}
function navigateChat(nextId: string) {
/** /**
* FIXME: Using the intended navigate function causes a rerender for <Chat /> that breaks the app. * FIXME: Using the intended navigate function causes a rerender for <Chat /> that breaks the app.
* *
@ -75,12 +106,4 @@ export function useChatHistory() {
url.pathname = `/chat/${nextId}`; url.pathname = `/chat/${nextId}`;
window.history.replaceState({}, '', url); window.history.replaceState({}, '', url);
} else {
await setMessages(db, entryId, messages);
}
} else {
await setMessages(db, chatId as string, messages);
}
},
};
} }

View File

@ -11,6 +11,7 @@ import { PreviewsStore } from './previews';
import { TerminalStore } from './terminal'; import { TerminalStore } from './terminal';
export interface ArtifactState { export interface ArtifactState {
id: string;
title: string; title: string;
closed: boolean; closed: boolean;
runner: ActionRunner; runner: ActionRunner;
@ -27,10 +28,11 @@ export class WorkbenchStore {
#terminalStore = new TerminalStore(webcontainer); #terminalStore = new TerminalStore(webcontainer);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false); showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>()); unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
modifiedFiles = new Set<string>(); modifiedFiles = new Set<string>();
artifactList: string[] = []; artifactIdList: string[] = [];
constructor() { constructor() {
if (import.meta.hot) { if (import.meta.hot) {
@ -56,6 +58,14 @@ export class WorkbenchStore {
return this.#editorStore.selectedFile; return this.#editorStore.selectedFile;
} }
get firstArtifact(): ArtifactState | undefined {
return this.#getArtifact(this.artifactIdList[0]);
}
get filesCount(): number {
return this.#filesStore.filesCount;
}
get showTerminal() { get showTerminal() {
return this.#terminalStore.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? // 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); const artifact = this.#getArtifact(messageId);
if (artifact) { if (artifact) {
this.artifactList.push(messageId);
return; return;
} }
if (!this.artifactIdList.includes(messageId)) {
this.artifactIdList.push(messageId);
}
this.artifacts.setKey(messageId, { this.artifacts.setKey(messageId, {
id,
title, title,
closed: false, closed: false,
runner: new ActionRunner(webcontainer), runner: new ActionRunner(webcontainer),