mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 14:32:46 +00:00
feat: use artifact id in urls, store metadata in history (#15)
This commit is contained in:
parent
4eb54949cf
commit
a9036a1030
@ -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<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
|
||||
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<string, ActionState>) => 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));
|
||||
|
@ -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<IDBDatabase | undefined> {
|
||||
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<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) => {
|
||||
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<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) => {
|
||||
const transaction = db.transaction('chats', 'readonly');
|
||||
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) => {
|
||||
const transaction = db.transaction('chats', 'readonly');
|
||||
const store = transaction.objectStore('chats');
|
||||
@ -65,3 +90,44 @@ export async function getNextID(db: IDBDatabase): Promise<string> {
|
||||
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);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -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<Message[]>([]);
|
||||
const [ready, setReady] = useState<boolean>(false);
|
||||
const [entryId, setEntryId] = useState<string | undefined>();
|
||||
const [urlId, setUrlId] = useState<string | undefined>();
|
||||
const [description, setDescription] = useState<string | undefined>();
|
||||
|
||||
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 <Chat /> 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 <Chat /> that breaks the app.
|
||||
*
|
||||
* `navigate(`/chat/${nextId}`, { replace: true });`
|
||||
*/
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = `/chat/${nextId}`;
|
||||
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
@ -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<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
||||
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
||||
modifiedFiles = new Set<string>();
|
||||
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),
|
||||
|
Loading…
Reference in New Issue
Block a user