diff --git a/ai-service/src/handlers/chat/route.ts b/ai-service/src/handlers/chat/route.ts index fbc9edd6a..15ece5b98 100644 --- a/ai-service/src/handlers/chat/route.ts +++ b/ai-service/src/handlers/chat/route.ts @@ -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[] = []; + + const updateStep: OnStepUpdateFn = ({ + id, + status, + text + }: Parameters[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(); diff --git a/ai-service/src/lib/ai/custom-parts/workspace-state.ts b/ai-service/src/lib/ai/custom-parts/workspace-state.ts index 5b7fbbdbb..32bd6e4de 100644 --- a/ai-service/src/lib/ai/custom-parts/workspace-state.ts +++ b/ai-service/src/lib/ai/custom-parts/workspace-state.ts @@ -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 { diff --git a/ai-service/src/lib/daytona-utils.ts b/ai-service/src/lib/daytona-utils.ts index a5146bb86..d2aad6e67 100644 --- a/ai-service/src/lib/daytona-utils.ts +++ b/ai-service/src/lib/daytona-utils.ts @@ -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 diff --git a/src/lib/components/studio/chat/chat.svelte b/src/lib/components/studio/chat/chat.svelte index e6726f5d3..920ed7fea 100644 --- a/src/lib/components/studio/chat/chat.svelte +++ b/src/lib/components/studio/chat/chat.svelte @@ -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, }); } } diff --git a/src/lib/stores/chat.ts b/src/lib/stores/chat.ts index fe22149b7..ce4cd9d54 100644 --- a/src/lib/stores/chat.ts +++ b/src/lib/stores/chat.ts @@ -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(); + + +export type WorkspaceState = Omit & { + workspaceUrl: SvelteURL | null; +} + export const workspaceState = writable({ - ready: false, + state: "pending", + steps: [], workspaceUrl: null, }); diff --git a/src/routes/(console)/project-[region]-[project]/studio/artifact-[artifact]/+page.svelte b/src/routes/(console)/project-[region]-[project]/studio/artifact-[artifact]/+page.svelte index a18c0d668..5eb307785 100644 --- a/src/routes/(console)/project-[region]-[project]/studio/artifact-[artifact]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/studio/artifact-[artifact]/+page.svelte @@ -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 @@ + +{#if $workspaceState.state === "in-progress"} + +{/if} + +{#if $workspaceState.state === "completed" && $workspaceState.workspaceUrl}
@@ -51,7 +58,6 @@ variant="extra-compact" type="button" size="s" - disabled={!$workspaceState.ready} onclick={() => alert('back button clicked')}> @@ -59,7 +65,6 @@ variant="extra-compact" type="button" size="s" - disabled={!$workspaceState.ready} onclick={() => alert('forward button clicked')}> @@ -67,19 +72,16 @@ variant="extra-compact" type="button" size="s" - disabled={!$workspaceState.ready} on:click={() => (refresh = !refresh)}> { showMobileDevice = !showMobileDevice; }} @@ -88,7 +90,6 @@ - {#if $workspaceState.ready && $workspaceState.workspaceUrl} + {#if $workspaceState["state"] === "completed" && $workspaceState.workspaceUrl} {#key refresh}