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),
|