Kirjava
commited on
feat: use artifact id in urls, store metadata in history (#15)
Browse files
packages/bolt/app/components/header/OpenStackBlitz.client.tsx
CHANGED
|
@@ -5,12 +5,6 @@ import type { FileMap } from '~/lib/stores/files';
|
|
| 5 |
import { workbenchStore, type ArtifactState } from '~/lib/stores/workbench';
|
| 6 |
import { WORK_DIR } from '~/utils/constants';
|
| 7 |
import { memo, useCallback, useEffect, useState } from 'react';
|
| 8 |
-
import type { ActionState } from '~/lib/runtime/action-runner';
|
| 9 |
-
|
| 10 |
-
// return false if some file-writing actions haven't completed
|
| 11 |
-
const fileActionsComplete = (actions: Record<string, ActionState>) => {
|
| 12 |
-
return !Object.values(actions).some((action) => action.type === 'file' && action.status !== 'complete');
|
| 13 |
-
};
|
| 14 |
|
| 15 |
// extract relative path and content from file, wrapped in array for flatMap use
|
| 16 |
const extractContent = ([file, value]: [string, FileMap[string]]) => {
|
|
@@ -31,19 +25,17 @@ const extractContent = ([file, value]: [string, FileMap[string]]) => {
|
|
| 31 |
};
|
| 32 |
|
| 33 |
// subscribe to changes in first artifact's runner actions
|
| 34 |
-
const useFirstArtifact = (): [boolean, ArtifactState] => {
|
| 35 |
const [hasLoaded, setHasLoaded] = useState(false);
|
| 36 |
-
const artifacts = useStore(workbenchStore.artifacts);
|
| 37 |
-
const firstArtifact = artifacts[workbenchStore.artifactList[0]];
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
|
| 44 |
useEffect(() => {
|
| 45 |
if (firstArtifact) {
|
| 46 |
-
return firstArtifact.runner.actions.subscribe(
|
| 47 |
}
|
| 48 |
|
| 49 |
return undefined;
|
|
@@ -56,6 +48,10 @@ export const OpenStackBlitz = memo(() => {
|
|
| 56 |
const [artifactLoaded, artifact] = useFirstArtifact();
|
| 57 |
|
| 58 |
const handleClick = useCallback(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
// extract relative path and content from files map
|
| 60 |
const workbenchFiles = workbenchStore.files.get();
|
| 61 |
const files = Object.fromEntries(Object.entries(workbenchFiles).flatMap(extractContent));
|
|
|
|
| 5 |
import { workbenchStore, type ArtifactState } from '~/lib/stores/workbench';
|
| 6 |
import { WORK_DIR } from '~/utils/constants';
|
| 7 |
import { memo, useCallback, useEffect, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
// extract relative path and content from file, wrapped in array for flatMap use
|
| 10 |
const extractContent = ([file, value]: [string, FileMap[string]]) => {
|
|
|
|
| 25 |
};
|
| 26 |
|
| 27 |
// subscribe to changes in first artifact's runner actions
|
| 28 |
+
const useFirstArtifact = (): [boolean, ArtifactState | undefined] => {
|
| 29 |
const [hasLoaded, setHasLoaded] = useState(false);
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
// react to artifact changes
|
| 32 |
+
useStore(workbenchStore.artifacts);
|
| 33 |
+
|
| 34 |
+
const { firstArtifact } = workbenchStore;
|
| 35 |
|
| 36 |
useEffect(() => {
|
| 37 |
if (firstArtifact) {
|
| 38 |
+
return firstArtifact.runner.actions.subscribe((_) => setHasLoaded(workbenchStore.filesCount > 0));
|
| 39 |
}
|
| 40 |
|
| 41 |
return undefined;
|
|
|
|
| 48 |
const [artifactLoaded, artifact] = useFirstArtifact();
|
| 49 |
|
| 50 |
const handleClick = useCallback(() => {
|
| 51 |
+
if (!artifact) {
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
// extract relative path and content from files map
|
| 56 |
const workbenchFiles = workbenchStore.files.get();
|
| 57 |
const files = Object.fromEntries(Object.entries(workbenchFiles).flatMap(extractContent));
|
packages/bolt/app/lib/persistence/db.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
import type { ChatHistory } from './useChatHistory';
|
| 2 |
import type { Message } from 'ai';
|
|
|
|
| 3 |
import { createScopedLogger } from '~/utils/logger';
|
| 4 |
|
| 5 |
const logger = createScopedLogger('ChatHistory');
|
|
@@ -15,6 +15,7 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
|
|
| 15 |
if (!db.objectStoreNames.contains('chats')) {
|
| 16 |
const store = db.createObjectStore('chats', { keyPath: 'id' });
|
| 17 |
store.createIndex('id', 'id', { unique: true });
|
|
|
|
| 18 |
}
|
| 19 |
};
|
| 20 |
|
|
@@ -29,7 +30,13 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
|
|
| 29 |
});
|
| 30 |
}
|
| 31 |
|
| 32 |
-
export async function setMessages(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
return new Promise((resolve, reject) => {
|
| 34 |
const transaction = db.transaction('chats', 'readwrite');
|
| 35 |
const store = transaction.objectStore('chats');
|
|
@@ -37,6 +44,8 @@ export async function setMessages(db: IDBDatabase, id: string, messages: Message
|
|
| 37 |
const request = store.put({
|
| 38 |
id,
|
| 39 |
messages,
|
|
|
|
|
|
|
| 40 |
});
|
| 41 |
|
| 42 |
request.onsuccess = () => resolve();
|
|
@@ -45,6 +54,22 @@ export async function setMessages(db: IDBDatabase, id: string, messages: Message
|
|
| 45 |
}
|
| 46 |
|
| 47 |
export async function getMessages(db: IDBDatabase, id: string): Promise<ChatHistory> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
return new Promise((resolve, reject) => {
|
| 49 |
const transaction = db.transaction('chats', 'readonly');
|
| 50 |
const store = transaction.objectStore('chats');
|
|
@@ -55,7 +80,7 @@ export async function getMessages(db: IDBDatabase, id: string): Promise<ChatHist
|
|
| 55 |
});
|
| 56 |
}
|
| 57 |
|
| 58 |
-
export async function
|
| 59 |
return new Promise((resolve, reject) => {
|
| 60 |
const transaction = db.transaction('chats', 'readonly');
|
| 61 |
const store = transaction.objectStore('chats');
|
|
@@ -65,3 +90,44 @@ export async function getNextID(db: IDBDatabase): Promise<string> {
|
|
| 65 |
request.onerror = () => reject(request.error);
|
| 66 |
});
|
| 67 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import type { Message } from 'ai';
|
| 2 |
+
import type { ChatHistory } from './useChatHistory';
|
| 3 |
import { createScopedLogger } from '~/utils/logger';
|
| 4 |
|
| 5 |
const logger = createScopedLogger('ChatHistory');
|
|
|
|
| 15 |
if (!db.objectStoreNames.contains('chats')) {
|
| 16 |
const store = db.createObjectStore('chats', { keyPath: 'id' });
|
| 17 |
store.createIndex('id', 'id', { unique: true });
|
| 18 |
+
store.createIndex('urlId', 'urlId', { unique: true });
|
| 19 |
}
|
| 20 |
};
|
| 21 |
|
|
|
|
| 30 |
});
|
| 31 |
}
|
| 32 |
|
| 33 |
+
export async function setMessages(
|
| 34 |
+
db: IDBDatabase,
|
| 35 |
+
id: string,
|
| 36 |
+
messages: Message[],
|
| 37 |
+
urlId?: string,
|
| 38 |
+
description?: string,
|
| 39 |
+
): Promise<void> {
|
| 40 |
return new Promise((resolve, reject) => {
|
| 41 |
const transaction = db.transaction('chats', 'readwrite');
|
| 42 |
const store = transaction.objectStore('chats');
|
|
|
|
| 44 |
const request = store.put({
|
| 45 |
id,
|
| 46 |
messages,
|
| 47 |
+
urlId,
|
| 48 |
+
description,
|
| 49 |
});
|
| 50 |
|
| 51 |
request.onsuccess = () => resolve();
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
export async function getMessages(db: IDBDatabase, id: string): Promise<ChatHistory> {
|
| 57 |
+
return (await getMessagesById(db, id)) || (await getMessagesByUrlId(db, id));
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export async function getMessagesByUrlId(db: IDBDatabase, id: string): Promise<ChatHistory> {
|
| 61 |
+
return new Promise((resolve, reject) => {
|
| 62 |
+
const transaction = db.transaction('chats', 'readonly');
|
| 63 |
+
const store = transaction.objectStore('chats');
|
| 64 |
+
const index = store.index('urlId');
|
| 65 |
+
const request = index.get(id);
|
| 66 |
+
|
| 67 |
+
request.onsuccess = () => resolve(request.result as ChatHistory);
|
| 68 |
+
request.onerror = () => reject(request.error);
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export async function getMessagesById(db: IDBDatabase, id: string): Promise<ChatHistory> {
|
| 73 |
return new Promise((resolve, reject) => {
|
| 74 |
const transaction = db.transaction('chats', 'readonly');
|
| 75 |
const store = transaction.objectStore('chats');
|
|
|
|
| 80 |
});
|
| 81 |
}
|
| 82 |
|
| 83 |
+
export async function getNextId(db: IDBDatabase): Promise<string> {
|
| 84 |
return new Promise((resolve, reject) => {
|
| 85 |
const transaction = db.transaction('chats', 'readonly');
|
| 86 |
const store = transaction.objectStore('chats');
|
|
|
|
| 90 |
request.onerror = () => reject(request.error);
|
| 91 |
});
|
| 92 |
}
|
| 93 |
+
|
| 94 |
+
export async function getUrlId(db: IDBDatabase, id: string): Promise<string> {
|
| 95 |
+
const idList = await getUrlIds(db);
|
| 96 |
+
|
| 97 |
+
if (!idList.includes(id)) {
|
| 98 |
+
return id;
|
| 99 |
+
} else {
|
| 100 |
+
let i = 2;
|
| 101 |
+
|
| 102 |
+
while (idList.includes(`${id}-${i}`)) {
|
| 103 |
+
i++;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
return `${id}-${i}`;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async function getUrlIds(db: IDBDatabase): Promise<string[]> {
|
| 111 |
+
return new Promise((resolve, reject) => {
|
| 112 |
+
const transaction = db.transaction('chats', 'readonly');
|
| 113 |
+
const store = transaction.objectStore('chats');
|
| 114 |
+
const idList: string[] = [];
|
| 115 |
+
|
| 116 |
+
const request = store.openCursor();
|
| 117 |
+
|
| 118 |
+
request.onsuccess = (event: Event) => {
|
| 119 |
+
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
| 120 |
+
|
| 121 |
+
if (cursor) {
|
| 122 |
+
idList.push(cursor.value.urlId);
|
| 123 |
+
cursor.continue();
|
| 124 |
+
} else {
|
| 125 |
+
resolve(idList);
|
| 126 |
+
}
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
request.onerror = () => {
|
| 130 |
+
reject(request.error);
|
| 131 |
+
};
|
| 132 |
+
});
|
| 133 |
+
}
|
packages/bolt/app/lib/persistence/useChatHistory.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
import { useNavigate, useLoaderData } from '@remix-run/react';
|
| 2 |
import { useState, useEffect } from 'react';
|
| 3 |
import type { Message } from 'ai';
|
| 4 |
-
import { openDatabase, setMessages, getMessages,
|
| 5 |
import { toast } from 'react-toastify';
|
|
|
|
| 6 |
|
| 7 |
export interface ChatHistory {
|
| 8 |
id: string;
|
| 9 |
-
|
|
|
|
| 10 |
messages: Message[];
|
| 11 |
}
|
| 12 |
|
|
@@ -21,6 +23,8 @@ export function useChatHistory() {
|
|
| 21 |
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
|
| 22 |
const [ready, setReady] = useState<boolean>(false);
|
| 23 |
const [entryId, setEntryId] = useState<string | undefined>();
|
|
|
|
|
|
|
| 24 |
|
| 25 |
useEffect(() => {
|
| 26 |
if (!db) {
|
|
@@ -58,29 +62,48 @@ export function useChatHistory() {
|
|
| 58 |
return;
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
if (initialMessages.length === 0) {
|
| 62 |
if (!entryId) {
|
| 63 |
-
const nextId = await
|
| 64 |
|
| 65 |
-
await setMessages(db, nextId, messages);
|
| 66 |
|
| 67 |
setEntryId(nextId);
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
* `navigate(`/chat/${nextId}`, { replace: true });`
|
| 73 |
-
*/
|
| 74 |
-
const url = new URL(window.location.href);
|
| 75 |
-
url.pathname = `/chat/${nextId}`;
|
| 76 |
-
|
| 77 |
-
window.history.replaceState({}, '', url);
|
| 78 |
} else {
|
| 79 |
-
await setMessages(db, entryId, messages);
|
| 80 |
}
|
| 81 |
} else {
|
| 82 |
-
await setMessages(db, chatId as string, messages);
|
| 83 |
}
|
| 84 |
},
|
| 85 |
};
|
| 86 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useNavigate, useLoaderData } from '@remix-run/react';
|
| 2 |
import { useState, useEffect } from 'react';
|
| 3 |
import type { Message } from 'ai';
|
| 4 |
+
import { openDatabase, setMessages, getMessages, getNextId, getUrlId } from './db';
|
| 5 |
import { toast } from 'react-toastify';
|
| 6 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
| 7 |
|
| 8 |
export interface ChatHistory {
|
| 9 |
id: string;
|
| 10 |
+
urlId?: string;
|
| 11 |
+
description?: string;
|
| 12 |
messages: Message[];
|
| 13 |
}
|
| 14 |
|
|
|
|
| 23 |
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
|
| 24 |
const [ready, setReady] = useState<boolean>(false);
|
| 25 |
const [entryId, setEntryId] = useState<string | undefined>();
|
| 26 |
+
const [urlId, setUrlId] = useState<string | undefined>();
|
| 27 |
+
const [description, setDescription] = useState<string | undefined>();
|
| 28 |
|
| 29 |
useEffect(() => {
|
| 30 |
if (!db) {
|
|
|
|
| 62 |
return;
|
| 63 |
}
|
| 64 |
|
| 65 |
+
const { firstArtifact } = workbenchStore;
|
| 66 |
+
|
| 67 |
+
if (!urlId && firstArtifact?.id) {
|
| 68 |
+
const urlId = await getUrlId(db, firstArtifact.id);
|
| 69 |
+
|
| 70 |
+
navigateChat(urlId);
|
| 71 |
+
setUrlId(urlId);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (!description && firstArtifact?.title) {
|
| 75 |
+
setDescription(firstArtifact?.title);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
if (initialMessages.length === 0) {
|
| 79 |
if (!entryId) {
|
| 80 |
+
const nextId = await getNextId(db);
|
| 81 |
|
| 82 |
+
await setMessages(db, nextId, messages, urlId, description);
|
| 83 |
|
| 84 |
setEntryId(nextId);
|
| 85 |
|
| 86 |
+
if (!urlId) {
|
| 87 |
+
navigateChat(nextId);
|
| 88 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
} else {
|
| 90 |
+
await setMessages(db, entryId, messages, urlId, description);
|
| 91 |
}
|
| 92 |
} else {
|
| 93 |
+
await setMessages(db, chatId as string, messages, urlId, description);
|
| 94 |
}
|
| 95 |
},
|
| 96 |
};
|
| 97 |
}
|
| 98 |
+
|
| 99 |
+
function navigateChat(nextId: string) {
|
| 100 |
+
/**
|
| 101 |
+
* FIXME: Using the intended navigate function causes a rerender for <Chat /> that breaks the app.
|
| 102 |
+
*
|
| 103 |
+
* `navigate(`/chat/${nextId}`, { replace: true });`
|
| 104 |
+
*/
|
| 105 |
+
const url = new URL(window.location.href);
|
| 106 |
+
url.pathname = `/chat/${nextId}`;
|
| 107 |
+
|
| 108 |
+
window.history.replaceState({}, '', url);
|
| 109 |
+
}
|
packages/bolt/app/lib/stores/workbench.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { PreviewsStore } from './previews';
|
|
| 11 |
import { TerminalStore } from './terminal';
|
| 12 |
|
| 13 |
export interface ArtifactState {
|
|
|
|
| 14 |
title: string;
|
| 15 |
closed: boolean;
|
| 16 |
runner: ActionRunner;
|
|
@@ -27,10 +28,11 @@ export class WorkbenchStore {
|
|
| 27 |
#terminalStore = new TerminalStore(webcontainer);
|
| 28 |
|
| 29 |
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
|
|
|
| 30 |
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
| 31 |
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
| 32 |
modifiedFiles = new Set<string>();
|
| 33 |
-
|
| 34 |
|
| 35 |
constructor() {
|
| 36 |
if (import.meta.hot) {
|
|
@@ -56,6 +58,14 @@ export class WorkbenchStore {
|
|
| 56 |
return this.#editorStore.selectedFile;
|
| 57 |
}
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
get showTerminal() {
|
| 60 |
return this.#terminalStore.showTerminal;
|
| 61 |
}
|
|
@@ -200,15 +210,19 @@ export class WorkbenchStore {
|
|
| 200 |
// TODO: what do we wanna do and how do we wanna recover from this?
|
| 201 |
}
|
| 202 |
|
| 203 |
-
addArtifact({ messageId, title }: ArtifactCallbackData) {
|
| 204 |
const artifact = this.#getArtifact(messageId);
|
| 205 |
|
| 206 |
if (artifact) {
|
| 207 |
-
this.artifactList.push(messageId);
|
| 208 |
return;
|
| 209 |
}
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
this.artifacts.setKey(messageId, {
|
|
|
|
| 212 |
title,
|
| 213 |
closed: false,
|
| 214 |
runner: new ActionRunner(webcontainer),
|
|
|
|
| 11 |
import { TerminalStore } from './terminal';
|
| 12 |
|
| 13 |
export interface ArtifactState {
|
| 14 |
+
id: string;
|
| 15 |
title: string;
|
| 16 |
closed: boolean;
|
| 17 |
runner: ActionRunner;
|
|
|
|
| 28 |
#terminalStore = new TerminalStore(webcontainer);
|
| 29 |
|
| 30 |
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
| 31 |
+
|
| 32 |
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
| 33 |
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
| 34 |
modifiedFiles = new Set<string>();
|
| 35 |
+
artifactIdList: string[] = [];
|
| 36 |
|
| 37 |
constructor() {
|
| 38 |
if (import.meta.hot) {
|
|
|
|
| 58 |
return this.#editorStore.selectedFile;
|
| 59 |
}
|
| 60 |
|
| 61 |
+
get firstArtifact(): ArtifactState | undefined {
|
| 62 |
+
return this.#getArtifact(this.artifactIdList[0]);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
get filesCount(): number {
|
| 66 |
+
return this.#filesStore.filesCount;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
get showTerminal() {
|
| 70 |
return this.#terminalStore.showTerminal;
|
| 71 |
}
|
|
|
|
| 210 |
// TODO: what do we wanna do and how do we wanna recover from this?
|
| 211 |
}
|
| 212 |
|
| 213 |
+
addArtifact({ messageId, title, id }: ArtifactCallbackData) {
|
| 214 |
const artifact = this.#getArtifact(messageId);
|
| 215 |
|
| 216 |
if (artifact) {
|
|
|
|
| 217 |
return;
|
| 218 |
}
|
| 219 |
|
| 220 |
+
if (!this.artifactIdList.includes(messageId)) {
|
| 221 |
+
this.artifactIdList.push(messageId);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
this.artifacts.setKey(messageId, {
|
| 225 |
+
id,
|
| 226 |
title,
|
| 227 |
closed: false,
|
| 228 |
runner: new ActionRunner(webcontainer),
|