Kirjava commited on
Commit
a9036a1
·
unverified ·
1 Parent(s): 4eb5494

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
- const handleActionChange = useCallback(
40
- (actions: Record<string, ActionState>) => setHasLoaded(fileActionsComplete(actions)),
41
- [firstArtifact],
42
- );
43
 
44
  useEffect(() => {
45
  if (firstArtifact) {
46
- return firstArtifact.runner.actions.subscribe(handleActionChange);
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(db: IDBDatabase, id: string, messages: Message[]): Promise<void> {
 
 
 
 
 
 
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 getNextID(db: IDBDatabase): Promise<string> {
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, getNextID } from './db';
5
  import { toast } from 'react-toastify';
 
6
 
7
  export interface ChatHistory {
8
  id: string;
9
- displayName?: string;
 
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 getNextID(db);
64
 
65
- await setMessages(db, nextId, messages);
66
 
67
  setEntryId(nextId);
68
 
69
- /**
70
- * FIXME: Using the intended navigate function causes a rerender for <Chat /> that breaks the app.
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
- artifactList: string[] = [];
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),