bootstrap progress updates

This commit is contained in:
Ariel Weinberger
2025-07-19 11:37:58 -05:00
parent e8ae43c377
commit 9d528cb12e
7 changed files with 238 additions and 47 deletions
+58 -15
View File
@@ -18,6 +18,7 @@ import { createSynapseClient } from '@/lib/synapse-http-client';
import { daytona } from '@/lib/daytona-client';
import { Sandbox } from '@daytonaio/sdk';
import { getOrCreateArtifactSandbox, startDevServer } from '@/lib/daytona-utils';
import { OnStepUpdateFn, WorkspaceStepId, workspaceStepSchema } from '@/lib/ai/custom-parts/workspace-state';
export const handleChatRequest = async (c: Context) => {
c.res.headers.set('x-vercel-ai-ui-message-stream', 'v1');
@@ -68,15 +69,6 @@ export const handleChatRequest = async (c: Context) => {
throw new Error('Conversation not found');
}
const { sandbox } = await getOrCreateArtifactSandbox({
artifactId
});
const { previewUrl } = await startDevServer({
sandbox
});
const workspaceUrl = previewUrl;
const convertedMessages = convertToModelMessages(messages);
const latestMessage = convertedMessages[convertedMessages.length - 1];
@@ -91,23 +83,74 @@ export const handleChatRequest = async (c: Context) => {
originalMessages: messages,
execute: async (params) => {
const writer = params.writer as WriterType;
writer.write({
type: 'data-workspace-state',
data: {
state: 'pending',
workspaceUrl: null,
steps: []
},
transient: true
});
const steps: z.infer<typeof workspaceStepSchema>[] = [];
const updateStep: OnStepUpdateFn = ({
id,
status,
text
}: Parameters<OnStepUpdateFn>[0]) => {
const found = steps.find((step) => step.id === id);
if (found) {
found.status = status;
found.text = text;
} else {
steps.push({ id, status, text });
}
writer.write({
type: "data-workspace-state",
data: {
state: "in-progress",
steps,
workspaceUrl: null
}
})
};
const { sandbox } = await getOrCreateArtifactSandbox({
artifactId,
onStepUpdate: updateStep
});
const { previewUrl } = await startDevServer({
sandbox,
onStepUpdate: updateStep
});
const workspaceUrl = previewUrl;
const runtimeContext = createRuntimeContext({
writer,
artifactId,
restMessages,
isFirstMessage: isNewConversation,
signal,
sandbox,
sandbox
});
console.log("Reporting compelted");
writer.write({
type: 'data-workspace-state',
type: "data-workspace-state",
data: {
state: 'ready',
state: "completed",
steps,
workspaceUrl
},
transient: true
});
}
})
c.set('runtimeContext', runtimeContext);
const run = await mastra.getWorkflow('codeWorkflow').createRunAsync();
@@ -1,8 +1,33 @@
import { z } from "zod";
export enum WorkspaceStepId {
CREATE_SANDBOX = "create-sandbox",
REPOSITORY_SETUP = "repository-setup",
INSTALL_DEPENDENCIES = "install-dependencies",
START_DEV_SERVER = "start-dev-server",
}
export type OnStepUpdateFn = ({
id,
status,
text
}: {
id: WorkspaceStepId;
status: "pending" | "in-progress" | "completed";
text: string;
}) => void;
export const workspaceStepSchema = z.object({
id: z.nativeEnum(WorkspaceStepId),
status: z.enum(["pending", "in-progress", "completed"]),
text: z.string(),
});
export const workspaceStateUIDataPartSchema = z.object({
state: z.enum(["ready"]),
workspaceUrl: z.string(),
state: z.enum(["pending", "in-progress", "completed"]),
steps: z.array(workspaceStepSchema),
workspaceUrl: z.string().nullable(),
});
export interface WorkspaceStateUIDataPart {
+79 -14
View File
@@ -1,6 +1,8 @@
import { Sandbox } from '@daytonaio/sdk';
import { daytona } from './daytona-client';
import { DaytonaNotFoundError } from '@daytonaio/sdk/src/errors/DaytonaError';
import { WriterType } from './ai/mastra/utils/runtime-context';
import { OnStepUpdateFn, WorkspaceStepId } from './ai/custom-parts/workspace-state';
const sandboxId = 'b62d2a47-2f7c-4367-aa53-4980f3fe6627';
const baseDir = '/home/daytona/workspace';
@@ -210,12 +212,12 @@ const createSandbox = async ({ artifactId }: { artifactId: string }) => {
await deleteOldSandboxes();
try {
sandbox = await createSandbox({
artifactId
});
sandbox = await createSandbox({
artifactId
});
} catch (e) {
console.log("Error creating sandbox", e);
throw e;
console.log('Error creating sandbox', e);
throw e;
}
}
@@ -224,9 +226,11 @@ const createSandbox = async ({ artifactId }: { artifactId: string }) => {
};
export const getOrCreateArtifactSandbox = async ({
artifactId
artifactId,
onStepUpdate
}: {
artifactId: string;
onStepUpdate: OnStepUpdateFn;
}): Promise<{ sandbox: Sandbox }> => {
const sandboxes = await daytona.list({
artifactId
@@ -237,10 +241,26 @@ export const getOrCreateArtifactSandbox = async ({
let sandbox: Sandbox;
if (existingSandbox) {
onStepUpdate({
id: WorkspaceStepId.CREATE_SANDBOX,
status: 'in-progress',
text: 'Getting existing workspace...'
});
sandbox = await daytona.get(existingSandbox.id);
onStepUpdate({
id: WorkspaceStepId.CREATE_SANDBOX,
status: 'completed',
text: 'Workspace found'
});
} else {
console.log('Workspace not found, creating...');
onStepUpdate({
id: WorkspaceStepId.CREATE_SANDBOX,
status: 'in-progress',
text: 'Creating workspace...'
});
sandbox = await createSandbox({
artifactId
});
@@ -252,6 +272,18 @@ export const getOrCreateArtifactSandbox = async ({
console.log('Creating artifact directory');
await sandbox.fs.createFolder(cwd, '755');
onStepUpdate({
id: WorkspaceStepId.CREATE_SANDBOX,
status: 'completed',
text: 'Sandbox created'
});
onStepUpdate({
id: WorkspaceStepId.REPOSITORY_SETUP,
status: 'in-progress',
text: 'Setting up repository...'
});
const ls = await sandbox.process.executeCommand('npm install -g bun', cwd, {}, 30 * 1000);
console.log('ls', ls);
@@ -266,16 +298,46 @@ export const getOrCreateArtifactSandbox = async ({
console.log('Template cloned', clone);
onStepUpdate({
id: WorkspaceStepId.REPOSITORY_SETUP,
status: 'completed',
text: 'Repository set up'
});
onStepUpdate({
id: WorkspaceStepId.INSTALL_DEPENDENCIES,
status: 'in-progress',
text: 'Installing dependencies...'
});
console.log('Installing dependencies');
const install = await sandbox.process.executeCommand('bun install', cwd, {}, 60);
console.log('Dependencies installed', install);
onStepUpdate({
id: WorkspaceStepId.INSTALL_DEPENDENCIES,
status: 'completed',
text: 'Dependencies installed'
});
}
return { sandbox };
};
export async function startDevServer({ sandbox }: { sandbox: Sandbox }) {
export async function startDevServer({
sandbox,
onStepUpdate
}: {
sandbox: Sandbox;
onStepUpdate: OnStepUpdateFn;
}) {
onStepUpdate({
id: WorkspaceStepId.START_DEV_SERVER,
status: 'in-progress',
text: 'Starting dev server...'
});
console.log('Exposing port 3000');
const previewLink = await sandbox.getPreviewLink(3000);
console.log('Preview link', { ...previewLink });
@@ -307,15 +369,18 @@ export async function startDevServer({ sandbox }: { sandbox: Sandbox }) {
if (!cmd.cmdId) {
throw new Error('Command ID not found');
}
// Wait 3 seconds
await new Promise((resolve) => setTimeout(resolve, 3000));
// Read logs
const logs = await sandbox.process.getSessionCommandLogs('devserver', cmd.cmdId!);
console.log('logs', logs);
}
onStepUpdate({
id: WorkspaceStepId.START_DEV_SERVER,
status: 'completed',
text: 'Dev server started'
});
// Wait 3 seconds
await new Promise((resolve) => setTimeout(resolve, 3000));
// Read logs
return {
success: true,
previewUrl: previewLink.url
+4 -3
View File
@@ -46,10 +46,11 @@
if (dataPart.type === "data-workspace-state") {
const data = dataPart.data as ImagineUIDataParts["workspace-state"];
const { state, workspaceUrl } = data;
const { state, workspaceUrl, steps } = data;
workspaceState.set({
ready: state === "ready",
workspaceUrl: new SvelteURL(workspaceUrl),
state,
workspaceUrl: workspaceUrl ? new SvelteURL(workspaceUrl) : null,
steps,
});
}
}
+9 -6
View File
@@ -1,17 +1,20 @@
import { writable } from 'svelte/store';
import type { Conversation } from '$lib/sdk/imagine';
import { asyncWritable } from '$lib/helpers/stores';
import type { WorkspaceStateUIDataPart } from '../../../ai-service/src/lib/ai/custom-parts/workspace-state';
import type { SvelteURL } from 'svelte/reactivity';
type WorkspaceState = {
ready: boolean;
workspaceUrl: SvelteURL | null;
}
export const showChat = writable(false);
export const showPrompt = writable(false);
export const conversation = asyncWritable<Conversation>();
export type WorkspaceState = Omit<WorkspaceStateUIDataPart["data"], "workspaceUrl"> & {
workspaceUrl: SvelteURL | null;
}
export const workspaceState = writable<WorkspaceState>({
ready: false,
state: "pending",
steps: [],
workspaceUrl: null,
});
@@ -12,6 +12,7 @@
import type { EventHandler } from 'svelte/elements';
import { onMount } from 'svelte';
import { workspaceState } from '$lib/stores/chat';
import WorkspaceProgress from './workspaceProgress.svelte';
let iframeRef: HTMLIFrameElement | null = $state(null);
let iframeContainerRef: HTMLDivElement | null = $state(null);
@@ -44,6 +45,12 @@
</script>
<svelte:window on:resize={calculateIframeHeight} />
{#if $workspaceState.state === "in-progress"}
<WorkspaceProgress />
{/if}
{#if $workspaceState.state === "completed" && $workspaceState.workspaceUrl}
<Layout.Stack direction="column" gap="s">
<form {onsubmit}>
<Layout.Stack direction="row" alignItems="center">
@@ -51,7 +58,6 @@
variant="extra-compact"
type="button"
size="s"
disabled={!$workspaceState.ready}
onclick={() => alert('back button clicked')}>
<Icon icon={IconChevronLeft} color="--fgcolor-neutral-tertiary" />
</Button.Button>
@@ -59,7 +65,6 @@
variant="extra-compact"
type="button"
size="s"
disabled={!$workspaceState.ready}
onclick={() => alert('forward button clicked')}>
<Icon icon={IconChevronRight} color="--fgcolor-neutral-tertiary" />
</Button.Button>
@@ -67,19 +72,16 @@
variant="extra-compact"
type="button"
size="s"
disabled={!$workspaceState.ready}
on:click={() => (refresh = !refresh)}>
<Icon icon={IconRefresh} color="--fgcolor-neutral-tertiary" />
</Button.Button>
<InputText
disabled={!$workspaceState.ready}
name="path"
id="previewUrl"
value={$workspaceState.workspaceUrl?.pathname ?? ''} />
<Button.Button
variant="extra-compact"
type="button"
disabled={!$workspaceState.ready}
onclick={() => {
showMobileDevice = !showMobileDevice;
}}
@@ -88,7 +90,6 @@
<Button.Anchor
variant="extra-compact"
type="button"
disabled={!$workspaceState.ready}
href={$workspaceState.workspaceUrl?.toString() ?? ''}
size="s"
target="_blank"
@@ -104,7 +105,7 @@
class="iframe-container"
class:mobile-container={showMobileDevice}
bind:this={iframeContainerRef}>
{#if $workspaceState.ready && $workspaceState.workspaceUrl}
{#if $workspaceState["state"] === "completed" && $workspaceState.workspaceUrl}
{#key refresh}
<iframe
src={$workspaceState.workspaceUrl.toString()}
@@ -115,6 +116,7 @@
{/key}
{/if}
</div>
{/if}
<style lang="scss">
.iframe-container {
@@ -0,0 +1,52 @@
<script lang="ts">
import { workspaceState } from '$lib/stores/chat';
import { IconCheckCircle } from '@appwrite.io/pink-icons-svelte';
import { Icon, Spinner } from '@appwrite.io/pink-svelte';
</script>
<div class="workspace-progress">
<div class="progress-steps">
{#each $workspaceState.steps as step}
<div class="step">
<div class="step-status">
{#if step.status === "in-progress"}
<Icon icon={Spinner} size="s" />
{/if}
{#if step.status === "completed"}
<Icon icon={IconCheckCircle} size="s" />
{/if}
</div>
<div class="step-text">{step.text}</div>
</div>
{/each}
</div>
</div>
<style lang="scss">
.workspace-progress {
height: 100%;
width: 100%;
background-color: #000;
justify-content: center;
align-items: center;
display: flex;
}
.progress-steps {
width: 240px;
background-color: #282828;
border-radius: 8px;
color: #fff;
}
.step {
padding: 12px 16px;
display: flex;
gap: 12px;
}
.step:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
</style>