mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 22:42:21 +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 { 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));
|
||||||
|
@ -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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
Loading…
Reference in New Issue
Block a user