diff --git a/packages/next/package.json b/packages/next/package.json index 30690885b..af2474abd 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -31,6 +31,7 @@ "@workflow/builders": "workspace:*", "@workflow/core": "workspace:*", "@workflow/swc-plugin": "workspace:*", + "esbuild": "catalog:", "semver": "7.7.3", "watchpack": "2.4.4" }, diff --git a/packages/next/src/browser-worker-builder.ts b/packages/next/src/browser-worker-builder.ts new file mode 100644 index 000000000..98be2ec80 --- /dev/null +++ b/packages/next/src/browser-worker-builder.ts @@ -0,0 +1,666 @@ +/** + * Browser Worker Builder + * + * Bundles browser workflow files into a SharedWorker script for Next.js + * using esbuild to create a self-contained bundle with all dependencies. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { transform } from '@swc/core'; +import * as esbuild from 'esbuild'; +import type { BrowserWorkflowConfig } from './index.js'; + +export interface BrowserWorkerBuilderOptions { + /** Glob patterns for browser workflow files */ + include: string[]; + /** Working directory */ + workingDir: string; + /** Output directory (typically .next/static) */ + outputDir: string; + /** Database path for browser storage */ + database?: string; +} + +interface WorkflowMetadata { + workflowId: string; + functionName: string; +} + +interface TransformedWorkflow { + code: string; + workflows: WorkflowMetadata[]; + path: string; + relativePath: string; +} + +/** + * Simple glob pattern matcher + */ +function matchGlob(filePath: string, pattern: string): boolean { + const normalizedPath = filePath.replace(/\\/g, '/'); + const normalizedPattern = pattern.replace(/\\/g, '/'); + + // Convert glob pattern to regex by processing character by character + let regexStr = ''; + let i = 0; + + while (i < normalizedPattern.length) { + const char = normalizedPattern[i]; + const nextChar = normalizedPattern[i + 1]; + + if (char === '*' && nextChar === '*') { + // Handle ** + if (normalizedPattern[i + 2] === '/') { + // **/ - match any path segment (including none) + regexStr += '(?:.*/)?'; + i += 3; + } else { + // ** at end - match anything + regexStr += '.*'; + i += 2; + } + } else if (char === '*') { + // Single * - match anything except / + regexStr += '[^/]*'; + i++; + } else if (char === '?') { + // ? - match single char except / + regexStr += '[^/]'; + i++; + } else if ('.+^${}()|[]\\'.includes(char)) { + // Escape regex special chars + regexStr += '\\' + char; + i++; + } else { + regexStr += char; + i++; + } + } + + const regex = new RegExp(`^${regexStr}$`); + return regex.test(normalizedPath); +} + +/** + * Find all files matching the browser workflow patterns + */ +function findBrowserWorkflowFiles( + dir: string, + patterns: string[], + baseDir: string +): string[] { + const files: string[] = []; + + function walkDir(currentDir: string) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/'); + + if (entry.isDirectory()) { + // Skip node_modules and hidden directories + if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) { + walkDir(fullPath); + } + } else if (entry.isFile()) { + // Check if file matches any pattern + if (patterns.some((pattern) => matchGlob(relativePath, pattern))) { + files.push(fullPath); + } + } + } + } + + if (fs.existsSync(dir)) { + walkDir(dir); + } + + return files; +} + +/** + * Transform a workflow file and extract all workflow metadata + */ +async function transformWorkflowFile( + filePath: string, + workingDir: string +): Promise { + const content = fs.readFileSync(filePath, 'utf-8'); + + // Only process files with workflow directives + if ( + !content.includes("'use workflow'") && + !content.includes('"use workflow"') + ) { + return null; + } + + const relativePath = path.relative(workingDir, filePath).replace(/\\/g, '/'); + const isTypeScript = filePath.endsWith('.ts') || filePath.endsWith('.tsx'); + + // Transform with workflow mode to get the actual workflow code that can be executed + // (workflow mode keeps the function body, step mode replaces it with a guard) + const result = await transform(content, { + filename: relativePath, + jsc: { + parser: { + syntax: isTypeScript ? 'typescript' : 'ecmascript', + tsx: filePath.endsWith('.tsx'), + }, + target: 'es2022', + experimental: { + plugins: [ + [require.resolve('@workflow/swc-plugin'), { mode: 'workflow' }], + ], + }, + }, + minify: false, + sourceMaps: false, + }); + + // Extract workflow metadata from the comment + const metadataMatch = result.code.match( + /\/\*\*__internal_workflows(\{.*?\})\*\// + ); + if (!metadataMatch) { + return null; + } + + try { + const metadata = JSON.parse(metadataMatch[1]); + const workflowsForFile = metadata.workflows?.[relativePath]; + if (!workflowsForFile || Object.keys(workflowsForFile).length === 0) { + return null; + } + + // Extract all workflow function names and their IDs + const workflows: WorkflowMetadata[] = Object.entries(workflowsForFile).map( + ([functionName, data]) => ({ + functionName, + workflowId: (data as { workflowId: string }).workflowId, + }) + ); + + return { + code: result.code, + workflows, + path: filePath, + relativePath, + }; + } catch { + return null; + } +} + +/** + * Generate a browser-compatible shim for workflow/internal/private + * This replaces the Node.js-specific code with browser-safe implementations. + */ +function generatePrivateShim(): string { + return `/** + * Browser-compatible shim for workflow/internal/private + * This provides step registration without Node.js dependencies. + */ + +const registeredSteps = new Map(); + +export function registerStepFunction(stepId, stepFn) { + registeredSteps.set(stepId, stepFn); +} + +export function getStepFunction(stepId) { + return registeredSteps.get(stepId); +} + +// Browser shim for __private_getClosureVars +// In the browser, closure variables are handled differently +export function __private_getClosureVars() { + // Return empty object - browser workflows don't use the same closure mechanism + return {}; +} +`; +} + +/** + * Generate the SharedWorker entry point code + */ +function generateWorkerEntryCode( + transformedWorkflows: TransformedWorkflow[], + database: string +): string { + // Generate imports for each workflow file + const workflowImports = transformedWorkflows + .map((tw, index) => { + const exports = tw.workflows.map((w) => w.functionName).join(', '); + return `import { ${exports} } from './workflow_${index}.js';`; + }) + .join('\n'); + + // Generate registry entries + const registryEntries = transformedWorkflows + .flatMap((tw) => + tw.workflows.map( + (w) => `workflowRegistry.set('${w.workflowId}', ${w.functionName});` + ) + ) + .join('\n '); + + return `/** + * Auto-generated SharedWorker bundle for browser workflows + * Generated at: ${new Date().toISOString()} + */ + +import { createBrowserWorld } from '@workflow/world-browser'; +import { startQueueProcessor } from '@workflow/world-browser'; +import { executeWorkflow, setWorkflowRegistry } from '@workflow/world-browser/worker'; + +// Import workflow functions +${workflowImports} + +// Workflow registry +const workflowRegistry = new Map(); +${registryEntries} + +// Set the registry for the worker module +setWorkflowRegistry(workflowRegistry); + +// Worker state +let world = null; +const subscriptions = new Map(); + +// Broadcast to subscribers (posts to main thread) +function broadcastToSubscribers(runId, event) { + const callbacks = subscriptions.get(runId); + if (callbacks) { + // Post the event to the main thread + self.postMessage(event); + } +} + +console.log('[browser-worker] Worker loaded, initializing...'); + +// Initialize world immediately +let worldReady = (async () => { + console.log('[browser-worker] Initializing world...'); + try { + world = await createBrowserWorld({ database: '${database}' }); + console.log('[browser-worker] World initialized successfully'); + + // Start queue processor + startQueueProcessor(world.db, { + workflow: async (message, meta) => { + if (!('runId' in message)) return; + + const run = await world.runs.get(message.runId); + const workflowFn = workflowRegistry.get(run.workflowName); + + if (!workflowFn) { + console.error('[browser-worker] Workflow not found:', run.workflowName); + await world.runs.update(run.runId, { + status: 'failed', + error: { message: 'Workflow not found: ' + run.workflowName }, + }); + return; + } + + try { + // Update status to running + await world.runs.update(run.runId, { status: 'running' }); + broadcastToSubscribers(run.runId, { + type: 'RUN_UPDATED', + runId: run.runId, + run: await world.runs.get(run.runId), + }); + + // Execute workflow + const events = await world.events.list({ runId: run.runId }); + const result = await executeWorkflow(workflowFn, run, events.data, world); + + // Update with result + const completedRun = await world.runs.update(run.runId, { + status: 'completed', + output: result, + }); + + broadcastToSubscribers(run.runId, { + type: 'RUN_COMPLETED', + runId: run.runId, + run: completedRun, + }); + } catch (error) { + const failedRun = await world.runs.update(run.runId, { + status: 'failed', + error: { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + }); + + broadcastToSubscribers(run.runId, { + type: 'RUN_FAILED', + runId: run.runId, + run: failedRun, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + step: async () => { + // Step execution is handled within workflow context + }, + }); + console.log('[browser-worker] Queue processor started'); + return world; + } catch (err) { + console.error('[browser-worker] Failed to initialize world:', err); + throw err; + } +})(); + +// Handle messages from main thread +self.onmessage = async (msgEvent) => { + const { id, type, ...payload } = msgEvent.data; + console.log('[browser-worker] Received message:', type, id); + + // Wait for world to be ready before handling messages + try { + await worldReady; + } catch (err) { + self.postMessage({ + id, + success: false, + error: 'Worker initialization failed: ' + (err instanceof Error ? err.message : String(err)), + }); + return; + } + + try { + let result; + + switch (type) { + case 'TRIGGER': { + const run = await world.runs.create({ + workflowName: payload.workflowId, + deploymentId: 'browser', + input: payload.args, + }); + await world.queue('__wkf_workflow_' + payload.workflowId, { runId: run.runId }); + result = { runId: run.runId }; + break; + } + + case 'GET_STATUS': { + result = await world.runs.get(payload.runId); + break; + } + + case 'LIST_RUNS': { + result = await world.runs.list({ + workflowName: payload.workflowName, + status: payload.status, + pagination: { + limit: payload.limit, + cursor: payload.cursor, + }, + }); + break; + } + + case 'CANCEL': { + result = await world.runs.cancel(payload.runId); + broadcastToSubscribers(payload.runId, { + type: 'RUN_UPDATED', + runId: payload.runId, + run: result, + }); + break; + } + + case 'PAUSE': { + result = await world.runs.pause(payload.runId); + broadcastToSubscribers(payload.runId, { + type: 'RUN_UPDATED', + runId: payload.runId, + run: result, + }); + break; + } + + case 'RESUME': { + result = await world.runs.resume(payload.runId); + broadcastToSubscribers(payload.runId, { + type: 'RUN_UPDATED', + runId: payload.runId, + run: result, + }); + // Re-queue for execution + await world.queue('__wkf_workflow_' + result.workflowName, { runId: result.runId }); + break; + } + + case 'SUBSCRIBE': { + if (!subscriptions.has(payload.runId)) { + subscriptions.set(payload.runId, true); + } + result = await world.runs.get(payload.runId); + break; + } + + case 'UNSUBSCRIBE': { + subscriptions.delete(payload.runId); + result = null; + break; + } + + case 'GET_STEPS': { + result = await world.steps.list({ runId: payload.runId }); + break; + } + + case 'GET_EVENTS': { + result = await world.events.list({ runId: payload.runId }); + break; + } + + default: + result = { error: 'Unknown request type: ' + type }; + } + + self.postMessage({ id, success: true, data: result }); + } catch (error) { + self.postMessage({ + id, + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } +}; +`; +} + +/** + * Generate the SharedWorker bundle using esbuild + */ +export async function buildBrowserWorker( + options: BrowserWorkerBuilderOptions +): Promise { + const { include, workingDir, outputDir, database = 'workflows.db' } = options; + + // Find all browser workflow files + const workflowFiles = findBrowserWorkflowFiles( + workingDir, + include, + workingDir + ); + + if (workflowFiles.length === 0) { + console.log('[browser-worker] No browser workflow files found'); + return; + } + + console.log( + `[browser-worker] Found ${workflowFiles.length} browser workflow files` + ); + + // Transform each workflow file + const transformedWorkflows: TransformedWorkflow[] = []; + + for (const filePath of workflowFiles) { + const result = await transformWorkflowFile(filePath, workingDir); + if (result) { + transformedWorkflows.push(result); + } + } + + if (transformedWorkflows.length === 0) { + console.log('[browser-worker] No workflows to bundle'); + return; + } + + console.log( + `[browser-worker] Bundling ${transformedWorkflows.reduce((acc, tw) => acc + tw.workflows.length, 0)} workflows` + ); + + // Create a temporary directory for the build + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-browser-')); + + // Map temp file names to original source directories for import resolution + const tempToOriginalDir = new Map(); + + try { + // Write the browser-compatible private shim + const shimPath = path.join(tmpDir, 'workflow-private-shim.js'); + fs.writeFileSync(shimPath, generatePrivateShim(), 'utf-8'); + + // Write each transformed workflow file and track original paths + for (let i = 0; i < transformedWorkflows.length; i++) { + const tw = transformedWorkflows[i]; + const workflowFilePath = path.join(tmpDir, `workflow_${i}.js`); + fs.writeFileSync(workflowFilePath, tw.code, 'utf-8'); + // Map temp file to original source directory + tempToOriginalDir.set(workflowFilePath, path.dirname(tw.path)); + } + + // Generate and write the entry file + const entryCode = generateWorkerEntryCode(transformedWorkflows, database); + const entryPath = path.join(tmpDir, 'worker-entry.js'); + fs.writeFileSync(entryPath, entryCode, 'utf-8'); + + // Ensure output directory exists + fs.mkdirSync(outputDir, { recursive: true }); + + // Output directly to public folder with the expected filename + const workerPath = path.join(outputDir, '__workflow-worker.js'); + + // Create esbuild plugin to resolve relative imports from original source locations + const resolveFromOriginalPlugin: esbuild.Plugin = { + name: 'resolve-from-original', + setup(build) { + // Intercept relative imports in temp workflow files + build.onResolve({ filter: /^\.\.?\// }, (args) => { + // Check if this import is from a temp workflow file + const importerDir = tempToOriginalDir.get(args.importer); + if (importerDir) { + // Resolve relative to the original source directory + const resolvedPath = path.resolve(importerDir, args.path); + // Try with .ts, .tsx, .js extensions + const extensions = ['', '.ts', '.tsx', '.js', '.jsx']; + for (const ext of extensions) { + const fullPath = resolvedPath + ext; + if (fs.existsSync(fullPath)) { + return { path: fullPath }; + } + // Also try /index variants + const indexPath = path.join(resolvedPath, `index${ext || '.ts'}`); + if (fs.existsSync(indexPath)) { + return { path: indexPath }; + } + } + } + return undefined; // Let esbuild handle it normally + }); + }, + }; + + // Bundle with esbuild + await esbuild.build({ + entryPoints: [entryPath], + bundle: true, + format: 'esm', + target: 'es2022', + outfile: workerPath, + minify: false, + sourcemap: false, + // Bundle all dependencies into the worker + external: [], + // Define environment + define: { + 'process.env.NODE_ENV': '"production"', + }, + // Use plugin to resolve relative imports from original locations + plugins: [resolveFromOriginalPlugin], + // Handle node: protocol imports + platform: 'browser', + // Log level + logLevel: 'warning', + // Resolve modules from the working directory's node_modules + nodePaths: [ + path.join(workingDir, 'node_modules'), + // Also check monorepo root node_modules + path.resolve(workingDir, '../../node_modules'), + ], + // Set absWorkingDir so relative imports in temp files work + absWorkingDir: workingDir, + // Alias workflow/internal/private to our browser-compatible shim + alias: { + 'workflow/internal/private': shimPath, + }, + }); + + console.log(`[browser-worker] Generated worker bundle at ${workerPath}`); + + // Copy Turso WASM file to output directory + try { + const tursoWasmPath = require.resolve( + '@tursodatabase/database-wasm/dist/turso.wasm32-wasi.wasm' + ); + const destWasmPath = path.join(outputDir, 'turso.wasm32-wasi.wasm'); + fs.copyFileSync(tursoWasmPath, destWasmPath); + console.log(`[browser-worker] Copied Turso WASM to ${destWasmPath}`); + } catch (err) { + console.warn( + '[browser-worker] Could not copy Turso WASM file:', + err instanceof Error ? err.message : err + ); + } + } finally { + // Clean up temp directory + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Create browser worker builder from config + */ +export function createBrowserWorkerBuilder( + config: BrowserWorkflowConfig, + workingDir: string, + outputDir: string +) { + return { + async build() { + await buildBrowserWorker({ + include: config.include, + workingDir, + outputDir, + database: config.database, + }); + }, + }; +} diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index ab29ddaa6..5b4437fb9 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -2,6 +2,49 @@ import type { NextConfig } from 'next'; import semver from 'semver'; import { getNextBuilder } from './builder.js'; +/** + * Browser workflow configuration. + */ +export interface BrowserWorkflowConfig { + /** + * Glob patterns for files containing browser workflows. + * These files will be transformed with 'browser' mode instead of 'client' mode. + * @example ['src/workflows/browser/**\/*.ts'] + */ + include: string[]; + + /** + * Path to custom SharedWorker entry file (optional). + * If not provided, a default worker entry will be generated. + */ + workerEntry?: string; + + /** + * OPFS database path for browser storage. + * @default 'workflows.db' + */ + database?: string; +} + +/** + * Workflow configuration options. + */ +export interface WorkflowOptions { + workflows?: { + embedded?: { + port?: number; + dataDir?: string; + }; + }; + + /** + * Browser workflow configuration. + * When provided, enables browser-based workflow execution using SharedWorker. + * Files matching the include patterns will be transformed to call the browser workflow client. + */ + browser?: BrowserWorkflowConfig; +} + export function withWorkflow( nextConfigOrFn: | NextConfig @@ -9,16 +52,7 @@ export function withWorkflow( phase: string, ctx: { defaultConfig: NextConfig } ) => Promise), - { - workflows, - }: { - workflows?: { - embedded?: { - port?: number; - dataDir?: string; - }; - }; - } = {} + { workflows, browser }: WorkflowOptions = {} ) { if (!process.env.VERCEL_DEPLOYMENT_ID) { if (!process.env.WORKFLOW_TARGET_WORLD) { @@ -35,6 +69,17 @@ export function withWorkflow( } } + // Store browser workflow config for the loader + if (browser) { + process.env.WORKFLOW_BROWSER_INCLUDE = JSON.stringify(browser.include); + if (browser.workerEntry) { + process.env.WORKFLOW_BROWSER_WORKER_ENTRY = browser.workerEntry; + } + if (browser.database) { + process.env.WORKFLOW_BROWSER_DATABASE = browser.database; + } + } + return async function buildConfig( phase: string, ctx: { defaultConfig: NextConfig } @@ -126,9 +171,26 @@ export function withWorkflow( }); await workflowBuilder.build(); + + // Build browser worker if browser config is provided + if (browser) { + const { createBrowserWorkerBuilder } = await import( + './browser-worker-builder.js' + ); + // Output to public directory so it's served as a static file + const browserBuilder = createBrowserWorkerBuilder( + browser, + process.cwd(), + 'public' + ); + await browserBuilder.build(); + } + process.env.WORKFLOW_NEXT_PRIVATE_BUILT = '1'; } + // No rewrites needed - worker is served directly from public folder + return nextConfig; }; } diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index b1ffb6788..d4dbfdcf5 100644 --- a/packages/next/src/loader.ts +++ b/packages/next/src/loader.ts @@ -1,8 +1,81 @@ import { relative } from 'node:path'; import { transform } from '@swc/core'; +/** + * Match a file path against a glob pattern. + * Supports **, *, and ? wildcards. + */ +function matchGlob(filePath: string, pattern: string): boolean { + const normalizedPath = filePath.replace(/\\/g, '/'); + const normalizedPattern = pattern.replace(/\\/g, '/'); + + // Convert glob pattern to regex by processing character by character + let regexStr = ''; + let i = 0; + + while (i < normalizedPattern.length) { + const char = normalizedPattern[i]; + const nextChar = normalizedPattern[i + 1]; + + if (char === '*' && nextChar === '*') { + // Handle ** + if (normalizedPattern[i + 2] === '/') { + // **/ matches zero or more directories + regexStr += '(?:.*/)?'; + i += 3; + } else { + // ** at end matches everything + regexStr += '.*'; + i += 2; + } + } else if (char === '*') { + // * matches anything except / + regexStr += '[^/]*'; + i++; + } else if (char === '?') { + // ? matches single char except / + regexStr += '[^/]'; + i++; + } else if ('.+^${}()|[]\\'.includes(char)) { + // Escape regex special characters + regexStr += '\\' + char; + i++; + } else { + regexStr += char; + i++; + } + } + + const regex = new RegExp(`^${regexStr}$`); + return regex.test(normalizedPath); +} + +/** + * Check if a file path matches any of the browser workflow patterns. + */ +function isBrowserWorkflow(filePath: string): boolean { + const browserInclude = process.env.WORKFLOW_BROWSER_INCLUDE; + if (!browserInclude) { + return false; + } + + try { + const patterns: string[] = JSON.parse(browserInclude); + return patterns.some((pattern) => matchGlob(filePath, pattern)); + } catch { + return false; + } +} + +/** + * Determine the transform mode for a file. + */ +function getTransformMode(filePath: string): 'client' | 'browser' { + return isBrowserWorkflow(filePath) ? 'browser' : 'client'; +} + // This loader applies the "use workflow"/"use step" -// client transformation +// client/browser transformation export default async function workflowLoader( this: { resourcePath: string; @@ -60,6 +133,9 @@ export default async function workflowLoader( relativeFilename = normalizedFilepath.split('/').pop() || 'unknown.ts'; } + // Determine transform mode based on file path + const transformMode = getTransformMode(relativeFilename); + // Transform with SWC const result = await transform(normalizedSource, { filename: relativeFilename, @@ -71,7 +147,7 @@ export default async function workflowLoader( target: 'es2022', experimental: { plugins: [ - [require.resolve('@workflow/swc-plugin'), { mode: 'client' }], + [require.resolve('@workflow/swc-plugin'), { mode: transformMode }], ], }, }, diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs index ce578e3ac..9c20bf462 100644 --- a/packages/swc-plugin-workflow/transform/src/lib.rs +++ b/packages/swc-plugin-workflow/transform/src/lib.rs @@ -144,6 +144,7 @@ pub enum TransformMode { Step, Workflow, Client, + Browser, } #[derive(Debug)] @@ -212,6 +213,8 @@ pub struct StepTransform { current_var_context: Option, // Track module-level imports to exclude from closure variables module_imports: HashSet, + // Track if we need to add the browser workflow client import (for browser mode) + needs_browser_client_import: bool, } // Structure to track variable names and their access patterns @@ -857,7 +860,7 @@ impl StepTransform { *stmt = Stmt::Decl(var_decl); return; } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function self.remove_use_step_directive(&mut fn_decl.function.body); return; @@ -900,7 +903,7 @@ impl StepTransform { })]; } } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { self.remove_use_step_directive(&mut fn_decl.function.body); stmt.visit_mut_children_with(self); } @@ -990,6 +993,19 @@ impl StepTransform { .push((fn_name.clone(), fn_span)); stmt.visit_mut_children_with(self); } + TransformMode::Browser => { + self.remove_use_workflow_directive(&mut fn_decl.function.body); + let workflow_id = self.create_id(Some(&fn_name), fn_span, true); + let new_body = self.create_browser_client_call_body( + &workflow_id, + &fn_decl.function.params, + ); + fn_decl.function.body = Some(new_body); + self.needs_browser_client_import = true; + self.workflow_functions_needing_id + .push((fn_name.clone(), fn_span)); + stmt.visit_mut_children_with(self); + } } } } else { @@ -1070,6 +1086,7 @@ impl StepTransform { object_property_workflow_conversions: Vec::new(), current_var_context: None, module_imports: HashSet::new(), + needs_browser_client_import: false, } } @@ -1110,6 +1127,129 @@ impl StepTransform { name } + // Generate browser client call body for browser mode + // Creates: return __browserWorkflowClient.run(workflowId, [param1, param2, ...]) + fn create_browser_client_call_body(&self, workflow_id: &str, params: &[Param]) -> BlockStmt { + // Create array of parameter references + let param_array_elems: Vec> = params + .iter() + .filter_map(|param| { + if let Pat::Ident(ident) = ¶m.pat { + Some(Some(ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(ident.id.clone())), + })) + } else { + None + } + }) + .collect(); + + // Create the array literal: [param1, param2, ...] + let params_array = Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: param_array_elems, + }); + + // Create: __browserWorkflowClient.run(workflowId, [...params]) + let call_expr = Expr::Call(CallExpr { + span: DUMMY_SP, + ctxt: SyntaxContext::empty(), + callee: Callee::Expr(Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Ident(Ident::new( + "__browserWorkflowClient".into(), + DUMMY_SP, + SyntaxContext::empty(), + ))), + prop: MemberProp::Ident(IdentName::new("run".into(), DUMMY_SP)), + }))), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: workflow_id.into(), + raw: None, + }))), + }, + ExprOrSpread { + spread: None, + expr: Box::new(params_array), + }, + ], + type_args: None, + }); + + // Create return statement + BlockStmt { + span: DUMMY_SP, + ctxt: SyntaxContext::empty(), + stmts: vec![Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(Box::new(call_expr)), + })], + } + } + + // Generate browser client call body for arrow functions + // Similar to above but works with arrow function params + fn create_browser_client_call_body_arrow(&self, workflow_id: &str, params: &[Pat]) -> BlockStmtOrExpr { + // Create array of parameter references + let param_array_elems: Vec> = params + .iter() + .filter_map(|param| { + if let Pat::Ident(ident) = param { + Some(Some(ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(ident.id.clone())), + })) + } else { + None + } + }) + .collect(); + + // Create the array literal: [param1, param2, ...] + let params_array = Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: param_array_elems, + }); + + // Create: __browserWorkflowClient.run(workflowId, [...params]) + let call_expr = Expr::Call(CallExpr { + span: DUMMY_SP, + ctxt: SyntaxContext::empty(), + callee: Callee::Expr(Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Ident(Ident::new( + "__browserWorkflowClient".into(), + DUMMY_SP, + SyntaxContext::empty(), + ))), + prop: MemberProp::Ident(IdentName::new("run".into(), DUMMY_SP)), + }))), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: workflow_id.into(), + raw: None, + }))), + }, + ExprOrSpread { + spread: None, + expr: Box::new(params_array), + }, + ], + type_args: None, + }); + + // Return as expression body + BlockStmtOrExpr::Expr(Box::new(call_expr)) + } + // Collect all declared identifiers in the module to avoid naming collisions fn collect_declared_identifiers(&mut self, items: &[ModuleItem]) { for item in items { @@ -1479,7 +1619,7 @@ impl StepTransform { step_id, )); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive (already done above) } } @@ -1538,7 +1678,7 @@ impl StepTransform { step_id, )); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive } } @@ -2942,7 +3082,7 @@ impl VisitMut for StepTransform { )); } } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // No imports needed for client mode since step functions are not transformed } } @@ -3268,7 +3408,7 @@ impl VisitMut for StepTransform { module_items.push(self.create_private_imports(true, false)); } } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // No imports needed for workflow mode } } @@ -3471,7 +3611,7 @@ impl VisitMut for StepTransform { let should_remove = match self.mode { TransformMode::Step => value == "use step" || value == "use workflow", TransformMode::Workflow => value == "use workflow", - TransformMode::Client => value == "use step" || value == "use workflow", + TransformMode::Client | TransformMode::Browser => value == "use step" || value == "use workflow", }; if should_remove { items.remove(0); @@ -3677,7 +3817,7 @@ impl VisitMut for StepTransform { item.visit_mut_with(self); // After visiting the item, check if we need to add a workflowId assignment - if matches!(self.mode, TransformMode::Client | TransformMode::Step) { + if matches!(self.mode, TransformMode::Client | TransformMode::Step | TransformMode::Browser) { match item { ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) => { if let Decl::Fn(fn_decl) = &export_decl.decl { @@ -4064,6 +4204,35 @@ impl VisitMut for StepTransform { } } + // In browser mode, add import for __browserWorkflowClient if needed + if self.mode == TransformMode::Browser && self.needs_browser_client_import { + // Create import: import { __browserWorkflowClient } from '@workflow/world-browser/client'; + let import_item = ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: Ident::new( + "__browserWorkflowClient".into(), + DUMMY_SP, + SyntaxContext::empty(), + ), + imported: None, + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + value: "@workflow/world-browser/client".into(), + raw: None, + }), + type_only: false, + with: None, + phase: ImportPhase::Evaluation, + })); + + // Insert at the beginning of the module + items.insert(0, import_item); + } + // Perform dead code elimination in workflow and client mode self.remove_dead_code(items); } @@ -4092,7 +4261,7 @@ impl VisitMut for StepTransform { // For workflow mode, we need to replace the entire declaration // This will be handled at a higher level } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Step functions are completely removed in client mode // This will be handled at a higher level } @@ -4117,7 +4286,7 @@ impl VisitMut for StepTransform { // For workflow mode, we need to replace the entire declaration // This will be handled at a higher level } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Workflow functions are transformed in client mode // This will be handled at a higher level } @@ -4206,7 +4375,7 @@ impl VisitMut for StepTransform { fn_decl.function.span, )); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function as-is self.remove_use_step_directive(&mut fn_decl.function.body); export_decl.visit_mut_children_with(self); @@ -4290,6 +4459,18 @@ impl VisitMut for StepTransform { self.workflow_functions_needing_id .push((fn_name.clone(), fn_decl.function.span)); } + TransformMode::Browser => { + self.remove_use_workflow_directive(&mut fn_decl.function.body); + let workflow_id = self.create_id(Some(&fn_name), fn_decl.function.span, true); + let new_body = self.create_browser_client_call_body( + &workflow_id, + &fn_decl.function.params, + ); + fn_decl.function.body = Some(new_body); + self.needs_browser_client_import = true; + self.workflow_functions_needing_id + .push((fn_name.clone(), fn_decl.function.span)); + } } } // Visit children for workflow functions OUTSIDE the match to avoid borrow issues @@ -4400,7 +4581,7 @@ impl VisitMut for StepTransform { self.create_step_initializer(&step_id), ); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function as-is self.remove_use_step_directive( &mut fn_expr.function.body, @@ -4475,7 +4656,7 @@ impl VisitMut for StepTransform { fn_expr.function.span, )); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Only replace with throw if function has inline directive let has_inline_directive = self .has_use_workflow_directive( @@ -4571,7 +4752,7 @@ impl VisitMut for StepTransform { self.create_step_initializer(&step_id), ); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function as-is self.remove_use_step_directive_arrow( &mut arrow_expr.body, @@ -4652,7 +4833,7 @@ impl VisitMut for StepTransform { arrow_expr.span, )); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Only replace with throw if function has inline directive let has_inline_directive = self .has_workflow_directive_arrow( @@ -4847,7 +5028,7 @@ impl VisitMut for StepTransform { })]; } } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function as-is self.remove_use_step_directive( &mut fn_expr.function.body, @@ -4910,7 +5091,7 @@ impl VisitMut for StepTransform { &mut fn_expr.function.body, ); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Replace workflow function body with error throw self.remove_use_workflow_directive( &mut fn_expr.function.body, @@ -5090,7 +5271,7 @@ impl VisitMut for StepTransform { &closure_vars, )); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function self.remove_use_step_directive_arrow( &mut arrow_expr.body, @@ -5147,7 +5328,7 @@ impl VisitMut for StepTransform { Box::new(proxy_call), )); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function as-is self.remove_use_step_directive_arrow( &mut arrow_expr.body, @@ -5214,7 +5395,7 @@ impl VisitMut for StepTransform { &mut arrow_expr.body, ); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Replace workflow function body with error throw self.remove_use_workflow_directive_arrow( &mut arrow_expr.body, @@ -5491,7 +5672,7 @@ impl VisitMut for StepTransform { *expr = self.create_step_proxy_reference(&step_id, &closure_vars); return; // Don't visit children since we replaced the expr } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function self.remove_use_step_directive(&mut fn_expr.function.body); } @@ -5615,7 +5796,7 @@ impl VisitMut for StepTransform { *expr = self.create_step_proxy_reference(&step_id, &closure_vars); return; // Don't visit children since we replaced the expr } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // In client mode, just remove the directive and keep the function self.remove_use_step_directive_arrow(&mut arrow_expr.body); } @@ -5659,7 +5840,7 @@ impl VisitMut for StepTransform { self.workflow_function_names.insert("default".to_string()); match self.mode { - TransformMode::Step | TransformMode::Client => { + TransformMode::Step | TransformMode::Client | TransformMode::Browser => { // In step/client mode, replace workflow function body with error throw self.remove_use_workflow_directive(&mut fn_expr.function.body); @@ -5796,7 +5977,7 @@ impl VisitMut for StepTransform { })]; } } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Transform step function body to use step run call self.remove_use_step_directive(&mut fn_expr.function.body); } @@ -5829,7 +6010,7 @@ impl VisitMut for StepTransform { self.workflow_function_names.insert("default".to_string()); match self.mode { - TransformMode::Step | TransformMode::Client => { + TransformMode::Step | TransformMode::Client | TransformMode::Browser => { // In step/client mode, replace workflow function body with error throw self.remove_use_workflow_directive(&mut fn_expr.function.body); let error_msg = format!( @@ -5927,7 +6108,7 @@ impl VisitMut for StepTransform { self.workflow_function_names.insert("default".to_string()); match self.mode { - TransformMode::Step | TransformMode::Client => { + TransformMode::Step | TransformMode::Client | TransformMode::Browser => { // In step/client mode, replace arrow body with throw error self.remove_use_workflow_directive_arrow(&mut arrow_expr.body); let error_msg = format!( @@ -6186,7 +6367,7 @@ impl VisitMut for StepTransform { &closure_vars, ); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Just remove directive self.remove_use_step_directive_arrow( &mut arrow_expr.body, @@ -6278,7 +6459,7 @@ impl VisitMut for StepTransform { &closure_vars, ); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Just remove directive self.remove_use_step_directive( &mut fn_expr.function.body, @@ -6392,7 +6573,7 @@ impl VisitMut for StepTransform { ), })); } - TransformMode::Client => { + TransformMode::Client | TransformMode::Browser => { // Just remove directive self.remove_use_step_directive( &mut method_prop.function.body, diff --git a/packages/swc-plugin-workflow/transform/tests/errors.rs b/packages/swc-plugin-workflow/transform/tests/errors.rs index 81def9671..6109436c7 100644 --- a/packages/swc-plugin-workflow/transform/tests/errors.rs +++ b/packages/swc-plugin-workflow/transform/tests/errors.rs @@ -64,3 +64,23 @@ fn client_mode(input: PathBuf) { }, ); } + +#[testing::fixture("tests/errors/**/input.js")] +fn browser_mode(input: PathBuf) { + // Browser mode error tests only run when there's an explicit browser output file + let browser_output = input.parent().unwrap().join("output-browser.js"); + if !browser_output.exists() { + return; + } + test_fixture( + Default::default(), + &|_| visit_mut_pass(StepTransform::new(TransformMode::Browser, input.file_name().unwrap().to_string_lossy().to_string())), + &input, + &browser_output, + FixtureTestConfig { + allow_error: true, + module: Some(true), + ..Default::default() + }, + ); +} diff --git a/packages/swc-plugin-workflow/transform/tests/fixture.rs b/packages/swc-plugin-workflow/transform/tests/fixture.rs index e8b73edc9..0022bd05e 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture.rs +++ b/packages/swc-plugin-workflow/transform/tests/fixture.rs @@ -48,4 +48,24 @@ fn client_mode(input: PathBuf) { ..Default::default() }, ); +} + +#[testing::fixture("tests/fixture/**/input.js")] +fn browser_mode(input: PathBuf) { + let browser_output = input.parent().unwrap().join("output-browser.js"); + // Browser mode tests only run when there's an explicit browser output file + // This allows gradual rollout of browser mode test coverage + if !browser_output.exists() { + return; + } + test_fixture( + Default::default(), + &|_| visit_mut_pass(StepTransform::new(TransformMode::Browser, input.file_name().unwrap().to_string_lossy().to_string())), + &input, + &browser_output, + FixtureTestConfig { + module: Some(true), + ..Default::default() + }, + ); } \ No newline at end of file diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/single-workflow/output-browser.js b/packages/swc-plugin-workflow/transform/tests/fixture/single-workflow/output-browser.js new file mode 100644 index 000000000..fb3c361e0 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/fixture/single-workflow/output-browser.js @@ -0,0 +1,9 @@ +import { __browserWorkflowClient } from "@workflow/world-browser/client"; +/**__internal_workflows{"workflows":{"input.js":{"workflow":{"workflowId":"workflow//input.js//workflow"}}}}*/; +export async function workflow(a, b) { + return __browserWorkflowClient.run("workflow//input.js//workflow", [ + a, + b + ]); +} +workflow.workflowId = "workflow//input.js//workflow"; diff --git a/packages/world-browser/CHANGELOG.md b/packages/world-browser/CHANGELOG.md new file mode 100644 index 000000000..ab6779519 --- /dev/null +++ b/packages/world-browser/CHANGELOG.md @@ -0,0 +1,15 @@ +# @workflow/world-browser + +## 0.0.1 + +### Added + +- Initial release with browser-based World implementation +- Turso WASM (SQLite) based storage for runs, steps, events, and hooks +- SQLite-backed queue with polling processor +- Proxy-based deterministic context (Math.random, Date.now, crypto.randomUUID) +- SharedWorker entry point for multi-tab support +- Workflow execution engine for browser +- Main thread BrowserWorkflowClient SDK +- React hooks (useWorkflowRun, useWorkflowProgress, useWorkflowRuns) +- Support for `browser` transform mode in SWC plugin diff --git a/packages/world-browser/README.md b/packages/world-browser/README.md new file mode 100644 index 000000000..a97e0d1c0 --- /dev/null +++ b/packages/world-browser/README.md @@ -0,0 +1,69 @@ +# @workflow/world-browser + +Browser-based World implementation for Workflow DevKit using SharedWorker and Turso WASM. + +## Overview + +This package enables running workflows entirely in the browser using: + +- **SharedWorker** for background execution across tabs +- **Turso WASM** (SQLite) for persistent storage via OPFS +- **Web Streams API** for streaming support + +## Installation + +```bash +npm install @workflow/world-browser +``` + +## Usage + +### Define Workflows (same syntax as server) + +```typescript +// src/workflows/browser/my-workflow.ts +async function myStep(x: number) { + 'use step'; + return x * 2; +} + +export async function myWorkflow(input: { value: number }) { + 'use workflow'; + return await myStep(input.value); +} +``` + +### Configure Next.js + +```typescript +// next.config.ts +import { withWorkflow } from '@workflow/next'; + +export default withWorkflow(nextConfig, { + browser: { + include: ['src/workflows/browser/**/*.ts'], + }, +}); +``` + +### Call Workflows + +```typescript +// In a client component +import { myWorkflow } from '@/workflows/browser/my-workflow'; + +// Just call it like a regular function! +const result = await myWorkflow({ value: 21 }); +console.log(result); // 42 +``` + +## Features + +- **Same syntax** as server workflows (`'use workflow'`, `'use step'`) +- **Offline-capable** - workflows persist in browser storage +- **Multi-tab support** via SharedWorker +- **Deterministic execution** for reliable replay + +## License + +Apache-2.0 diff --git a/packages/world-browser/package.json b/packages/world-browser/package.json new file mode 100644 index 000000000..df3e3c86e --- /dev/null +++ b/packages/world-browser/package.json @@ -0,0 +1,68 @@ +{ + "name": "@workflow/world-browser", + "version": "0.0.1", + "description": "Browser-based World implementation for Workflow DevKit using SharedWorker and Turso WASM", + "type": "module", + "main": "dist/index.js", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/vercel/workflow.git", + "directory": "packages/world-browser" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./client": { + "types": "./dist/client/sdk.d.ts", + "default": "./dist/client/sdk.js" + }, + "./worker": { + "types": "./dist/worker/index.d.ts", + "default": "./dist/worker/index.js" + }, + "./react": { + "types": "./dist/client/react.d.ts", + "default": "./dist/client/react.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "tsc --build --clean && rm -rf dist", + "test": "vitest run src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tursodatabase/database-wasm": "0.3.2", + "@workflow/errors": "workspace:*", + "@workflow/world": "workspace:*", + "seedrandom": "3.0.5", + "ulid": "3.0.1", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/react": "^19", + "@types/seedrandom": "3.0.8", + "@workflow/tsconfig": "workspace:*", + "react": "^18.0.0 || ^19.0.0", + "vitest": "catalog:" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } +} diff --git a/packages/world-browser/src/client/index.ts b/packages/world-browser/src/client/index.ts new file mode 100644 index 000000000..89e73552d --- /dev/null +++ b/packages/world-browser/src/client/index.ts @@ -0,0 +1,10 @@ +/** + * Client exports for browser workflows. + */ + +export { + BrowserWorkflowClient, + __browserWorkflowClient, + type BrowserWorkflowClientOptions, + type WorkflowSubscriptionCallback, +} from './sdk.js'; diff --git a/packages/world-browser/src/client/react.ts b/packages/world-browser/src/client/react.ts new file mode 100644 index 000000000..cedf733d6 --- /dev/null +++ b/packages/world-browser/src/client/react.ts @@ -0,0 +1,390 @@ +/** + * React hooks for browser workflows. + * + * These hooks provide a convenient way to interact with browser workflows + * from React components, with proper SSR safety. + */ + +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Step, WorkflowRun } from '@workflow/world'; +import { + BrowserWorkflowClient, + type WorkflowSubscriptionCallback, +} from './sdk.js'; +import type { AnyWorkerEvent } from '../worker/message-types.js'; + +// Lazy-initialized client instance +let clientInstance: BrowserWorkflowClient | null = null; + +function getClient(): BrowserWorkflowClient { + if (typeof window === 'undefined') { + throw new Error( + 'Browser workflow hooks can only be used on the client side' + ); + } + if (!clientInstance) { + clientInstance = new BrowserWorkflowClient(); + } + return clientInstance; +} + +/** + * Hook for workflow operations (trigger, cancel, etc.) + * + * @example + * ```tsx + * const { trigger, cancel } = useWorkflow(); + * + * const handleStart = async () => { + * const { runId } = await trigger('my-workflow', { input: 'value' }); + * }; + * ``` + */ +export function useWorkflow() { + const trigger = useCallback( + async (workflowId: string, ...args: unknown[]) => { + const client = getClient(); + return client.trigger(workflowId, args); + }, + [] + ); + + const cancel = useCallback(async (runId: string) => { + const client = getClient(); + return client.cancel(runId); + }, []); + + const pause = useCallback(async (runId: string) => { + const client = getClient(); + return client.pause(runId); + }, []); + + const resume = useCallback(async (runId: string) => { + const client = getClient(); + return client.resume(runId); + }, []); + + const getStatus = useCallback(async (runId: string) => { + const client = getClient(); + return client.getStatus(runId); + }, []); + + return { + trigger, + cancel, + pause, + resume, + getStatus, + }; +} + +/** + * State returned by useWorkflowRun hook. + */ +export interface UseWorkflowRunState { + /** The workflow run data, null if not loaded yet */ + run: WorkflowRun | null; + /** Current status of the run */ + status: WorkflowRun['status'] | null; + /** Output of the completed workflow */ + output: unknown; + /** Error if the workflow failed */ + error: { message: string; stack?: string; code?: string } | null; + /** Steps of the workflow */ + steps: Step[]; + /** Whether the data is still loading */ + isLoading: boolean; + /** Whether the workflow is currently running */ + isRunning: boolean; + /** Whether the workflow has completed (successfully or with failure) */ + isComplete: boolean; +} + +/** + * Hook to subscribe to a workflow run's status and updates. + * + * @param runId - The run ID to subscribe to, or null to not subscribe + * @returns The current state of the workflow run + * + * @example + * ```tsx + * const { status, steps, output, isLoading } = useWorkflowRun(runId); + * + * if (isLoading) return ; + * if (status === 'completed') return
Result: {output}
; + * ``` + */ +export function useWorkflowRun(runId: string | null): UseWorkflowRunState { + const [state, setState] = useState({ + run: null, + status: null, + output: undefined, + error: null, + steps: [], + isLoading: !!runId, + isRunning: false, + isComplete: false, + }); + + // Track mounted state + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (!runId || typeof window === 'undefined') { + setState({ + run: null, + status: null, + output: undefined, + error: null, + steps: [], + isLoading: false, + isRunning: false, + isComplete: false, + }); + return; + } + + const client = getClient(); + let unsubscribe: (() => void) | null = null; + + // Fetch initial state + const fetchInitialState = async () => { + try { + const [run, stepsResult] = await Promise.all([ + client.getStatus(runId), + client.getSteps(runId), + ]); + + if (!mountedRef.current) return; + + const isComplete = + run.status === 'completed' || + run.status === 'failed' || + run.status === 'cancelled'; + + setState({ + run, + status: run.status, + output: run.output, + error: run.error ?? null, + steps: stepsResult.data, + isLoading: false, + isRunning: run.status === 'running', + isComplete, + }); + } catch (error) { + if (!mountedRef.current) return; + + setState((prev) => ({ + ...prev, + isLoading: false, + error: { + message: error instanceof Error ? error.message : String(error), + }, + })); + } + }; + + // Subscribe to updates + const handleEvent: WorkflowSubscriptionCallback = ( + event: AnyWorkerEvent + ) => { + if (!mountedRef.current) return; + + switch (event.type) { + case 'RUN_UPDATED': + case 'RUN_COMPLETED': + case 'RUN_FAILED': { + const run = event.run; + const isComplete = + run.status === 'completed' || + run.status === 'failed' || + run.status === 'cancelled'; + + setState((prev) => ({ + ...prev, + run, + status: run.status, + output: run.output, + error: run.error ?? null, + isRunning: run.status === 'running', + isComplete, + })); + break; + } + case 'STEP_UPDATED': { + setState((prev) => { + const existingIndex = prev.steps.findIndex( + (s) => s.stepId === event.step.stepId + ); + if (existingIndex >= 0) { + const newSteps = [...prev.steps]; + newSteps[existingIndex] = event.step; + return { ...prev, steps: newSteps }; + } else { + return { ...prev, steps: [...prev.steps, event.step] }; + } + }); + break; + } + } + }; + + unsubscribe = client.subscribe(runId, handleEvent); + fetchInitialState(); + + return () => { + if (unsubscribe) { + unsubscribe(); + } + }; + }, [runId]); + + return state; +} + +/** + * Options for useWorkflowRuns hook. + */ +export interface UseWorkflowRunsOptions { + /** Filter by workflow name */ + workflowName?: string; + /** Filter by status */ + status?: WorkflowRun['status']; + /** Maximum number of runs to return */ + limit?: number; +} + +/** + * State returned by useWorkflowRuns hook. + */ +export interface UseWorkflowRunsState { + /** List of workflow runs */ + runs: WorkflowRun[]; + /** Whether the data is still loading */ + isLoading: boolean; + /** Error if the fetch failed */ + error: Error | null; + /** Whether there are more runs to load */ + hasMore: boolean; + /** Cursor for pagination */ + cursor: string | null; + /** Function to refresh the list */ + refresh: () => Promise; +} + +/** + * Hook to list workflow runs. + * + * @param options - Options for filtering and pagination + * @returns The list of workflow runs and loading state + * + * @example + * ```tsx + * const { runs, isLoading, refresh } = useWorkflowRuns({ limit: 10 }); + * ``` + */ +export function useWorkflowRuns( + options: UseWorkflowRunsOptions = {} +): UseWorkflowRunsState { + const [state, setState] = useState({ + runs: [], + isLoading: true, + error: null, + hasMore: false, + cursor: null, + refresh: async () => {}, + }); + + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const fetchRuns = useCallback(async () => { + if (typeof window === 'undefined') return; + + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const client = getClient(); + const result = await client.listRuns({ + workflowName: options.workflowName, + status: options.status, + limit: options.limit, + }); + + if (!mountedRef.current) return; + + setState((prev) => ({ + ...prev, + runs: result.data, + hasMore: result.hasMore, + cursor: result.cursor, + isLoading: false, + })); + } catch (error) { + if (!mountedRef.current) return; + + setState((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error : new Error(String(error)), + })); + } + }, [options.workflowName, options.status, options.limit]); + + useEffect(() => { + fetchRuns(); + }, [fetchRuns]); + + // Return state with refresh function + return { + ...state, + refresh: fetchRuns, + }; +} + +/** + * Hook to get current step progress of a workflow. + * + * @param runId - The run ID to get progress for + * @returns The current step information + */ +export function useWorkflowProgress(runId: string | null) { + const { steps, status, isLoading } = useWorkflowRun(runId); + + const currentStep = steps.find((s) => s.status === 'running'); + const completedSteps = steps.filter((s) => s.status === 'completed'); + const failedSteps = steps.filter((s) => s.status === 'failed'); + const pendingSteps = steps.filter((s) => s.status === 'pending'); + + const progress = + steps.length > 0 ? (completedSteps.length / steps.length) * 100 : 0; + + return { + steps, + currentStep, + completedSteps, + failedSteps, + pendingSteps, + progress, + status, + isLoading, + totalSteps: steps.length, + completedCount: completedSteps.length, + }; +} diff --git a/packages/world-browser/src/client/sdk.ts b/packages/world-browser/src/client/sdk.ts new file mode 100644 index 000000000..b2d3d0f67 --- /dev/null +++ b/packages/world-browser/src/client/sdk.ts @@ -0,0 +1,351 @@ +/** + * Browser Workflow Client SDK + * + * This is the main thread client for communicating with the SharedWorker. + * It provides a promise-based API for triggering and managing workflows. + */ + +import type { WorkflowRun } from '@workflow/world'; +import type { + AnyWorkerRequest, + AnyWorkerResponse, + AnyWorkerEvent, + TriggerResponse, + ListRunsResponse, + GetStepsResponse, + GetEventsResponse, +} from '../worker/message-types.js'; + +/** + * Subscription callback for workflow updates. + */ +export type WorkflowSubscriptionCallback = (event: AnyWorkerEvent) => void; + +/** + * Options for the BrowserWorkflowClient. + */ +export interface BrowserWorkflowClientOptions { + /** + * Path to the SharedWorker script. + * @default '/__workflow-worker.js' + */ + workerPath?: string; +} + +/** + * Client for interacting with browser workflows. + * + * This class handles communication with the SharedWorker that runs workflows. + */ +export class BrowserWorkflowClient { + // Using regular Worker instead of SharedWorker because SharedWorkers + // cannot create nested Workers (needed by Turso WASM thread pool) + private worker: Worker | null = null; + private pending = new Map< + string, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + private subscriptions = new Map>(); + private workerPath: string; + private messageIdCounter = 0; + private initialized = false; + + constructor(options: BrowserWorkflowClientOptions = {}) { + this.workerPath = options.workerPath ?? '/__workflow-worker.js'; + } + + /** + * Generate a unique message ID. + */ + private generateMessageId(): string { + return `msg_${++this.messageIdCounter}_${Date.now()}`; + } + + /** + * Get or create the Worker. + */ + private getWorker(): Worker { + if (typeof window === 'undefined') { + throw new Error('BrowserWorkflowClient can only be used in the browser'); + } + + if (!this.worker) { + this.worker = new Worker(this.workerPath, { type: 'module' }); + + this.worker.onmessage = (event: MessageEvent) => { + this.handleMessage(event.data); + }; + + this.worker.onerror = (event) => { + console.error('[BrowserWorkflowClient] Worker error:', event); + }; + + this.initialized = true; + } + + return this.worker; + } + + /** + * Handle incoming messages from the worker. + */ + private handleMessage( + data: AnyWorkerResponse | AnyWorkerEvent + ): void { + // Check if this is a response to a pending request + if ('id' in data && 'success' in data) { + const response = data as AnyWorkerResponse; + const pending = this.pending.get(response.id); + + if (pending) { + this.pending.delete(response.id); + + if (response.success) { + pending.resolve(response.data); + } else { + pending.reject(new Error(response.error)); + } + } + return; + } + + // This is an event from a subscription + const event = data as AnyWorkerEvent; + if ('type' in event && 'runId' in event) { + const callbacks = this.subscriptions.get(event.runId); + if (callbacks) { + for (const callback of callbacks) { + try { + callback(event); + } catch (error) { + console.error( + '[BrowserWorkflowClient] Subscription callback error:', + error + ); + } + } + } + } + } + + /** + * Send a request to the worker and wait for a response. + */ + private async request( + type: string, + payload: Record = {} + ): Promise { + const worker = this.getWorker(); + const id = this.generateMessageId(); + + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + }); + + const request: AnyWorkerRequest = { + id, + type, + ...payload, + } as AnyWorkerRequest; + + worker.postMessage(request); + + // Timeout after 30 seconds + setTimeout(() => { + if (this.pending.has(id)) { + this.pending.delete(id); + reject(new Error(`Request timeout: ${type}`)); + } + }, 30000); + }); + } + + /** + * Run a workflow and wait for completion. + * This is the primary method used by transformed workflow functions. + * + * @param workflowId - The workflow identifier + * @param args - Arguments to pass to the workflow + * @returns The workflow result + */ + async run(workflowId: string, args: unknown[]): Promise { + const { runId } = await this.trigger(workflowId, args); + return this.waitForCompletion(runId); + } + + /** + * Trigger a workflow without waiting for completion. + * + * @param workflowId - The workflow identifier + * @param args - Arguments to pass to the workflow + * @returns The run ID + */ + async trigger(workflowId: string, args: unknown[]): Promise { + return this.request('TRIGGER', { workflowId, args }); + } + + /** + * Wait for a workflow run to complete. + * + * @param runId - The run ID to wait for + * @returns The workflow result + */ + async waitForCompletion(runId: string): Promise { + return new Promise((resolve, reject) => { + const unsubscribe = this.subscribe(runId, (event) => { + if (event.type === 'RUN_COMPLETED') { + unsubscribe(); + resolve(event.run.output); + } else if (event.type === 'RUN_FAILED') { + unsubscribe(); + reject(new Error(event.error)); + } + }); + + // Also check current status in case it's already complete + this.getStatus(runId) + .then((run) => { + if (run.status === 'completed') { + unsubscribe(); + resolve(run.output); + } else if (run.status === 'failed') { + unsubscribe(); + reject(new Error(run.error?.message ?? 'Workflow failed')); + } + }) + .catch(reject); + }); + } + + /** + * Get the status of a workflow run. + * + * @param runId - The run ID + * @returns The workflow run record + */ + async getStatus(runId: string): Promise { + return this.request('GET_STATUS', { runId }); + } + + /** + * List workflow runs. + * + * @param params - List parameters + * @returns Paginated list of runs + */ + async listRuns(params?: { + workflowName?: string; + status?: string; + limit?: number; + cursor?: string; + }): Promise { + return this.request('LIST_RUNS', params ?? {}); + } + + /** + * Cancel a workflow run. + * + * @param runId - The run ID to cancel + * @returns The updated run record + */ + async cancel(runId: string): Promise { + return this.request('CANCEL', { runId }); + } + + /** + * Pause a workflow run. + * + * @param runId - The run ID to pause + * @returns The updated run record + */ + async pause(runId: string): Promise { + return this.request('PAUSE', { runId }); + } + + /** + * Resume a paused workflow run. + * + * @param runId - The run ID to resume + * @returns The updated run record + */ + async resume(runId: string): Promise { + return this.request('RESUME', { runId }); + } + + /** + * Get steps for a workflow run. + * + * @param runId - The run ID + * @returns Paginated list of steps + */ + async getSteps(runId: string): Promise { + return this.request('GET_STEPS', { runId }); + } + + /** + * Get events for a workflow run. + * + * @param runId - The run ID + * @returns Paginated list of events + */ + async getEvents(runId: string): Promise { + return this.request('GET_EVENTS', { runId }); + } + + /** + * Subscribe to updates for a workflow run. + * + * @param runId - The run ID to subscribe to + * @param callback - Callback for updates + * @returns Unsubscribe function + */ + subscribe(runId: string, callback: WorkflowSubscriptionCallback): () => void { + if (!this.subscriptions.has(runId)) { + this.subscriptions.set(runId, new Set()); + // Tell the worker we're subscribing + this.request('SUBSCRIBE', { runId }).catch(console.error); + } + + this.subscriptions.get(runId)!.add(callback); + + return () => { + const callbacks = this.subscriptions.get(runId); + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + this.subscriptions.delete(runId); + // Tell the worker we're unsubscribing + this.request('UNSUBSCRIBE', { runId }).catch(console.error); + } + } + }; + } + + /** + * Check if the client is initialized. + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Disconnect from the worker. + */ + disconnect(): void { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + this.initialized = false; + this.pending.clear(); + this.subscriptions.clear(); + } +} + +/** + * Default client instance. + * This is used by transformed workflow functions. + */ +export const __browserWorkflowClient = new BrowserWorkflowClient(); diff --git a/packages/world-browser/src/deterministic.test.ts b/packages/world-browser/src/deterministic.test.ts new file mode 100644 index 000000000..a9caf11d0 --- /dev/null +++ b/packages/world-browser/src/deterministic.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createDeterministicContext } from './deterministic.js'; + +describe('createDeterministicContext', () => { + let originalMathRandom: typeof Math.random; + let originalDateNow: typeof Date.now; + + beforeEach(() => { + originalMathRandom = Math.random; + originalDateNow = Date.now; + }); + + afterEach(() => { + // Restore in case test fails to call restore + Math.random = originalMathRandom; + Date.now = originalDateNow; + }); + + it('should produce deterministic Math.random values for the same seed', () => { + const ctx1 = createDeterministicContext('test-seed', 1000); + const values1 = [Math.random(), Math.random(), Math.random()]; + ctx1.restore(); + + const ctx2 = createDeterministicContext('test-seed', 1000); + const values2 = [Math.random(), Math.random(), Math.random()]; + ctx2.restore(); + + expect(values1).toEqual(values2); + }); + + it('should produce different values for different seeds', () => { + const ctx1 = createDeterministicContext('seed-1', 1000); + const values1 = [Math.random(), Math.random()]; + ctx1.restore(); + + const ctx2 = createDeterministicContext('seed-2', 1000); + const values2 = [Math.random(), Math.random()]; + ctx2.restore(); + + expect(values1).not.toEqual(values2); + }); + + it('should return fixed timestamp from Date.now()', () => { + const fixedTime = 1699000000000; + const ctx = createDeterministicContext('seed', fixedTime); + + expect(Date.now()).toBe(fixedTime); + expect(Date.now()).toBe(fixedTime); + + ctx.restore(); + }); + + it('should allow updating the timestamp', () => { + const initialTime = 1699000000000; + const ctx = createDeterministicContext('seed', initialTime); + + expect(Date.now()).toBe(initialTime); + + const newTime = 1699000001000; + ctx.updateTimestamp(newTime); + + expect(Date.now()).toBe(newTime); + expect(ctx.getTimestamp()).toBe(newTime); + + ctx.restore(); + }); + + it('should restore original functions after restore()', () => { + const ctx = createDeterministicContext('seed', 1000); + + // Verify patched + expect(Date.now()).toBe(1000); + + ctx.restore(); + + // Verify restored + expect(Date.now).toBe(originalDateNow); + expect(Math.random).toBe(originalMathRandom); + + // Date.now should return real time now + const realTime = Date.now(); + expect(realTime).toBeGreaterThan(1000); + }); + + it('should create new Date() with fixed timestamp when no args', () => { + const fixedTime = 1699000000000; + const ctx = createDeterministicContext('seed', fixedTime); + + const date = new Date(); + expect(date.getTime()).toBe(fixedTime); + + ctx.restore(); + }); + + it('should create new Date() normally when args provided', () => { + const fixedTime = 1699000000000; + const ctx = createDeterministicContext('seed', fixedTime); + + const customTime = 1500000000000; + const date = new Date(customTime); + expect(date.getTime()).toBe(customTime); + + ctx.restore(); + }); +}); diff --git a/packages/world-browser/src/deterministic.ts b/packages/world-browser/src/deterministic.ts new file mode 100644 index 000000000..1b4f2925a --- /dev/null +++ b/packages/world-browser/src/deterministic.ts @@ -0,0 +1,170 @@ +/** + * Deterministic context for browser workflow execution. + * + * This replaces node:vm by patching global functions to be deterministic + * based on a seed value. This ensures workflow replay produces identical + * results for Math.random(), Date.now(), and crypto.randomUUID(). + */ + +import seedrandom from 'seedrandom'; + +export interface DeterministicContext { + /** + * Restore original global functions. + */ + restore: () => void; + + /** + * Update the fixed timestamp used by Date.now() and new Date(). + */ + updateTimestamp: (timestamp: number) => void; + + /** + * Get the current fixed timestamp. + */ + getTimestamp: () => number; +} + +interface OriginalGlobals { + mathRandom: typeof Math.random; + dateNow: typeof Date.now; + dateConstructor: DateConstructor; + cryptoRandomUUID: typeof crypto.randomUUID; + cryptoGetRandomValues: typeof crypto.getRandomValues; +} + +/** + * Generate a deterministic UUID v4 using the seeded RNG. + */ +function createSeededUUID( + rng: () => number +): () => `${string}-${string}-${string}-${string}-${string}` { + return () => { + // Generate 16 random bytes + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + bytes[i] = Math.floor(rng() * 256); + } + + // Set version (4) and variant (8, 9, A, or B) + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + // Convert to hex string + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join( + '' + ); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` as `${string}-${string}-${string}-${string}-${string}`; + }; +} + +/** + * Create a deterministic context for workflow execution. + * + * This patches global functions to be deterministic based on the seed. + * Call `restore()` after workflow execution to restore original behavior. + * + * @param seed - Seed for random number generation (typically runId) + * @param initialTimestamp - Initial fixed timestamp for Date operations + */ +export function createDeterministicContext( + seed: string, + initialTimestamp: number +): DeterministicContext { + let fixedTimestamp = initialTimestamp; + + // Create seeded RNG + const rng = seedrandom(seed); + + // Store original functions + const original: OriginalGlobals = { + mathRandom: Math.random, + dateNow: Date.now, + dateConstructor: Date, + cryptoRandomUUID: crypto.randomUUID.bind(crypto), + cryptoGetRandomValues: crypto.getRandomValues.bind(crypto), + }; + + // Create seeded UUID generator + const seededUUID = createSeededUUID(rng); + + // Patch Math.random + Math.random = () => rng(); + + // Patch Date.now + Date.now = () => fixedTimestamp; + + // Patch Date constructor - we only override Date.now() and leave constructor + // relatively untouched since the primary concern is deterministic timestamps + const OriginalDate = original.dateConstructor; + + // Create a proxy-based Date constructor that intercepts no-arg calls + const PatchedDateConstructor = new Proxy(OriginalDate, { + construct(target, args: unknown[]) { + if (args.length === 0) { + return new target(fixedTimestamp); + } + // Use Reflect.construct for proper spread + return Reflect.construct(target, args); + }, + apply(_target, _thisArg, args: unknown[]) { + // Date() called as function returns string representation + if (args.length === 0) { + return new OriginalDate(fixedTimestamp).toString(); + } + // Use Function.prototype.apply for proper spread + return OriginalDate.apply(null, args as []).toString(); + }, + get(target, prop, receiver) { + if (prop === 'now') { + return () => fixedTimestamp; + } + return Reflect.get(target, prop, receiver); + }, + }); + + // @ts-ignore - replacing global Date with proxy + globalThis.Date = PatchedDateConstructor; + + // Patch crypto.randomUUID + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + crypto.randomUUID = seededUUID; + } + + // Patch crypto.getRandomValues for deterministic random bytes + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues = function ( + array: T + ): T { + if (array === null) return array; + + const view = array as unknown as Uint8Array; + if (view.length !== undefined) { + for (let i = 0; i < view.length; i++) { + view[i] = Math.floor(rng() * 256); + } + } + return array; + }; + } + + return { + restore: () => { + Math.random = original.mathRandom; + Date.now = original.dateNow; + // @ts-ignore - restoring global Date + globalThis.Date = original.dateConstructor; + if (typeof crypto !== 'undefined') { + crypto.randomUUID = original.cryptoRandomUUID; + crypto.getRandomValues = original.cryptoGetRandomValues; + } + }, + + updateTimestamp: (timestamp: number) => { + fixedTimestamp = timestamp; + }, + + getTimestamp: () => fixedTimestamp, + }; +} diff --git a/packages/world-browser/src/index.ts b/packages/world-browser/src/index.ts new file mode 100644 index 000000000..107fdf7f1 --- /dev/null +++ b/packages/world-browser/src/index.ts @@ -0,0 +1,15 @@ +/** + * @workflow/world-browser + * + * Browser-based World implementation using SharedWorker and Turso WASM. + */ + +export { createBrowserWorld, type BrowserWorldConfig } from './world.js'; +export { createStorage } from './storage.js'; +export { createQueue, startQueueProcessor } from './queue.js'; +export { createStreamer } from './streamer.js'; +export { + createDeterministicContext, + type DeterministicContext, +} from './deterministic.js'; +export * from './schema.js'; diff --git a/packages/world-browser/src/queue.ts b/packages/world-browser/src/queue.ts new file mode 100644 index 000000000..76273f893 --- /dev/null +++ b/packages/world-browser/src/queue.ts @@ -0,0 +1,244 @@ +/** + * Queue implementation for browser using SQLite-backed job queue. + */ + +import type { + MessageId, + Queue, + QueuePayload, + ValidQueueName, +} from '@workflow/world'; +import { monotonicFactory } from 'ulid'; +import type { BrowserDatabase } from './schema.js'; + +const ulid = monotonicFactory(); + +// Queue row type +interface QueueRow { + id: number; + queue_name: string; + message_id: string; + payload: string; + status: string; + attempt: number; + idempotency_key: string | null; + created_at: string; + process_after: string; +} + +// Queue job for processing +export interface QueueJob { + id: number; + queueName: ValidQueueName; + messageId: MessageId; + payload: QueuePayload; + attempt: number; +} + +// Handler type for queue processing +export type QueueHandler = ( + message: QueuePayload, + meta: { attempt: number; queueName: ValidQueueName; messageId: MessageId } +) => Promise; + +/** + * Create a Queue implementation using SQLite. + */ +export function createQueue(db: BrowserDatabase): Queue { + const queue: Queue['queue'] = async (queueName, message, opts) => { + const messageId = `msg_${ulid()}` as MessageId; + + // Check for existing message with same idempotency key + if (opts?.idempotencyKey) { + const existing = await db + .prepare(` + SELECT message_id FROM workflow_queue WHERE idempotency_key = ? AND status != 'completed' + `) + .get<{ message_id: string }>([opts.idempotencyKey]); + + if (existing) { + return { messageId: existing.message_id as MessageId }; + } + } + + const now = new Date().toISOString(); + + await db + .prepare(` + INSERT INTO workflow_queue (queue_name, message_id, payload, status, attempt, idempotency_key, created_at, process_after) + VALUES (?, ?, ?, 'pending', 0, ?, ?, ?) + `) + .run([ + queueName, + messageId, + JSON.stringify(message), + opts?.idempotencyKey ?? null, + now, + now, + ]); + + return { messageId }; + }; + + const createQueueHandler: Queue['createQueueHandler'] = () => { + // In browser, we don't use HTTP-based queue handlers. + // Queue processing is done internally via startQueueProcessor. + return async () => { + return Response.json( + { error: 'Queue handlers not supported in browser' }, + { status: 501 } + ); + }; + }; + + const getDeploymentId: Queue['getDeploymentId'] = async () => { + return 'browser'; + }; + + return { queue, createQueueHandler, getDeploymentId }; +} + +/** + * Dequeue the next available job from the queue. + */ +export async function dequeueJob( + db: BrowserDatabase +): Promise { + const now = new Date().toISOString(); + + // Find and claim a pending job + const row = await db + .prepare(` + SELECT * FROM workflow_queue + WHERE status = 'pending' AND process_after <= ? + ORDER BY created_at ASC + LIMIT 1 + `) + .get([now]); + + if (!row) { + return null; + } + + // Mark as processing + await db + .prepare(` + UPDATE workflow_queue SET status = 'processing', attempt = attempt + 1 WHERE id = ? + `) + .run([row.id]); + + return { + id: row.id, + queueName: row.queue_name as ValidQueueName, + messageId: row.message_id as MessageId, + payload: JSON.parse(row.payload) as QueuePayload, + attempt: row.attempt + 1, + }; +} + +/** + * Mark a job as completed. + */ +export async function completeJob( + db: BrowserDatabase, + jobId: number +): Promise { + await db + .prepare(` + UPDATE workflow_queue SET status = 'completed' WHERE id = ? + `) + .run([jobId]); +} + +/** + * Mark a job as failed and optionally schedule retry. + */ +export async function failJob( + db: BrowserDatabase, + jobId: number, + retryAfterSeconds?: number +): Promise { + if (retryAfterSeconds !== undefined && retryAfterSeconds > 0) { + const processAfter = new Date( + Date.now() + retryAfterSeconds * 1000 + ).toISOString(); + await db + .prepare(` + UPDATE workflow_queue SET status = 'pending', process_after = ? WHERE id = ? + `) + .run([processAfter, jobId]); + } else { + await db + .prepare(` + UPDATE workflow_queue SET status = 'failed' WHERE id = ? + `) + .run([jobId]); + } +} + +/** + * Start the queue processor loop. + * This should be called from within the SharedWorker. + */ +export function startQueueProcessor( + db: BrowserDatabase, + handlers: { + workflow: QueueHandler; + step: QueueHandler; + }, + options?: { + pollIntervalMs?: number; + } +): { stop: () => void } { + const pollInterval = options?.pollIntervalMs ?? 100; + let running = true; + + const poll = async () => { + if (!running) return; + + try { + const job = await dequeueJob(db); + + if (job) { + const handler = job.queueName.startsWith('__wkf_workflow_') + ? handlers.workflow + : handlers.step; + + try { + const result = await handler(job.payload, { + attempt: job.attempt, + queueName: job.queueName, + messageId: job.messageId, + }); + + if (result && typeof result.timeoutSeconds === 'number') { + // Reschedule with timeout + await failJob(db, job.id, result.timeoutSeconds); + } else { + await completeJob(db, job.id); + } + } catch (error) { + console.error('[browser-queue] Job failed:', error); + // Retry after 5 seconds by default + await failJob(db, job.id, 5); + } + } + } catch (error) { + console.error('[browser-queue] Queue processor error:', error); + } + + // Schedule next poll + if (running) { + setTimeout(poll, pollInterval); + } + }; + + // Start polling + poll(); + + return { + stop: () => { + running = false; + }, + }; +} diff --git a/packages/world-browser/src/schema.ts b/packages/world-browser/src/schema.ts new file mode 100644 index 000000000..8f1843dec --- /dev/null +++ b/packages/world-browser/src/schema.ts @@ -0,0 +1,121 @@ +/** + * SQLite schema for browser workflow storage. + * Uses Turso WASM database. + */ + +// Type for Turso WASM database connection +export interface BrowserDatabase { + prepare(sql: string): { + run( + params?: unknown[] + ): Promise<{ changes: number; lastInsertRowid: number }>; + get(params?: unknown[]): Promise; + all(params?: unknown[]): Promise; + }; + exec(sql: string): Promise; +} + +/** + * Initialize the database schema. + */ +export async function createSchema(db: BrowserDatabase): Promise { + await db.exec(` + -- Workflow runs table + CREATE TABLE IF NOT EXISTS workflow_runs ( + run_id TEXT PRIMARY KEY, + workflow_name TEXT NOT NULL, + deployment_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + input TEXT, + output TEXT, + execution_context TEXT, + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_runs_workflow_name ON workflow_runs(workflow_name); + CREATE INDEX IF NOT EXISTS idx_runs_status ON workflow_runs(status); + + -- Workflow steps table + CREATE TABLE IF NOT EXISTS workflow_steps ( + step_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + step_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + input TEXT, + output TEXT, + error TEXT, + attempt INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + retry_after TEXT, + FOREIGN KEY (run_id) REFERENCES workflow_runs(run_id) + ); + + CREATE INDEX IF NOT EXISTS idx_steps_run_id ON workflow_steps(run_id); + CREATE INDEX IF NOT EXISTS idx_steps_status ON workflow_steps(status); + + -- Workflow events table + CREATE TABLE IF NOT EXISTS workflow_events ( + event_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + event_type TEXT NOT NULL, + correlation_id TEXT, + event_data TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (run_id) REFERENCES workflow_runs(run_id) + ); + + CREATE INDEX IF NOT EXISTS idx_events_run_id ON workflow_events(run_id); + CREATE INDEX IF NOT EXISTS idx_events_correlation_id ON workflow_events(correlation_id); + + -- Workflow hooks table + CREATE TABLE IF NOT EXISTS workflow_hooks ( + hook_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + metadata TEXT, + owner_id TEXT NOT NULL, + project_id TEXT NOT NULL, + environment TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (run_id) REFERENCES workflow_runs(run_id) + ); + + CREATE INDEX IF NOT EXISTS idx_hooks_run_id ON workflow_hooks(run_id); + CREATE INDEX IF NOT EXISTS idx_hooks_token ON workflow_hooks(token); + + -- Queue table for job processing + CREATE TABLE IF NOT EXISTS workflow_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + queue_name TEXT NOT NULL, + message_id TEXT NOT NULL UNIQUE, + payload TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt INTEGER NOT NULL DEFAULT 0, + idempotency_key TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + process_after TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_queue_status ON workflow_queue(status, process_after); + CREATE INDEX IF NOT EXISTS idx_queue_idempotency ON workflow_queue(idempotency_key); + + -- Stream chunks table + CREATE TABLE IF NOT EXISTS workflow_stream_chunks ( + chunk_id TEXT NOT NULL, + stream_id TEXT NOT NULL, + chunk_data BLOB NOT NULL, + eof INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (stream_id, chunk_id) + ); + + CREATE INDEX IF NOT EXISTS idx_stream_chunks_stream_id ON workflow_stream_chunks(stream_id); + `); +} diff --git a/packages/world-browser/src/storage.ts b/packages/world-browser/src/storage.ts new file mode 100644 index 000000000..af3ad9563 --- /dev/null +++ b/packages/world-browser/src/storage.ts @@ -0,0 +1,832 @@ +/** + * Storage implementation for browser using Turso WASM (SQLite). + */ + +import { WorkflowAPIError } from '@workflow/errors'; +import type { + CreateEventParams, + CreateEventRequest, + CreateHookRequest, + CreateStepRequest, + CreateWorkflowRunRequest, + Event, + GetHookParams, + GetStepParams, + GetWorkflowRunParams, + Hook, + ListEventsByCorrelationIdParams, + ListEventsParams, + ListHooksParams, + ListWorkflowRunsParams, + ListWorkflowRunStepsParams, + PaginatedResponse, + ResolveData, + Step, + Storage, + UpdateStepRequest, + UpdateWorkflowRunRequest, + WorkflowRun, +} from '@workflow/world'; +import { monotonicFactory } from 'ulid'; +import type { BrowserDatabase } from './schema.js'; + +const ulid = monotonicFactory(); + +// Helper to serialize JSON for storage +function serialize(value: unknown): string | null { + if (value === undefined || value === null) return null; + return JSON.stringify(value); +} + +// Helper to deserialize JSON from storage +function deserialize(value: string | null | undefined): T | undefined { + if (value === null || value === undefined) return undefined; + try { + return JSON.parse(value) as T; + } catch { + return undefined; + } +} + +// Helper to format Date for SQLite +function toSqliteDate(date: Date): string { + return date.toISOString(); +} + +// Helper to parse SQLite date +function fromSqliteDate(value: string | null | undefined): Date | undefined { + if (!value) return undefined; + return new Date(value); +} + +// Filter helpers based on resolveData +function filterRunData( + run: WorkflowRun, + resolveData: ResolveData +): WorkflowRun { + if (resolveData === 'none') { + return { ...run, input: [], output: undefined }; + } + return run; +} + +function filterStepData(step: Step, resolveData: ResolveData): Step { + if (resolveData === 'none') { + return { ...step, input: [], output: undefined }; + } + return step; +} + +function filterEventData(event: Event, resolveData: ResolveData): Event { + if (resolveData === 'none') { + const { eventData: _, ...rest } = event as Event & { eventData?: unknown }; + return rest as Event; + } + return event; +} + +function filterHookData(hook: Hook, resolveData: ResolveData): Hook { + if (resolveData === 'none') { + return { ...hook, metadata: undefined }; + } + return hook; +} + +// Row types from database +interface RunRow { + run_id: string; + workflow_name: string; + deployment_id: string; + status: string; + input: string | null; + output: string | null; + execution_context: string | null; + error: string | null; + created_at: string; + updated_at: string; + started_at: string | null; + completed_at: string | null; +} + +interface StepRow { + step_id: string; + run_id: string; + step_name: string; + status: string; + input: string | null; + output: string | null; + error: string | null; + attempt: number; + created_at: string; + updated_at: string; + started_at: string | null; + completed_at: string | null; + retry_after: string | null; +} + +interface EventRow { + event_id: string; + run_id: string; + event_type: string; + correlation_id: string | null; + event_data: string | null; + created_at: string; +} + +interface HookRow { + hook_id: string; + run_id: string; + token: string; + metadata: string | null; + owner_id: string; + project_id: string; + environment: string; + created_at: string; +} + +// Convert database row to WorkflowRun +function rowToRun(row: RunRow): WorkflowRun { + const error = row.error + ? deserialize<{ message: string; stack?: string; code?: string }>(row.error) + : undefined; + // Use type assertion since WorkflowRun is a discriminated union + return { + runId: row.run_id, + workflowName: row.workflow_name, + deploymentId: row.deployment_id, + status: row.status, + input: deserialize(row.input) ?? [], + output: deserialize(row.output), + executionContext: deserialize>( + row.execution_context + ), + error, + createdAt: fromSqliteDate(row.created_at)!, + updatedAt: fromSqliteDate(row.updated_at)!, + startedAt: fromSqliteDate(row.started_at), + completedAt: fromSqliteDate(row.completed_at), + } as WorkflowRun; +} + +// Convert database row to Step +function rowToStep(row: StepRow): Step { + const error = row.error + ? deserialize<{ message: string; stack?: string; code?: string }>(row.error) + : undefined; + return { + stepId: row.step_id, + runId: row.run_id, + stepName: row.step_name, + status: row.status as Step['status'], + input: deserialize(row.input) ?? [], + output: deserialize(row.output), + error, + attempt: row.attempt, + createdAt: fromSqliteDate(row.created_at)!, + updatedAt: fromSqliteDate(row.updated_at)!, + startedAt: fromSqliteDate(row.started_at), + completedAt: fromSqliteDate(row.completed_at), + }; +} + +// Convert database row to Event +function rowToEvent(row: EventRow): Event { + return { + eventId: row.event_id, + runId: row.run_id, + eventType: row.event_type as Event['eventType'], + correlationId: row.correlation_id ?? undefined, + eventData: deserialize(row.event_data), + createdAt: fromSqliteDate(row.created_at)!, + } as Event; +} + +// Convert database row to Hook +function rowToHook(row: HookRow): Hook { + return { + hookId: row.hook_id, + runId: row.run_id, + token: row.token, + metadata: deserialize(row.metadata), + ownerId: row.owner_id, + projectId: row.project_id, + environment: row.environment, + createdAt: fromSqliteDate(row.created_at)!, + }; +} + +/** + * Create a Storage implementation using Turso WASM database. + */ +export function createStorage(db: BrowserDatabase): Storage { + return { + runs: createRunsStorage(db), + steps: createStepsStorage(db), + events: createEventsStorage(db), + hooks: createHooksStorage(db), + }; +} + +function createRunsStorage(db: BrowserDatabase): Storage['runs'] { + return { + async create(data: CreateWorkflowRunRequest): Promise { + const runId = `wrun_${ulid()}`; + const now = new Date(); + const nowStr = toSqliteDate(now); + + await db + .prepare(` + INSERT INTO workflow_runs (run_id, workflow_name, deployment_id, status, input, execution_context, created_at, updated_at) + VALUES (?, ?, ?, 'pending', ?, ?, ?, ?) + `) + .run([ + runId, + data.workflowName, + data.deploymentId, + serialize(data.input), + serialize(data.executionContext), + nowStr, + nowStr, + ]); + + return { + runId, + workflowName: data.workflowName, + deploymentId: data.deploymentId, + status: 'pending', + input: (data.input as unknown[]) ?? [], + output: undefined, + executionContext: data.executionContext as + | Record + | undefined, + error: undefined, + createdAt: now, + updatedAt: now, + startedAt: undefined, + completedAt: undefined, + }; + }, + + async get(id: string, params?: GetWorkflowRunParams): Promise { + const row = await db + .prepare(` + SELECT * FROM workflow_runs WHERE run_id = ? + `) + .get([id]); + + if (!row) { + throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 }); + } + + const run = rowToRun(row); + const resolveData = params?.resolveData ?? 'all'; + return filterRunData(run, resolveData); + }, + + async update( + id: string, + data: UpdateWorkflowRunRequest + ): Promise { + const existing = await db + .prepare(` + SELECT * FROM workflow_runs WHERE run_id = ? + `) + .get([id]); + + if (!existing) { + throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 }); + } + + const now = new Date(); + const updates: string[] = ['updated_at = ?']; + const values: unknown[] = [toSqliteDate(now)]; + + if (data.status !== undefined) { + updates.push('status = ?'); + values.push(data.status); + + // Set startedAt on first transition to running + if (data.status === 'running' && !existing.started_at) { + updates.push('started_at = ?'); + values.push(toSqliteDate(now)); + } + + // Set completedAt on terminal states + if ( + data.status === 'completed' || + data.status === 'failed' || + data.status === 'cancelled' + ) { + updates.push('completed_at = ?'); + values.push(toSqliteDate(now)); + } + } + + if (data.output !== undefined) { + updates.push('output = ?'); + values.push(serialize(data.output)); + } + + if (data.error !== undefined) { + updates.push('error = ?'); + values.push(serialize(data.error)); + } + + values.push(id); + + await db + .prepare(` + UPDATE workflow_runs SET ${updates.join(', ')} WHERE run_id = ? + `) + .run(values); + + // Clean up hooks on terminal states + if ( + data.status === 'completed' || + data.status === 'failed' || + data.status === 'cancelled' + ) { + await db + .prepare(`DELETE FROM workflow_hooks WHERE run_id = ?`) + .run([id]); + } + + const updated = await db + .prepare(` + SELECT * FROM workflow_runs WHERE run_id = ? + `) + .get([id]); + + return rowToRun(updated!); + }, + + async list( + params?: ListWorkflowRunsParams + ): Promise> { + const limit = params?.pagination?.limit ?? 20; + const cursor = params?.pagination?.cursor; + const resolveData = params?.resolveData ?? 'all'; + + let query = 'SELECT * FROM workflow_runs WHERE 1=1'; + const queryParams: unknown[] = []; + + if (params?.workflowName) { + query += ' AND workflow_name = ?'; + queryParams.push(params.workflowName); + } + + if (params?.status) { + query += ' AND status = ?'; + queryParams.push(params.status); + } + + if (cursor) { + query += ' AND run_id < ?'; + queryParams.push(cursor); + } + + query += ' ORDER BY run_id DESC LIMIT ?'; + queryParams.push(limit + 1); + + const rows = await db.prepare(query).all(queryParams); + const hasMore = rows.length > limit; + const data = rows + .slice(0, limit) + .map((row) => filterRunData(rowToRun(row), resolveData)); + + return { + data, + hasMore, + cursor: data.at(-1)?.runId ?? null, + }; + }, + + async cancel(id: string, params?): Promise { + const run = await this.update(id, { status: 'cancelled' }); + const resolveData = params?.resolveData ?? 'all'; + return filterRunData(run, resolveData); + }, + + async pause(id: string, params?): Promise { + const run = await this.update(id, { status: 'paused' }); + const resolveData = params?.resolveData ?? 'all'; + return filterRunData(run, resolveData); + }, + + async resume(id: string, params?): Promise { + const run = await this.update(id, { status: 'running' }); + const resolveData = params?.resolveData ?? 'all'; + return filterRunData(run, resolveData); + }, + }; +} + +function createStepsStorage(db: BrowserDatabase): Storage['steps'] { + return { + async create(runId: string, data: CreateStepRequest): Promise { + const now = new Date(); + const nowStr = toSqliteDate(now); + + await db + .prepare(` + INSERT INTO workflow_steps (step_id, run_id, step_name, status, input, attempt, created_at, updated_at) + VALUES (?, ?, ?, 'pending', ?, 0, ?, ?) + `) + .run([ + data.stepId, + runId, + data.stepName, + serialize(data.input), + nowStr, + nowStr, + ]); + + return { + stepId: data.stepId, + runId, + stepName: data.stepName, + status: 'pending', + input: (data.input as unknown[]) ?? [], + output: undefined, + error: undefined, + attempt: 0, + createdAt: now, + updatedAt: now, + startedAt: undefined, + completedAt: undefined, + }; + }, + + async get( + runId: string | undefined, + stepId: string, + params?: GetStepParams + ): Promise { + let row: StepRow | undefined; + + if (runId) { + row = await db + .prepare(` + SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ? + `) + .get([runId, stepId]); + } else { + row = await db + .prepare(` + SELECT * FROM workflow_steps WHERE step_id = ? + `) + .get([stepId]); + } + + if (!row) { + throw new WorkflowAPIError(`Step not found: ${stepId}`, { + status: 404, + }); + } + + const step = rowToStep(row); + const resolveData = params?.resolveData ?? 'all'; + return filterStepData(step, resolveData); + }, + + async update( + runId: string, + stepId: string, + data: UpdateStepRequest + ): Promise { + const existing = await db + .prepare(` + SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ? + `) + .get([runId, stepId]); + + if (!existing) { + throw new WorkflowAPIError(`Step not found: ${stepId}`, { + status: 404, + }); + } + + const now = new Date(); + const updates: string[] = ['updated_at = ?']; + const values: unknown[] = [toSqliteDate(now)]; + + if (data.status !== undefined) { + updates.push('status = ?'); + values.push(data.status); + + if (data.status === 'running' && !existing.started_at) { + updates.push('started_at = ?'); + values.push(toSqliteDate(now)); + } + + if (data.status === 'completed' || data.status === 'failed') { + updates.push('completed_at = ?'); + values.push(toSqliteDate(now)); + } + } + + if (data.output !== undefined) { + updates.push('output = ?'); + values.push(serialize(data.output)); + } + + if (data.error !== undefined) { + updates.push('error = ?'); + values.push(serialize(data.error)); + } + + if (data.attempt !== undefined) { + updates.push('attempt = ?'); + values.push(data.attempt); + } + + values.push(runId, stepId); + + await db + .prepare(` + UPDATE workflow_steps SET ${updates.join(', ')} WHERE run_id = ? AND step_id = ? + `) + .run(values); + + const updated = await db + .prepare(` + SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ? + `) + .get([runId, stepId]); + + return rowToStep(updated!); + }, + + async list( + params: ListWorkflowRunStepsParams + ): Promise> { + const limit = params.pagination?.limit ?? 20; + const cursor = params.pagination?.cursor; + const resolveData = params.resolveData ?? 'all'; + + let query = 'SELECT * FROM workflow_steps WHERE run_id = ?'; + const queryParams: unknown[] = [params.runId]; + + if (cursor) { + query += ' AND step_id < ?'; + queryParams.push(cursor); + } + + query += ' ORDER BY step_id DESC LIMIT ?'; + queryParams.push(limit + 1); + + const rows = await db.prepare(query).all(queryParams); + const hasMore = rows.length > limit; + const data = rows + .slice(0, limit) + .map((row) => filterStepData(rowToStep(row), resolveData)); + + return { + data, + hasMore, + cursor: data.at(-1)?.stepId ?? null, + }; + }, + }; +} + +function createEventsStorage(db: BrowserDatabase): Storage['events'] { + return { + async create( + runId: string, + data: CreateEventRequest, + params?: CreateEventParams + ): Promise { + const eventId = `evnt_${ulid()}`; + const now = new Date(); + + await db + .prepare(` + INSERT INTO workflow_events (event_id, run_id, event_type, correlation_id, event_data, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `) + .run([ + eventId, + runId, + data.eventType, + data.correlationId ?? null, + serialize('eventData' in data ? data.eventData : undefined), + toSqliteDate(now), + ]); + + const event: Event = { + eventId, + runId, + eventType: data.eventType, + correlationId: data.correlationId, + eventData: 'eventData' in data ? data.eventData : undefined, + createdAt: now, + } as Event; + + const resolveData = params?.resolveData ?? 'all'; + return filterEventData(event, resolveData); + }, + + async list(params: ListEventsParams): Promise> { + const limit = params.pagination?.limit ?? 100; + const cursor = params.pagination?.cursor; + const sortOrder = params.pagination?.sortOrder ?? 'asc'; + const resolveData = params.resolveData ?? 'all'; + + const operator = sortOrder === 'desc' ? '<' : '>'; + const orderDir = sortOrder === 'desc' ? 'DESC' : 'ASC'; + + let query = 'SELECT * FROM workflow_events WHERE run_id = ?'; + const queryParams: unknown[] = [params.runId]; + + if (cursor) { + query += ` AND event_id ${operator} ?`; + queryParams.push(cursor); + } + + query += ` ORDER BY event_id ${orderDir} LIMIT ?`; + queryParams.push(limit + 1); + + const rows = await db.prepare(query).all(queryParams); + const hasMore = rows.length > limit; + const data = rows + .slice(0, limit) + .map((row) => filterEventData(rowToEvent(row), resolveData)); + + return { + data, + hasMore, + cursor: data.at(-1)?.eventId ?? null, + }; + }, + + async listByCorrelationId( + params: ListEventsByCorrelationIdParams + ): Promise> { + const limit = params.pagination?.limit ?? 100; + const cursor = params.pagination?.cursor; + const sortOrder = params.pagination?.sortOrder ?? 'asc'; + const resolveData = params.resolveData ?? 'all'; + + const operator = sortOrder === 'desc' ? '<' : '>'; + const orderDir = sortOrder === 'desc' ? 'DESC' : 'ASC'; + + let query = 'SELECT * FROM workflow_events WHERE correlation_id = ?'; + const queryParams: unknown[] = [params.correlationId]; + + if (cursor) { + query += ` AND event_id ${operator} ?`; + queryParams.push(cursor); + } + + query += ` ORDER BY event_id ${orderDir} LIMIT ?`; + queryParams.push(limit + 1); + + const rows = await db.prepare(query).all(queryParams); + const hasMore = rows.length > limit; + const data = rows + .slice(0, limit) + .map((row) => filterEventData(rowToEvent(row), resolveData)); + + return { + data, + hasMore, + cursor: data.at(-1)?.eventId ?? null, + }; + }, + }; +} + +function createHooksStorage(db: BrowserDatabase): Storage['hooks'] { + return { + async create( + runId: string, + data: CreateHookRequest, + params?: GetHookParams + ): Promise { + const now = new Date(); + + await db + .prepare(` + INSERT INTO workflow_hooks (hook_id, run_id, token, metadata, owner_id, project_id, environment, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `) + .run([ + data.hookId, + runId, + data.token, + serialize(data.metadata), + 'browser-owner', + 'browser-project', + 'browser', + toSqliteDate(now), + ]); + + const hook: Hook = { + hookId: data.hookId, + runId, + token: data.token, + metadata: data.metadata, + ownerId: 'browser-owner', + projectId: 'browser-project', + environment: 'browser', + createdAt: now, + }; + + const resolveData = params?.resolveData ?? 'all'; + return filterHookData(hook, resolveData); + }, + + async get(hookId: string, params?: GetHookParams): Promise { + const row = await db + .prepare(` + SELECT * FROM workflow_hooks WHERE hook_id = ? + `) + .get([hookId]); + + if (!row) { + throw new WorkflowAPIError(`Hook not found: ${hookId}`, { + status: 404, + }); + } + + const hook = rowToHook(row); + const resolveData = params?.resolveData ?? 'all'; + return filterHookData(hook, resolveData); + }, + + async getByToken(token: string, params?: GetHookParams): Promise { + const row = await db + .prepare(` + SELECT * FROM workflow_hooks WHERE token = ? + `) + .get([token]); + + if (!row) { + throw new WorkflowAPIError(`Hook not found for token: ${token}`, { + status: 404, + }); + } + + const hook = rowToHook(row); + const resolveData = params?.resolveData ?? 'all'; + return filterHookData(hook, resolveData); + }, + + async list(params: ListHooksParams): Promise> { + const limit = params.pagination?.limit ?? 100; + const cursor = params.pagination?.cursor; + const resolveData = params.resolveData ?? 'all'; + + let query = 'SELECT * FROM workflow_hooks WHERE 1=1'; + const queryParams: unknown[] = []; + + if (params.runId) { + query += ' AND run_id = ?'; + queryParams.push(params.runId); + } + + if (cursor) { + query += ' AND hook_id < ?'; + queryParams.push(cursor); + } + + query += ' ORDER BY hook_id DESC LIMIT ?'; + queryParams.push(limit + 1); + + const rows = await db.prepare(query).all(queryParams); + const hasMore = rows.length > limit; + const data = rows + .slice(0, limit) + .map((row) => filterHookData(rowToHook(row), resolveData)); + + return { + data, + hasMore, + cursor: data.at(-1)?.hookId ?? null, + }; + }, + + async dispose(hookId: string, params?: GetHookParams): Promise { + const row = await db + .prepare(` + SELECT * FROM workflow_hooks WHERE hook_id = ? + `) + .get([hookId]); + + if (!row) { + throw new WorkflowAPIError(`Hook not found: ${hookId}`, { + status: 404, + }); + } + + await db + .prepare(`DELETE FROM workflow_hooks WHERE hook_id = ?`) + .run([hookId]); + + const hook = rowToHook(row); + const resolveData = params?.resolveData ?? 'all'; + return filterHookData(hook, resolveData); + }, + }; +} diff --git a/packages/world-browser/src/streamer.ts b/packages/world-browser/src/streamer.ts new file mode 100644 index 000000000..2da3490ac --- /dev/null +++ b/packages/world-browser/src/streamer.ts @@ -0,0 +1,196 @@ +/** + * Streamer implementation for browser using SQLite and Web Streams API. + */ + +import type { Streamer } from '@workflow/world'; +import { monotonicFactory } from 'ulid'; +import type { BrowserDatabase } from './schema.js'; + +const ulid = monotonicFactory(); + +// Stream chunk row type +interface StreamChunkRow { + chunk_id: string; + stream_id: string; + chunk_data: Uint8Array; + eof: number; + created_at: string; +} + +// Event emitter for stream updates (simple implementation) +type StreamListener = (chunk: { + id: string; + data: Uint8Array; + eof: boolean; +}) => void; +const streamListeners = new Map>(); + +function emitStreamChunk( + streamId: string, + chunk: { id: string; data: Uint8Array; eof: boolean } +) { + const listeners = streamListeners.get(streamId); + if (listeners) { + for (const listener of listeners) { + listener(chunk); + } + } +} + +function addStreamListener( + streamId: string, + listener: StreamListener +): () => void { + if (!streamListeners.has(streamId)) { + streamListeners.set(streamId, new Set()); + } + streamListeners.get(streamId)!.add(listener); + return () => { + streamListeners.get(streamId)?.delete(listener); + if (streamListeners.get(streamId)?.size === 0) { + streamListeners.delete(streamId); + } + }; +} + +/** + * Create a Streamer implementation using SQLite for chunk storage. + */ +export function createStreamer(db: BrowserDatabase): Streamer { + const genChunkId = () => `chnk_${ulid()}` as const; + + return { + async writeToStream( + name: string, + _runId: string | Promise, + chunk: string | Uint8Array + ): Promise { + // Await runId if it's a promise + await _runId; + + const chunkId = genChunkId(); + const chunkData = + typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk; + + await db + .prepare(` + INSERT INTO workflow_stream_chunks (chunk_id, stream_id, chunk_data, eof, created_at) + VALUES (?, ?, ?, 0, datetime('now')) + `) + .run([chunkId, name, chunkData]); + + // Emit to listeners + emitStreamChunk(name, { id: chunkId, data: chunkData, eof: false }); + }, + + async closeStream( + name: string, + _runId: string | Promise + ): Promise { + // Await runId if it's a promise + await _runId; + + const chunkId = genChunkId(); + + await db + .prepare(` + INSERT INTO workflow_stream_chunks (chunk_id, stream_id, chunk_data, eof, created_at) + VALUES (?, ?, ?, 1, datetime('now')) + `) + .run([chunkId, name, new Uint8Array(0)]); + + // Emit EOF to listeners + emitStreamChunk(name, { + id: chunkId, + data: new Uint8Array(0), + eof: true, + }); + }, + + async readFromStream( + name: string, + startIndex?: number + ): Promise> { + return new ReadableStream({ + async start(controller) { + let lastChunkId = ''; + let offset = startIndex ?? 0; + let buffer: Array<{ + id: string; + data: Uint8Array; + eof: boolean; + }> | null = []; + + function enqueue(chunk: { + id: string; + data: Uint8Array; + eof: boolean; + }) { + // Skip if already processed or out of order + if (lastChunkId >= chunk.id) { + return; + } + + // Handle offset + if (offset > 0) { + offset--; + return; + } + + if (chunk.data.byteLength > 0) { + controller.enqueue(new Uint8Array(chunk.data)); + } + + if (chunk.eof) { + controller.close(); + } + + lastChunkId = chunk.id; + } + + // Subscribe to new chunks + const removeListener = addStreamListener(name, (chunk) => { + if (buffer) { + buffer.push(chunk); + return; + } + enqueue(chunk); + }); + + // Load existing chunks + const rows = await db + .prepare(` + SELECT chunk_id, stream_id, chunk_data, eof + FROM workflow_stream_chunks + WHERE stream_id = ? + ORDER BY chunk_id ASC + `) + .all([name]); + + // Process existing chunks and buffered chunks + const existingChunks = rows.map((row) => ({ + id: row.chunk_id, + data: row.chunk_data, + eof: row.eof === 1, + })); + + for (const chunk of [...existingChunks, ...(buffer ?? [])]) { + enqueue(chunk); + } + + buffer = null; + + // Store cleanup function for cancel + (controller as any)._cleanup = removeListener; + }, + + cancel(controller) { + const cleanup = (controller as any)?._cleanup; + if (typeof cleanup === 'function') { + cleanup(); + } + }, + }); + }, + }; +} diff --git a/packages/world-browser/src/worker/engine.ts b/packages/world-browser/src/worker/engine.ts new file mode 100644 index 000000000..9f9182eaa --- /dev/null +++ b/packages/world-browser/src/worker/engine.ts @@ -0,0 +1,208 @@ +/** + * Workflow execution engine for browser. + * + * This handles executing workflow functions with deterministic context + * and event replay for resumption. + */ + +import type { Event, World, WorkflowRun } from '@workflow/world'; +import { createDeterministicContext } from '../deterministic.js'; +import type { WorkflowFunction } from './registry.js'; + +/** + * Events consumer for replaying events during workflow execution. + */ +class EventsConsumer { + private events: Event[]; + private index: number = 0; + private subscribers: Array<(event: Event | null) => void> = []; + + constructor(events: Event[]) { + this.events = events; + } + + /** + * Subscribe to event consumption. + */ + subscribe(callback: (event: Event | null) => void): void { + this.subscribers.push(callback); + } + + /** + * Consume the next event if it matches the expected type. + */ + consume(expectedType: string): Event | null { + if (this.index >= this.events.length) { + return null; + } + + const event = this.events[this.index]; + if (event.eventType === expectedType) { + this.index++; + this.notifySubscribers(event); + return event; + } + + return null; + } + + /** + * Peek at the next event without consuming it. + */ + peek(): Event | null { + if (this.index >= this.events.length) { + return null; + } + return this.events[this.index]; + } + + /** + * Check if all events have been consumed. + */ + isExhausted(): boolean { + return this.index >= this.events.length; + } + + private notifySubscribers(event: Event | null): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } +} + +/** + * Hydrate workflow arguments from serialized input. + */ +function hydrateWorkflowArguments(input: unknown[]): unknown[] { + // For now, simple passthrough. In the future, this could handle + // special types like Date, ArrayBuffer, etc. + return input ?? []; +} + +/** + * Dehydrate workflow return value for serialization. + */ +function dehydrateWorkflowReturnValue(value: unknown): unknown { + // For now, simple passthrough. In the future, this could handle + // special types like Date, ArrayBuffer, etc. + return value; +} + +/** + * Execute a workflow with deterministic context. + * + * @param workflowFn - The workflow function to execute + * @param run - The workflow run record + * @param events - Previous events for replay + * @param world - The world instance for storage/queue operations + */ +export async function executeWorkflow( + workflowFn: WorkflowFunction, + run: WorkflowRun, + events: Event[], + _world: World +): Promise { + const startedAt = run.startedAt ?? run.createdAt; + + // Create deterministic context + const ctx = createDeterministicContext(run.runId, +startedAt); + + try { + // Set up events consumer for replay + const eventsConsumer = new EventsConsumer(events); + + // Subscribe to update timestamp as events are consumed + eventsConsumer.subscribe((event) => { + if (event?.createdAt) { + ctx.updateTimestamp(+event.createdAt); + } + }); + + // Hydrate arguments + const args = hydrateWorkflowArguments(run.input as unknown[]); + + // Execute the workflow function + const result = await workflowFn(...args); + + // Dehydrate result + return dehydrateWorkflowReturnValue(result); + } finally { + // Always restore original globals + ctx.restore(); + } +} + +/** + * Create a step runner for use within workflow execution. + * This is used by the transformed step functions to execute steps. + */ +export function createStepRunner( + world: World, + runId: string, + _eventsConsumer: EventsConsumer +) { + return async function runStep( + stepId: string, + stepName: string, + stepFn: () => Promise + ): Promise { + // Check if we have a cached result from previous execution + try { + const existingStep = await world.steps.get(runId, stepId); + if ( + existingStep.status === 'completed' && + existingStep.output !== undefined + ) { + // Return cached result + return existingStep.output as T; + } + } catch { + // Step doesn't exist yet, create it + } + + // Create or update step record + try { + await world.steps.create(runId, { + stepId, + stepName, + input: [], + }); + } catch { + // Step might already exist + } + + // Update step to running + await world.steps.update(runId, stepId, { status: 'running' }); + + try { + // Execute the step function + const result = await stepFn(); + + // Record step completion + await world.steps.update(runId, stepId, { + status: 'completed', + output: result as any, + }); + + // Create step completion event + await world.events.create(runId, { + eventType: 'step_completed', + correlationId: stepId, + eventData: { result }, + }); + + return result; + } catch (error) { + // Record step failure + await world.steps.update(runId, stepId, { + status: 'failed', + error: { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + }); + + throw error; + } + }; +} diff --git a/packages/world-browser/src/worker/index.ts b/packages/world-browser/src/worker/index.ts new file mode 100644 index 000000000..557a89c88 --- /dev/null +++ b/packages/world-browser/src/worker/index.ts @@ -0,0 +1,15 @@ +/** + * Worker exports for browser workflows. + */ + +export { executeWorkflow, createStepRunner } from './engine.js'; +export { + setWorkflowRegistry, + getWorkflowRegistry, + type WorkflowFunction, + type WorkflowRegistry, +} from './registry.js'; +export * from './message-types.js'; + +// Note: shared-worker.ts contains side effects (self.onconnect) and should NOT +// be re-exported here. It's only used when running as an actual SharedWorker. diff --git a/packages/world-browser/src/worker/message-types.ts b/packages/world-browser/src/worker/message-types.ts new file mode 100644 index 000000000..b7ae5a5ff --- /dev/null +++ b/packages/world-browser/src/worker/message-types.ts @@ -0,0 +1,172 @@ +/** + * Message types for SharedWorker communication. + */ + +import type { WorkflowRun, Step, Event } from '@workflow/world'; + +// Request message types +export type WorkerRequestType = + | 'TRIGGER' + | 'GET_STATUS' + | 'LIST_RUNS' + | 'CANCEL' + | 'PAUSE' + | 'RESUME' + | 'SUBSCRIBE' + | 'UNSUBSCRIBE' + | 'GET_STEPS' + | 'GET_EVENTS'; + +export interface WorkerRequest { + id: string; + type: WorkerRequestType; +} + +export interface TriggerRequest extends WorkerRequest { + type: 'TRIGGER'; + workflowId: string; + args: unknown[]; +} + +export interface GetStatusRequest extends WorkerRequest { + type: 'GET_STATUS'; + runId: string; +} + +export interface ListRunsRequest extends WorkerRequest { + type: 'LIST_RUNS'; + workflowName?: string; + status?: string; + limit?: number; + cursor?: string; +} + +export interface CancelRequest extends WorkerRequest { + type: 'CANCEL'; + runId: string; +} + +export interface PauseRequest extends WorkerRequest { + type: 'PAUSE'; + runId: string; +} + +export interface ResumeRequest extends WorkerRequest { + type: 'RESUME'; + runId: string; +} + +export interface SubscribeRequest extends WorkerRequest { + type: 'SUBSCRIBE'; + runId: string; +} + +export interface UnsubscribeRequest extends WorkerRequest { + type: 'UNSUBSCRIBE'; + runId: string; +} + +export interface GetStepsRequest extends WorkerRequest { + type: 'GET_STEPS'; + runId: string; +} + +export interface GetEventsRequest extends WorkerRequest { + type: 'GET_EVENTS'; + runId: string; +} + +export type AnyWorkerRequest = + | TriggerRequest + | GetStatusRequest + | ListRunsRequest + | CancelRequest + | PauseRequest + | ResumeRequest + | SubscribeRequest + | UnsubscribeRequest + | GetStepsRequest + | GetEventsRequest; + +// Response message types +export interface WorkerResponse { + id: string; + success: boolean; +} + +export interface SuccessResponse extends WorkerResponse { + success: true; + data: T; +} + +export interface ErrorResponse extends WorkerResponse { + success: false; + error: string; +} + +export type AnyWorkerResponse = SuccessResponse | ErrorResponse; + +// Event message types (pushed from worker to client) +export type WorkerEventType = + | 'RUN_UPDATED' + | 'STEP_UPDATED' + | 'RUN_COMPLETED' + | 'RUN_FAILED'; + +export interface WorkerEvent { + type: WorkerEventType; + runId: string; +} + +export interface RunUpdatedEvent extends WorkerEvent { + type: 'RUN_UPDATED'; + run: WorkflowRun; +} + +export interface StepUpdatedEvent extends WorkerEvent { + type: 'STEP_UPDATED'; + step: Step; +} + +export interface RunCompletedEvent extends WorkerEvent { + type: 'RUN_COMPLETED'; + run: WorkflowRun; +} + +export interface RunFailedEvent extends WorkerEvent { + type: 'RUN_FAILED'; + run: WorkflowRun; + error: string; +} + +export type AnyWorkerEvent = + | RunUpdatedEvent + | StepUpdatedEvent + | RunCompletedEvent + | RunFailedEvent; + +// Trigger response +export interface TriggerResponse { + runId: string; +} + +// List runs response +export interface ListRunsResponse { + data: WorkflowRun[]; + hasMore: boolean; + cursor: string | null; +} + +// Steps response +export interface GetStepsResponse { + data: Step[]; + hasMore: boolean; + cursor: string | null; +} + +// Events response +export interface GetEventsResponse { + data: Event[]; + hasMore: boolean; + cursor: string | null; +} diff --git a/packages/world-browser/src/worker/registry.ts b/packages/world-browser/src/worker/registry.ts new file mode 100644 index 000000000..be67a3401 --- /dev/null +++ b/packages/world-browser/src/worker/registry.ts @@ -0,0 +1,27 @@ +/** + * Workflow registry for browser workflows. + * This is a separate file to avoid side effects from shared-worker.ts. + */ + +export type WorkflowFunction = ( + ...args: unknown[] +) => Promise | unknown; + +export type WorkflowRegistry = Map; + +// Workflow registry (injected at build time) +let workflowRegistry: WorkflowRegistry = new Map(); + +/** + * Set the workflow registry. Called during worker initialization. + */ +export function setWorkflowRegistry(registry: WorkflowRegistry): void { + workflowRegistry = registry; +} + +/** + * Get the workflow registry. + */ +export function getWorkflowRegistry(): WorkflowRegistry { + return workflowRegistry; +} diff --git a/packages/world-browser/src/worker/shared-worker.ts b/packages/world-browser/src/worker/shared-worker.ts new file mode 100644 index 000000000..207305a2d --- /dev/null +++ b/packages/world-browser/src/worker/shared-worker.ts @@ -0,0 +1,299 @@ +/** + * SharedWorker entry point for browser workflow execution. + * + * This worker handles: + * - Workflow triggering and status queries + * - Queue processing for step execution + * - Subscriptions for real-time updates + */ + +import type { World, WorkflowRun, Step } from '@workflow/world'; +import { createBrowserWorld } from '../world.js'; +import { startQueueProcessor } from '../queue.js'; +import { executeWorkflow } from './engine.js'; +import { getWorkflowRegistry } from './registry.js'; +import type { + AnyWorkerRequest, + AnyWorkerResponse, + AnyWorkerEvent, + TriggerResponse, + ListRunsResponse, + GetStepsResponse, + GetEventsResponse, + RunUpdatedEvent, + StepUpdatedEvent, + RunCompletedEvent, + RunFailedEvent, +} from './message-types.js'; + +// Declare SharedWorkerGlobalScope for TypeScript +declare const self: SharedWorkerGlobalScope; + +// Connected ports +const ports = new Set(); + +// Subscriptions: runId -> Set of ports +const subscriptions = new Map>(); + +// World instance (initialized once) +let world: World | null = null; + +/** + * Broadcast an event to all subscribed ports. + */ +function broadcastToSubscribers(runId: string, event: AnyWorkerEvent): void { + const subscribedPorts = subscriptions.get(runId); + if (subscribedPorts) { + for (const port of subscribedPorts) { + try { + port.postMessage(event); + } catch { + // Port may be closed, remove it + subscribedPorts.delete(port); + } + } + } +} + +/** + * Broadcast run update to subscribers. + */ +export function notifyRunUpdate(run: WorkflowRun): void { + const event: RunUpdatedEvent = { + type: 'RUN_UPDATED', + runId: run.runId, + run, + }; + broadcastToSubscribers(run.runId, event); +} + +/** + * Broadcast step update to subscribers. + */ +export function notifyStepUpdate(step: Step): void { + const event: StepUpdatedEvent = { + type: 'STEP_UPDATED', + runId: step.runId, + step, + }; + broadcastToSubscribers(step.runId, event); +} + +/** + * Handle incoming messages from the main thread. + */ +async function handleMessage( + port: MessagePort, + request: AnyWorkerRequest, + currentWorld: World +): Promise> { + try { + switch (request.type) { + case 'TRIGGER': { + const { workflowId, args } = request; + + // Create workflow run + const run = await currentWorld.runs.create({ + workflowName: workflowId, + deploymentId: 'browser', + input: args, + }); + + // Queue the workflow for execution + await currentWorld.queue(`__wkf_workflow_${workflowId}` as any, { + runId: run.runId, + }); + + const response: TriggerResponse = { runId: run.runId }; + return { id: request.id, success: true, data: response }; + } + + case 'GET_STATUS': { + const run = await currentWorld.runs.get(request.runId); + return { id: request.id, success: true, data: run }; + } + + case 'LIST_RUNS': { + const result = await currentWorld.runs.list({ + workflowName: request.workflowName, + status: request.status as WorkflowRun['status'], + pagination: { + limit: request.limit, + cursor: request.cursor, + }, + }); + const response: ListRunsResponse = result; + return { id: request.id, success: true, data: response }; + } + + case 'CANCEL': { + const run = await currentWorld.runs.cancel(request.runId); + notifyRunUpdate(run); + return { id: request.id, success: true, data: run }; + } + + case 'PAUSE': { + const run = await currentWorld.runs.pause(request.runId); + notifyRunUpdate(run); + return { id: request.id, success: true, data: run }; + } + + case 'RESUME': { + const run = await currentWorld.runs.resume(request.runId); + notifyRunUpdate(run); + + // Re-queue for execution + await currentWorld.queue(`__wkf_workflow_${run.workflowName}` as any, { + runId: run.runId, + }); + + return { id: request.id, success: true, data: run }; + } + + case 'SUBSCRIBE': { + if (!subscriptions.has(request.runId)) { + subscriptions.set(request.runId, new Set()); + } + subscriptions.get(request.runId)!.add(port); + + // Return current status + const run = await currentWorld.runs.get(request.runId); + return { id: request.id, success: true, data: run }; + } + + case 'UNSUBSCRIBE': { + subscriptions.get(request.runId)?.delete(port); + return { id: request.id, success: true, data: null }; + } + + case 'GET_STEPS': { + const result = await currentWorld.steps.list({ runId: request.runId }); + const response: GetStepsResponse = result; + return { id: request.id, success: true, data: response }; + } + + case 'GET_EVENTS': { + const result = await currentWorld.events.list({ runId: request.runId }); + const response: GetEventsResponse = result; + return { id: request.id, success: true, data: response }; + } + + default: { + // TypeScript exhaustiveness check - this should never happen + const _exhaustive: never = request; + return { + id: (_exhaustive as AnyWorkerRequest).id, + success: false as const, + error: `Unknown request type: ${(request as AnyWorkerRequest).type}`, + }; + } + } + } catch (error) { + return { + id: request.id, + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Initialize the SharedWorker. + */ +self.onconnect = async (event: MessageEvent) => { + const port = event.ports[0]; + ports.add(port); + + // Initialize world on first connection + if (!world) { + const browserWorld = await createBrowserWorld({ database: 'workflows.db' }); + world = browserWorld; + + // Start queue processor + startQueueProcessor(browserWorld.db, { + workflow: async (message, _meta) => { + if (!('runId' in message)) return; + + const run = await world!.runs.get(message.runId); + const workflowFn = getWorkflowRegistry().get(run.workflowName); + + if (!workflowFn) { + console.error(`Workflow not found: ${run.workflowName}`); + await world!.runs.update(run.runId, { + status: 'failed', + error: { message: `Workflow not found: ${run.workflowName}` }, + }); + return; + } + + try { + // Update status to running + await world!.runs.update(run.runId, { status: 'running' }); + notifyRunUpdate(await world!.runs.get(run.runId)); + + // Execute workflow + const events = await world!.events.list({ runId: run.runId }); + const result = await executeWorkflow( + workflowFn, + run, + events.data, + world! + ); + + // Update with result + const completedRun = await world!.runs.update(run.runId, { + status: 'completed', + output: result as any, + }); + + const completedEvent: RunCompletedEvent = { + type: 'RUN_COMPLETED', + runId: run.runId, + run: completedRun, + }; + broadcastToSubscribers(run.runId, completedEvent); + } catch (error) { + const failedRun = await world!.runs.update(run.runId, { + status: 'failed', + error: { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + }); + + const failedEvent: RunFailedEvent = { + type: 'RUN_FAILED', + runId: run.runId, + run: failedRun, + error: error instanceof Error ? error.message : String(error), + }; + broadcastToSubscribers(run.runId, failedEvent); + } + }, + step: async (_message, _meta) => { + // Step execution is handled by the workflow engine + // This is for future step-level queueing if needed + }, + }); + } + + // Handle messages from this port + port.onmessage = async (msgEvent: MessageEvent) => { + const response = await handleMessage(port, msgEvent.data, world!); + port.postMessage(response); + }; + + // Clean up on disconnect + port.onmessageerror = () => { + ports.delete(port); + // Remove from all subscriptions + for (const [runId, subscribedPorts] of subscriptions) { + subscribedPorts.delete(port); + if (subscribedPorts.size === 0) { + subscriptions.delete(runId); + } + } + }; + + port.start(); +}; diff --git a/packages/world-browser/src/world.ts b/packages/world-browser/src/world.ts new file mode 100644 index 000000000..404dfd1c3 --- /dev/null +++ b/packages/world-browser/src/world.ts @@ -0,0 +1,47 @@ +import type { World } from '@workflow/world'; +import { createQueue } from './queue.js'; +import { createSchema, type BrowserDatabase } from './schema.js'; +import { createStorage } from './storage.js'; +import { createStreamer } from './streamer.js'; + +export interface BrowserWorldConfig { + /** + * Database path. Use ':memory:' for ephemeral in-memory database + * or a filename like 'workflows.db' for OPFS persistence. + * @default 'workflows.db' + */ + database?: string; +} + +/** + * Creates a browser-based World instance using Turso WASM for storage. + * This should be called from within a SharedWorker. + */ +export async function createBrowserWorld( + config: BrowserWorldConfig = {} +): Promise }> { + const dbPath = config.database ?? 'workflows.db'; + + // Dynamic import to avoid issues during SSR + // Use bundle import which has everything (including WASM and workers) inlined + // @ts-expect-error - bundle export doesn't have type definitions + const { connect } = await import('@tursodatabase/database-wasm/bundle'); + const db = await connect(dbPath); + + // Initialize schema + await createSchema(db); + + const storage = createStorage(db); + const queue = createQueue(db); + const streamer = createStreamer(db); + + return { + ...storage, + ...queue, + ...streamer, + db, + async start() { + // Queue processor is started separately when workflows are registered + }, + }; +} diff --git a/packages/world-browser/tsconfig.json b/packages/world-browser/tsconfig.json new file mode 100644 index 000000000..8022b5b22 --- /dev/null +++ b/packages/world-browser/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@workflow/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "target": "es2022", + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a1e8834..ea7228c7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -578,6 +578,9 @@ importers: '@workflow/swc-plugin': specifier: workspace:* version: link:../swc-plugin-workflow + esbuild: + specifier: 'catalog:' + version: 0.25.12 semver: specifier: 7.7.3 version: 7.7.3 @@ -977,6 +980,46 @@ importers: specifier: 'catalog:' version: 4.1.11 + packages/world-browser: + dependencies: + '@tursodatabase/database-wasm': + specifier: 0.3.2 + version: 0.3.2 + '@workflow/errors': + specifier: workspace:* + version: link:../errors + '@workflow/world': + specifier: workspace:* + version: link:../world + seedrandom: + specifier: 3.0.5 + version: 3.0.5 + ulid: + specifier: 3.0.1 + version: 3.0.1 + zod: + specifier: 'catalog:' + version: 4.1.11 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.19.0 + '@types/react': + specifier: ^19 + version: 19.1.13 + '@types/seedrandom': + specifier: 3.0.8 + version: 3.0.8 + '@workflow/tsconfig': + specifier: workspace:* + version: link:../tsconfig + react: + specifier: ^18.0.0 || ^19.0.0 + version: 19.2.0 + vitest: + specifier: 'catalog:' + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + packages/world-local: dependencies: '@vercel/queue': @@ -1255,21 +1298,60 @@ importers: '@ai-sdk/react': specifier: 2.0.76 version: 2.0.76(react@19.1.1)(zod@4.1.11) + '@mlc-ai/web-llm': + specifier: ^0.2.80 + version: 0.2.80 '@node-rs/xxhash': specifier: 1.7.6 version: 1.7.6 '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 + '@radix-ui/react-collapsible': + specifier: 1.1.12 + version: 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-dialog': + specifier: 1.1.15 + version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-dropdown-menu': + specifier: 2.1.16 + version: 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-hover-card': + specifier: 1.1.15 + version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-progress': + specifier: 1.1.8 + version: 1.1.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-scroll-area': + specifier: 1.2.10 + version: 1.2.10(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-select': + specifier: 2.2.6 + version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-separator': + specifier: 1.1.8 + version: 1.1.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': + specifier: 1.1.1 + version: 1.1.1(@types/react@19.1.13)(react@19.1.1) '@radix-ui/react-tooltip': specifier: 1.2.8 version: 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-controllable-state': + specifier: 1.2.2 + version: 1.2.2(@types/react@19.1.13)(react@19.1.1) '@vercel/otel': specifier: ^1.13.0 version: 1.13.0(@opentelemetry/api-logs@0.57.2)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) '@workflow/ai': specifier: workspace:* version: link:../../packages/ai + '@workflow/world-browser': + specifier: workspace:* + version: link:../../packages/world-browser + '@xyflow/react': + specifier: 12.9.3 + version: 12.9.3(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) ai: specifier: 'catalog:' version: 5.0.104(zod@4.1.11) @@ -1279,6 +1361,12 @@ importers: clsx: specifier: 2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@19.1.1) lodash.chunk: specifier: ^4.2.0 version: 4.2.0 @@ -1288,6 +1376,12 @@ importers: mixpart: specifier: 0.0.4 version: 0.0.4 + motion: + specifier: ^12.23.24 + version: 12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + nanoid: + specifier: 5.1.6 + version: 5.1.6 next: specifier: 16.0.1 version: 16.0.1(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1300,9 +1394,21 @@ importers: react-dom: specifier: ^19.0.0 version: 19.1.1(react@19.1.1) + shiki: + specifier: 3.13.0 + version: 3.13.0 + streamdown: + specifier: 1.4.0 + version: 1.4.0(@types/react@19.1.13)(react@19.1.1) tailwind-merge: specifier: 3.4.0 version: 3.4.0 + tokenlens: + specifier: 1.3.1 + version: 1.3.1 + use-stick-to-bottom: + specifier: 1.1.1 + version: 1.1.1(react@19.1.1) workflow: specifier: workspace:* version: link:../../packages/workflow @@ -1331,6 +1437,9 @@ importers: tailwindcss: specifier: ^4 version: 4.1.13 + tw-animate-css: + specifier: ^1.3.8 + version: 1.3.8 typescript: specifier: 'catalog:' version: 5.9.3 @@ -2938,6 +3047,9 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@mlc-ai/web-llm@0.2.80': + resolution: {integrity: sha512-Hwy1OCsK5cOU4nKr2wIJ2qA1g595PENtO5f2d9Wd/GgFsj5X04uxfaaJfqED8eFAJOpQpn/DirogdEY/yp5jQg==} + '@mux/mux-data-google-ima@0.2.8': resolution: {integrity: sha512-0ZEkHdcZ6bS8QtcjFcoJeZxJTpX7qRIledf4q1trMWPznugvtajCjCM2kieK/pzkZj1JM6liDRFs1PJSfVUs2A==} @@ -4599,6 +4711,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -4943,6 +5064,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.7': resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} peerDependencies: @@ -4956,6 +5090,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.8': + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.3.8': resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} peerDependencies: @@ -5021,6 +5168,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slider@1.3.6': resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} peerDependencies: @@ -5981,12 +6141,33 @@ packages: '@testcontainers/postgresql@11.7.1': resolution: {integrity: sha512-8PfGNqwdyoMPQuubZM0wd07/tfi4vhLAjXP791tM105vSCmzCOhLfYu2CIq04GKVlmW1J5z5nOZWLNlU9WrUuQ==} + '@tokenlens/core@1.3.0': + resolution: {integrity: sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ==} + + '@tokenlens/fetch@1.3.0': + resolution: {integrity: sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ==} + + '@tokenlens/helpers@1.3.1': + resolution: {integrity: sha512-t6yL8N6ES8337E6eVSeH4hCKnPdWkZRFpupy9w5E66Q9IeqQ9IO7XQ6gh12JKjvWiRHuyyJ8MBP5I549Cr41EQ==} + + '@tokenlens/models@1.3.0': + resolution: {integrity: sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw==} + '@ts-morph/common@0.28.0': resolution: {integrity: sha512-4w6X/oFmvXcwux6y6ExfM/xSqMHw20cYwFJH+BlYrtGa6nwY9qGq8GXnUs1sVYeF2o/KT3S8hAH6sKBI3VOkBg==} '@ts-morph/common@0.28.1': resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} + '@tursodatabase/database-common@0.3.2': + resolution: {integrity: sha512-CPkPuvDMUS6mKIdAwpCbfU7g6U81/mSfCcMDXZ6Bt1dA8DJyOgTZRf80MDaWTKLQ+0mjkmQ0cZPb9zD/R41Vkg==} + + '@tursodatabase/database-wasm-common@0.3.2': + resolution: {integrity: sha512-ljpo471synyHzse8unmD+d2azo32A9nbiONS43URWlvEhSOpnkhuE5MjNgP9m468f8VyEU2qjvbgqxH8Ko9Q6A==} + + '@tursodatabase/database-wasm@0.3.2': + resolution: {integrity: sha512-fzlC7woFz2bhPagpWdlSnpWACVoQO7MlbPURY+dxXIcfuo71eFwI1PBLKWcdI3cGvFMu7ON2epQQ6u3nL6yFmg==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -6556,6 +6737,15 @@ packages: resolution: {integrity: sha512-ueFCcIPaMgtuYDS9u0qlUoEvj6GiSsKrwnOLPp9SshqjtcRaR1IEHRjoReq3sXNydsF5i0ZnmuYgXq9dV53t0g==} engines: {node: '>=18.0.0'} + '@xyflow/react@12.9.3': + resolution: {integrity: sha512-PSWoJ8vHiEqSIkLIkge+0eiHWiw4C6dyFDA03VKWJkqbU4A13VlDIVwKqf/Znuysn2GQw/zA61zpHE4rGgax7Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.73': + resolution: {integrity: sha512-C2ymH2V4mYDkdVSiRx0D7R0s3dvfXiupVBcko6tXP5K4tVdSBMo22/e3V9yRNdn+2HQFv44RFKzwOyCcUUDAVQ==} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -7012,6 +7202,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clean-stack@3.0.1: resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} engines: {node: '>=10'} @@ -9293,6 +9486,10 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -11606,6 +11803,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tokenlens@1.3.1: + resolution: {integrity: sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA==} + toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} @@ -12583,6 +12783,21 @@ packages: zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -13482,17 +13697,14 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 - optional: true '@emnapi/runtime@1.5.0': dependencies: tslib: 2.8.1 - optional: true '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 - optional: true '@epic-web/invariant@1.0.0': {} @@ -14086,6 +14298,10 @@ snapshots: dependencies: langium: 3.3.1 + '@mlc-ai/web-llm@0.2.80': + dependencies: + loglevel: 1.9.2 + '@mux/mux-data-google-ima@0.2.8': dependencies: mux-embed: 5.9.0 @@ -14135,7 +14351,6 @@ snapshots: '@emnapi/core': 1.6.0 '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.10.1 - optional: true '@netlify/binary-info@1.0.0': {} @@ -15659,6 +15874,22 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -15687,6 +15918,18 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.0) @@ -15705,6 +15948,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-compose-refs@1.1.1(@types/react@19.1.13)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.13)(react@19.1.0)': dependencies: react: 19.1.0 @@ -15761,6 +16010,34 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-context@1.1.3(@types/react@19.1.13)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.1) + aria-hidden: 1.2.6 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-remove-scroll: 2.7.1(@types/react@19.1.13)(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -15811,6 +16088,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-direction@1.1.1(@types/react@19.1.13)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-direction@1.1.1(@types/react@19.1.13)(react@19.2.0)': dependencies: react: 19.2.0 @@ -15869,6 +16152,21 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -15896,6 +16194,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.13)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.13)(react@19.2.0)': dependencies: react: 19.2.0 @@ -15924,6 +16228,17 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.0) @@ -15949,6 +16264,23 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -16012,6 +16344,32 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.1.1) + aria-hidden: 1.2.6 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-remove-scroll: 2.7.1(@types/react@19.1.13)(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -16307,6 +16665,15 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.2.0) @@ -16317,6 +16684,16 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -16335,6 +16712,23 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -16352,6 +16746,23 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/number': 1.1.1 @@ -16398,6 +16809,35 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + aria-hidden: 1.2.6 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-remove-scroll: 2.7.1(@types/react@19.1.13)(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/number': 1.1.1 @@ -16436,6 +16876,15 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/number': 1.1.1 @@ -16462,6 +16911,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-slot@1.1.1(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-slot@1.2.3(@types/react@19.1.13)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.0) @@ -16483,6 +16939,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-slot@1.2.4(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-slot@1.2.4(@types/react@19.1.13)(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.0) @@ -16804,6 +17267,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.13)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.13)(react@19.2.0)': dependencies: react: 19.2.0 @@ -17660,6 +18129,21 @@ snapshots: - react-native-b4a - supports-color + '@tokenlens/core@1.3.0': {} + + '@tokenlens/fetch@1.3.0': + dependencies: + '@tokenlens/core': 1.3.0 + + '@tokenlens/helpers@1.3.1': + dependencies: + '@tokenlens/core': 1.3.0 + '@tokenlens/fetch': 1.3.0 + + '@tokenlens/models@1.3.0': + dependencies: + '@tokenlens/core': 1.3.0 + '@ts-morph/common@0.28.0': dependencies: minimatch: 10.0.3 @@ -17672,10 +18156,20 @@ snapshots: path-browserify: 1.0.1 tinyglobby: 0.2.14 + '@tursodatabase/database-common@0.3.2': {} + + '@tursodatabase/database-wasm-common@0.3.2': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + + '@tursodatabase/database-wasm@0.3.2': + dependencies: + '@tursodatabase/database-common': 0.3.2 + '@tursodatabase/database-wasm-common': 0.3.2 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 - optional: true '@types/aws-lambda@8.10.159': {} @@ -18401,6 +18895,29 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@xyflow/react@12.9.3(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@xyflow/system': 0.0.73 + classcat: 5.0.5 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + zustand: 4.5.7(@types/react@19.1.13)(react@19.1.1) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.73': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + abbrev@3.0.1: {} abort-controller@3.0.0: @@ -18884,6 +19401,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clean-stack@3.0.1: dependencies: escape-string-regexp: 4.0.0 @@ -18931,6 +19450,18 @@ snapshots: cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + cmdk@1.1.1(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.0) @@ -19655,6 +20186,12 @@ snapshots: electron-to-chromium@1.5.238: {} + embla-carousel-react@8.6.0(react@19.1.1): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.1.1 + embla-carousel-react@8.6.0(react@19.2.0): dependencies: embla-carousel: 8.6.0 @@ -20208,6 +20745,15 @@ snapshots: fraction.js@4.3.7: {} + framer-motion@12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: motion-dom: 12.23.23 @@ -21295,6 +21841,8 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 + loglevel@1.9.2: {} + long@5.3.2: {} longest-streak@3.1.0: {} @@ -21317,6 +21865,10 @@ snapshots: dependencies: react: 19.1.0 + lucide-react@0.542.0(react@19.1.1): + dependencies: + react: 19.1.1 + lucide-react@0.542.0(react@19.2.0): dependencies: react: 19.2.0 @@ -21977,6 +22529,14 @@ snapshots: motion-utils@12.23.6: {} + motion@12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + framer-motion: 12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + tslib: 2.8.1 + optionalDependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: framer-motion: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -23714,6 +24274,24 @@ snapshots: react-is@18.3.1: {} + react-markdown@10.1.0(@types/react@19.1.13)(react@19.1.1): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.1.13 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.0 + react: 19.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-markdown@10.1.0(@types/react@19.1.13)(react@19.2.0): dependencies: '@types/hast': 3.0.4 @@ -23763,6 +24341,14 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + react-remove-scroll-bar@2.3.8(@types/react@19.1.13)(react@19.1.1): + dependencies: + react: 19.1.1 + react-style-singleton: 2.2.3(@types/react@19.1.13)(react@19.1.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.13 + react-remove-scroll-bar@2.3.8(@types/react@19.1.13)(react@19.2.0): dependencies: react: 19.2.0 @@ -23782,6 +24368,17 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + react-remove-scroll@2.7.1(@types/react@19.1.13)(react@19.1.1): + dependencies: + react: 19.1.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.1.13)(react@19.1.1) + react-style-singleton: 2.2.3(@types/react@19.1.13)(react@19.1.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.1.13)(react@19.1.1) + use-sidecar: 1.1.3(@types/react@19.1.13)(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + react-remove-scroll@2.7.1(@types/react@19.1.13)(react@19.2.0): dependencies: react: 19.2.0 @@ -23814,6 +24411,14 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + react-style-singleton@2.2.3(@types/react@19.1.13)(react@19.1.1): + dependencies: + get-nonce: 1.0.1 + react: 19.1.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.13 + react-style-singleton@2.2.3(@types/react@19.1.13)(react@19.2.0): dependencies: get-nonce: 1.0.1 @@ -24459,6 +25064,26 @@ snapshots: stdin-discarder@0.2.2: {} + streamdown@1.4.0(@types/react@19.1.13)(react@19.1.1): + dependencies: + clsx: 2.1.1 + katex: 0.16.25 + lucide-react: 0.542.0(react@19.1.1) + marked: 16.4.1 + mermaid: 11.12.1 + react: 19.1.1 + react-markdown: 10.1.0(@types/react@19.1.13)(react@19.1.1) + rehype-harden: 1.1.5 + rehype-katex: 7.0.1 + rehype-raw: 7.0.0 + remark-gfm: 4.0.1 + remark-math: 6.0.0 + shiki: 3.13.0 + tailwind-merge: 3.4.0 + transitivePeerDependencies: + - '@types/react' + - supports-color + streamdown@1.4.0(@types/react@19.1.13)(react@19.2.0): dependencies: clsx: 2.1.1 @@ -24822,6 +25447,13 @@ snapshots: toidentifier@1.0.1: {} + tokenlens@1.3.1: + dependencies: + '@tokenlens/core': 1.3.0 + '@tokenlens/fetch': 1.3.0 + '@tokenlens/helpers': 1.3.1 + '@tokenlens/models': 1.3.0 + toml@3.0.0: {} totalist@3.0.1: {} @@ -25223,6 +25855,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + use-callback-ref@1.3.3(@types/react@19.1.13)(react@19.1.1): + dependencies: + react: 19.1.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.13 + use-callback-ref@1.3.3(@types/react@19.1.13)(react@19.2.0): dependencies: react: 19.2.0 @@ -25238,6 +25877,14 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + use-sidecar@1.1.3(@types/react@19.1.13)(react@19.1.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.1.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.13 + use-sidecar@1.1.3(@types/react@19.1.13)(react@19.2.0): dependencies: detect-node-es: 1.1.0 @@ -25246,6 +25893,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + use-stick-to-bottom@1.1.1(react@19.1.1): + dependencies: + react: 19.1.1 + use-stick-to-bottom@1.1.1(react@19.2.0): dependencies: react: 19.2.0 @@ -25821,4 +26472,11 @@ snapshots: zod@4.1.12: {} + zustand@4.5.7(@types/react@19.1.13)(react@19.1.1): + dependencies: + use-sync-external-store: 1.5.0(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + react: 19.1.1 + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7b6adf245..a301eee6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,14 +4,14 @@ packages: - docs catalog: - "@biomejs/biome": ^2.2.7 - "@swc/core": 1.11.24 - "@types/json-schema": ^7.0.15 - "@types/node": 22.19.0 - "@vercel/functions": ^3.1.4 - "@vercel/oidc": 3.0.5 - "@vercel/queue": 0.0.0-alpha.29 - "@vitest/coverage-v8": ^3.2.4 + '@biomejs/biome': ^2.2.7 + '@swc/core': 1.11.24 + '@types/json-schema': ^7.0.15 + '@types/node': 22.19.0 + '@vercel/functions': ^3.1.4 + '@vercel/oidc': 3.0.5 + '@vercel/queue': 0.0.0-alpha.29 + '@vitest/coverage-v8': ^3.2.4 ai: 5.0.104 esbuild: ^0.25.11 nitro: 3.0.1-alpha.1 @@ -22,4 +22,4 @@ catalog: onlyBuiltDependencies: - esbuild -savePrefix: "" +savePrefix: '' diff --git a/workbench/nextjs-turbopack/.gitignore b/workbench/nextjs-turbopack/.gitignore index 16abee95e..151504f24 100644 --- a/workbench/nextjs-turbopack/.gitignore +++ b/workbench/nextjs-turbopack/.gitignore @@ -43,3 +43,5 @@ next-env.d.ts # workflow _workflows.ts +public/__workflow-worker.js +public/turso.wasm32-wasi.wasm diff --git a/workbench/nextjs-turbopack/app/chat/page.tsx b/workbench/nextjs-turbopack/app/chat/page.tsx new file mode 100644 index 000000000..5bfac46b6 --- /dev/null +++ b/workbench/nextjs-turbopack/app/chat/page.tsx @@ -0,0 +1,275 @@ +'use client'; + +import { useState, useCallback, useId, useEffect } from 'react'; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from '@/components/ai-elements/conversation'; +import { + Message, + MessageContent, + MessageResponse, +} from '@/components/ai-elements/message'; +import { + PromptInput, + PromptInputTextarea, + PromptInputFooter, + PromptInputTools, + PromptInputSubmit, + type PromptInputMessage, +} from '@/components/ai-elements/prompt-input'; +import { Progress } from '@/components/ui/progress'; +import { + SparklesIcon, + PlusIcon, + DownloadIcon, + AlertCircleIcon, + ArrowLeftIcon, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { + chatWithLLM, + type ChatMessage as WorkflowChatMessage, +} from '@/app/workflows/browser/chat'; +import { + subscribeToProgress, + initWebLLM, + type LoadingProgress, +} from '@/app/lib/webllm-service'; + +type ChatMessage = { + id: string; + role: 'user' | 'assistant'; + content: string; + createdAt: Date; +}; + +export default function ChatPage() { + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [modelProgress, setModelProgress] = useState({ + status: 'idle', + progress: 0, + text: '', + }); + const messageIdBase = useId(); + + // Subscribe to model loading progress + useEffect(() => { + const unsubscribe = subscribeToProgress(setModelProgress); + return unsubscribe; + }, []); + + // Start loading model + const handleLoadModel = useCallback(async () => { + try { + await initWebLLM(); + } catch (error) { + console.error('Failed to load model:', error); + } + }, []); + + const handleSubmit = useCallback( + async (message: PromptInputMessage) => { + if (!message.text.trim()) return; + + const userMessage: ChatMessage = { + id: `${messageIdBase}-${Date.now()}-user`, + role: 'user', + content: message.text, + createdAt: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setIsLoading(true); + + try { + // Build conversation history for the LLM + const conversationHistory: WorkflowChatMessage[] = [ + { + role: 'system', + content: 'You are a helpful AI assistant. Be concise and friendly.', + }, + ...messages.map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })), + { role: 'user', content: message.text }, + ]; + + // Call the browser workflow + const result = await chatWithLLM({ messages: conversationHistory }); + + const assistantMessage: ChatMessage = { + id: `${messageIdBase}-${Date.now()}-assistant`, + role: 'assistant', + content: + result.content || + 'I apologize, but I could not generate a response.', + createdAt: new Date(), + }; + + setMessages((prev) => [...prev, assistantMessage]); + } catch (error) { + console.error('Failed to generate response:', error); + const errorMessage: ChatMessage = { + id: `${messageIdBase}-${Date.now()}-error`, + role: 'assistant', + content: `Sorry, I encountered an error: ${error instanceof Error ? error.message : 'Unknown error'}`, + createdAt: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + } finally { + setIsLoading(false); + } + }, + [messageIdBase, messages] + ); + + const handleClear = useCallback(() => { + setMessages([]); + }, []); + + const isModelReady = modelProgress.status === 'ready'; + const isModelLoading = modelProgress.status === 'loading'; + const isModelError = modelProgress.status === 'error'; + + return ( +
+ {/* Header */} +
+ + + + +
+ + {/* Model Loading Banner */} + {!isModelReady && ( +
+
+ {modelProgress.status === 'idle' && ( +
+
+ + + Load the AI model to start chatting (requires WebGPU) + +
+ +
+ )} + {isModelLoading && ( +
+
+ {modelProgress.text} + + {Math.round(modelProgress.progress)}% + +
+ +
+ )} + {isModelError && ( +
+ + {modelProgress.text} + +
+ )} +
+
+ )} + + {/* Chat Area */} + + + {messages.length === 0 ? ( + + +
+ } + /> + ) : ( + <> + {messages.map((message) => ( + + + {message.role === 'assistant' ? ( + {message.content} + ) : ( + message.content + )} + + + ))} + {isLoading && ( + + +
+ + + +
+
+
+ )} + + )} + + + + + {/* Input Area */} +
+
+ + + + + + {isModelReady + ? 'Press Enter to send' + : 'WebGPU required (Chrome 113+)'} + + + + + +
+
+ + ); +} diff --git a/workbench/nextjs-turbopack/app/globals.css b/workbench/nextjs-turbopack/app/globals.css index da541d0b5..554beecfc 100644 --- a/workbench/nextjs-turbopack/app/globals.css +++ b/workbench/nextjs-turbopack/app/globals.css @@ -1,5 +1,8 @@ @import "tailwindcss"; +/* Scan web-shared package for Tailwind classes */ +@source "../../../web-shared/src"; + :root { --background: 0 0% 100%; --foreground: 0 0% 3.9%; @@ -21,34 +24,128 @@ --input: 0 0% 89.8%; --ring: 0 0% 3.9%; --radius: 0.5rem; -} -@theme inline { - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - --color-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + /* Geist Design System Colors */ + /* Backgrounds */ + --ds-background-100: rgb(255 255 255); + --ds-background-200: rgb(250 250 250); + + /* Gray Scale */ + --ds-gray-100: rgb(250 250 250); + --ds-gray-200: rgb(245 245 245); + --ds-gray-300: rgb(229 229 229); + --ds-gray-400: rgb(212 212 212); + --ds-gray-500: rgb(163 163 163); + --ds-gray-600: rgb(115 115 115); + --ds-gray-700: rgb(82 82 82); + --ds-gray-800: rgb(64 64 64); + --ds-gray-900: rgb(23 23 23); + --ds-gray-1000: rgb(0 0 0); + + /* Gray Alpha (with transparency) */ + --ds-gray-alpha-100: rgb(0 0 0 / 0.02); + --ds-gray-alpha-200: rgb(0 0 0 / 0.04); + --ds-gray-alpha-300: rgb(0 0 0 / 0.08); + --ds-gray-alpha-400: rgb(0 0 0 / 0.12); + --ds-gray-alpha-500: rgb(0 0 0 / 0.3); + --ds-gray-alpha-600: rgb(0 0 0 / 0.5); + --ds-gray-alpha-700: rgb(0 0 0 / 0.6); + --ds-gray-alpha-800: rgb(0 0 0 / 0.7); + --ds-gray-alpha-900: rgb(0 0 0 / 0.9); + + /* Blue */ + --ds-blue-100: rgb(224 242 254); + --ds-blue-200: rgb(186 230 253); + --ds-blue-300: rgb(125 211 252); + --ds-blue-400: rgb(56 189 248); + --ds-blue-500: rgb(14 165 233); + --ds-blue-600: rgb(2 132 199); + --ds-blue-700: rgb(3 105 161); + --ds-blue-800: rgb(7 89 133); + --ds-blue-900: rgb(12 74 110); + --ds-blue-1000: rgb(8 47 73); + + /* Red */ + --ds-red-100: rgb(254 226 226); + --ds-red-200: rgb(254 202 202); + --ds-red-300: rgb(252 165 165); + --ds-red-400: rgb(248 113 113); + --ds-red-500: rgb(239 68 68); + --ds-red-600: rgb(220 38 38); + --ds-red-700: rgb(185 28 28); + --ds-red-800: rgb(153 27 27); + --ds-red-900: rgb(127 29 29); + --ds-red-1000: rgb(69 10 10); + + /* Amber */ + --ds-amber-100: rgb(254 243 199); + --ds-amber-200: rgb(253 230 138); + --ds-amber-300: rgb(252 211 77); + --ds-amber-400: rgb(251 191 36); + --ds-amber-500: rgb(245 158 11); + --ds-amber-600: rgb(217 119 6); + --ds-amber-700: rgb(180 83 9); + --ds-amber-800: rgb(146 64 14); + --ds-amber-900: rgb(120 53 15); + --ds-amber-1000: rgb(69 26 3); + + /* Green */ + --ds-green-100: rgb(220 252 231); + --ds-green-200: rgb(187 247 208); + --ds-green-300: rgb(134 239 172); + --ds-green-400: rgb(74 222 128); + --ds-green-500: rgb(34 197 94); + --ds-green-600: rgb(22 163 74); + --ds-green-700: rgb(21 128 61); + --ds-green-800: rgb(22 101 52); + --ds-green-900: rgb(20 83 45); + --ds-green-1000: rgb(5 46 22); + + /* Teal */ + --ds-teal-100: rgb(204 251 241); + --ds-teal-200: rgb(153 246 228); + --ds-teal-300: rgb(94 234 212); + --ds-teal-400: rgb(45 212 191); + --ds-teal-500: rgb(20 184 166); + --ds-teal-600: rgb(13 148 136); + --ds-teal-700: rgb(15 118 110); + --ds-teal-800: rgb(17 94 89); + --ds-teal-900: rgb(19 78 74); + --ds-teal-1000: rgb(4 47 46); + + /* Purple */ + --ds-purple-100: rgb(243 232 255); + --ds-purple-200: rgb(233 213 255); + --ds-purple-300: rgb(216 180 254); + --ds-purple-400: rgb(192 132 252); + --ds-purple-500: rgb(168 85 247); + --ds-purple-600: rgb(147 51 234); + --ds-purple-700: rgb(126 34 206); + --ds-purple-800: rgb(107 33 168); + --ds-purple-900: rgb(88 28 135); + --ds-purple-1000: rgb(59 7 100); + + /* Pink */ + --ds-pink-100: rgb(252 231 243); + --ds-pink-200: rgb(251 207 232); + --ds-pink-300: rgb(249 168 212); + --ds-pink-400: rgb(244 114 182); + --ds-pink-500: rgb(236 72 153); + --ds-pink-600: rgb(219 39 119); + --ds-pink-700: rgb(190 24 93); + --ds-pink-800: rgb(157 23 77); + --ds-pink-900: rgb(131 24 67); + --ds-pink-1000: rgb(80 7 36); + + /* Shadows */ + --ds-shadow-small: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.05), 0 12px 24px rgba(0, 0, 0, 0.05); + --ds-shadow-medium: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 4px 8px rgba(0, 0, 0, 0.07), 0 24px 48px rgba(0, 0, 0, 0.07); + --ds-shadow-large: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 8px 16px rgba(0, 0, 0, 0.10), 0 48px 96px rgba(0, 0, 0, 0.10); } +/* Dark mode: applies when system prefers dark (and no .light class override) */ @media (prefers-color-scheme: dark) { - :root { + :root:not(.light) { --background: 0 0% 3.9%; --foreground: 0 0% 98%; --card: 0 0% 3.9%; @@ -68,11 +165,301 @@ --border: 0 0% 14.9%; --input: 0 0% 14.9%; --ring: 0 0% 83.1%; + + /* Geist Design System Colors - Dark Mode */ + /* Backgrounds */ + --ds-background-100: rgb(0 0 0); + --ds-background-200: rgb(10 10 10); + + /* Gray Scale */ + --ds-gray-100: rgb(17 17 17); + --ds-gray-200: rgb(23 23 23); + --ds-gray-300: rgb(41 41 41); + --ds-gray-400: rgb(64 64 64); + --ds-gray-500: rgb(115 115 115); + --ds-gray-600: rgb(163 163 163); + --ds-gray-700: rgb(212 212 212); + --ds-gray-800: rgb(229 229 229); + --ds-gray-900: rgb(245 245 245); + --ds-gray-1000: rgb(255 255 255); + + /* Gray Alpha (with transparency) */ + --ds-gray-alpha-100: rgb(255 255 255 / 0.02); + --ds-gray-alpha-200: rgb(255 255 255 / 0.04); + --ds-gray-alpha-300: rgb(255 255 255 / 0.08); + --ds-gray-alpha-400: rgb(255 255 255 / 0.12); + --ds-gray-alpha-500: rgb(255 255 255 / 0.3); + --ds-gray-alpha-600: rgb(255 255 255 / 0.5); + --ds-gray-alpha-700: rgb(255 255 255 / 0.6); + --ds-gray-alpha-800: rgb(255 255 255 / 0.7); + --ds-gray-alpha-900: rgb(255 255 255 / 0.9); + + /* Blue - Dark Mode */ + --ds-blue-100: rgb(8 47 73); + --ds-blue-200: rgb(12 74 110); + --ds-blue-300: rgb(7 89 133); + --ds-blue-400: rgb(3 105 161); + --ds-blue-500: rgb(2 132 199); + --ds-blue-600: rgb(14 165 233); + --ds-blue-700: rgb(56 189 248); + --ds-blue-800: rgb(125 211 252); + --ds-blue-900: rgb(186 230 253); + --ds-blue-1000: rgb(224 242 254); + + /* Red - Dark Mode */ + --ds-red-100: rgb(69 10 10); + --ds-red-200: rgb(127 29 29); + --ds-red-300: rgb(153 27 27); + --ds-red-400: rgb(185 28 28); + --ds-red-500: rgb(220 38 38); + --ds-red-600: rgb(239 68 68); + --ds-red-700: rgb(248 113 113); + --ds-red-800: rgb(252 165 165); + --ds-red-900: rgb(254 202 202); + --ds-red-1000: rgb(254 226 226); + + /* Amber - Dark Mode */ + --ds-amber-100: rgb(69 26 3); + --ds-amber-200: rgb(120 53 15); + --ds-amber-300: rgb(146 64 14); + --ds-amber-400: rgb(180 83 9); + --ds-amber-500: rgb(217 119 6); + --ds-amber-600: rgb(245 158 11); + --ds-amber-700: rgb(251 191 36); + --ds-amber-800: rgb(252 211 77); + --ds-amber-900: rgb(253 230 138); + --ds-amber-1000: rgb(254 243 199); + + /* Green - Dark Mode */ + --ds-green-100: rgb(5 46 22); + --ds-green-200: rgb(20 83 45); + --ds-green-300: rgb(22 101 52); + --ds-green-400: rgb(21 128 61); + --ds-green-500: rgb(22 163 74); + --ds-green-600: rgb(34 197 94); + --ds-green-700: rgb(74 222 128); + --ds-green-800: rgb(134 239 172); + --ds-green-900: rgb(187 247 208); + --ds-green-1000: rgb(220 252 231); + + /* Teal - Dark Mode */ + --ds-teal-100: rgb(4 47 46); + --ds-teal-200: rgb(19 78 74); + --ds-teal-300: rgb(17 94 89); + --ds-teal-400: rgb(15 118 110); + --ds-teal-500: rgb(13 148 136); + --ds-teal-600: rgb(20 184 166); + --ds-teal-700: rgb(45 212 191); + --ds-teal-800: rgb(94 234 212); + --ds-teal-900: rgb(153 246 228); + --ds-teal-1000: rgb(204 251 241); + + /* Purple - Dark Mode */ + --ds-purple-100: rgb(59 7 100); + --ds-purple-200: rgb(88 28 135); + --ds-purple-300: rgb(107 33 168); + --ds-purple-400: rgb(126 34 206); + --ds-purple-500: rgb(147 51 234); + --ds-purple-600: rgb(168 85 247); + --ds-purple-700: rgb(192 132 252); + --ds-purple-800: rgb(216 180 254); + --ds-purple-900: rgb(233 213 255); + --ds-purple-1000: rgb(243 232 255); + + /* Pink - Dark Mode */ + --ds-pink-100: rgb(80 7 36); + --ds-pink-200: rgb(131 24 67); + --ds-pink-300: rgb(157 23 77); + --ds-pink-400: rgb(190 24 93); + --ds-pink-500: rgb(219 39 119); + --ds-pink-600: rgb(236 72 153); + --ds-pink-700: rgb(244 114 182); + --ds-pink-800: rgb(249 168 212); + --ds-pink-900: rgb(251 207 232); + --ds-pink-1000: rgb(252 231 243); + + /* Shadows - Dark Mode */ + --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); + --ds-shadow-medium: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 4px 8px rgba(0, 0, 0, 0.4), 0 24px 48px rgba(0, 0, 0, 0.4); + --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); } } +/* Dark mode: applies when .dark class is present (overrides system preference) */ +.dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + + /* Geist Design System Colors - Dark Mode */ + /* Backgrounds */ + --ds-background-100: rgb(0 0 0); + --ds-background-200: rgb(10 10 10); + + /* Gray Scale */ + --ds-gray-100: rgb(17 17 17); + --ds-gray-200: rgb(23 23 23); + --ds-gray-300: rgb(41 41 41); + --ds-gray-400: rgb(64 64 64); + --ds-gray-500: rgb(115 115 115); + --ds-gray-600: rgb(163 163 163); + --ds-gray-700: rgb(212 212 212); + --ds-gray-800: rgb(229 229 229); + --ds-gray-900: rgb(245 245 245); + --ds-gray-1000: rgb(255 255 255); + + /* Gray Alpha (with transparency) */ + --ds-gray-alpha-100: rgb(255 255 255 / 0.02); + --ds-gray-alpha-200: rgb(255 255 255 / 0.04); + --ds-gray-alpha-300: rgb(255 255 255 / 0.08); + --ds-gray-alpha-400: rgb(255 255 255 / 0.12); + --ds-gray-alpha-500: rgb(255 255 255 / 0.3); + --ds-gray-alpha-600: rgb(255 255 255 / 0.5); + --ds-gray-alpha-700: rgb(255 255 255 / 0.6); + --ds-gray-alpha-800: rgb(255 255 255 / 0.7); + --ds-gray-alpha-900: rgb(255 255 255 / 0.9); + + /* Blue - Dark Mode */ + --ds-blue-100: rgb(8 47 73); + --ds-blue-200: rgb(12 74 110); + --ds-blue-300: rgb(7 89 133); + --ds-blue-400: rgb(3 105 161); + --ds-blue-500: rgb(2 132 199); + --ds-blue-600: rgb(14 165 233); + --ds-blue-700: rgb(56 189 248); + --ds-blue-800: rgb(125 211 252); + --ds-blue-900: rgb(186 230 253); + --ds-blue-1000: rgb(224 242 254); + + /* Red - Dark Mode */ + --ds-red-100: rgb(69 10 10); + --ds-red-200: rgb(127 29 29); + --ds-red-300: rgb(153 27 27); + --ds-red-400: rgb(185 28 28); + --ds-red-500: rgb(220 38 38); + --ds-red-600: rgb(239 68 68); + --ds-red-700: rgb(248 113 113); + --ds-red-800: rgb(252 165 165); + --ds-red-900: rgb(254 202 202); + --ds-red-1000: rgb(254 226 226); + + /* Amber - Dark Mode */ + --ds-amber-100: rgb(69 26 3); + --ds-amber-200: rgb(120 53 15); + --ds-amber-300: rgb(146 64 14); + --ds-amber-400: rgb(180 83 9); + --ds-amber-500: rgb(217 119 6); + --ds-amber-600: rgb(245 158 11); + --ds-amber-700: rgb(251 191 36); + --ds-amber-800: rgb(252 211 77); + --ds-amber-900: rgb(253 230 138); + --ds-amber-1000: rgb(254 243 199); + + /* Green - Dark Mode */ + --ds-green-100: rgb(5 46 22); + --ds-green-200: rgb(20 83 45); + --ds-green-300: rgb(22 101 52); + --ds-green-400: rgb(21 128 61); + --ds-green-500: rgb(22 163 74); + --ds-green-600: rgb(34 197 94); + --ds-green-700: rgb(74 222 128); + --ds-green-800: rgb(134 239 172); + --ds-green-900: rgb(187 247 208); + --ds-green-1000: rgb(220 252 231); + + /* Teal - Dark Mode */ + --ds-teal-100: rgb(4 47 46); + --ds-teal-200: rgb(19 78 74); + --ds-teal-300: rgb(17 94 89); + --ds-teal-400: rgb(15 118 110); + --ds-teal-500: rgb(13 148 136); + --ds-teal-600: rgb(20 184 166); + --ds-teal-700: rgb(45 212 191); + --ds-teal-800: rgb(94 234 212); + --ds-teal-900: rgb(153 246 228); + --ds-teal-1000: rgb(204 251 241); + + /* Purple - Dark Mode */ + --ds-purple-100: rgb(59 7 100); + --ds-purple-200: rgb(88 28 135); + --ds-purple-300: rgb(107 33 168); + --ds-purple-400: rgb(126 34 206); + --ds-purple-500: rgb(147 51 234); + --ds-purple-600: rgb(168 85 247); + --ds-purple-700: rgb(192 132 252); + --ds-purple-800: rgb(216 180 254); + --ds-purple-900: rgb(233 213 255); + --ds-purple-1000: rgb(243 232 255); + + /* Pink - Dark Mode */ + --ds-pink-100: rgb(80 7 36); + --ds-pink-200: rgb(131 24 67); + --ds-pink-300: rgb(157 23 77); + --ds-pink-400: rgb(190 24 93); + --ds-pink-500: rgb(219 39 119); + --ds-pink-600: rgb(236 72 153); + --ds-pink-700: rgb(244 114 182); + --ds-pink-800: rgb(249 168 212); + --ds-pink-900: rgb(251 207 232); + --ds-pink-1000: rgb(252 231 243); + + /* Shadows - Dark Mode */ + --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); + --ds-shadow-medium: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 4px 8px rgba(0, 0, 0, 0.4), 0 24px 48px rgba(0, 0, 0, 0.4); + --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); +} + +@theme { + /* Allow Tailwind's default color palette while also defining custom colors */ + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --radius-sm: 0.375rem; + --radius: calc(var(--radius)); + --radius-md: calc(var(--radius)); + --radius-lg: 0.75rem; + --radius-xl: 1rem; +} + +* { + border-color: hsl(var(--border)); +} + body { background: hsl(var(--background)); color: hsl(var(--foreground)); - font-family: var(--font-sans), Arial, Helvetica, sans-serif; -} + font-feature-settings: "rlig" 1, "calt" 1; +} \ No newline at end of file diff --git a/workbench/nextjs-turbopack/app/lib/webllm-service.ts b/workbench/nextjs-turbopack/app/lib/webllm-service.ts new file mode 100644 index 000000000..a5bf23543 --- /dev/null +++ b/workbench/nextjs-turbopack/app/lib/webllm-service.ts @@ -0,0 +1,155 @@ +/** + * WebLLM Service - Singleton for in-browser LLM inference + * + * Uses WebLLM with a dedicated Web Worker for GPU-accelerated inference. + * Model weights are cached in IndexedDB after first download. + */ + +import type { MLCEngine, ChatCompletionMessageParam } from '@mlc-ai/web-llm'; + +// Model to use - SmolLM is tiny (~200MB) and fast +const MODEL_ID = 'SmolLM2-360M-Instruct-q4f16_1-MLC'; + +export type LoadingProgress = { + status: 'idle' | 'loading' | 'ready' | 'error'; + progress: number; // 0-100 + text: string; +}; + +type ProgressCallback = (progress: LoadingProgress) => void; + +let engine: MLCEngine | null = null; +let loadingPromise: Promise | null = null; +let currentProgress: LoadingProgress = { + status: 'idle', + progress: 0, + text: '', +}; +const progressListeners = new Set(); + +/** + * Subscribe to loading progress updates + */ +export function subscribeToProgress(callback: ProgressCallback): () => void { + progressListeners.add(callback); + // Immediately call with current progress + callback(currentProgress); + return () => progressListeners.delete(callback); +} + +function updateProgress(progress: LoadingProgress) { + currentProgress = progress; + for (const listener of progressListeners) { + listener(progress); + } +} + +/** + * Initialize the WebLLM engine (lazy singleton) + */ +export async function initWebLLM(): Promise { + // Return existing engine if ready + if (engine) return engine; + + // Return existing loading promise if in progress + if (loadingPromise) return loadingPromise; + + // Start loading + loadingPromise = (async () => { + try { + updateProgress({ + status: 'loading', + progress: 0, + text: 'Initializing WebLLM...', + }); + + // Dynamic import to avoid SSR issues + const webllm = await import('@mlc-ai/web-llm'); + + updateProgress({ + status: 'loading', + progress: 5, + text: 'Loading model (first time may take a minute)...', + }); + + // Create engine with progress callback + engine = await webllm.CreateMLCEngine(MODEL_ID, { + initProgressCallback: (report) => { + const progress = Math.min(95, 5 + report.progress * 90); + updateProgress({ + status: 'loading', + progress, + text: report.text || 'Loading model...', + }); + }, + }); + + updateProgress({ + status: 'ready', + progress: 100, + text: 'Model ready', + }); + + return engine; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + updateProgress({ + status: 'error', + progress: 0, + text: `Failed to load model: ${errorMessage}`, + }); + loadingPromise = null; + throw error; + } + })(); + + return loadingPromise; +} + +/** + * Check if WebLLM is ready + */ +export function isReady(): boolean { + return engine !== null; +} + +/** + * Get current loading status + */ +export function getStatus(): LoadingProgress { + return currentProgress; +} + +/** + * Generate a response from the LLM + */ +export async function generateResponse( + messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> +): Promise { + const llm = await initWebLLM(); + + const response = await llm.chat.completions.create({ + messages: messages as ChatCompletionMessageParam[], + temperature: 0.7, + max_tokens: 512, + }); + + return response.choices[0]?.message?.content || ''; +} + +/** + * Reset the engine (useful for cleanup) + */ +export async function resetEngine(): Promise { + if (engine) { + await engine.unload(); + engine = null; + loadingPromise = null; + updateProgress({ + status: 'idle', + progress: 0, + text: '', + }); + } +} diff --git a/workbench/nextjs-turbopack/app/page.tsx b/workbench/nextjs-turbopack/app/page.tsx index ae063154d..eb71ef46f 100644 --- a/workbench/nextjs-turbopack/app/page.tsx +++ b/workbench/nextjs-turbopack/app/page.tsx @@ -1,14 +1,20 @@ 'use client'; -import { useRef, useEffect, useCallback } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; import { WORKFLOW_DEFINITIONS } from '@/app/workflows/definitions'; import { WorkflowButton } from '@/components/workflow-button'; import { TerminalLog } from '@/components/terminal-log'; import { InvocationsPanel } from '@/components/invocations-panel'; import { TooltipProvider } from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; import { useWorkflowStorage } from '@/hooks'; +import { BrowserWorkflowDemo } from '@/components/browser-workflow-demo'; +import Link from 'next/link'; +import { MessageSquareIcon } from 'lucide-react'; export default function Home() { + const [activeTab, setActiveTab] = useState<'server' | 'browser'>('server'); + // Track active stream abort controllers const streamAbortControllers = useRef>( new Map() @@ -311,64 +317,106 @@ export default function Home() {
-
-

