Dominic Elm commited on
Commit
f4987a4
·
unverified ·
1 Parent(s): cae55a7

refactor: workbench store and move logic into action runner (#4)

Browse files
packages/bolt/app/components/chat/Artifact.tsx CHANGED
@@ -3,11 +3,11 @@ import { AnimatePresence, motion } from 'framer-motion';
3
  import { computed } from 'nanostores';
4
  import { memo, useEffect, useRef, useState } from 'react';
5
  import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
 
6
  import { chatStore } from '../../lib/stores/chat';
7
- import { getArtifactKey, workbenchStore, type ActionState } from '../../lib/stores/workbench';
8
  import { classNames } from '../../utils/classNames';
9
  import { cubicEasingFn } from '../../utils/easings';
10
- import { IconButton } from '../ui/IconButton';
11
 
12
  const highlighterOptions = {
13
  langs: ['shell'],
@@ -22,20 +22,19 @@ if (import.meta.hot) {
22
  }
23
 
24
  interface ArtifactProps {
25
- artifactId: string;
26
  messageId: string;
27
  }
28
 
29
- export const Artifact = memo(({ artifactId, messageId }: ArtifactProps) => {
30
  const userToggledActions = useRef(false);
31
  const [showActions, setShowActions] = useState(false);
32
 
33
  const chat = useStore(chatStore);
34
  const artifacts = useStore(workbenchStore.artifacts);
35
- const artifact = artifacts[getArtifactKey(artifactId, messageId)];
36
 
37
  const actions = useStore(
38
- computed(artifact.actions, (actions) => {
39
  return Object.values(actions);
40
  }),
41
  );
@@ -100,50 +99,7 @@ export const Artifact = memo(({ artifactId, messageId }: ArtifactProps) => {
100
  transition={{ duration: 0.15 }}
101
  >
102
  <div className="p-4 text-left border-t">
103
- <motion.div
104
- initial={{ opacity: 0 }}
105
- animate={{ opacity: 1 }}
106
- exit={{ opacity: 0 }}
107
- transition={{ duration: 0.15 }}
108
- >
109
- <h4 className="font-semibold mb-2">Actions</h4>
110
- <ul className="list-none space-y-2.5">
111
- {actions.map((action, index) => {
112
- const { status, type, content, abort } = action;
113
-
114
- return (
115
- <li key={index}>
116
- <div className="flex items-center gap-1.5">
117
- <div className={classNames('text-lg', getTextColor(action.status))}>
118
- {status === 'running' ? (
119
- <div className="i-svg-spinners:90-ring-with-bg"></div>
120
- ) : status === 'pending' ? (
121
- <div className="i-ph:circle-duotone"></div>
122
- ) : status === 'complete' ? (
123
- <div className="i-ph:check-circle-duotone"></div>
124
- ) : status === 'failed' || status === 'aborted' ? (
125
- <div className="i-ph:x-circle-duotone"></div>
126
- ) : null}
127
- </div>
128
- {type === 'file' ? (
129
- <div>
130
- Create <code className="bg-gray-100 text-gray-700">{action.filePath}</code>
131
- </div>
132
- ) : type === 'shell' ? (
133
- <div className="flex items-center w-full min-h-[28px]">
134
- <span className="flex-1">Run command</span>
135
- {abort !== undefined && status === 'running' && (
136
- <IconButton icon="i-ph:x-circle" size="xl" onClick={() => abort()} />
137
- )}
138
- </div>
139
- ) : null}
140
- </div>
141
- {type === 'shell' && <ShellCodeBlock classsName="mt-1" code={content} />}
142
- </li>
143
- );
144
- })}
145
- </ul>
146
- </motion.div>
147
  </div>
148
  </motion.div>
149
  )}
@@ -188,3 +144,61 @@ function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
188
  ></div>
189
  );
190
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import { computed } from 'nanostores';
4
  import { memo, useEffect, useRef, useState } from 'react';
5
  import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
6
+ import type { ActionState } from '../../lib/runtime/action-runner';
7
  import { chatStore } from '../../lib/stores/chat';
8
+ import { workbenchStore } from '../../lib/stores/workbench';
9
  import { classNames } from '../../utils/classNames';
10
  import { cubicEasingFn } from '../../utils/easings';
 
11
 
12
  const highlighterOptions = {
13
  langs: ['shell'],
 
22
  }
23
 
24
  interface ArtifactProps {
 
25
  messageId: string;
26
  }
27
 
28
+ export const Artifact = memo(({ messageId }: ArtifactProps) => {
29
  const userToggledActions = useRef(false);
30
  const [showActions, setShowActions] = useState(false);
31
 
32
  const chat = useStore(chatStore);
33
  const artifacts = useStore(workbenchStore.artifacts);
34
+ const artifact = artifacts[messageId];
35
 
36
  const actions = useStore(
37
+ computed(artifact.runner.actions, (actions) => {
38
  return Object.values(actions);
39
  }),
40
  );
 
99
  transition={{ duration: 0.15 }}
100
  >
101
  <div className="p-4 text-left border-t">
102
+ <ActionList actions={actions} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  </div>
104
  </motion.div>
105
  )}
 
144
  ></div>
145
  );
146
  }
147
+
148
+ interface ActionListProps {
149
+ actions: ActionState[];
150
+ }
151
+
152
+ const actionVariants = {
153
+ hidden: { opacity: 0, y: 20 },
154
+ visible: { opacity: 1, y: 0 },
155
+ };
156
+
157
+ const ActionList = memo(({ actions }: ActionListProps) => {
158
+ return (
159
+ <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
160
+ <ul className="list-none space-y-2.5">
161
+ {actions.map((action, index) => {
162
+ const { status, type, content } = action;
163
+
164
+ return (
165
+ <motion.li
166
+ key={index}
167
+ variants={actionVariants}
168
+ initial="hidden"
169
+ animate="visible"
170
+ transition={{
171
+ duration: 0.2,
172
+ ease: cubicEasingFn,
173
+ }}
174
+ >
175
+ <div className="flex items-center gap-1.5">
176
+ <div className={classNames('text-lg', getTextColor(action.status))}>
177
+ {status === 'running' ? (
178
+ <div className="i-svg-spinners:90-ring-with-bg"></div>
179
+ ) : status === 'pending' ? (
180
+ <div className="i-ph:circle-duotone"></div>
181
+ ) : status === 'complete' ? (
182
+ <div className="i-ph:check-circle-duotone"></div>
183
+ ) : status === 'failed' || status === 'aborted' ? (
184
+ <div className="i-ph:x-circle-duotone"></div>
185
+ ) : null}
186
+ </div>
187
+ {type === 'file' ? (
188
+ <div>
189
+ Create <code className="bg-gray-100 text-gray-700">{action.filePath}</code>
190
+ </div>
191
+ ) : type === 'shell' ? (
192
+ <div className="flex items-center w-full min-h-[28px]">
193
+ <span className="flex-1">Run command</span>
194
+ </div>
195
+ ) : null}
196
+ </div>
197
+ {type === 'shell' && <ShellCodeBlock classsName="mt-1" code={content} />}
198
+ </motion.li>
199
+ );
200
+ })}
201
+ </ul>
202
+ </motion.div>
203
+ );
204
+ });
packages/bolt/app/components/chat/Markdown.tsx CHANGED
@@ -21,18 +21,13 @@ export const Markdown = memo(({ children }: MarkdownProps) => {
21
  return {
22
  div: ({ className, children, node, ...props }) => {
23
  if (className?.includes('__boltArtifact__')) {
24
- const artifactId = node?.properties.dataArtifactId as string;
25
  const messageId = node?.properties.dataMessageId as string;
26
 
27
- if (!artifactId) {
28
- logger.debug(`Invalid artifact id ${messageId}`);
29
- }
30
-
31
  if (!messageId) {
32
- logger.debug(`Invalid message id ${messageId}`);
33
  }
34
 
35
- return <Artifact artifactId={artifactId} messageId={messageId} />;
36
  }
37
 
38
  return (
 
21
  return {
22
  div: ({ className, children, node, ...props }) => {
23
  if (className?.includes('__boltArtifact__')) {
 
24
  const messageId = node?.properties.dataMessageId as string;
25
 
 
 
 
 
26
  if (!messageId) {
27
+ logger.error(`Invalid message id ${messageId}`);
28
  }
29
 
30
+ return <Artifact messageId={messageId} />;
31
  }
32
 
33
  return (
packages/bolt/app/components/workbench/Preview.tsx CHANGED
@@ -13,7 +13,14 @@ export const Preview = memo(() => {
13
  const [iframeUrl, setIframeUrl] = useState<string | undefined>();
14
 
15
  useEffect(() => {
16
- if (activePreview && !iframeUrl) {
 
 
 
 
 
 
 
17
  const { baseUrl } = activePreview;
18
 
19
  setUrl(baseUrl);
@@ -31,16 +38,16 @@ export const Preview = memo(() => {
31
  <div className="w-full h-full flex flex-col">
32
  <div className="bg-gray-100 rounded-t-lg p-2 flex items-center space-x-1.5">
33
  <div className="flex items-center gap-2 text-gray-800">
34
- <div className="i-ph:app-window-duotone scale-130 ml-1.5"></div>
35
  <span className="text-sm">Preview</span>
36
  </div>
37
- <div className="flex-grow"></div>
38
  </div>
39
  <div className="bg-white p-2 flex items-center gap-1.5">
40
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
41
  <div className="flex items-center gap-1 flex-grow bg-gray-100 rounded-full px-3 py-1 text-sm text-gray-600 hover:bg-gray-200 hover:focus-within:bg-white focus-within:bg-white focus-within:ring-2 focus-within:ring-accent">
42
  <div className="bg-white rounded-full p-[2px] -ml-1">
43
- <div className="i-ph:info-bold text-lg"></div>
44
  </div>
45
  <input
46
  className="w-full bg-transparent outline-none"
@@ -54,7 +61,7 @@ export const Preview = memo(() => {
54
  </div>
55
  <div className="flex-1 bg-white border-t">
56
  {activePreview ? (
57
- <iframe ref={iframeRef} className="border-none w-full h-full" src={iframeUrl}></iframe>
58
  ) : (
59
  <div className="flex w-full h-full justify-center items-center">No preview available</div>
60
  )}
 
13
  const [iframeUrl, setIframeUrl] = useState<string | undefined>();
14
 
15
  useEffect(() => {
16
+ if (!activePreview) {
17
+ setUrl('');
18
+ setIframeUrl(undefined);
19
+
20
+ return;
21
+ }
22
+
23
+ if (!iframeUrl) {
24
  const { baseUrl } = activePreview;
25
 
26
  setUrl(baseUrl);
 
38
  <div className="w-full h-full flex flex-col">
39
  <div className="bg-gray-100 rounded-t-lg p-2 flex items-center space-x-1.5">
40
  <div className="flex items-center gap-2 text-gray-800">
41
+ <div className="i-ph:app-window-duotone scale-130 ml-1.5" />
42
  <span className="text-sm">Preview</span>
43
  </div>
44
+ <div className="flex-grow" />
45
  </div>
46
  <div className="bg-white p-2 flex items-center gap-1.5">
47
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
48
  <div className="flex items-center gap-1 flex-grow bg-gray-100 rounded-full px-3 py-1 text-sm text-gray-600 hover:bg-gray-200 hover:focus-within:bg-white focus-within:bg-white focus-within:ring-2 focus-within:ring-accent">
49
  <div className="bg-white rounded-full p-[2px] -ml-1">
50
+ <div className="i-ph:info-bold text-lg" />
51
  </div>
52
  <input
53
  className="w-full bg-transparent outline-none"
 
61
  </div>
62
  <div className="flex-1 bg-white border-t">
63
  {activePreview ? (
64
+ <iframe ref={iframeRef} className="border-none w-full h-full" src={iframeUrl} />
65
  ) : (
66
  <div className="flex w-full h-full justify-center items-center">No preview available</div>
67
  )}
packages/bolt/app/lib/.server/llm/switchable-stream.ts CHANGED
@@ -24,6 +24,8 @@ export default class SwitchableStream extends TransformStream {
24
  await this._currentReader.cancel();
25
  }
26
 
 
 
27
  this._currentReader = newStream.getReader();
28
 
29
  this._pumpStream();
 
24
  await this._currentReader.cancel();
25
  }
26
 
27
+ console.log('Switching stream');
28
+
29
  this._currentReader = newStream.getReader();
30
 
31
  this._pumpStream();
packages/bolt/app/lib/runtime/action-runner.ts CHANGED
@@ -1,68 +1,187 @@
1
  import { WebContainer } from '@webcontainer/api';
 
2
  import * as nodePath from 'node:path';
 
3
  import { createScopedLogger } from '../../utils/logger';
 
4
  import type { ActionCallbackData } from './message-parser';
5
 
6
  const logger = createScopedLogger('ActionRunner');
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  export class ActionRunner {
9
  #webcontainer: Promise<WebContainer>;
 
 
 
10
 
11
  constructor(webcontainerPromise: Promise<WebContainer>) {
12
  this.#webcontainer = webcontainerPromise;
 
 
 
 
13
  }
14
 
15
- async runAction({ action }: ActionCallbackData, abortSignal?: AbortSignal) {
16
- logger.trace('Running action', action);
17
 
18
- const { content } = action;
19
 
20
- const webcontainer = await this.#webcontainer;
 
 
 
21
 
22
- switch (action.type) {
23
- case 'file': {
24
- let folder = nodePath.dirname(action.filePath);
25
 
26
- // remove trailing slashes
27
- folder = folder.replace(/\/$/g, '');
 
 
 
 
 
 
 
 
28
 
29
- if (folder !== '.') {
30
- try {
31
- await webcontainer.fs.mkdir(folder, { recursive: true });
32
- logger.debug('Created folder', folder);
33
- } catch (error) {
34
- logger.error('Failed to create folder\n', error);
35
- }
36
- }
37
 
38
- try {
39
- await webcontainer.fs.writeFile(action.filePath, content);
40
- logger.debug(`File written ${action.filePath}`);
41
- } catch (error) {
42
- logger.error('Failed to write file\n', error);
43
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
- break;
 
 
 
 
 
 
 
 
 
46
  }
47
- case 'shell': {
48
- const process = await webcontainer.spawn('jsh', ['-c', content]);
49
 
50
- abortSignal?.addEventListener('abort', () => {
51
- process.kill();
52
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- process.output.pipeTo(
55
- new WritableStream({
56
- write(data) {
57
- console.log(data);
58
- },
59
- }),
60
- );
61
 
62
- const exitCode = await process.exit;
 
 
 
 
 
 
 
 
 
 
63
 
64
- logger.debug(`Process terminated with code ${exitCode}`);
 
 
 
 
 
 
 
 
65
  }
66
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  }
68
  }
 
1
  import { WebContainer } from '@webcontainer/api';
2
+ import { map, type MapStore } from 'nanostores';
3
  import * as nodePath from 'node:path';
4
+ import type { BoltAction } from '../../types/actions';
5
  import { createScopedLogger } from '../../utils/logger';
6
+ import { unreachable } from '../../utils/unreachable';
7
  import type { ActionCallbackData } from './message-parser';
8
 
9
  const logger = createScopedLogger('ActionRunner');
10
 
11
+ export type ActionStatus = 'pending' | 'running' | 'complete' | 'aborted' | 'failed';
12
+
13
+ export type BaseActionState = BoltAction & {
14
+ status: Exclude<ActionStatus, 'failed'>;
15
+ abort: () => void;
16
+ executed: boolean;
17
+ abortSignal: AbortSignal;
18
+ };
19
+
20
+ export type FailedActionState = BoltAction &
21
+ Omit<BaseActionState, 'status'> & {
22
+ status: Extract<ActionStatus, 'failed'>;
23
+ error: string;
24
+ };
25
+
26
+ export type ActionState = BaseActionState | FailedActionState;
27
+
28
+ type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'abort' | 'executed'>>;
29
+
30
+ export type ActionStateUpdate =
31
+ | BaseActionUpdate
32
+ | (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string });
33
+
34
+ type ActionsMap = MapStore<Record<string, ActionState>>;
35
+
36
  export class ActionRunner {
37
  #webcontainer: Promise<WebContainer>;
38
+ #currentExecutionPromise: Promise<void> = Promise.resolve();
39
+
40
+ actions: ActionsMap = import.meta.hot?.data.actions ?? map({});
41
 
42
  constructor(webcontainerPromise: Promise<WebContainer>) {
43
  this.#webcontainer = webcontainerPromise;
44
+
45
+ if (import.meta.hot) {
46
+ import.meta.hot.data.actions = this.actions;
47
+ }
48
  }
49
 
50
+ addAction(data: ActionCallbackData) {
51
+ const { actionId } = data;
52
 
53
+ const action = this.actions.get()[actionId];
54
 
55
+ if (action) {
56
+ // action already added
57
+ return;
58
+ }
59
 
60
+ const abortController = new AbortController();
 
 
61
 
62
+ this.actions.setKey(actionId, {
63
+ ...data.action,
64
+ status: 'pending',
65
+ executed: false,
66
+ abort: () => {
67
+ abortController.abort();
68
+ this.#updateAction(actionId, { status: 'aborted' });
69
+ },
70
+ abortSignal: abortController.signal,
71
+ });
72
 
73
+ this.#currentExecutionPromise.then(() => {
74
+ this.#updateAction(actionId, { status: 'running' });
75
+ });
76
+ }
 
 
 
 
77
 
78
+ async runAction(data: ActionCallbackData) {
79
+ const { actionId } = data;
80
+ const action = this.actions.get()[actionId];
81
+
82
+ if (!action) {
83
+ unreachable(`Action ${actionId} not found`);
84
+ }
85
+
86
+ if (action.executed) {
87
+ return;
88
+ }
89
+
90
+ this.#updateAction(actionId, { ...action, ...data.action, executed: true });
91
+
92
+ this.#currentExecutionPromise = this.#currentExecutionPromise
93
+ .then(() => {
94
+ return this.#executeAction(actionId);
95
+ })
96
+ .catch((error) => {
97
+ console.error('Action execution failed:', error);
98
+ });
99
+ }
100
+
101
+ async #executeAction(actionId: string) {
102
+ const action = this.actions.get()[actionId];
103
+
104
+ this.#updateAction(actionId, { status: 'running' });
105
 
106
+ try {
107
+ switch (action.type) {
108
+ case 'shell': {
109
+ await this.#runShellAction(action);
110
+ break;
111
+ }
112
+ case 'file': {
113
+ await this.#runFileAction(action);
114
+ break;
115
+ }
116
  }
 
 
117
 
118
+ this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' });
119
+ } catch (error) {
120
+ this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
121
+
122
+ // re-throw the error to be caught in the promise chain
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ async #runShellAction(action: ActionState) {
128
+ if (action.type !== 'shell') {
129
+ unreachable('Expected shell action');
130
+ }
131
+
132
+ const webcontainer = await this.#webcontainer;
133
+
134
+ const process = await webcontainer.spawn('jsh', ['-c', action.content]);
135
+
136
+ action.abortSignal.addEventListener('abort', () => {
137
+ process.kill();
138
+ });
139
+
140
+ process.output.pipeTo(
141
+ new WritableStream({
142
+ write(data) {
143
+ console.log(data);
144
+ },
145
+ }),
146
+ );
147
 
148
+ const exitCode = await process.exit;
 
 
 
 
 
 
149
 
150
+ logger.debug(`Process terminated with code ${exitCode}`);
151
+ }
152
+
153
+ async #runFileAction(action: ActionState) {
154
+ if (action.type !== 'file') {
155
+ unreachable('Expected file action');
156
+ }
157
+
158
+ const webcontainer = await this.#webcontainer;
159
+
160
+ let folder = nodePath.dirname(action.filePath);
161
 
162
+ // remove trailing slashes
163
+ folder = folder.replace(/\/+$/g, '');
164
+
165
+ if (folder !== '.') {
166
+ try {
167
+ await webcontainer.fs.mkdir(folder, { recursive: true });
168
+ logger.debug('Created folder', folder);
169
+ } catch (error) {
170
+ logger.error('Failed to create folder\n', error);
171
  }
172
  }
173
+
174
+ try {
175
+ await webcontainer.fs.writeFile(action.filePath, action.content);
176
+ logger.debug(`File written ${action.filePath}`);
177
+ } catch (error) {
178
+ logger.error('Failed to write file\n', error);
179
+ }
180
+ }
181
+
182
+ #updateAction(id: string, newState: ActionStateUpdate) {
183
+ const actions = this.actions.get();
184
+
185
+ this.actions.setKey(id, { ...actions[id], ...newState });
186
  }
187
  }
packages/bolt/app/lib/runtime/message-parser.spec.ts CHANGED
@@ -179,7 +179,7 @@ function runTest(input: string | string[], outputOrExpectedResult: string | Expe
179
  };
180
 
181
  const parser = new StreamingMessageParser({
182
- artifactElement: '',
183
  callbacks,
184
  });
185
 
 
179
  };
180
 
181
  const parser = new StreamingMessageParser({
182
+ artifactElement: () => '',
183
  callbacks,
184
  });
185
 
packages/bolt/app/lib/runtime/message-parser.ts CHANGED
@@ -31,11 +31,15 @@ export interface ParserCallbacks {
31
  onActionClose?: ActionCallback;
32
  }
33
 
34
- type ElementFactory = () => string;
 
 
 
 
35
 
36
  export interface StreamingMessageParserOptions {
37
  callbacks?: ParserCallbacks;
38
- artifactElement?: string | ElementFactory;
39
  }
40
 
41
  interface MessageState {
@@ -193,9 +197,9 @@ export class StreamingMessageParser {
193
 
194
  this._options.callbacks?.onArtifactOpen?.({ messageId, ...currentArtifact });
195
 
196
- output +=
197
- this._options.artifactElement ??
198
- `<div class="__boltArtifact__" data-artifact-id="${artifactId}" data-message-id="${messageId}"></div>`;
199
 
200
  i = openTagEnd + 1;
201
  } else {
@@ -264,3 +268,18 @@ export class StreamingMessageParser {
264
  return match ? match[1] : undefined;
265
  }
266
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  onActionClose?: ActionCallback;
32
  }
33
 
34
+ interface ElementFactoryProps {
35
+ messageId: string;
36
+ }
37
+
38
+ type ElementFactory = (props: ElementFactoryProps) => string;
39
 
40
  export interface StreamingMessageParserOptions {
41
  callbacks?: ParserCallbacks;
42
+ artifactElement?: ElementFactory;
43
  }
44
 
45
  interface MessageState {
 
197
 
198
  this._options.callbacks?.onArtifactOpen?.({ messageId, ...currentArtifact });
199
 
200
+ const artifactFactory = this._options.artifactElement ?? createArtifactElement;
201
+
202
+ output += artifactFactory({ messageId });
203
 
204
  i = openTagEnd + 1;
205
  } else {
 
268
  return match ? match[1] : undefined;
269
  }
270
  }
271
+
272
+ const createArtifactElement: ElementFactory = (props) => {
273
+ const elementProps = [
274
+ 'class="__boltArtifact__"',
275
+ Object.entries(props).map(([key, value]) => {
276
+ return `data-${camelToDashCase(key)}=${JSON.stringify(value)}`;
277
+ }),
278
+ ];
279
+
280
+ return `<div ${elementProps.join(' ')}></div>`;
281
+ };
282
+
283
+ function camelToDashCase(input: string) {
284
+ return input.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
285
+ }
packages/bolt/app/lib/stores/previews.ts CHANGED
@@ -25,6 +25,13 @@ export class PreviewsStore {
25
  webcontainer.on('port', (port, type, url) => {
26
  let previewInfo = this.#availablePreviews.get(port);
27
 
 
 
 
 
 
 
 
28
  const previews = this.previews.get();
29
 
30
  if (!previewInfo) {
 
25
  webcontainer.on('port', (port, type, url) => {
26
  let previewInfo = this.#availablePreviews.get(port);
27
 
28
+ if (type === 'close' && previewInfo) {
29
+ this.#availablePreviews.delete(port);
30
+ this.previews.set(this.previews.get().filter((preview) => preview.port !== port));
31
+
32
+ return;
33
+ }
34
+
35
  const previews = this.previews.get();
36
 
37
  if (!previewInfo) {
packages/bolt/app/lib/stores/workbench.ts CHANGED
@@ -1,48 +1,24 @@
1
  import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
2
  import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
3
- import type { BoltAction } from '../../types/actions';
4
  import { unreachable } from '../../utils/unreachable';
5
  import { ActionRunner } from '../runtime/action-runner';
6
  import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
7
  import { webcontainer } from '../webcontainer';
8
- import { chatStore } from './chat';
9
  import { EditorStore } from './editor';
10
  import { FilesStore, type FileMap } from './files';
11
  import { PreviewsStore } from './previews';
12
 
13
- const MIN_SPINNER_TIME = 200;
14
-
15
- export type BaseActionState = BoltAction & {
16
- status: 'running' | 'complete' | 'pending' | 'aborted';
17
- executing: boolean;
18
- abort?: () => void;
19
- };
20
-
21
- export type FailedActionState = BoltAction &
22
- Omit<BaseActionState, 'status'> & {
23
- status: 'failed';
24
- error: string;
25
- };
26
-
27
- export type ActionState = BaseActionState | FailedActionState;
28
-
29
- type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'executing' | 'abort'>>;
30
-
31
- export type ActionStateUpdate =
32
- | BaseActionUpdate
33
- | (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string });
34
-
35
  export interface ArtifactState {
36
  title: string;
37
  closed: boolean;
38
- currentActionPromise: Promise<void>;
39
- actions: MapStore<Record<string, ActionState>>;
40
  }
41
 
 
 
42
  type Artifacts = MapStore<Record<string, ArtifactState>>;
43
 
44
  export class WorkbenchStore {
45
- #actionRunner = new ActionRunner(webcontainer);
46
  #previewsStore = new PreviewsStore(webcontainer);
47
  #filesStore = new FilesStore(webcontainer);
48
  #editorStore = new EditorStore(webcontainer);
@@ -102,148 +78,63 @@ export class WorkbenchStore {
102
  }
103
 
104
  abortAllActions() {
105
- for (const [, artifact] of Object.entries(this.artifacts.get())) {
106
- for (const [, action] of Object.entries(artifact.actions.get())) {
107
- if (action.status === 'running') {
108
- action.abort?.();
109
- }
110
- }
111
- }
112
  }
113
 
114
- addArtifact({ id, messageId, title }: ArtifactCallbackData) {
115
- const artifacts = this.artifacts.get();
116
- const artifactKey = getArtifactKey(id, messageId);
117
- const artifact = artifacts[artifactKey];
118
 
119
  if (artifact) {
120
  return;
121
  }
122
 
123
- this.artifacts.setKey(artifactKey, {
124
  title,
125
  closed: false,
126
- actions: map({}),
127
- currentActionPromise: Promise.resolve(),
128
  });
129
  }
130
 
131
- updateArtifact({ id, messageId }: ArtifactCallbackData, state: Partial<ArtifactState>) {
132
- const artifacts = this.artifacts.get();
133
- const key = getArtifactKey(id, messageId);
134
- const artifact = artifacts[key];
135
 
136
  if (!artifact) {
137
  return;
138
  }
139
 
140
- this.artifacts.setKey(key, { ...artifact, ...state });
141
  }
142
 
143
  async addAction(data: ActionCallbackData) {
144
- const { artifactId, messageId, actionId } = data;
145
 
146
- const artifacts = this.artifacts.get();
147
- const key = getArtifactKey(artifactId, messageId);
148
- const artifact = artifacts[key];
149
 
150
  if (!artifact) {
151
  unreachable('Artifact not found');
152
  }
153
 
154
- const actions = artifact.actions.get();
155
- const action = actions[actionId];
156
-
157
- if (action) {
158
- return;
159
- }
160
-
161
- artifact.actions.setKey(actionId, { ...data.action, status: 'pending', executing: false });
162
-
163
- artifact.currentActionPromise.then(() => {
164
- if (chatStore.get().aborted) {
165
- return;
166
- }
167
-
168
- this.#updateAction(key, actionId, { status: 'running' });
169
- });
170
  }
171
 
172
  async runAction(data: ActionCallbackData) {
173
- const { artifactId, messageId, actionId } = data;
174
 
175
- const artifacts = this.artifacts.get();
176
- const key = getArtifactKey(artifactId, messageId);
177
- const artifact = artifacts[key];
178
 
179
  if (!artifact) {
180
  unreachable('Artifact not found');
181
  }
182
 
183
- const actions = artifact.actions.get();
184
- const action = actions[actionId];
185
-
186
- if (!action) {
187
- unreachable('Expected action to exist');
188
- }
189
-
190
- if (action.executing || action.status === 'complete' || action.status === 'failed' || action.status === 'aborted') {
191
- return;
192
- }
193
-
194
- artifact.currentActionPromise = artifact.currentActionPromise.then(async () => {
195
- if (chatStore.get().aborted) {
196
- return;
197
- }
198
-
199
- const abortController = new AbortController();
200
-
201
- this.#updateAction(key, actionId, {
202
- status: 'running',
203
- executing: true,
204
- abort: () => {
205
- abortController.abort();
206
- this.#updateAction(key, actionId, { status: 'aborted' });
207
- },
208
- });
209
-
210
- try {
211
- await Promise.all([
212
- this.#actionRunner.runAction(data, abortController.signal),
213
- new Promise((resolve) => setTimeout(resolve, MIN_SPINNER_TIME)),
214
- ]);
215
-
216
- if (!abortController.signal.aborted) {
217
- this.#updateAction(key, actionId, { status: 'complete' });
218
- }
219
- } catch (error) {
220
- this.#updateAction(key, actionId, { status: 'failed', error: 'Action failed' });
221
-
222
- throw error;
223
- } finally {
224
- this.#updateAction(key, actionId, { executing: false });
225
- }
226
- });
227
  }
228
 
229
- #updateAction(artifactId: string, actionId: string, newState: ActionStateUpdate) {
230
  const artifacts = this.artifacts.get();
231
- const artifact = artifacts[artifactId];
232
-
233
- if (!artifact) {
234
- return;
235
- }
236
-
237
- const actions = artifact.actions.get();
238
-
239
- artifact.actions.setKey(actionId, { ...actions[actionId], ...newState });
240
  }
241
  }
242
 
243
- export function getArtifactKey(artifactId: string, messageId: string) {
244
- return `${artifactId}_${messageId}`;
245
- }
246
-
247
  export const workbenchStore = new WorkbenchStore();
248
 
249
  if (import.meta.hot) {
 
1
  import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
2
  import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
 
3
  import { unreachable } from '../../utils/unreachable';
4
  import { ActionRunner } from '../runtime/action-runner';
5
  import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
6
  import { webcontainer } from '../webcontainer';
 
7
  import { EditorStore } from './editor';
8
  import { FilesStore, type FileMap } from './files';
9
  import { PreviewsStore } from './previews';
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  export interface ArtifactState {
12
  title: string;
13
  closed: boolean;
14
+ runner: ActionRunner;
 
15
  }
16
 
17
+ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
18
+
19
  type Artifacts = MapStore<Record<string, ArtifactState>>;
20
 
21
  export class WorkbenchStore {
 
22
  #previewsStore = new PreviewsStore(webcontainer);
23
  #filesStore = new FilesStore(webcontainer);
24
  #editorStore = new EditorStore(webcontainer);
 
78
  }
79
 
80
  abortAllActions() {
81
+ // TODO: what do we wanna do and how do we wanna recover from this?
 
 
 
 
 
 
82
  }
83
 
84
+ addArtifact({ messageId, title }: ArtifactCallbackData) {
85
+ const artifact = this.#getArtifact(messageId);
 
 
86
 
87
  if (artifact) {
88
  return;
89
  }
90
 
91
+ this.artifacts.setKey(messageId, {
92
  title,
93
  closed: false,
94
+ runner: new ActionRunner(webcontainer),
 
95
  });
96
  }
97
 
98
+ updateArtifact({ messageId }: ArtifactCallbackData, state: Partial<ArtifactUpdateState>) {
99
+ const artifact = this.#getArtifact(messageId);
 
 
100
 
101
  if (!artifact) {
102
  return;
103
  }
104
 
105
+ this.artifacts.setKey(messageId, { ...artifact, ...state });
106
  }
107
 
108
  async addAction(data: ActionCallbackData) {
109
+ const { messageId } = data;
110
 
111
+ const artifact = this.#getArtifact(messageId);
 
 
112
 
113
  if (!artifact) {
114
  unreachable('Artifact not found');
115
  }
116
 
117
+ artifact.runner.addAction(data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  }
119
 
120
  async runAction(data: ActionCallbackData) {
121
+ const { messageId } = data;
122
 
123
+ const artifact = this.#getArtifact(messageId);
 
 
124
 
125
  if (!artifact) {
126
  unreachable('Artifact not found');
127
  }
128
 
129
+ artifact.runner.runAction(data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  }
131
 
132
+ #getArtifact(id: string) {
133
  const artifacts = this.artifacts.get();
134
+ return artifacts[id];
 
 
 
 
 
 
 
 
135
  }
136
  }
137
 
 
 
 
 
138
  export const workbenchStore = new WorkbenchStore();
139
 
140
  if (import.meta.hot) {
packages/bolt/app/routes/api.chat.ts CHANGED
@@ -1,9 +1,9 @@
1
  import { type ActionFunctionArgs } from '@remix-run/cloudflare';
2
- import { MAX_RESPONSE_SEGMENTS } from '../lib/.server/llm/constants';
 
3
  import { CONTINUE_PROMPT } from '../lib/.server/llm/prompts';
4
  import { streamText, type Messages, type StreamingOptions } from '../lib/.server/llm/stream-text';
5
  import SwitchableStream from '../lib/.server/llm/switchable-stream';
6
- import { StreamingTextResponse } from 'ai';
7
 
8
  export async function action({ context, request }: ActionFunctionArgs) {
9
  const { messages } = await request.json<{ messages: Messages }>();
@@ -18,9 +18,13 @@ export async function action({ context, request }: ActionFunctionArgs) {
18
  }
19
 
20
  if (stream.switches >= MAX_RESPONSE_SEGMENTS) {
21
- throw Error('Cannot continue message: maximum segments reached');
22
  }
23
 
 
 
 
 
24
  messages.push({ role: 'assistant', content });
25
  messages.push({ role: 'user', content: CONTINUE_PROMPT });
26
 
 
1
  import { type ActionFunctionArgs } from '@remix-run/cloudflare';
2
+ import { StreamingTextResponse } from 'ai';
3
+ import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '../lib/.server/llm/constants';
4
  import { CONTINUE_PROMPT } from '../lib/.server/llm/prompts';
5
  import { streamText, type Messages, type StreamingOptions } from '../lib/.server/llm/stream-text';
6
  import SwitchableStream from '../lib/.server/llm/switchable-stream';
 
7
 
8
  export async function action({ context, request }: ActionFunctionArgs) {
9
  const { messages } = await request.json<{ messages: Messages }>();
 
18
  }
19
 
20
  if (stream.switches >= MAX_RESPONSE_SEGMENTS) {
21
+ throw Error('Cannot continue message: Maximum segments reached');
22
  }
23
 
24
+ const switchesLeft = MAX_RESPONSE_SEGMENTS - stream.switches;
25
+
26
+ console.log(`Reached max token limit (${MAX_TOKENS}): Continuing message (${switchesLeft} switches left)`);
27
+
28
  messages.push({ role: 'assistant', content });
29
  messages.push({ role: 'user', content: CONTINUE_PROMPT });
30