- Workflow DevKit Examples -

-

- Select a workflow to start a run and view its output -

+
+
+

+ Workflow DevKit Examples +

+

+ Select a workflow to start a run and view its output +

+
+ + +
-
- {/* Left Column - Workflow List */} -
-

Available Workflows

-
- {Object.entries( - WORKFLOW_DEFINITIONS.reduce( - (acc, workflow) => { - if (!acc[workflow.workflowFile]) { - acc[workflow.workflowFile] = []; - } - acc[workflow.workflowFile].push(workflow); - return acc; - }, - {} as Record - ) - ).map(([workflowFile, workflows]) => ( -
-

- {workflowFile} -

-
- {workflows.map((workflow) => ( - - ))} + {/* Tab navigation */} +
+ + +
+ + {/* Browser Workflows Tab */} + {activeTab === 'browser' && ( +
+ +
+ )} + + {/* Server Workflows Tab */} + {activeTab === 'server' && ( +
+ {/* Left Column - Workflow List */} +
+

Available Workflows

+
+ {Object.entries( + WORKFLOW_DEFINITIONS.reduce( + (acc, workflow) => { + if (!acc[workflow.workflowFile]) { + acc[workflow.workflowFile] = []; + } + acc[workflow.workflowFile].push(workflow); + return acc; + }, + {} as Record + ) + ).map(([workflowFile, workflows]) => ( +
+

+ {workflowFile} +

+
+ {workflows.map((workflow) => ( + + ))} +
-
- ))} + ))} +
-
- {/* Middle Column - Invocations */} -
- -
+ {/* Middle Column - Invocations */} +
+ +
- {/* Right Column - Terminal Log */} -
- + {/* Right Column - Terminal Log */} +
+ +
-
+ )}
diff --git a/workbench/nextjs-turbopack/app/workflows/browser/chat.ts b/workbench/nextjs-turbopack/app/workflows/browser/chat.ts new file mode 100644 index 000000000..ed7da3f5a --- /dev/null +++ b/workbench/nextjs-turbopack/app/workflows/browser/chat.ts @@ -0,0 +1,37 @@ +/** + * Browser workflow for LLM chat inference. + * + * This workflow runs entirely in the browser using WebLLM. + * The LLM inference happens in a dedicated Web Worker for GPU acceleration. + */ + +import { generateResponse } from '../../lib/webllm-service'; + +export type ChatMessage = { + role: 'user' | 'assistant' | 'system'; + content: string; +}; + +export type ChatInput = { + messages: ChatMessage[]; +}; + +export type ChatOutput = { + content: string; +}; + +/** + * Generate a chat response using the local LLM. + * + * @param input - The conversation messages + * @returns The assistant's response + */ +export async function chatWithLLM(input: ChatInput): Promise { + 'use workflow'; + + const response = await generateResponse(input.messages); + + return { + content: response, + }; +} diff --git a/workbench/nextjs-turbopack/app/workflows/browser/example.ts b/workbench/nextjs-turbopack/app/workflows/browser/example.ts new file mode 100644 index 000000000..503eebdf7 --- /dev/null +++ b/workbench/nextjs-turbopack/app/workflows/browser/example.ts @@ -0,0 +1,19 @@ +/** + * Example browser workflow - simplified for testing. + */ + +/** + * Simple browser workflow that processes text. + */ +export async function browserExample(input: { text: string }) { + 'use workflow'; + + // Simple processing - no steps for now + const result = input.text.toUpperCase(); + + return { + input: input.text, + processed: result, + timestamp: Date.now(), + }; +} diff --git a/workbench/nextjs-turbopack/components.json b/workbench/nextjs-turbopack/components.json new file mode 100644 index 000000000..5d989a5d7 --- /dev/null +++ b/workbench/nextjs-turbopack/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" + } +} diff --git a/workbench/nextjs-turbopack/components/ai-elements/artifact.tsx b/workbench/nextjs-turbopack/components/ai-elements/artifact.tsx new file mode 100644 index 000000000..a9922e7d1 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/artifact.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { type LucideIcon, XIcon } from 'lucide-react'; +import type { ComponentProps, HTMLAttributes } from 'react'; + +export type ArtifactProps = HTMLAttributes; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = 'sm', + variant = 'ghost', + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = 'sm', + variant = 'ghost', + ...props +}: ArtifactActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/workbench/nextjs-turbopack/components/ai-elements/canvas.tsx b/workbench/nextjs-turbopack/components/ai-elements/canvas.tsx new file mode 100644 index 000000000..1c06639bd --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/canvas.tsx @@ -0,0 +1,22 @@ +import { Background, ReactFlow, type ReactFlowProps } from '@xyflow/react'; +import type { ReactNode } from 'react'; +import '@xyflow/react/dist/style.css'; + +type CanvasProps = ReactFlowProps & { + children?: ReactNode; +}; + +export const Canvas = ({ children, ...props }: CanvasProps) => ( + + + {children} + +); diff --git a/workbench/nextjs-turbopack/components/ai-elements/chain-of-thought.tsx b/workbench/nextjs-turbopack/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 000000000..1f0c22f0c --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,228 @@ +'use client'; + +import { useControllableState } from '@radix-ui/react-use-controllable-state'; +import { Badge } from '@/components/ui/badge'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import { + BrainIcon, + ChevronDownIcon, + DotIcon, + type LucideIcon, +} from 'lucide-react'; +import type { ComponentProps, ReactNode } from 'react'; +import { createContext, memo, useContext, useMemo } from 'react'; + +type ChainOfThoughtContextValue = { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +const ChainOfThoughtContext = createContext( + null +); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error( + 'ChainOfThought components must be used within ChainOfThought' + ); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<'div'> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export const ChainOfThought = memo( + ({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props + }: ChainOfThoughtProps) => { + const [isOpen, setIsOpen] = useControllableState({ + prop: open, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const chainOfThoughtContext = useMemo( + () => ({ isOpen, setIsOpen }), + [isOpen, setIsOpen] + ); + + return ( + +
+ {children} +
+
+ ); + } +); + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export const ChainOfThoughtHeader = memo( + ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { + const { isOpen, setIsOpen } = useChainOfThought(); + + return ( + + + + + {children ?? 'Chain of Thought'} + + + + + ); + } +); + +export type ChainOfThoughtStepProps = ComponentProps<'div'> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: 'complete' | 'active' | 'pending'; +}; + +export const ChainOfThoughtStep = memo( + ({ + className, + icon: Icon = DotIcon, + label, + description, + status = 'complete', + children, + ...props + }: ChainOfThoughtStepProps) => { + const statusStyles = { + complete: 'text-muted-foreground', + active: 'text-foreground', + pending: 'text-muted-foreground/50', + }; + + return ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ); + } +); + +export type ChainOfThoughtSearchResultsProps = ComponentProps<'div'>; + +export const ChainOfThoughtSearchResults = memo( + ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( +
+ ) +); + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export const ChainOfThoughtSearchResult = memo( + ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( + + {children} + + ) +); + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export const ChainOfThoughtContent = memo( + ({ className, children, ...props }: ChainOfThoughtContentProps) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {children} + + + ); + } +); + +export type ChainOfThoughtImageProps = ComponentProps<'div'> & { + caption?: string; +}; + +export const ChainOfThoughtImage = memo( + ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ) +); + +ChainOfThought.displayName = 'ChainOfThought'; +ChainOfThoughtHeader.displayName = 'ChainOfThoughtHeader'; +ChainOfThoughtStep.displayName = 'ChainOfThoughtStep'; +ChainOfThoughtSearchResults.displayName = 'ChainOfThoughtSearchResults'; +ChainOfThoughtSearchResult.displayName = 'ChainOfThoughtSearchResult'; +ChainOfThoughtContent.displayName = 'ChainOfThoughtContent'; +ChainOfThoughtImage.displayName = 'ChainOfThoughtImage'; diff --git a/workbench/nextjs-turbopack/components/ai-elements/checkpoint.tsx b/workbench/nextjs-turbopack/components/ai-elements/checkpoint.tsx new file mode 100644 index 000000000..0faaead26 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/checkpoint.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { BookmarkIcon, type LucideProps } from 'lucide-react'; +import type { ComponentProps, HTMLAttributes } from 'react'; + +export type CheckpointProps = HTMLAttributes; + +export const Checkpoint = ({ + className, + children, + ...props +}: CheckpointProps) => ( +
+ {children} + +
+); + +export type CheckpointIconProps = LucideProps; + +export const CheckpointIcon = ({ + className, + children, + ...props +}: CheckpointIconProps) => + children ?? ( + + ); + +export type CheckpointTriggerProps = ComponentProps & { + tooltip?: string; +}; + +export const CheckpointTrigger = ({ + children, + className, + variant = 'ghost', + size = 'sm', + tooltip, + ...props +}: CheckpointTriggerProps) => + tooltip ? ( + + + + + + {tooltip} + + + ) : ( + + ); diff --git a/workbench/nextjs-turbopack/components/ai-elements/code-block.tsx b/workbench/nextjs-turbopack/components/ai-elements/code-block.tsx new file mode 100644 index 000000000..5434c218b --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/code-block.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { CheckIcon, CopyIcon } from 'lucide-react'; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { type BundledLanguage, codeToHtml, type ShikiTransformer } from 'shiki'; + +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +type CodeBlockContextType = { + code: string; +}; + +const CodeBlockContext = createContext({ + code: '', +}); + +const lineNumberTransformer: ShikiTransformer = { + name: 'line-numbers', + line(node, line) { + node.children.unshift({ + type: 'element', + tagName: 'span', + properties: { + className: [ + 'inline-block', + 'min-w-10', + 'mr-4', + 'text-right', + 'select-none', + 'text-muted-foreground', + ], + }, + children: [{ type: 'text', value: String(line) }], + }); + }, +}; + +export async function highlightCode( + code: string, + language: BundledLanguage, + showLineNumbers = false +) { + const transformers: ShikiTransformer[] = showLineNumbers + ? [lineNumberTransformer] + : []; + + return await Promise.all([ + codeToHtml(code, { + lang: language, + theme: 'one-light', + transformers, + }), + codeToHtml(code, { + lang: language, + theme: 'one-dark-pro', + transformers, + }), + ]); +} + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const [html, setHtml] = useState(''); + const [darkHtml, setDarkHtml] = useState(''); + const mounted = useRef(false); + + useEffect(() => { + highlightCode(code, language, showLineNumbers).then(([light, dark]) => { + if (!mounted.current) { + setHtml(light); + setDarkHtml(dark); + mounted.current = true; + } + }); + + return () => { + mounted.current = false; + }; + }, [code, language, showLineNumbers]); + + return ( + +
+
+
+
+ {children && ( +
+ {children} +
+ )} +
+
+ + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = async () => { + if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) { + onError?.(new Error('Clipboard API not available')); + return; + } + + try { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + setTimeout(() => setIsCopied(false), timeout); + } catch (error) { + onError?.(error as Error); + } + }; + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; diff --git a/workbench/nextjs-turbopack/components/ai-elements/confirmation.tsx b/workbench/nextjs-turbopack/components/ai-elements/confirmation.tsx new file mode 100644 index 000000000..e0d150d59 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/confirmation.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { ToolUIPart } from 'ai'; +import { + type ComponentProps, + createContext, + type ReactNode, + useContext, +} from 'react'; + +type ToolUIPartApproval = + | { + id: string; + approved?: never; + reason?: never; + } + | { + id: string; + approved: boolean; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: false; + reason?: string; + } + | undefined; + +type ConfirmationContextValue = { + approval: ToolUIPartApproval; + state: ToolUIPart['state']; +}; + +const ConfirmationContext = createContext( + null +); + +const useConfirmation = () => { + const context = useContext(ConfirmationContext); + + if (!context) { + throw new Error('Confirmation components must be used within Confirmation'); + } + + return context; +}; + +export type ConfirmationProps = ComponentProps & { + approval?: ToolUIPartApproval; + state: ToolUIPart['state']; +}; + +export const Confirmation = ({ + className, + approval, + state, + ...props +}: ConfirmationProps) => { + if (!approval || state === 'input-streaming' || state === 'input-available') { + return null; + } + + return ( + + + + ); +}; + +export type ConfirmationTitleProps = ComponentProps; + +export const ConfirmationTitle = ({ + className, + ...props +}: ConfirmationTitleProps) => ( + +); + +export type ConfirmationRequestProps = { + children?: ReactNode; +}; + +export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + // @ts-expect-error state only available in AI SDK v6 + if (state !== 'approval-requested') { + return null; + } + + return children; +}; + +export type ConfirmationAcceptedProps = { + children?: ReactNode; +}; + +export const ConfirmationAccepted = ({ + children, +}: ConfirmationAcceptedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when approved and in response states + if ( + !approval?.approved || + // @ts-expect-error state only available in AI SDK v6 + (state !== 'approval-responded' && + // @ts-expect-error state only available in AI SDK v6 + state !== 'output-denied' && + state !== 'output-available') + ) { + return null; + } + + return children; +}; + +export type ConfirmationRejectedProps = { + children?: ReactNode; +}; + +export const ConfirmationRejected = ({ + children, +}: ConfirmationRejectedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when rejected and in response states + if ( + approval?.approved !== false || + // @ts-expect-error state only available in AI SDK v6 + (state !== 'approval-responded' && + // @ts-expect-error state only available in AI SDK v6 + state !== 'output-denied' && + state !== 'output-available') + ) { + return null; + } + + return children; +}; + +export type ConfirmationActionsProps = ComponentProps<'div'>; + +export const ConfirmationActions = ({ + className, + ...props +}: ConfirmationActionsProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + // @ts-expect-error state only available in AI SDK v6 + if (state !== 'approval-requested') { + return null; + } + + return ( +
+ ); +}; + +export type ConfirmationActionProps = ComponentProps; + +export const ConfirmationAction = (props: ConfirmationActionProps) => ( + + )} + + ); +}; + +export type ContextContentProps = ComponentProps; + +export const ContextContent = ({ + className, + ...props +}: ContextContentProps) => ( + +); + +export type ContextContentHeaderProps = ComponentProps<'div'>; + +export const ContextContentHeader = ({ + children, + className, + ...props +}: ContextContentHeaderProps) => { + const { usedTokens, maxTokens } = useContextValue(); + const usedPercent = usedTokens / maxTokens; + const displayPct = new Intl.NumberFormat('en-US', { + style: 'percent', + maximumFractionDigits: 1, + }).format(usedPercent); + const used = new Intl.NumberFormat('en-US', { + notation: 'compact', + }).format(usedTokens); + const total = new Intl.NumberFormat('en-US', { + notation: 'compact', + }).format(maxTokens); + + return ( +
+ {children ?? ( + <> +
+

{displayPct}

+

+ {used} / {total} +

+
+
+ +
+ + )} +
+ ); +}; + +export type ContextContentBodyProps = ComponentProps<'div'>; + +export const ContextContentBody = ({ + children, + className, + ...props +}: ContextContentBodyProps) => ( +
+ {children} +
+); + +export type ContextContentFooterProps = ComponentProps<'div'>; + +export const ContextContentFooter = ({ + children, + className, + ...props +}: ContextContentFooterProps) => { + const { modelId, usage } = useContextValue(); + const costUSD = modelId + ? getUsage({ + modelId, + usage: { + input: usage?.inputTokens ?? 0, + output: usage?.outputTokens ?? 0, + }, + }).costUSD?.totalUSD + : undefined; + const totalCost = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(costUSD ?? 0); + + return ( +
+ {children ?? ( + <> + Total cost + {totalCost} + + )} +
+ ); +}; + +export type ContextInputUsageProps = ComponentProps<'div'>; + +export const ContextInputUsage = ({ + className, + children, + ...props +}: ContextInputUsageProps) => { + const { usage, modelId } = useContextValue(); + const inputTokens = usage?.inputTokens ?? 0; + + if (children) { + return children; + } + + if (!inputTokens) { + return null; + } + + const inputCost = modelId + ? getUsage({ + modelId, + usage: { input: inputTokens, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const inputCostText = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(inputCost ?? 0); + + return ( +
+ Input + +
+ ); +}; + +export type ContextOutputUsageProps = ComponentProps<'div'>; + +export const ContextOutputUsage = ({ + className, + children, + ...props +}: ContextOutputUsageProps) => { + const { usage, modelId } = useContextValue(); + const outputTokens = usage?.outputTokens ?? 0; + + if (children) { + return children; + } + + if (!outputTokens) { + return null; + } + + const outputCost = modelId + ? getUsage({ + modelId, + usage: { input: 0, output: outputTokens }, + }).costUSD?.totalUSD + : undefined; + const outputCostText = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(outputCost ?? 0); + + return ( +
+ Output + +
+ ); +}; + +export type ContextReasoningUsageProps = ComponentProps<'div'>; + +export const ContextReasoningUsage = ({ + className, + children, + ...props +}: ContextReasoningUsageProps) => { + const { usage, modelId } = useContextValue(); + const reasoningTokens = usage?.reasoningTokens ?? 0; + + if (children) { + return children; + } + + if (!reasoningTokens) { + return null; + } + + const reasoningCost = modelId + ? getUsage({ + modelId, + usage: { reasoningTokens }, + }).costUSD?.totalUSD + : undefined; + const reasoningCostText = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(reasoningCost ?? 0); + + return ( +
+ Reasoning + +
+ ); +}; + +export type ContextCacheUsageProps = ComponentProps<'div'>; + +export const ContextCacheUsage = ({ + className, + children, + ...props +}: ContextCacheUsageProps) => { + const { usage, modelId } = useContextValue(); + const cacheTokens = usage?.cachedInputTokens ?? 0; + + if (children) { + return children; + } + + if (!cacheTokens) { + return null; + } + + const cacheCost = modelId + ? getUsage({ + modelId, + usage: { cacheReads: cacheTokens, input: 0, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const cacheCostText = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(cacheCost ?? 0); + + return ( +
+ Cache + +
+ ); +}; + +const TokensWithCost = ({ + tokens, + costText, +}: { + tokens?: number; + costText?: string; +}) => ( + + {tokens === undefined + ? '—' + : new Intl.NumberFormat('en-US', { + notation: 'compact', + }).format(tokens)} + {costText ? ( + • {costText} + ) : null} + +); diff --git a/workbench/nextjs-turbopack/components/ai-elements/controls.tsx b/workbench/nextjs-turbopack/components/ai-elements/controls.tsx new file mode 100644 index 000000000..4fb2e25c4 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/controls.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { Controls as ControlsPrimitive } from '@xyflow/react'; +import type { ComponentProps } from 'react'; + +export type ControlsProps = ComponentProps; + +export const Controls = ({ className, ...props }: ControlsProps) => ( + button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!', + className + )} + {...props} + /> +); diff --git a/workbench/nextjs-turbopack/components/ai-elements/conversation.tsx b/workbench/nextjs-turbopack/components/ai-elements/conversation.tsx new file mode 100644 index 000000000..89aae6083 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/conversation.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { ArrowDownIcon } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { useCallback } from 'react'; +import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<'div'> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = 'No messages yet', + description = 'Start a conversation to see messages here', + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/workbench/nextjs-turbopack/components/ai-elements/edge.tsx b/workbench/nextjs-turbopack/components/ai-elements/edge.tsx new file mode 100644 index 000000000..8996a3fc6 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/edge.tsx @@ -0,0 +1,140 @@ +import { + BaseEdge, + type EdgeProps, + getBezierPath, + getSimpleBezierPath, + type InternalNode, + type Node, + Position, + useInternalNode, +} from '@xyflow/react'; + +const Temporary = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: EdgeProps) => { + const [edgePath] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +const getHandleCoordsByPosition = ( + node: InternalNode, + handlePosition: Position +) => { + // Choose the handle type based on position - Left is for target, Right is for source + const handleType = handlePosition === Position.Left ? 'target' : 'source'; + + const handle = node.internals.handleBounds?.[handleType]?.find( + (h) => h.position === handlePosition + ); + + if (!handle) { + return [0, 0] as const; + } + + let offsetX = handle.width / 2; + let offsetY = handle.height / 2; + + // this is a tiny detail to make the markerEnd of an edge visible. + // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset + // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position + switch (handlePosition) { + case Position.Left: + offsetX = 0; + break; + case Position.Right: + offsetX = handle.width; + break; + case Position.Top: + offsetY = 0; + break; + case Position.Bottom: + offsetY = handle.height; + break; + default: + throw new Error(`Invalid handle position: ${handlePosition}`); + } + + const x = node.internals.positionAbsolute.x + handle.x + offsetX; + const y = node.internals.positionAbsolute.y + handle.y + offsetY; + + return [x, y] as const; +}; + +const getEdgeParams = ( + source: InternalNode, + target: InternalNode +) => { + const sourcePos = Position.Right; + const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); + const targetPos = Position.Left; + const [tx, ty] = getHandleCoordsByPosition(target, targetPos); + + return { + sx, + sy, + tx, + ty, + sourcePos, + targetPos, + }; +}; + +const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!(sourceNode && targetNode)) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetX: tx, + targetY: ty, + targetPosition: targetPos, + }); + + return ( + <> + + + + + + ); +}; + +export const Edge = { + Temporary, + Animated, +}; diff --git a/workbench/nextjs-turbopack/components/ai-elements/image.tsx b/workbench/nextjs-turbopack/components/ai-elements/image.tsx new file mode 100644 index 000000000..405e1ee27 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/image.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/lib/utils'; +import type { Experimental_GeneratedImage } from 'ai'; + +export type ImageProps = Experimental_GeneratedImage & { + className?: string; + alt?: string; +}; + +export const Image = ({ + base64, + uint8Array, + mediaType, + ...props +}: ImageProps) => ( + {props.alt} +); diff --git a/workbench/nextjs-turbopack/components/ai-elements/inline-citation.tsx b/workbench/nextjs-turbopack/components/ai-elements/inline-citation.tsx new file mode 100644 index 000000000..daf7af9e1 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/inline-citation.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, +} from '@/components/ui/carousel'; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '@/components/ui/hover-card'; +import { cn } from '@/lib/utils'; +import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react'; +import { + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; + +export type InlineCitationProps = ComponentProps<'span'>; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<'span'>; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; +}; + +export const InlineCitationCardTrigger = ({ + sources, + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {sources[0] ? ( + <> + {new URL(sources[0]).hostname}{' '} + {sources.length > 1 && `+${sources.length - 1}`} + + ) : ( + 'unknown' + )} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<'div'>; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +const CarouselApiContext = createContext(undefined); + +const useCarouselApi = () => { + const context = useContext(CarouselApiContext); + return context; +}; + +export type InlineCitationCarouselProps = ComponentProps; + +export const InlineCitationCarousel = ({ + className, + children, + ...props +}: InlineCitationCarouselProps) => { + const [api, setApi] = useState(); + + return ( + + + {children} + + + ); +}; + +export type InlineCitationCarouselContentProps = ComponentProps<'div'>; + +export const InlineCitationCarouselContent = ( + props: InlineCitationCarouselContentProps +) => ; + +export type InlineCitationCarouselItemProps = ComponentProps<'div'>; + +export const InlineCitationCarouselItem = ({ + className, + ...props +}: InlineCitationCarouselItemProps) => ( + +); + +export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>; + +export const InlineCitationCarouselHeader = ({ + className, + ...props +}: InlineCitationCarouselHeaderProps) => ( +
+); + +export type InlineCitationCarouselIndexProps = ComponentProps<'div'>; + +export const InlineCitationCarouselIndex = ({ + children, + className, + ...props +}: InlineCitationCarouselIndexProps) => { + const api = useCarouselApi(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!api) { + return; + } + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + api.on('select', () => { + setCurrent(api.selectedScrollSnap() + 1); + }); + }, [api]); + + return ( +
+ {children ?? `${current}/${count}`} +
+ ); +}; + +export type InlineCitationCarouselPrevProps = ComponentProps<'button'>; + +export const InlineCitationCarouselPrev = ({ + className, + ...props +}: InlineCitationCarouselPrevProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollPrev(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationCarouselNextProps = ComponentProps<'button'>; + +export const InlineCitationCarouselNext = ({ + className, + ...props +}: InlineCitationCarouselNextProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollNext(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationSourceProps = ComponentProps<'div'> & { + title?: string; + url?: string; + description?: string; +}; + +export const InlineCitationSource = ({ + title, + url, + description, + className, + children, + ...props +}: InlineCitationSourceProps) => ( +
+ {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {description && ( +

+ {description} +

+ )} + {children} +
+); + +export type InlineCitationQuoteProps = ComponentProps<'blockquote'>; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); diff --git a/workbench/nextjs-turbopack/components/ai-elements/loader.tsx b/workbench/nextjs-turbopack/components/ai-elements/loader.tsx new file mode 100644 index 000000000..be469aa27 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from '@/lib/utils'; +import type { HTMLAttributes } from 'react'; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/workbench/nextjs-turbopack/components/ai-elements/message.tsx b/workbench/nextjs-turbopack/components/ai-elements/message.tsx new file mode 100644 index 000000000..42e6d701b --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/message.tsx @@ -0,0 +1,445 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { ButtonGroup, ButtonGroupText } from '@/components/ui/button-group'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import type { FileUIPart, UIMessage } from 'ai'; +import { + ChevronLeftIcon, + ChevronRightIcon, + PaperclipIcon, + XIcon, +} from 'lucide-react'; +import type { ComponentProps, HTMLAttributes, ReactElement } from 'react'; +import { createContext, memo, useContext, useEffect, useState } from 'react'; +import { Streamdown } from 'streamdown'; + +export type MessageProps = HTMLAttributes & { + from: UIMessage['role']; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<'div'>; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = 'ghost', + size = 'icon-sm', + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +type MessageBranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const MessageBranchContext = createContext( + null +); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error( + 'MessageBranch components must be used within MessageBranch' + ); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }; + + const goToPrevious = () => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }; + + const goToNext = () => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }; + + const contextValue: MessageBranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0', className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = Array.isArray(children) ? children : [children]; + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0', + index === currentBranch ? 'block' : 'hidden' + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = HTMLAttributes & { + from: UIMessage['role']; +}; + +export const MessageBranchSelector = ({ + className, + from, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ + children, + className, + ...props +}: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ + className, + ...props +}: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0', + className + )} + {...props} + /> + ), + (prevProps, nextProps) => prevProps.children === nextProps.children +); + +MessageResponse.displayName = 'MessageResponse'; + +export type MessageAttachmentProps = HTMLAttributes & { + data: FileUIPart; + className?: string; + onRemove?: () => void; +}; + +export function MessageAttachment({ + data, + className, + onRemove, + ...props +}: MessageAttachmentProps) { + const filename = data.filename || ''; + const mediaType = + data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file'; + const isImage = mediaType === 'image'; + const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment'); + + return ( +
+ {isImage ? ( + <> + {filename + {onRemove && ( + + )} + + ) : ( + <> + + +
+ +
+
+ +

{attachmentLabel}

+
+
+ {onRemove && ( + + )} + + )} +
+ ); +} + +export type MessageAttachmentsProps = ComponentProps<'div'>; + +export function MessageAttachments({ + children, + className, + ...props +}: MessageAttachmentsProps) { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +export type MessageToolbarProps = ComponentProps<'div'>; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/workbench/nextjs-turbopack/components/ai-elements/model-selector.tsx b/workbench/nextjs-turbopack/components/ai-elements/model-selector.tsx new file mode 100644 index 000000000..ab8b1404c --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/model-selector.tsx @@ -0,0 +1,205 @@ +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from '@/components/ui/command'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; +import type { ComponentProps, ReactNode } from 'react'; + +export type ModelSelectorProps = ComponentProps; + +export const ModelSelector = (props: ModelSelectorProps) => ( + +); + +export type ModelSelectorTriggerProps = ComponentProps; + +export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( + +); + +export type ModelSelectorContentProps = ComponentProps & { + title?: ReactNode; +}; + +export const ModelSelectorContent = ({ + className, + children, + title = 'Model Selector', + ...props +}: ModelSelectorContentProps) => ( + + {title} + + {children} + + +); + +export type ModelSelectorDialogProps = ComponentProps; + +export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( + +); + +export type ModelSelectorInputProps = ComponentProps; + +export const ModelSelectorInput = ({ + className, + ...props +}: ModelSelectorInputProps) => ( + +); + +export type ModelSelectorListProps = ComponentProps; + +export const ModelSelectorList = (props: ModelSelectorListProps) => ( + +); + +export type ModelSelectorEmptyProps = ComponentProps; + +export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( + +); + +export type ModelSelectorGroupProps = ComponentProps; + +export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( + +); + +export type ModelSelectorItemProps = ComponentProps; + +export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( + +); + +export type ModelSelectorShortcutProps = ComponentProps; + +export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( + +); + +export type ModelSelectorSeparatorProps = ComponentProps< + typeof CommandSeparator +>; + +export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( + +); + +export type ModelSelectorLogoProps = Omit< + ComponentProps<'img'>, + 'src' | 'alt' +> & { + provider: + | 'moonshotai-cn' + | 'lucidquery' + | 'moonshotai' + | 'zai-coding-plan' + | 'alibaba' + | 'xai' + | 'vultr' + | 'nvidia' + | 'upstage' + | 'groq' + | 'github-copilot' + | 'mistral' + | 'vercel' + | 'nebius' + | 'deepseek' + | 'alibaba-cn' + | 'google-vertex-anthropic' + | 'venice' + | 'chutes' + | 'cortecs' + | 'github-models' + | 'togetherai' + | 'azure' + | 'baseten' + | 'huggingface' + | 'opencode' + | 'fastrouter' + | 'google' + | 'google-vertex' + | 'cloudflare-workers-ai' + | 'inception' + | 'wandb' + | 'openai' + | 'zhipuai-coding-plan' + | 'perplexity' + | 'openrouter' + | 'zenmux' + | 'v0' + | 'iflowcn' + | 'synthetic' + | 'deepinfra' + | 'zhipuai' + | 'submodel' + | 'zai' + | 'inference' + | 'requesty' + | 'morph' + | 'lmstudio' + | 'anthropic' + | 'aihubmix' + | 'fireworks-ai' + | 'modelscope' + | 'llama' + | 'scaleway' + | 'amazon-bedrock' + | 'cerebras' + | (string & {}); +}; + +export const ModelSelectorLogo = ({ + provider, + className, + ...props +}: ModelSelectorLogoProps) => ( + {`${provider} +); + +export type ModelSelectorLogoGroupProps = ComponentProps<'div'>; + +export const ModelSelectorLogoGroup = ({ + className, + ...props +}: ModelSelectorLogoGroupProps) => ( +
img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground', + className + )} + {...props} + /> +); + +export type ModelSelectorNameProps = ComponentProps<'span'>; + +export const ModelSelectorName = ({ + className, + ...props +}: ModelSelectorNameProps) => ( + +); diff --git a/workbench/nextjs-turbopack/components/ai-elements/node.tsx b/workbench/nextjs-turbopack/components/ai-elements/node.tsx new file mode 100644 index 000000000..d5be98e00 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/node.tsx @@ -0,0 +1,71 @@ +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { Handle, Position } from '@xyflow/react'; +import type { ComponentProps } from 'react'; + +export type NodeProps = ComponentProps & { + handles: { + target: boolean; + source: boolean; + }; +}; + +export const Node = ({ handles, className, ...props }: NodeProps) => ( + + {handles.target && } + {handles.source && } + {props.children} + +); + +export type NodeHeaderProps = ComponentProps; + +export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( + +); + +export type NodeTitleProps = ComponentProps; + +export const NodeTitle = (props: NodeTitleProps) => ; + +export type NodeDescriptionProps = ComponentProps; + +export const NodeDescription = (props: NodeDescriptionProps) => ( + +); + +export type NodeActionProps = ComponentProps; + +export const NodeAction = (props: NodeActionProps) => ; + +export type NodeContentProps = ComponentProps; + +export const NodeContent = ({ className, ...props }: NodeContentProps) => ( + +); + +export type NodeFooterProps = ComponentProps; + +export const NodeFooter = ({ className, ...props }: NodeFooterProps) => ( + +); diff --git a/workbench/nextjs-turbopack/components/ai-elements/open-in-chat.tsx b/workbench/nextjs-turbopack/components/ai-elements/open-in-chat.tsx new file mode 100644 index 000000000..8d02a590f --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/open-in-chat.tsx @@ -0,0 +1,365 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; +import { + ChevronDownIcon, + ExternalLinkIcon, + MessageCircleIcon, +} from 'lucide-react'; +import { type ComponentProps, createContext, useContext } from 'react'; + +const providers = { + github: { + title: 'Open in GitHub', + createUrl: (url: string) => url, + icon: ( + + GitHub + + + ), + }, + scira: { + title: 'Open in Scira', + createUrl: (q: string) => + `https://scira.ai/?${new URLSearchParams({ + q, + })}`, + icon: ( + + Scira AI + + + + + + + + + ), + }, + chatgpt: { + title: 'Open in ChatGPT', + createUrl: (prompt: string) => + `https://chatgpt.com/?${new URLSearchParams({ + hints: 'search', + prompt, + })}`, + icon: ( + + OpenAI + + + ), + }, + claude: { + title: 'Open in Claude', + createUrl: (q: string) => + `https://claude.ai/new?${new URLSearchParams({ + q, + })}`, + icon: ( + + Claude + + + ), + }, + t3: { + title: 'Open in T3 Chat', + createUrl: (q: string) => + `https://t3.chat/new?${new URLSearchParams({ + q, + })}`, + icon: , + }, + v0: { + title: 'Open in v0', + createUrl: (q: string) => + `https://v0.app?${new URLSearchParams({ + q, + })}`, + icon: ( + + v0 + + + + ), + }, + cursor: { + title: 'Open in Cursor', + createUrl: (text: string) => { + const url = new URL('https://cursor.com/link/prompt'); + url.searchParams.set('text', text); + return url.toString(); + }, + icon: ( + + Cursor + + + ), + }, +}; + +const OpenInContext = createContext<{ query: string } | undefined>(undefined); + +const useOpenInContext = () => { + const context = useContext(OpenInContext); + if (!context) { + throw new Error('OpenIn components must be used within an OpenIn provider'); + } + return context; +}; + +export type OpenInProps = ComponentProps & { + query: string; +}; + +export const OpenIn = ({ query, ...props }: OpenInProps) => ( + + + +); + +export type OpenInContentProps = ComponentProps; + +export const OpenInContent = ({ className, ...props }: OpenInContentProps) => ( + +); + +export type OpenInItemProps = ComponentProps; + +export const OpenInItem = (props: OpenInItemProps) => ( + +); + +export type OpenInLabelProps = ComponentProps; + +export const OpenInLabel = (props: OpenInLabelProps) => ( + +); + +export type OpenInSeparatorProps = ComponentProps; + +export const OpenInSeparator = (props: OpenInSeparatorProps) => ( + +); + +export type OpenInTriggerProps = ComponentProps; + +export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => ( + + {children ?? ( + + )} + +); + +export type OpenInChatGPTProps = ComponentProps; + +export const OpenInChatGPT = (props: OpenInChatGPTProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.chatgpt.icon} + {providers.chatgpt.title} + + + + ); +}; + +export type OpenInClaudeProps = ComponentProps; + +export const OpenInClaude = (props: OpenInClaudeProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.claude.icon} + {providers.claude.title} + + + + ); +}; + +export type OpenInT3Props = ComponentProps; + +export const OpenInT3 = (props: OpenInT3Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.t3.icon} + {providers.t3.title} + + + + ); +}; + +export type OpenInSciraProps = ComponentProps; + +export const OpenInScira = (props: OpenInSciraProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.scira.icon} + {providers.scira.title} + + + + ); +}; + +export type OpenInv0Props = ComponentProps; + +export const OpenInv0 = (props: OpenInv0Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.v0.icon} + {providers.v0.title} + + + + ); +}; + +export type OpenInCursorProps = ComponentProps; + +export const OpenInCursor = (props: OpenInCursorProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.cursor.icon} + {providers.cursor.title} + + + + ); +}; diff --git a/workbench/nextjs-turbopack/components/ai-elements/panel.tsx b/workbench/nextjs-turbopack/components/ai-elements/panel.tsx new file mode 100644 index 000000000..51017a5b9 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/panel.tsx @@ -0,0 +1,15 @@ +import { cn } from '@/lib/utils'; +import { Panel as PanelPrimitive } from '@xyflow/react'; +import type { ComponentProps } from 'react'; + +type PanelProps = ComponentProps; + +export const Panel = ({ className, ...props }: PanelProps) => ( + +); diff --git a/workbench/nextjs-turbopack/components/ai-elements/plan.tsx b/workbench/nextjs-turbopack/components/ai-elements/plan.tsx new file mode 100644 index 000000000..0d24f58f1 --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/plan.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import { ChevronsUpDownIcon } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { createContext, useContext } from 'react'; +import { Shimmer } from './shimmer'; + +type PlanContextValue = { + isStreaming: boolean; +}; + +const PlanContext = createContext(null); + +const usePlan = () => { + const context = useContext(PlanContext); + if (!context) { + throw new Error('Plan components must be used within Plan'); + } + return context; +}; + +export type PlanProps = ComponentProps & { + isStreaming?: boolean; +}; + +export const Plan = ({ + className, + isStreaming = false, + children, + ...props +}: PlanProps) => ( + + + {children} + + +); + +export type PlanHeaderProps = ComponentProps; + +export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => ( + +); + +export type PlanTitleProps = Omit< + ComponentProps, + 'children' +> & { + children: string; +}; + +export const PlanTitle = ({ children, ...props }: PlanTitleProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanDescriptionProps = Omit< + ComponentProps, + 'children' +> & { + children: string; +}; + +export const PlanDescription = ({ + className, + children, + ...props +}: PlanDescriptionProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanActionProps = ComponentProps; + +export const PlanAction = (props: PlanActionProps) => ( + +); + +export type PlanContentProps = ComponentProps; + +export const PlanContent = (props: PlanContentProps) => ( + + + +); + +export type PlanFooterProps = ComponentProps<'div'>; + +export const PlanFooter = (props: PlanFooterProps) => ( + +); + +export type PlanTriggerProps = ComponentProps; + +export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => ( + + + +); diff --git a/workbench/nextjs-turbopack/components/ai-elements/prompt-input.tsx b/workbench/nextjs-turbopack/components/ai-elements/prompt-input.tsx new file mode 100644 index 000000000..6ab0f2f8e --- /dev/null +++ b/workbench/nextjs-turbopack/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1404 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '@/components/ui/hover-card'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from '@/components/ui/input-group'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import type { ChatStatus, FileUIPart } from 'ai'; +import { + CornerDownLeftIcon, + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SquareIcon, + XIcon, +} from 'lucide-react'; +import { nanoid } from 'nanoid'; +import { + type ChangeEvent, + type ChangeEventHandler, + Children, + type ClipboardEventHandler, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type PropsWithChildren, + type ReactNode, + type RefObject, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +export type TextInputContext = { + value: string; + setInput: (v: string) => void; + clear: () => void; +}; + +export type PromptInputControllerProps = { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void + ) => void; +}; + +const PromptInputController = createContext( + null +); +const ProviderAttachmentsContext = createContext( + null +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + 'Wrap your component inside to use usePromptInputController().' + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + 'Wrap your component inside to use useProviderAttachments().' + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export function PromptInputProvider({ + initialInput: initialTextInput = '', + children, +}: PromptInputProviderProps) { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(''), []); + + // ----- attachments state (global when wrapped) + const [attachmentFiles, setAttachmentFiles] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = Array.from(files); + if (incoming.length === 0) { + return; + } + + setAttachmentFiles((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: 'file' as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })) + ) + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachmentFiles((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachmentFiles((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + // Keep a ref to attachments for cleanup on unmount (avoids stale closure) + const attachmentsRef = useRef(attachmentFiles); + attachmentsRef.current = attachmentFiles; + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + for (const f of attachmentsRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }; + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachmentFiles, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachmentFiles, add, remove, clear, openFileDialog] + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [] + ); + + const controller = useMemo( + () => ({ + textInput: { + value: textInput, + setInput: setTextInput, + clear: clearInput, + }, + attachments, + __registerFileInput, + }), + [textInput, clearInput, attachments, __registerFileInput] + ); + + return ( + + + {children} + + + ); +} + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Dual-mode: prefer provider if present, otherwise use local + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = provider ?? local; + if (!context) { + throw new Error( + 'usePromptInputAttachments must be used within a PromptInput or PromptInputProvider' + ); + } + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ''; + + const mediaType = + data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file'; + const isImage = mediaType === 'image'; + + const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment'); + + return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? 'Image' : 'Attachment')} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + 'children' +> & { + children: (attachment: FileUIPart & { id: string }) => ReactNode; +}; + +export function PromptInputAttachments({ + children, + className, + ...props +}: PromptInputAttachmentsProps) { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return ( +
+ {attachments.files.map((file) => ( + {children(file)} + ))} +
+ ); +} + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = 'Add photos or files', + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + return ( + { + e.preventDefault(); + attachments.openFileDialog(); + }} + > + {label} + + ); +}; + +export type PromptInputMessage = { + text: string; + files: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + 'onSubmit' | 'onError' +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: 'max_files' | 'max_file_size' | 'accept'; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const formRef = useRef(null); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + filesRef.current = files; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === '') { + return true; + } + if (accept.includes('image/*')) { + return f.type.startsWith('image/'); + } + // NOTE: keep simple; expand as needed + return true; + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: 'accept', + message: 'No files match the accepted types.', + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: 'max_file_size', + message: 'All files exceed the maximum size.', + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === 'number' + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === 'number' ? sized.slice(0, capacity) : sized; + if (typeof capacity === 'number' && sized.length > capacity) { + onError?.({ + code: 'max_files', + message: 'Too many files. Some were not added.', + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: 'file', + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const removeLocal = useCallback( + (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [] + ); + + const clearLocal = useCallback( + () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [] + ); + + const add = usingProvider ? controller.attachments.add : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const clear = usingProvider ? controller.attachments.clear : clearLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ''; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes('Files')) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes('Files')) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener('dragover', onDragOver); + form.addEventListener('drop', onDrop); + return () => { + form.removeEventListener('dragover', onDragOver); + form.removeEventListener('drop', onDrop); + }; + }, [add]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes('Files')) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes('Files')) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener('dragover', onDragOver); + document.addEventListener('drop', onDrop); + return () => { + document.removeEventListener('dragover', onDragOver); + document.removeEventListener('drop', onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current + [usingProvider] + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ''; + }; + + const convertBlobUrlToDataUrl = async ( + url: string + ): Promise => { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + }; + + const ctx = useMemo( + () => ({ + files: files.map((item) => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [files, add, remove, clear, openFileDialog] + ); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get('message') as string) || ''; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + // Convert blob URLs to data URLs asynchronously + Promise.all( + files.map(async ({ id, ...item }) => { + if (item.url && item.url.startsWith('blob:')) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }) + ) + .then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + }; + + // Render with or without local provider + const inner = ( + <> + +
+ {children} +
+ + ); + + return usingProvider ? ( + inner + ) : ( + + {inner} + + ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ + className, + ...props +}: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps< + typeof InputGroupTextarea +>; + +export const PromptInputTextarea = ({ + onChange, + className, + placeholder = 'What would you like to know?', + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = (e) => { + if (e.key === 'Enter') { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const form = e.currentTarget.form; + const submitButton = form?.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === 'Backspace' && + e.currentTarget.value === '' && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }; + + const handlePaste: ClipboardEventHandler = (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }; + + const controlledProps = controller + ? { + value: controller.textInput.value, + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + } + : { + onChange, + }; + + return ( + setIsComposing(false)} + onCompositionStart={() => setIsComposing(true)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder} + {...props} + {...controlledProps} + /> + ); +}; + +export type PromptInputHeaderProps = Omit< + ComponentProps, + 'align' +>; + +export const PromptInputHeader = ({ + className, + ...props +}: PromptInputHeaderProps) => ( + +); + +export type PromptInputFooterProps = Omit< + ComponentProps, + 'align' +>; + +export const PromptInputFooter = ({ + className, + ...props +}: PromptInputFooterProps) => ( + +); + +export type PromptInputToolsProps = HTMLAttributes; + +export const PromptInputTools = ({ + className, + ...props +}: PromptInputToolsProps) => ( +
+); + +export type PromptInputButtonProps = ComponentProps; + +export const PromptInputButton = ({ + variant = 'ghost', + className, + size, + ...props +}: PromptInputButtonProps) => { + const newSize = + size ?? (Children.count(props.children) > 1 ? 'sm' : 'icon-sm'); + + return ( + + ); +}; + +export type PromptInputActionMenuProps = ComponentProps; +export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( + +); + +export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; + +export const PromptInputActionMenuTrigger = ({ + className, + children, + ...props +}: PromptInputActionMenuTriggerProps) => ( + + + {children ?? } + + +); + +export type PromptInputActionMenuContentProps = ComponentProps< + typeof DropdownMenuContent +>; +export const PromptInputActionMenuContent = ({ + className, + ...props +}: PromptInputActionMenuContentProps) => ( + +); + +export type PromptInputActionMenuItemProps = ComponentProps< + typeof DropdownMenuItem +>; +export const PromptInputActionMenuItem = ({ + className, + ...props +}: PromptInputActionMenuItemProps) => ( + +); + +// Note: Actions that perform side-effects (like opening a file dialog) +// are provided in opt-in modules (e.g., prompt-input-attachments). + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; +}; + +export const PromptInputSubmit = ({ + className, + variant = 'default', + size = 'icon-sm', + status, + children, + ...props +}: PromptInputSubmitProps) => { + let Icon = ; + + if (status === 'submitted') { + Icon = ; + } else if (status === 'streaming') { + Icon = ; + } else if (status === 'error') { + Icon = ; + } + + return ( + + {children ?? Icon} + + ); +}; + +interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + onstart: ((this: SpeechRecognition, ev: Event) => any) | null; + onend: ((this: SpeechRecognition, ev: Event) => any) | null; + onresult: + | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) + | null; + onerror: + | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) + | null; +} + +interface SpeechRecognitionEvent extends Event { + results: SpeechRecognitionResultList; + resultIndex: number; +} + +type SpeechRecognitionResultList = { + readonly length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; +}; + +type SpeechRecognitionResult = { + readonly length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; + isFinal: boolean; +}; + +type SpeechRecognitionAlternative = { + transcript: string; + confidence: number; +}; + +interface SpeechRecognitionErrorEvent extends Event { + error: string; +} + +declare global { + interface Window { + SpeechRecognition: { + new (): SpeechRecognition; + }; + webkitSpeechRecognition: { + new (): SpeechRecognition; + }; + } +} + +export type PromptInputSpeechButtonProps = ComponentProps< + typeof PromptInputButton +> & { + textareaRef?: RefObject; + onTranscriptionChange?: (text: string) => void; +}; + +export const PromptInputSpeechButton = ({ + className, + textareaRef, + onTranscriptionChange, + ...props +}: PromptInputSpeechButtonProps) => { + const [isListening, setIsListening] = useState(false); + const [recognition, setRecognition] = useState( + null + ); + const recognitionRef = useRef(null); + + useEffect(() => { + if ( + typeof window !== 'undefined' && + ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) + ) { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; + const speechRecognition = new SpeechRecognition(); + + speechRecognition.continuous = true; + speechRecognition.interimResults = true; + speechRecognition.lang = 'en-US'; + + speechRecognition.onstart = () => { + setIsListening(true); + }; + + speechRecognition.onend = () => { + setIsListening(false); + }; + + speechRecognition.onresult = (event) => { + let finalTranscript = ''; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + finalTranscript += result[0]?.transcript ?? ''; + } + } + + if (finalTranscript && textareaRef?.current) { + const textarea = textareaRef.current; + const currentValue = textarea.value; + const newValue = + currentValue + (currentValue ? ' ' : '') + finalTranscript; + + textarea.value = newValue; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + onTranscriptionChange?.(newValue); + } + }; + + speechRecognition.onerror = (event) => { + console.error('Speech recognition error:', event.error); + setIsListening(false); + }; + + recognitionRef.current = speechRecognition; + setRecognition(speechRecognition); + } + + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, [textareaRef, onTranscriptionChange]); + + const toggleListening = useCallback(() => { + if (!recognition) { + return; + } + + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [recognition, isListening]); + + return ( + + + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => ( + + ); +}; + +export type WebPreviewBodyProps = ComponentProps<'iframe'> & { + loading?: ReactNode; +}; + +export const WebPreviewBody = ({ + className, + loading, + src, + ...props +}: WebPreviewBodyProps) => { + const { url } = useWebPreview(); + + return ( +
+