diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/ArtifactAgent.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/ArtifactAgent.kt index f9d21bdca3..02a2b170b8 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/ArtifactAgent.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/ArtifactAgent.kt @@ -45,6 +45,7 @@ class ArtifactAgent( enum class ArtifactType(val mimeType: String) { HTML("application/autodev.artifacts.html"), REACT("application/autodev.artifacts.react"), + NODEJS("application/autodev.artifacts.nodejs"), PYTHON("application/autodev.artifacts.python"), SVG("application/autodev.artifacts.svg"), MERMAID("application/autodev.artifacts.mermaid"); @@ -57,6 +58,80 @@ class ArtifactAgent( } } + /** + * Fix a failed artifact based on execution error + * + * @param originalArtifact The artifact that failed to execute + * @param errorMessage The error message from execution + * @param onProgress Callback for streaming progress + * @return Fixed artifact result + */ + suspend fun fix( + originalArtifact: Artifact, + errorMessage: String, + onProgress: (String) -> Unit = {} + ): ArtifactResult { + val fixPrompt = buildFixPrompt(originalArtifact, errorMessage) + + logger.info { "🔧 Attempting to fix artifact: ${originalArtifact.title}" } + logger.info { "📝 Error: ${errorMessage.take(200)}..." } + + return generate(fixPrompt, onProgress) + } + + /** + * Build a prompt to fix a failed artifact + */ + private fun buildFixPrompt(artifact: Artifact, errorMessage: String): String { + val language = if (this.language == "ZH") "中文" else "English" + + return if (this.language == "ZH") { + """ +我之前生成的代码执行失败了,请帮我修复。 + +## 原始代码 +```${artifact.type.name.lowercase()} +${artifact.content} +``` + +## 执行错误 +``` +$errorMessage +``` + +## 要求 +1. 分析错误原因 +2. 修复代码使其能够正确执行 +3. 保持原有功能不变 +4. 使用相同的 artifact 格式输出修复后的代码 + +请生成修复后的 artifact。 + """.trimIndent() + } else { + """ +The code I generated earlier failed to execute. Please help me fix it. + +## Original Code +```${artifact.type.name.lowercase()} +${artifact.content} +``` + +## Execution Error +``` +$errorMessage +``` + +## Requirements +1. Analyze the error cause +2. Fix the code so it executes correctly +3. Keep the original functionality unchanged +4. Output the fixed code using the same artifact format + +Please generate the fixed artifact. + """.trimIndent() + } + } + /** * Generate artifact from user prompt */ diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/ArtifactAgentTemplate.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/ArtifactAgentTemplate.kt index 7742c66ad5..e745509ccb 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/ArtifactAgentTemplate.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/ArtifactAgentTemplate.kt @@ -25,16 +25,23 @@ object ArtifactAgentTemplate { - Can use Tailwind CSS for styling - Exports default component -3. **application/autodev.artifacts.python** - Python scripts +3. **application/autodev.artifacts.nodejs** - Node.js applications + - Complete Node.js application code (Express.js, etc.) + - **IMPORTANT**: Only include the JavaScript code (index.js), NOT package.json + - The system will auto-generate package.json based on require/import statements + - Use require() or import to declare dependencies (they will be auto-detected) + - Must be executable standalone with `node index.js` + +4. **application/autodev.artifacts.python** - Python scripts - Complete Python scripts with PEP 723 inline metadata - Dependencies declared in script header - Must be executable standalone -4. **application/autodev.artifacts.svg** - SVG images +5. **application/autodev.artifacts.svg** - SVG images - Complete SVG markup - Can include inline styles and animations -5. **application/autodev.artifacts.mermaid** - Diagrams +6. **application/autodev.artifacts.mermaid** - Diagrams - Mermaid diagram syntax - Flowcharts, sequence diagrams, etc. """ @@ -156,6 +163,38 @@ if __name__ == "__main__": main() ``` +## Node.js Application Guidelines + +When creating Node.js artifacts: + +1. **Code Only**: Include ONLY the JavaScript code, NOT package.json +2. **Single Artifact**: Generate exactly ONE artifact containing the main application code +3. **Dependencies via require/import**: Use require() or import statements to declare dependencies +4. **Self-Contained**: The script should be the complete application + +### Node.js Template Structure: + +```javascript +const express = require('express'); +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(express.json()); + +// Routes +app.get('/', (req, res) => { + res.json({ message: 'Hello World!' }); +}); + +// Start server +app.listen(PORT, () => { + console.log('Server running on http://localhost:' + PORT); +}); +``` + +**CRITICAL**: Do NOT create a separate artifact for package.json. The system automatically generates package.json by detecting require() and import statements in your code. + ## React Component Guidelines When creating React artifacts: @@ -298,6 +337,38 @@ $ARTIFACT_TYPES 2. **自包含**:脚本应无需外部设置即可运行 3. **清晰输出**:打印有意义的输出到 stdout +## Node.js 应用指南 + +创建 Node.js Artifact 时: + +1. **仅包含代码**:只包含 JavaScript 代码,不要包含 package.json +2. **单个 Artifact**:只生成一个包含主应用代码的 Artifact +3. **通过 require/import 声明依赖**:使用 require() 或 import 语句声明依赖 +4. **自包含**:脚本应该是完整的应用程序 + +### Node.js 模板结构: + +```javascript +const express = require('express'); +const app = express(); +const PORT = process.env.PORT || 3000; + +// 中间件 +app.use(express.json()); + +// 路由 +app.get('/', (req, res) => { + res.json({ message: 'Hello World!' }); +}); + +// 启动服务器 +app.listen(PORT, () => { + console.log('服务器运行在 http://localhost:' + PORT); +}); +``` + +**重要**:不要为 package.json 创建单独的 Artifact。系统会通过检测代码中的 require() 和 import 语句自动生成 package.json。 + ## React 组件指南 创建 React Artifact 时: diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBundle.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBundle.kt index 2f032db26f..53e10ae332 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBundle.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBundle.kt @@ -76,6 +76,10 @@ data class ArtifactBundle( /** * Create a bundle from an artifact generation result + * + * For Node.js artifacts, intelligently selects the correct artifact containing code + * (not package.json) when multiple artifacts are generated. + * Also auto-detects dependencies from require()/import statements. */ fun fromArtifact( artifact: cc.unitmesh.agent.ArtifactAgent.Artifact, @@ -86,17 +90,26 @@ data class ArtifactBundle( val type = when (artifact.type) { cc.unitmesh.agent.ArtifactAgent.Artifact.ArtifactType.HTML -> ArtifactType.HTML cc.unitmesh.agent.ArtifactAgent.Artifact.ArtifactType.REACT -> ArtifactType.REACT + cc.unitmesh.agent.ArtifactAgent.Artifact.ArtifactType.NODEJS -> ArtifactType.NODEJS cc.unitmesh.agent.ArtifactAgent.Artifact.ArtifactType.PYTHON -> ArtifactType.PYTHON cc.unitmesh.agent.ArtifactAgent.Artifact.ArtifactType.SVG -> ArtifactType.SVG cc.unitmesh.agent.ArtifactAgent.Artifact.ArtifactType.MERMAID -> ArtifactType.MERMAID } + // Auto-detect dependencies for Node.js/React artifacts + val dependencies = if (type == ArtifactType.NODEJS || type == ArtifactType.REACT) { + detectNodeDependencies(artifact.content) + } else { + emptyMap() + } + return ArtifactBundle( id = id, name = artifact.title, description = "Generated artifact: ${artifact.title}", type = type, mainContent = artifact.content, + dependencies = dependencies, context = ArtifactContext( model = modelInfo, conversationHistory = conversationHistory, @@ -105,6 +118,125 @@ data class ArtifactBundle( ) } + /** + * Detect Node.js dependencies from code content. + * Parses require() and import statements to extract package names. + * + * @param content The JavaScript/TypeScript code content + * @return Map of package name to version (using "latest" as default) + */ + private fun detectNodeDependencies(content: String): Map { + val dependencies = mutableSetOf() + + // Match require('package') or require("package") + val requirePattern = Regex("""require\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)""") + requirePattern.findAll(content).forEach { match -> + val packageName = match.groupValues[1].split("/").first() + dependencies.add(packageName) + } + + // Match import ... from 'package' or import ... from "package" + val importPattern = Regex("""import\s+.*?\s+from\s+['"]([^'"./][^'"]*)['"]\s*;?""") + importPattern.findAll(content).forEach { match -> + val packageName = match.groupValues[1].split("/").first() + dependencies.add(packageName) + } + + // Match import 'package' (side-effect imports) + val sideEffectImportPattern = Regex("""import\s+['"]([^'"./][^'"]*)['"]\s*;?""") + sideEffectImportPattern.findAll(content).forEach { match -> + val packageName = match.groupValues[1].split("/").first() + dependencies.add(packageName) + } + + // Filter out Node.js built-in modules + val builtInModules = setOf( + "assert", "async_hooks", "buffer", "child_process", "cluster", + "console", "constants", "crypto", "dgram", "diagnostics_channel", + "dns", "domain", "events", "fs", "http", "http2", "https", + "inspector", "module", "net", "os", "path", "perf_hooks", + "process", "punycode", "querystring", "readline", "repl", + "stream", "string_decoder", "sys", "timers", "tls", "trace_events", + "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", "zlib", + // Node.js prefixed modules + "node:assert", "node:buffer", "node:child_process", "node:cluster", + "node:console", "node:constants", "node:crypto", "node:dgram", + "node:dns", "node:events", "node:fs", "node:http", "node:http2", + "node:https", "node:module", "node:net", "node:os", "node:path", + "node:perf_hooks", "node:process", "node:querystring", "node:readline", + "node:repl", "node:stream", "node:string_decoder", "node:timers", + "node:tls", "node:tty", "node:url", "node:util", "node:v8", "node:vm", + "node:worker_threads", "node:zlib" + ) + + // Map common packages to recommended versions + val packageVersions = mapOf( + "express" to "^4.18.2", + "react" to "^18.2.0", + "react-dom" to "^18.2.0", + "axios" to "^1.6.0", + "lodash" to "^4.17.21", + "moment" to "^2.29.4", + "uuid" to "^9.0.0", + "dotenv" to "^16.3.1", + "cors" to "^2.8.5", + "body-parser" to "^1.20.2", + "mongoose" to "^8.0.0", + "pg" to "^8.11.3", + "mysql2" to "^3.6.0", + "redis" to "^4.6.10", + "socket.io" to "^4.7.2", + "jsonwebtoken" to "^9.0.2", + "bcrypt" to "^5.1.1", + "multer" to "^1.4.5-lts.1", + "nodemailer" to "^6.9.7", + "winston" to "^3.11.0", + "jest" to "^29.7.0", + "typescript" to "^5.3.2", + "ts-node" to "^10.9.1" + ) + + return dependencies + .filter { it !in builtInModules && !it.startsWith("node:") } + .associateWith { packageVersions[it] ?: "latest" } + } + + /** + * Select the best artifact from multiple artifacts + * + * For Node.js/React artifacts, avoids selecting package.json as main content + * and prefers the artifact containing actual code. + * + * @param artifacts List of artifacts to choose from + * @return The best artifact for creating a bundle, or null if empty + */ + fun selectBestArtifact(artifacts: List): cc.unitmesh.agent.ArtifactAgent.Artifact? { + if (artifacts.isEmpty()) return null + if (artifacts.size == 1) return artifacts.first() + + // Group artifacts by type + val nodeJsArtifacts = artifacts.filter { + it.type == cc.unitmesh.agent.ArtifactAgent.Artifact.ArtifactType.NODEJS || + it.type == cc.unitmesh.agent.ArtifactAgent.Artifact.ArtifactType.REACT + } + + if (nodeJsArtifacts.size > 1) { + // For Node.js, skip artifacts that look like package.json + val codeArtifact = nodeJsArtifacts.find { artifact -> + val content = artifact.content.trim() + // Skip if it's clearly JSON (package.json) + !(content.startsWith("{") && content.contains("\"name\"") && content.contains("\"dependencies\"")) + } + + if (codeArtifact != null) { + return codeArtifact + } + } + + // Fallback: return first artifact + return artifacts.first() + } + private fun generateId(): String { val timestamp = Clock.System.now().toEpochMilliseconds() val random = (0..999999).random() @@ -161,6 +293,14 @@ data class ArtifactBundle( appendLine("npm start") appendLine("```") } + ArtifactType.NODEJS -> { + appendLine("Install dependencies and run the Node.js application:") + appendLine() + appendLine("```bash") + appendLine("npm install") + appendLine("node index.js") + appendLine("```") + } ArtifactType.PYTHON -> { appendLine("Run the Python script:") appendLine() @@ -216,6 +356,14 @@ data class ArtifactBundle( appendLine(" \"setup\": \"npm install\"") appendLine(" },") } + ArtifactType.NODEJS -> { + appendLine(" \"main\": \"index.js\",") + // Note: Not using "type": "module" to support both CommonJS (require) and ES modules (import) + appendLine(" \"scripts\": {") + appendLine(" \"start\": \"node index.js\",") + appendLine(" \"setup\": \"npm install\"") + appendLine(" },") + } ArtifactType.PYTHON -> { appendLine(" \"main\": \"index.py\",") appendLine(" \"scripts\": {") @@ -254,6 +402,7 @@ data class ArtifactBundle( fun getMainFileName(): String = when (type) { ArtifactType.HTML -> "index.html" ArtifactType.REACT -> "index.jsx" + ArtifactType.NODEJS -> "index.js" ArtifactType.PYTHON -> "index.py" ArtifactType.SVG -> "index.svg" ArtifactType.MERMAID -> "diagram.mmd" @@ -261,16 +410,21 @@ data class ArtifactBundle( /** * Get all files to be included in the bundle + * Note: Core files (ARTIFACT.md, package.json, main file, context.json) take precedence + * over files in the additional files map to prevent conflicts. */ fun getAllFiles(): Map = buildMap { - // Core files + // Core files - these must not be overridden put(ARTIFACT_MD, generateArtifactMd()) put(PACKAGE_JSON, generatePackageJson()) put(getMainFileName(), mainContent) put(CONTEXT_JSON, json.encodeToString(context)) - // Additional files - putAll(files) + // Additional files - exclude any that conflict with core files + val coreFileNames = setOf(ARTIFACT_MD, PACKAGE_JSON, getMainFileName(), CONTEXT_JSON) + files.filterKeys { key -> key !in coreFileNames }.forEach { (key, value) -> + put(key, value) + } } } @@ -282,6 +436,7 @@ enum class ArtifactType(val extension: String, val mimeType: String) { HTML("html", "text/html"), REACT("jsx", "text/javascript"), PYTHON("py", "text/x-python"), + NODEJS("js", "application/autodev.artifacts.nodejs"), SVG("svg", "image/svg+xml"), MERMAID("mmd", "text/plain"); diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBundlePacker.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBundlePacker.kt index 47cdccaa44..dfd22e3940 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBundlePacker.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBundlePacker.kt @@ -156,6 +156,7 @@ object ArtifactBundleUtils { val mainFileName = when (type) { ArtifactType.HTML -> "index.html" ArtifactType.REACT -> "index.jsx" + ArtifactType.NODEJS -> "index.js" ArtifactType.PYTHON -> "index.py" ArtifactType.SVG -> "index.svg" ArtifactType.MERMAID -> "diagram.mmd" diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/ArtifactExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/ArtifactExecutor.kt new file mode 100644 index 0000000000..5c43835175 --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/ArtifactExecutor.kt @@ -0,0 +1,63 @@ +package cc.unitmesh.agent.artifact + +import cc.unitmesh.agent.artifact.executor.ArtifactExecutorFactory +import cc.unitmesh.agent.artifact.executor.ExecutionResult as NewExecutionResult + +/** + * Legacy ArtifactExecutor - now delegates to ArtifactExecutorFactory + * + * This maintains backward compatibility while using the new executor architecture. + * + * @deprecated Use ArtifactExecutorFactory.executeArtifact() directly + */ +@Deprecated("Use ArtifactExecutorFactory.executeArtifact() instead", ReplaceWith("ArtifactExecutorFactory.executeArtifact(unitFilePath, onOutput)")) +object ArtifactExecutor { + /** + * Result of artifact execution + * @deprecated Use executor.ExecutionResult instead + */ + @Deprecated("Use executor.ExecutionResult instead") + sealed class ExecutionResult { + @Deprecated("Use executor.ExecutionResult.Success instead") + data class Success(val output: String, val workingDirectory: String) : ExecutionResult() + @Deprecated("Use executor.ExecutionResult.Error instead") + data class Error(val message: String, val cause: Throwable? = null) : ExecutionResult() + } + + /** + * Execute a Node.js artifact from a .unit file + * + * @deprecated Use ArtifactExecutorFactory.executeArtifact() instead + */ + @Deprecated("Use ArtifactExecutorFactory.executeArtifact() instead") + suspend fun executeNodeJsArtifact( + unitFilePath: String, + onOutput: ((String) -> Unit)? = null + ): ExecutionResult { + // Delegate to new factory-based executor + val result = ArtifactExecutorFactory.executeArtifact(unitFilePath, onOutput) + + // Convert new ExecutionResult to legacy format + return when (result) { + is NewExecutionResult.Success -> { + LegacyExecutionResult.Success( + output = result.output, + workingDirectory = result.workingDirectory + ) + } + is NewExecutionResult.Error -> { + LegacyExecutionResult.Error( + message = result.message, + cause = result.cause + ) + } + } + } + + // Legacy result types for backward compatibility + private sealed class LegacyExecutionResult : ExecutionResult() { + data class Success(val output: String, val workingDirectory: String) : LegacyExecutionResult() + data class Error(val message: String, val cause: Throwable? = null) : LegacyExecutionResult() + } +} + diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/GenerateTestUnit.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/GenerateTestUnit.kt index b737892633..7eb38e352a 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/GenerateTestUnit.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/GenerateTestUnit.kt @@ -5,16 +5,34 @@ import kotlinx.coroutines.runBlocking import java.io.File /** - * Simple utility to generate a test .unit file for verification + * Simple utility to generate test .unit files for verification * Run with: ./gradlew :mpp-core:generateTestUnit + * + * Usage: + * - No args: Creates HTML demo + * - "express": Creates Express.js Node.js application */ fun main(args: Array) { runBlocking { - val artifact = ArtifactAgent.Artifact( - identifier = "demo-artifact", - type = ArtifactAgent.Artifact.ArtifactType.HTML, - title = "Demo HTML Page", - content = """ + val artifactType = args.getOrNull(0) ?: "html" + + when (artifactType.lowercase()) { + "express", "nodejs", "node" -> { + createExpressJsUnit() + } + else -> { + createHtmlDemo() + } + } + } +} + +private suspend fun createHtmlDemo() { + val artifact = ArtifactAgent.Artifact( + identifier = "demo-artifact", + type = ArtifactAgent.Artifact.ArtifactType.HTML, + title = "Demo HTML Page", + content = """ AutoDev Unit Demo @@ -28,30 +46,116 @@ fun main(args: Array) {

This is a demo artifact bundled as a .unit file.

""" - ) - - val bundle = ArtifactBundle.fromArtifact(artifact) - val outputPath = "/tmp/demo.unit" - - val packer = ArtifactBundlePacker() - val result = packer.pack(bundle, outputPath) - - when (result) { - is PackResult.Success -> { - val file = File(result.outputPath) - println("✅ Successfully created: ${result.outputPath}") - println(" File size: ${file.length()} bytes") - println() - println("To verify with unzip:") - println(" unzip -l ${result.outputPath}") - println() - println("To extract:") - println(" unzip ${result.outputPath} -d /tmp/demo-extracted") - } - is PackResult.Error -> { - println("❌ Error: ${result.message}") - result.cause?.printStackTrace() - } + ) + + val bundle = ArtifactBundle.fromArtifact(artifact) + val outputPath = "/tmp/demo.unit" + + val packer = ArtifactBundlePacker() + val result = packer.pack(bundle, outputPath) + + when (result) { + is PackResult.Success -> { + val file = File(result.outputPath) + println("✅ Successfully created: ${result.outputPath}") + println(" File size: ${file.length()} bytes") + println() + println("To verify with unzip:") + println(" unzip -l ${result.outputPath}") + println() + println("To extract:") + println(" unzip ${result.outputPath} -d /tmp/demo-extracted") + } + is PackResult.Error -> { + println("❌ Error: ${result.message}") + result.cause?.printStackTrace() + } + } +} + +private suspend fun createExpressJsUnit() { + println("Creating Express.js test .unit file...") + + // Create Express.js application code + val expressAppCode = """ +import express from 'express'; + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(express.json()); + +app.get('/', (req, res) => { + res.json({ + message: 'Hello from Express.js!', + timestamp: new Date().toISOString(), + version: '1.0.0' + }); +}); + +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', uptime: process.uptime() }); +}); + +app.post('/api/echo', (req, res) => { + res.json({ + received: req.body, + timestamp: new Date().toISOString() + }); +}); + +app.listen(PORT, () => { + console.log(`🚀 Express.js server running on http://localhost:${'$'}{PORT}`); + console.log(`📡 Health check: http://localhost:${'$'}{PORT}/api/health`); + console.log(`📝 Echo endpoint: POST http://localhost:${'$'}{PORT}/api/echo`); +}); +""".trimIndent() + + // Create bundle with Express.js artifact + val artifact = ArtifactAgent.Artifact( + identifier = "express-test-app", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Express.js Test Application", + content = expressAppCode + ) + + val bundle = ArtifactBundle.fromArtifact( + artifact = artifact, + conversationHistory = emptyList(), + modelInfo = null + ).copy( + dependencies = mapOf("express" to "^4.18.2") + ) + + val outputPath = "/tmp/express-test-app.unit" + val packer = ArtifactBundlePacker() + + when (val result = packer.pack(bundle, outputPath)) { + is PackResult.Success -> { + val file = File(result.outputPath) + println("✅ Created Express.js .unit file: ${result.outputPath}") + println(" File size: ${file.length()} bytes") + println() + println("📦 Bundle contains:") + println(" - index.js (Express.js application)") + println(" - package.json (with express dependency)") + println(" - ARTIFACT.md (metadata)") + println(" - .artifact/context.json (context)") + println() + println("To test execution:") + println(" 1. Load the .unit file in AutoDev") + println(" 2. Click the play button to execute") + println(" 3. The server will start on http://localhost:3000") + println() + println("To test manually:") + println(" unzip ${result.outputPath} -d /tmp/express-extracted") + println(" cd /tmp/express-extracted") + println(" npm install") + println(" node index.js") + } + is PackResult.Error -> { + println("❌ Failed to create .unit file: ${result.message}") + result.cause?.printStackTrace() } } } diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ArtifactExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ArtifactExecutor.kt new file mode 100644 index 0000000000..b4d21d3e84 --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ArtifactExecutor.kt @@ -0,0 +1,80 @@ +package cc.unitmesh.agent.artifact.executor + +import cc.unitmesh.agent.artifact.ArtifactType +import java.io.File + +/** + * Base interface for artifact executors + * + * Each executor handles a specific artifact type: + * - WebArtifactExecutor: HTML/JS artifacts (preview in browser or local server) + * - NodeJsArtifactExecutor: Node.js applications (npm install + node) + * - PythonArtifactExecutor: Python scripts (pip install + python) + */ +interface ArtifactExecutor { + /** + * Supported artifact types + */ + val supportedTypes: Set + + /** + * Execute an artifact from an extracted directory + * + * @param extractDir The directory where the .unit file was extracted + * @param bundleType The type of artifact bundle + * @param onOutput Callback for output lines (stdout/stderr) + * @return ExecutionResult with output and metadata + */ + suspend fun execute( + extractDir: File, + bundleType: ArtifactType, + onOutput: ((String) -> Unit)? + ): ExecutionResult + + /** + * Validate that the extracted directory contains required files + * + * @param extractDir The extracted directory + * @param bundleType The type of artifact bundle + * @return ValidationResult indicating if the artifact is valid + */ + suspend fun validate(extractDir: File, bundleType: ArtifactType): ValidationResult +} + +/** + * Result of artifact execution + */ +sealed class ExecutionResult { + /** + * Successful execution + * @param output Console output from the execution + * @param workingDirectory The directory where the artifact was executed + * @param serverUrl Optional URL if a server was started (for web artifacts) + * @param processId Optional process ID for long-running processes + */ + data class Success( + val output: String, + val workingDirectory: String, + val serverUrl: String? = null, + val processId: Long? = null + ) : ExecutionResult() + + /** + * Execution failed + * @param message Error message + * @param cause Optional exception that caused the failure + */ + data class Error( + val message: String, + val cause: Throwable? = null + ) : ExecutionResult() +} + +/** + * Result of artifact validation + */ +sealed class ValidationResult { + data class Valid(val message: String = "Artifact is valid") : ValidationResult() + data class Invalid(val errors: List) : ValidationResult() +} + diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ArtifactExecutorFactory.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ArtifactExecutorFactory.kt new file mode 100644 index 0000000000..91b25c04be --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ArtifactExecutorFactory.kt @@ -0,0 +1,111 @@ +package cc.unitmesh.agent.artifact.executor + +import cc.unitmesh.agent.artifact.ArtifactType +import cc.unitmesh.agent.logging.AutoDevLogger +import java.io.File +import java.nio.file.Files +import java.util.UUID + +/** + * Factory for creating appropriate artifact executors + * + * Provides a unified interface for executing different types of artifacts: + * - Web artifacts (HTML/SVG) -> WebArtifactExecutor + * - Node.js artifacts -> NodeJsArtifactExecutor + * - Python artifacts -> PythonArtifactExecutor + */ +object ArtifactExecutorFactory { + private val logger = AutoDevLogger + + // Available executors + private val executors = listOf( + WebArtifactExecutor(), + NodeJsArtifactExecutor(), + PythonArtifactExecutor() + ) + + /** + * Get executor for a specific artifact type + */ + fun getExecutor(artifactType: ArtifactType): ArtifactExecutor? { + return executors.firstOrNull { artifactType in it.supportedTypes } + } + + /** + * Execute an artifact from a .unit file + * + * This is the main entry point for executing artifacts. + * It handles: + * 1. Extracting the .unit file + * 2. Determining the artifact type + * 3. Finding the appropriate executor + * 4. Executing the artifact + * + * @param unitFilePath Path to the .unit file + * @param onOutput Callback for output lines + * @return ExecutionResult + */ + suspend fun executeArtifact( + unitFilePath: String, + onOutput: ((String) -> Unit)? = null + ): ExecutionResult { + return try { + logger.info("ArtifactExecutorFactory") { "🚀 Executing artifact from: $unitFilePath" } + + // Step 1: Extract .unit file + val tempDir = Files.createTempDirectory("autodev-artifact-${UUID.randomUUID()}") + val extractDir = tempDir.toFile() + + logger.info("ArtifactExecutorFactory") { "📦 Extracting to: ${extractDir.absolutePath}" } + val packer = cc.unitmesh.agent.artifact.ArtifactBundlePacker() + when (val extractResult = packer.extractToDirectory(unitFilePath, extractDir.absolutePath)) { + is cc.unitmesh.agent.artifact.PackResult.Success -> { + logger.info("ArtifactExecutorFactory") { "✅ Extracted successfully" } + } + is cc.unitmesh.agent.artifact.PackResult.Error -> { + return ExecutionResult.Error("Failed to extract bundle: ${extractResult.message}") + } + } + + // Step 2: Determine artifact type from ARTIFACT.md + val artifactType = determineArtifactType(extractDir) + if (artifactType == null) { + return ExecutionResult.Error("Could not determine artifact type from bundle") + } + + logger.info("ArtifactExecutorFactory") { "📋 Artifact type: $artifactType" } + + // Step 3: Get appropriate executor + val executor = getExecutor(artifactType) + if (executor == null) { + return ExecutionResult.Error("No executor available for artifact type: $artifactType") + } + + logger.info("ArtifactExecutorFactory") { "🔧 Using executor: ${executor::class.simpleName}" } + + // Step 4: Execute + executor.execute(extractDir, artifactType, onOutput) + } catch (e: Exception) { + logger.error("ArtifactExecutorFactory") { "❌ Execution failed: ${e.message}" } + ExecutionResult.Error("Execution failed: ${e.message}", e) + } + } + + /** + * Determine artifact type from extracted directory + */ + private suspend fun determineArtifactType(extractDir: File): ArtifactType? { + val artifactMd = File(extractDir, "ARTIFACT.md") + if (!artifactMd.exists()) { + return null + } + + val content = artifactMd.readText() + val typePattern = Regex("""type:\s*(\w+)""", RegexOption.IGNORE_CASE) + val match = typePattern.find(content) ?: return null + + val typeStr = match.groupValues[1].lowercase() + return ArtifactType.entries.find { it.name.lowercase() == typeStr } + } +} + diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/NodeJsArtifactExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/NodeJsArtifactExecutor.kt new file mode 100644 index 0000000000..653b42e72b --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/NodeJsArtifactExecutor.kt @@ -0,0 +1,345 @@ +package cc.unitmesh.agent.artifact.executor + +import cc.unitmesh.agent.artifact.ArtifactType +import cc.unitmesh.agent.logging.AutoDevLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Executor for Node.js artifacts + * + * Handles: + * - npm install for dependencies + * - node index.js execution + * - Express.js and other Node.js applications + */ +class NodeJsArtifactExecutor : ArtifactExecutor { + private val logger = AutoDevLogger + + override val supportedTypes: Set = setOf(ArtifactType.NODEJS, ArtifactType.REACT) + + override suspend fun validate(extractDir: File, bundleType: ArtifactType): ValidationResult = withContext(Dispatchers.IO) { + val errors = mutableListOf() + + val packageJsonFile = File(extractDir, "package.json") + if (!packageJsonFile.exists()) { + errors.add("package.json not found") + } + + val mainFile = when (bundleType) { + ArtifactType.NODEJS -> File(extractDir, "index.js") + ArtifactType.REACT -> File(extractDir, "index.jsx") + else -> null + } + + if (mainFile == null) { + errors.add("Unsupported bundle type: $bundleType") + } else if (!mainFile.exists()) { + errors.add("${mainFile.name} not found") + } else { + // Verify main file is actually code, not JSON + val content = mainFile.readText() + if (content.trim().startsWith("{") && content.contains("\"name\"")) { + // Try to recover from context.json + logger.warn("NodeJsArtifactExecutor") { "⚠️ ${mainFile.name} contains JSON (likely package.json). Attempting recovery from context..." } + val recovered = tryRecoverCodeFromContext(extractDir, bundleType) + if (recovered != null) { + logger.info("NodeJsArtifactExecutor") { "✅ Recovered code from context, fixing ${mainFile.name}..." } + mainFile.writeText(recovered) + // Re-validate after recovery + val newContent = mainFile.readText() + if (newContent.trim().startsWith("{") && newContent.contains("\"name\"")) { + errors.add("${mainFile.name} contains invalid content and recovery failed") + } + } else { + errors.add("${mainFile.name} contains invalid content (appears to be package.json). Could not recover from context.") + } + } + } + + if (errors.isEmpty()) { + ValidationResult.Valid() + } else { + ValidationResult.Invalid(errors) + } + } + + /** + * Try to recover JavaScript code from context.json conversation history + * This handles cases where AI mistakenly put package.json as artifact content + */ + private fun tryRecoverCodeFromContext(extractDir: File, bundleType: ArtifactType): String? { + try { + val contextFile = File(extractDir, ".artifact/context.json") + if (!contextFile.exists()) { + return null + } + + val contextJson = contextFile.readText() + // Parse JSON to extract conversation history + val json = kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + } + val context = json.decodeFromString(contextJson) + + // Look for the actual code in conversation history + // Usually the assistant's response contains multiple artifacts, and the code is in the second one + for (message in context.conversationHistory.reversed()) { + if (message.role == "assistant") { + // Try to extract the actual code artifact (not package.json) + val codeArtifact = extractCodeArtifactFromMessage(message.content, bundleType) + if (codeArtifact != null) { + return codeArtifact + } + } + } + + return null + } catch (e: Exception) { + logger.warn("NodeJsArtifactExecutor") { "Failed to recover from context: ${e.message}" } + return null + } + } + + /** + * Extract code artifact from assistant message + * Looks for the second or later artifact that contains actual code (not JSON) + */ + private fun extractCodeArtifactFromMessage(message: String, bundleType: ArtifactType): String? { + // Pattern to match ... + val artifactPattern = Regex( + """]+)>([\s\S]*?)""", + RegexOption.MULTILINE + ) + + val artifacts = artifactPattern.findAll(message).toList() + + // Look for artifacts that are NOT JSON (skip package.json artifacts) + for (match in artifacts) { + val attributesStr = match.groupValues[1] + val content = match.groupValues[2].trim() + + // Skip if it's clearly JSON (package.json) + if (content.trim().startsWith("{") && content.contains("\"name\"") && content.contains("\"dependencies\"")) { + continue + } + + // Check if it's the right type + val typeStr = extractAttribute(attributesStr, "type") ?: "" + val expectedType = when (bundleType) { + ArtifactType.NODEJS -> "application/autodev.artifacts.nodejs" + ArtifactType.REACT -> "application/autodev.artifacts.react" + else -> return null + } + + if (typeStr == expectedType && !content.trim().startsWith("{")) { + // This looks like actual code + return content + } + } + + // Fallback: return the last artifact that's not JSON + for (match in artifacts.reversed()) { + val content = match.groupValues[2].trim() + if (!content.trim().startsWith("{") || !content.contains("\"name\"")) { + return content + } + } + + return null + } + + private fun extractAttribute(attributesStr: String, name: String): String? { + val pattern = Regex("""$name\s*=\s*["']([^"']+)["']""") + return pattern.find(attributesStr)?.groupValues?.get(1) + } + + override suspend fun execute( + extractDir: File, + bundleType: ArtifactType, + onOutput: ((String) -> Unit)? + ): ExecutionResult = withContext(Dispatchers.IO) { + try { + logger.info("NodeJsArtifactExecutor") { "🚀 Executing Node.js artifact in: ${extractDir.absolutePath}" } + + // Validate first + when (val validation = validate(extractDir, bundleType)) { + is ValidationResult.Invalid -> { + return@withContext ExecutionResult.Error("Validation failed: ${validation.errors.joinToString(", ")}") + } + is ValidationResult.Valid -> { + logger.info("NodeJsArtifactExecutor") { "✅ Validation passed" } + } + } + + // Step 1: Check for dependencies and run npm install + val packageJsonFile = File(extractDir, "package.json") + val packageJsonContent = packageJsonFile.readText() + val hasDependencies = packageJsonContent.contains("\"dependencies\"") || + packageJsonContent.contains("\"devDependencies\"") + + if (hasDependencies) { + logger.info("NodeJsArtifactExecutor") { "📦 Installing dependencies..." } + onOutput?.invoke("Installing dependencies...\n") + + val installResult = executeCommand( + command = "npm install", + workingDirectory = extractDir.absolutePath, + onOutput = onOutput + ) + + if (installResult.exitCode != 0) { + logger.warn("NodeJsArtifactExecutor") { "⚠️ npm install failed with exit code ${installResult.exitCode}" } + onOutput?.invoke("Warning: npm install failed. Continuing anyway...\n") + } else { + logger.info("NodeJsArtifactExecutor") { "✅ Dependencies installed" } + onOutput?.invoke("Dependencies installed successfully.\n") + } + } else { + logger.info("NodeJsArtifactExecutor") { "ℹ️ No dependencies to install" } + onOutput?.invoke("No dependencies to install.\n") + } + + // Step 2: Execute the application + val mainFile = when (bundleType) { + ArtifactType.NODEJS -> "index.js" + ArtifactType.REACT -> "index.jsx" + else -> "index.js" + } + + logger.info("NodeJsArtifactExecutor") { "▶️ Executing: node $mainFile" } + onOutput?.invoke("Starting application...\n") + onOutput?.invoke("=".repeat(50) + "\n") + + // Use ProcessManager for long-running processes (like Express.js servers) + val (processId, initialOutput) = ProcessManager.startProcess( + command = "node $mainFile", + workingDirectory = extractDir.absolutePath, + onOutput = onOutput + ) + + if (processId == -1L) { + // Process exited immediately (one-shot script or error) + ExecutionResult.Success( + output = initialOutput, + workingDirectory = extractDir.absolutePath + ) + } else { + // Long-running process (server) + // Try to detect the server URL from output + val serverUrl = detectServerUrl(initialOutput) + + onOutput?.invoke("\n✅ Server started (Process #$processId)\n") + serverUrl?.let { url -> + onOutput?.invoke("🌐 Server URL: $url\n") + } + onOutput?.invoke("💡 Click 'Stop' to stop the server\n") + + ExecutionResult.Success( + output = initialOutput, + workingDirectory = extractDir.absolutePath, + serverUrl = serverUrl, + processId = processId + ) + } + } catch (e: Exception) { + logger.error("NodeJsArtifactExecutor") { "❌ Execution failed: ${e.message}" } + ExecutionResult.Error("Execution failed: ${e.message}", e) + } + } + + private suspend fun executeCommand( + command: String, + workingDirectory: String, + onOutput: ((String) -> Unit)? = null + ): CommandResult = withContext(Dispatchers.IO) { + try { + val processBuilder = ProcessBuilder() + .command("sh", "-c", command) + .directory(File(workingDirectory)) + .redirectErrorStream(true) + + val process = processBuilder.start() + val outputBuilder = StringBuilder() + + coroutineScope { + val outputJob = launch(Dispatchers.IO) { + process.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + outputBuilder.appendLine(line) + onOutput?.invoke("$line\n") + } + } + } + + val exitCode = process.waitFor() + outputJob.join() + + CommandResult( + exitCode = exitCode, + stdout = outputBuilder.toString(), + stderr = "" + ) + } + } catch (e: Exception) { + CommandResult( + exitCode = -1, + stdout = "", + stderr = "Error executing command: ${e.message}" + ) + } + } + + private data class CommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String + ) + + /** + * Try to detect server URL from console output + * Common patterns: "Server running on http://...", "listening on port ...", etc. + */ + private fun detectServerUrl(output: String): String? { + // Pattern 1: Direct URL mention + val urlPattern = Regex("""https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+/?""") + urlPattern.find(output)?.let { return it.value } + + // Pattern 2: "listening on port XXXX" or "running on port XXXX" + val portPattern = Regex("""(?:listening|running|started)\s+(?:on\s+)?port\s+(\d+)""", RegexOption.IGNORE_CASE) + portPattern.find(output)?.let { match -> + val port = match.groupValues[1] + return "http://localhost:$port" + } + + // Pattern 3: ":PORT" at end of line (common in Express) + val colonPortPattern = Regex(""":(\d{4,5})""") + colonPortPattern.find(output)?.let { match -> + val port = match.groupValues[1] + return "http://localhost:$port" + } + + return null + } + + companion object { + /** + * Stop a running Node.js process + */ + fun stopProcess(processId: Long): Boolean { + return ProcessManager.stopProcess(processId) + } + + /** + * Check if a process is still running + */ + fun isProcessRunning(processId: Long): Boolean { + return ProcessManager.isRunning(processId) + } + } +} + diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ProcessManager.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ProcessManager.kt new file mode 100644 index 0000000000..a7882671d1 --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/ProcessManager.kt @@ -0,0 +1,157 @@ +package cc.unitmesh.agent.artifact.executor + +import cc.unitmesh.agent.logging.AutoDevLogger +import kotlinx.coroutines.* +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Manages long-running processes for artifact execution. + * Allows starting, stopping, and monitoring processes like Express.js servers. + */ +object ProcessManager { + private val logger = AutoDevLogger + private val runningProcesses = ConcurrentHashMap() + private val idGenerator = AtomicLong(0) + + /** + * A running process with its metadata + */ + data class RunningProcess( + val id: Long, + val process: Process, + val command: String, + val workingDirectory: String, + val outputJob: Job?, + val startTime: Long = System.currentTimeMillis() + ) { + val isAlive: Boolean get() = process.isAlive + + fun stop() { + try { + // Try graceful shutdown first + process.destroy() + + // Wait a bit for graceful shutdown + Thread.sleep(500) + + // Force kill if still running + if (process.isAlive) { + process.destroyForcibly() + } + + outputJob?.cancel() + } catch (e: Exception) { + // Ignore exceptions during shutdown + } + } + } + + /** + * Start a long-running process (like Express.js server) + * + * @param command The command to execute + * @param workingDirectory The working directory + * @param onOutput Callback for output lines + * @return The process ID and initial output + */ + suspend fun startProcess( + command: String, + workingDirectory: String, + onOutput: ((String) -> Unit)? = null + ): Pair = withContext(Dispatchers.IO) { + val processId = idGenerator.incrementAndGet() + + logger.info("ProcessManager") { "🚀 Starting process #$processId: $command in $workingDirectory" } + + val processBuilder = ProcessBuilder() + .command("sh", "-c", command) + .directory(File(workingDirectory)) + .redirectErrorStream(true) + + val process = processBuilder.start() + val outputBuilder = StringBuilder() + + // Start output reading job + val outputJob = CoroutineScope(Dispatchers.IO).launch { + try { + process.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + outputBuilder.appendLine(line) + onOutput?.invoke("$line\n") + } + } + } catch (e: Exception) { + if (e !is CancellationException) { + logger.warn("ProcessManager") { "Output reading error: ${e.message}" } + } + } + } + + val runningProcess = RunningProcess( + id = processId, + process = process, + command = command, + workingDirectory = workingDirectory, + outputJob = outputJob + ) + + runningProcesses[processId] = runningProcess + + // Wait a bit to capture initial output (server startup messages) + delay(1000) + + // Check if process is still alive (server is running) + if (!process.isAlive) { + val exitCode = process.exitValue() + runningProcesses.remove(processId) + outputJob.cancel() + + // Return output even if process exited (for one-shot scripts) + return@withContext Pair(-1L, "Process exited with code $exitCode.\n${outputBuilder}") + } + + logger.info("ProcessManager") { "✅ Process #$processId started successfully" } + Pair(processId, outputBuilder.toString()) + } + + /** + * Stop a running process + * + * @param processId The process ID to stop + * @return true if stopped successfully + */ + fun stopProcess(processId: Long): Boolean { + val runningProcess = runningProcesses.remove(processId) ?: return false + + logger.info("ProcessManager") { "🛑 Stopping process #$processId" } + runningProcess.stop() + + return true + } + + /** + * Check if a process is still running + */ + fun isRunning(processId: Long): Boolean { + return runningProcesses[processId]?.isAlive == true + } + + /** + * Get all running processes + */ + fun getRunningProcesses(): List { + return runningProcesses.values.filter { it.isAlive }.toList() + } + + /** + * Stop all running processes + */ + fun stopAll() { + logger.info("ProcessManager") { "🛑 Stopping all ${runningProcesses.size} processes" } + runningProcesses.values.forEach { it.stop() } + runningProcesses.clear() + } +} + diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/PythonArtifactExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/PythonArtifactExecutor.kt new file mode 100644 index 0000000000..6046f95b95 --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/PythonArtifactExecutor.kt @@ -0,0 +1,211 @@ +package cc.unitmesh.agent.artifact.executor + +import cc.unitmesh.agent.artifact.ArtifactType +import cc.unitmesh.agent.logging.AutoDevLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Executor for Python artifacts + * + * Handles: + * - PEP 723 inline metadata parsing for dependencies + * - pip install for dependencies + * - python index.py execution + */ +class PythonArtifactExecutor : ArtifactExecutor { + private val logger = AutoDevLogger + + override val supportedTypes: Set = setOf(ArtifactType.PYTHON) + + override suspend fun validate(extractDir: File, bundleType: ArtifactType): ValidationResult = withContext(Dispatchers.IO) { + val errors = mutableListOf() + + val mainFile = File(extractDir, "index.py") + if (!mainFile.exists()) { + errors.add("index.py not found") + } else { + // Verify it's Python code + val content = mainFile.readText() + if (content.trim().startsWith("{") && content.contains("\"name\"")) { + errors.add("index.py contains invalid content (appears to be JSON)") + } + } + + if (errors.isEmpty()) { + ValidationResult.Valid() + } else { + ValidationResult.Invalid(errors) + } + } + + override suspend fun execute( + extractDir: File, + bundleType: ArtifactType, + onOutput: ((String) -> Unit)? + ): ExecutionResult = withContext(Dispatchers.IO) { + try { + logger.info("PythonArtifactExecutor") { "🚀 Executing Python artifact in: ${extractDir.absolutePath}" } + + // Validate first + when (val validation = validate(extractDir, bundleType)) { + is ValidationResult.Invalid -> { + return@withContext ExecutionResult.Error("Validation failed: ${validation.errors.joinToString(", ")}") + } + is ValidationResult.Valid -> { + logger.info("PythonArtifactExecutor") { "✅ Validation passed" } + } + } + + // Step 1: Parse PEP 723 metadata and install dependencies + val mainFile = File(extractDir, "index.py") + val pythonContent = mainFile.readText() + val dependencies = parsePep723Dependencies(pythonContent) + + if (dependencies.isNotEmpty()) { + logger.info("PythonArtifactExecutor") { "📦 Installing dependencies: $dependencies" } + onOutput?.invoke("Installing dependencies: ${dependencies.joinToString(", ")}...\n") + + // Create requirements.txt if needed + val requirementsFile = File(extractDir, "requirements.txt") + if (!requirementsFile.exists()) { + requirementsFile.writeText(dependencies.joinToString("\n")) + } + + val installResult = executeCommand( + command = "pip install -r requirements.txt", + workingDirectory = extractDir.absolutePath, + onOutput = onOutput + ) + + if (installResult.exitCode != 0) { + logger.warn("PythonArtifactExecutor") { "⚠️ pip install failed with exit code ${installResult.exitCode}" } + onOutput?.invoke("Warning: pip install failed. Continuing anyway...\n") + } else { + logger.info("PythonArtifactExecutor") { "✅ Dependencies installed" } + onOutput?.invoke("Dependencies installed successfully.\n") + } + } else { + logger.info("PythonArtifactExecutor") { "ℹ️ No dependencies to install" } + onOutput?.invoke("No dependencies to install.\n") + } + + // Step 2: Execute the Python script + logger.info("PythonArtifactExecutor") { "▶️ Executing: python index.py" } + onOutput?.invoke("Starting Python script...\n") + onOutput?.invoke("=".repeat(50) + "\n") + + val executeResult = executeCommand( + command = "python3 index.py", + workingDirectory = extractDir.absolutePath, + onOutput = onOutput + ) + + val output = if (executeResult.exitCode == 0) { + "Script executed successfully.\n${executeResult.stdout}" + } else { + "Script exited with code ${executeResult.exitCode}.\n${executeResult.stdout}\n${executeResult.stderr}" + } + + ExecutionResult.Success( + output = output, + workingDirectory = extractDir.absolutePath + ) + } catch (e: Exception) { + logger.error("PythonArtifactExecutor") { "❌ Execution failed: ${e.message}" } + ExecutionResult.Error("Execution failed: ${e.message}", e) + } + } + + /** + * Parse PEP 723 inline metadata from Python script + * + * PEP 723 format: + * ```python + * # /// script + * # requires-python = ">=3.11" + * # dependencies = [ + * # "requests>=2.28.0", + * # "pandas>=1.5.0", + * # ] + * # /// + * ``` + */ + private fun parsePep723Dependencies(pythonContent: String): List { + val dependencies = mutableListOf() + + // Look for PEP 723 metadata block + val pep723Pattern = Regex( + """#\s*///\s*script\s*\n(.*?)#\s*///""", + RegexOption.DOT_MATCHES_ALL + ) + + val match = pep723Pattern.find(pythonContent) ?: return emptyList() + val metadataBlock = match.groupValues[1] + + // Parse dependencies + val depsPattern = Regex("""dependencies\s*=\s*\[(.*?)\]""", RegexOption.DOT_MATCHES_ALL) + val depsMatch = depsPattern.find(metadataBlock) ?: return emptyList() + val depsContent = depsMatch.groupValues[1] + + // Extract individual dependencies + val depPattern = Regex("""["']([^"']+)["']""") + depPattern.findAll(depsContent).forEach { depMatch -> + dependencies.add(depMatch.groupValues[1]) + } + + return dependencies + } + + private suspend fun executeCommand( + command: String, + workingDirectory: String, + onOutput: ((String) -> Unit)? = null + ): CommandResult = withContext(Dispatchers.IO) { + try { + val processBuilder = ProcessBuilder() + .command("sh", "-c", command) + .directory(File(workingDirectory)) + .redirectErrorStream(true) + + val process = processBuilder.start() + val outputBuilder = StringBuilder() + + coroutineScope { + val outputJob = launch(Dispatchers.IO) { + process.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + outputBuilder.appendLine(line) + onOutput?.invoke("$line\n") + } + } + } + + val exitCode = process.waitFor() + outputJob.join() + + CommandResult( + exitCode = exitCode, + stdout = outputBuilder.toString(), + stderr = "" + ) + } + } catch (e: Exception) { + CommandResult( + exitCode = -1, + stdout = "", + stderr = "Error executing command: ${e.message}" + ) + } + } + + private data class CommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String + ) +} + diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/WebArtifactExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/WebArtifactExecutor.kt new file mode 100644 index 0000000000..d156b1d4e9 --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/WebArtifactExecutor.kt @@ -0,0 +1,168 @@ +package cc.unitmesh.agent.artifact.executor + +import cc.unitmesh.agent.artifact.ArtifactType +import cc.unitmesh.agent.logging.AutoDevLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.net.ServerSocket + +/** + * Executor for Web artifacts (HTML/JS) + * + * Handles: + * - Starting a local HTTP server for HTML artifacts + * - Opening in browser + * - Serving static files + */ +class WebArtifactExecutor : ArtifactExecutor { + private val logger = AutoDevLogger + + override val supportedTypes: Set = setOf(ArtifactType.HTML, ArtifactType.SVG) + + override suspend fun validate(extractDir: File, bundleType: ArtifactType): ValidationResult = withContext(Dispatchers.IO) { + val errors = mutableListOf() + + val mainFile = when (bundleType) { + ArtifactType.HTML -> File(extractDir, "index.html") + ArtifactType.SVG -> File(extractDir, "index.svg") + else -> null + } + + if (mainFile == null) { + errors.add("Unsupported bundle type: $bundleType") + } else if (!mainFile.exists()) { + errors.add("${mainFile.name} not found") + } + + if (errors.isEmpty()) { + ValidationResult.Valid() + } else { + ValidationResult.Invalid(errors) + } + } + + override suspend fun execute( + extractDir: File, + bundleType: ArtifactType, + onOutput: ((String) -> Unit)? + ): ExecutionResult = withContext(Dispatchers.IO) { + try { + logger.info("WebArtifactExecutor") { "🚀 Starting web server for artifact in: ${extractDir.absolutePath}" } + + // Validate first + when (val validation = validate(extractDir, bundleType)) { + is ValidationResult.Invalid -> { + return@withContext ExecutionResult.Error("Validation failed: ${validation.errors.joinToString(", ")}") + } + is ValidationResult.Valid -> { + logger.info("WebArtifactExecutor") { "✅ Validation passed" } + } + } + + // Find an available port + val port = findAvailablePort() + val serverUrl = "http://localhost:$port" + + logger.info("WebArtifactExecutor") { "🌐 Starting HTTP server on port $port" } + onOutput?.invoke("Starting web server on $serverUrl...\n") + + // Start a simple HTTP server + val serverProcess = startHttpServer(extractDir, port, onOutput) + + // Give server time to start + delay(1000) + + onOutput?.invoke("Web server started successfully.\n") + onOutput?.invoke("Open in browser: $serverUrl\n") + onOutput?.invoke("Press Ctrl+C to stop the server.\n") + + ExecutionResult.Success( + output = "Web server running on $serverUrl\nOpen in browser: $serverUrl", + workingDirectory = extractDir.absolutePath, + serverUrl = serverUrl, + processId = serverProcess?.pid() + ) + } catch (e: Exception) { + logger.error("WebArtifactExecutor") { "❌ Execution failed: ${e.message}" } + ExecutionResult.Error("Execution failed: ${e.message}", e) + } + } + + /** + * Find an available port starting from 8000 + */ + private fun findAvailablePort(startPort: Int = 8000): Int { + for (port in startPort..9000) { + try { + ServerSocket(port).use { socket -> + return socket.localPort + } + } catch (e: Exception) { + // Port is in use, try next + } + } + throw IllegalStateException("No available port found in range $startPort-9000") + } + + /** + * Start a simple HTTP server using Python's http.server or Node.js http-server + */ + private suspend fun startHttpServer( + directory: File, + port: Int, + onOutput: ((String) -> Unit)? = null + ): Process? = withContext(Dispatchers.IO) { + // Try Python's http.server first (usually available) + try { + val process = ProcessBuilder() + .command("python3", "-m", "http.server", port.toString()) + .directory(directory) + .redirectErrorStream(true) + .start() + + // Read output in background + coroutineScope { + launch(Dispatchers.IO) { + process.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + onOutput?.invoke("$line\n") + } + } + } + } + + return@withContext process + } catch (e: Exception) { + logger.warn("WebArtifactExecutor") { "Failed to start Python HTTP server: ${e.message}" } + } + + // Fallback: Try Node.js http-server if available + try { + val process = ProcessBuilder() + .command("npx", "-y", "http-server", directory.absolutePath, "-p", port.toString(), "--silent") + .redirectErrorStream(true) + .start() + + coroutineScope { + launch(Dispatchers.IO) { + process.inputStream.bufferedReader().use { reader -> + reader.lineSequence().forEach { line -> + onOutput?.invoke("$line\n") + } + } + } + } + + return@withContext process + } catch (e: Exception) { + logger.warn("WebArtifactExecutor") { "Failed to start Node.js http-server: ${e.message}" } + } + + null + } +} + diff --git a/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/ArtifactExecutorTest.kt b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/ArtifactExecutorTest.kt new file mode 100644 index 0000000000..93a26df650 --- /dev/null +++ b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/ArtifactExecutorTest.kt @@ -0,0 +1,290 @@ +package cc.unitmesh.agent.artifact + +import cc.unitmesh.agent.ArtifactAgent +import kotlinx.coroutines.runBlocking +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class ArtifactExecutorTest { + + private fun createTempDir(name: String): File { + val dir = File(System.getProperty("java.io.tmpdir"), "artifact-executor-test-$name-${System.currentTimeMillis()}") + dir.mkdirs() + return dir + } + + @Test + fun nodeJsArtifactShouldHaveCorrectPackageJson() = runBlocking { + val artifact = ArtifactAgent.Artifact( + identifier = "nodejs-test", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Node.js Test App", + content = """ + console.log('Hello from Node.js!'); + """.trimIndent() + ) + + val bundle = ArtifactBundle.fromArtifact( + artifact = artifact, + conversationHistory = emptyList(), + modelInfo = null + ).copy( + dependencies = mapOf("express" to "^4.18.2") + ) + + // Verify bundle type + assertTrue(bundle.type == ArtifactType.NODEJS, "Bundle type should be NODEJS") + assertTrue(bundle.mainContent.contains("Hello from Node.js"), "Should contain Node.js code") + + // Verify package.json generation + val packageJson = bundle.generatePackageJson() + // Note: We no longer include "type": "module" to support both CommonJS and ES modules + assertTrue(packageJson.contains("\"main\": \"index.js\""), "Should have index.js as main") + assertTrue(packageJson.contains("\"express\""), "Should contain express dependency") + assertTrue(packageJson.contains("\"start\": \"node index.js\""), "Should have start script") + } + + @Test + fun nodeJsArtifactShouldPackAndExtractCorrectly() = runBlocking { + val tempDir = createTempDir("nodejs-pack") + try { + val artifact = ArtifactAgent.Artifact( + identifier = "nodejs-pack-test", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Node.js Pack Test", + content = """ + import express from 'express'; + const app = express(); + app.get('/', (req, res) => res.json({ message: 'Hello' })); + app.listen(3000); + """.trimIndent() + ) + + val bundle = ArtifactBundle.fromArtifact( + artifact = artifact, + conversationHistory = emptyList(), + modelInfo = null + ).copy( + dependencies = mapOf("express" to "^4.18.2") + ) + + // Pack bundle + val outputFile = File(tempDir, "nodejs-test.unit") + val packer = ArtifactBundlePacker() + val packResult = packer.pack(bundle, outputFile.absolutePath) + + assertTrue(packResult is PackResult.Success, "Pack should succeed") + + // Extract to directory + val extractDir = File(tempDir, "extracted") + val extractResult = packer.extractToDirectory(outputFile.absolutePath, extractDir.absolutePath) + + assertTrue(extractResult is PackResult.Success, "Extract should succeed") + + // Verify extracted files + val indexJs = File(extractDir, "index.js") + val packageJson = File(extractDir, "package.json") + + assertTrue(indexJs.exists(), "index.js should exist") + assertTrue(packageJson.exists(), "package.json should exist") + + // Verify content + val indexContent = indexJs.readText() + assertTrue(indexContent.contains("express"), "index.js should contain express") + + val packageContent = packageJson.readText() + assertTrue(packageContent.contains("\"express\""), "package.json should contain express") + // Note: We no longer include "type": "module" to support both CommonJS and ES modules + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun artifactExecutorShouldExtractUnitFile() = runBlocking { + val tempDir = createTempDir("executor-extract") + try { + // Create a simple Node.js artifact + val artifact = ArtifactAgent.Artifact( + identifier = "executor-test", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Executor Test", + content = """ + console.log('Test output'); + """.trimIndent() + ) + + val bundle = ArtifactBundle.fromArtifact( + artifact = artifact, + conversationHistory = emptyList(), + modelInfo = null + ) + + // Pack bundle + val unitFile = File(tempDir, "test.unit") + val packer = ArtifactBundlePacker() + val packResult = packer.pack(bundle, unitFile.absolutePath) + + assertTrue(packResult is PackResult.Success, "Pack should succeed") + + // Test extraction (without actual execution, as it requires npm/node) + val extractDir = File(tempDir, "extracted") + val extractResult = packer.extractToDirectory(unitFile.absolutePath, extractDir.absolutePath) + + assertTrue(extractResult is PackResult.Success, "Extract should succeed") + + // Verify files exist + val indexJs = File(extractDir, "index.js") + val packageJson = File(extractDir, "package.json") + + assertTrue(indexJs.exists(), "index.js should exist after extraction") + assertTrue(packageJson.exists(), "package.json should exist after extraction") + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun nodeJsArtifactTypeShouldBeRecognized() { + // Test ArtifactType enum + val nodejsType = ArtifactType.NODEJS + assertTrue(nodejsType.extension == "js", "NODEJS extension should be 'js'") + assertTrue(nodejsType.mimeType == "application/autodev.artifacts.nodejs", "NODEJS mime type should match") + + // Test fromExtension + val fromExt = ArtifactType.fromExtension("js") + assertTrue(fromExt == ArtifactType.NODEJS, "Should recognize .js extension as NODEJS") + + // Test getMainFileName + val bundle = ArtifactBundle( + id = "test", + name = "Test", + description = "Test", + type = ArtifactType.NODEJS, + mainContent = "console.log('test');", + context = ArtifactContext() + ) + assertTrue(bundle.getMainFileName() == "index.js", "Main file name should be index.js") + } + + @Test + fun selectBestArtifactShouldSkipPackageJson() { + // Simulate AI generating two artifacts: package.json and code + val packageJsonArtifact = ArtifactAgent.Artifact( + identifier = "express-hello-world", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Express.js Hello World", + content = """ + { + "name": "express-hello-world", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2" + } + } + """.trimIndent() + ) + + val codeArtifact = ArtifactAgent.Artifact( + identifier = "express-hello-world-app", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Express.js App", + content = """ + const express = require('express'); + const app = express(); + app.get('/', (req, res) => res.json({ message: 'Hello World!' })); + app.listen(3000, () => console.log('Server running')); + """.trimIndent() + ) + + // Test selectBestArtifact + val artifacts = listOf(packageJsonArtifact, codeArtifact) + val selected = ArtifactBundle.selectBestArtifact(artifacts) + + assertTrue(selected != null, "Should select an artifact") + assertTrue(selected!!.identifier == "express-hello-world-app", "Should select code artifact, not package.json") + assertTrue(selected.content.contains("const express"), "Selected artifact should contain actual code") + } + + @Test + fun selectBestArtifactShouldReturnSingleArtifact() { + val singleArtifact = ArtifactAgent.Artifact( + identifier = "single-app", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Single App", + content = "console.log('Hello');" + ) + + val selected = ArtifactBundle.selectBestArtifact(listOf(singleArtifact)) + assertTrue(selected != null, "Should return single artifact") + assertTrue(selected!!.identifier == "single-app", "Should be the same artifact") + } + + @Test + fun selectBestArtifactShouldHandleEmptyList() { + val selected = ArtifactBundle.selectBestArtifact(emptyList()) + assertTrue(selected == null, "Should return null for empty list") + } + + @Test + fun nodeJsArtifactShouldAutoDetectDependencies() { + val codeWithRequire = """ + const express = require('express'); + const cors = require('cors'); + const path = require('path'); // built-in, should be ignored + const fs = require('fs'); // built-in, should be ignored + + const app = express(); + app.use(cors()); + app.listen(3000); + """.trimIndent() + + val artifact = ArtifactAgent.Artifact( + identifier = "deps-test", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Deps Test", + content = codeWithRequire + ) + + val bundle = ArtifactBundle.fromArtifact(artifact) + + // Should detect express and cors, but not path and fs (built-ins) + assertTrue(bundle.dependencies.containsKey("express"), "Should detect express dependency") + assertTrue(bundle.dependencies.containsKey("cors"), "Should detect cors dependency") + assertTrue(!bundle.dependencies.containsKey("path"), "Should not include path (built-in)") + assertTrue(!bundle.dependencies.containsKey("fs"), "Should not include fs (built-in)") + + // Verify package.json contains dependencies + val packageJson = bundle.generatePackageJson() + assertTrue(packageJson.contains("\"express\""), "package.json should contain express") + assertTrue(packageJson.contains("\"cors\""), "package.json should contain cors") + } + + @Test + fun nodeJsArtifactShouldAutoDetectImportDependencies() { + val codeWithImport = """ + import express from 'express'; + import { Router } from 'express'; + import axios from 'axios'; + import path from 'path'; // built-in + + const app = express(); + const router = Router(); + """.trimIndent() + + val artifact = ArtifactAgent.Artifact( + identifier = "import-test", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Import Test", + content = codeWithImport + ) + + val bundle = ArtifactBundle.fromArtifact(artifact) + + assertTrue(bundle.dependencies.containsKey("express"), "Should detect express from import") + assertTrue(bundle.dependencies.containsKey("axios"), "Should detect axios from import") + assertTrue(!bundle.dependencies.containsKey("path"), "Should not include path (built-in)") + } +} + diff --git a/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/NodeJsArtifactBundleTest.kt b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/NodeJsArtifactBundleTest.kt new file mode 100644 index 0000000000..731d798b28 --- /dev/null +++ b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/NodeJsArtifactBundleTest.kt @@ -0,0 +1,174 @@ +package cc.unitmesh.agent.artifact + +import cc.unitmesh.agent.ArtifactAgent +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.zip.ZipFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for Node.js artifact bundle creation and validation + */ +class NodeJsArtifactBundleTest { + + private fun createTempDir(name: String): File { + val dir = File(System.getProperty("java.io.tmpdir"), "nodejs-artifact-test-$name-${System.currentTimeMillis()}") + dir.mkdirs() + return dir + } + + @Test + fun nodeJsBundleShouldContainCorrectFiles() = runBlocking { + val tempDir = createTempDir("nodejs-files") + try { + val artifact = ArtifactAgent.Artifact( + identifier = "express-app", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Express.js Application", + content = """ + import express from 'express'; + const app = express(); + app.get('/', (req, res) => res.json({ message: 'Hello' })); + app.listen(3000, () => console.log('Server running')); + """.trimIndent() + ) + + val bundle = ArtifactBundle.fromArtifact( + artifact = artifact, + conversationHistory = emptyList(), + modelInfo = null + ).copy( + dependencies = mapOf("express" to "^4.18.2") + ) + + val outputFile = File(tempDir, "express-app.unit") + val packer = ArtifactBundlePacker() + val result = packer.pack(bundle, outputFile.absolutePath) + + assertTrue(result is PackResult.Success, "Pack should succeed") + + // Verify ZIP contents + ZipFile(outputFile).use { zip -> + val entries = zip.entries().toList() + val entryNames = entries.map { it.name } + + // Should contain all required files + assertTrue(entryNames.contains("index.js"), "Should contain index.js") + assertTrue(entryNames.contains("package.json"), "Should contain package.json") + assertTrue(entryNames.contains("ARTIFACT.md"), "Should contain ARTIFACT.md") + assertTrue(entryNames.contains(".artifact/context.json"), "Should contain context.json") + + // Verify index.js content + val indexEntry = entries.find { it.name == "index.js" }!! + val indexContent = zip.getInputStream(indexEntry).bufferedReader().readText() + assertTrue(indexContent.contains("express"), "index.js should contain express import") + assertTrue(indexContent.contains("app.listen"), "index.js should contain app.listen") + + // Verify package.json content + val packageEntry = entries.find { it.name == "package.json" }!! + val packageContent = zip.getInputStream(packageEntry).bufferedReader().readText() + assertTrue(packageContent.contains("\"type\": \"module\""), "package.json should have module type") + assertTrue(packageContent.contains("\"express\""), "package.json should contain express dependency") + assertTrue(packageContent.contains("\"start\": \"node index.js\""), "package.json should have start script") + } + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun nodeJsBundleShouldRoundtripCorrectly() = runBlocking { + val tempDir = createTempDir("nodejs-roundtrip") + try { + val originalArtifact = ArtifactAgent.Artifact( + identifier = "roundtrip-nodejs", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Roundtrip Node.js", + content = """ + console.log('Roundtrip test'); + export default function() { return 'test'; } + """.trimIndent() + ) + + val originalBundle = ArtifactBundle.fromArtifact( + artifact = originalArtifact, + conversationHistory = emptyList(), + modelInfo = null + ).copy( + dependencies = mapOf("express" to "^4.18.2") + ) + + // Pack + val outputFile = File(tempDir, "roundtrip.unit") + val packer = ArtifactBundlePacker() + val packResult = packer.pack(originalBundle, outputFile.absolutePath) + assertTrue(packResult is PackResult.Success, "Pack should succeed") + + // Unpack + val unpackResult = packer.unpack(outputFile.absolutePath) + assertTrue(unpackResult is UnpackResult.Success, "Unpack should succeed") + + val restoredBundle = (unpackResult as UnpackResult.Success).bundle + + // Verify restored data + assertEquals(originalBundle.name, restoredBundle.name, "Name should match") + assertEquals(originalBundle.type, restoredBundle.type, "Type should be NODEJS") + assertEquals(ArtifactType.NODEJS, restoredBundle.type, "Type should be NODEJS") + assertTrue(restoredBundle.mainContent.contains("Roundtrip test"), "Content should match") + assertTrue(restoredBundle.dependencies.containsKey("express"), "Dependencies should match") + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun nodeJsArtifactMdShouldContainCorrectInstructions() = runBlocking { + val artifact = ArtifactAgent.Artifact( + identifier = "nodejs-md-test", + type = ArtifactAgent.Artifact.ArtifactType.NODEJS, + title = "Node.js MD Test", + content = "console.log('test');" + ) + + val bundle = ArtifactBundle.fromArtifact( + artifact = artifact, + conversationHistory = emptyList(), + modelInfo = null + ) + + val artifactMd = bundle.generateArtifactMd() + + // Should contain Node.js specific instructions + assertTrue(artifactMd.contains("npm install"), "Should contain npm install instruction") + assertTrue(artifactMd.contains("node index.js"), "Should contain node index.js instruction") + assertTrue(artifactMd.contains("type: nodejs"), "Should contain nodejs type") + } + + @Test + fun nodeJsPackageJsonShouldHaveCorrectStructure() { + val bundle = ArtifactBundle( + id = "test-nodejs", + name = "Test Node.js App", + description = "Test", + type = ArtifactType.NODEJS, + mainContent = "console.log('test');", + dependencies = mapOf("express" to "^4.18.2", "cors" to "^2.8.5"), + context = ArtifactContext() + ) + + val packageJson = bundle.generatePackageJson() + + // Verify structure + assertTrue(packageJson.contains("\"name\": \"test-nodejs\""), "Should have name") + assertTrue(packageJson.contains("\"type\": \"module\""), "Should have module type") + assertTrue(packageJson.contains("\"main\": \"index.js\""), "Should have main entry") + assertTrue(packageJson.contains("\"start\": \"node index.js\""), "Should have start script") + assertTrue(packageJson.contains("\"setup\": \"npm install\""), "Should have setup script") + assertTrue(packageJson.contains("\"express\": \"^4.18.2\""), "Should contain express dependency") + assertTrue(packageJson.contains("\"cors\": \"^2.8.5\""), "Should contain cors dependency") + assertTrue(packageJson.contains("\"node\": \">=18\""), "Should have node engine requirement") + } +} + diff --git a/mpp-ui/build.gradle.kts b/mpp-ui/build.gradle.kts index 9623a92e1e..9acde5754f 100644 --- a/mpp-ui/build.gradle.kts +++ b/mpp-ui/build.gradle.kts @@ -508,8 +508,9 @@ compose.desktop { modules("java.naming", "java.sql") // File associations for .unit artifact bundles + // Note: .unit files are ZIP archives, so they can be opened with ZIP applications fileAssociation( - mimeType = "application/x-autodev-unit", + mimeType = "application/zip", extension = "unit", description = "AutoDev Unit Bundle" ) @@ -519,7 +520,7 @@ compose.desktop { bundleID = "cc.unitmesh.devins.desktop" iconFile.set(project.file("src/jvmMain/resources/icon.icns")) - // macOS-specific: register UTI for .unit files + // macOS-specific: register UTI for .unit files (as ZIP archives) infoPlist { extraKeysRawXml = """ CFBundleDocumentTypes @@ -543,11 +544,12 @@ compose.desktop { UTTypeIdentifier cc.unitmesh.devins.unit UTTypeDescription - AutoDev Unit Bundle + AutoDev Unit Bundle (ZIP Archive) UTTypeConformsTo - public.data + public.zip-archive public.archive + public.data UTTypeTagSpecification @@ -556,7 +558,7 @@ compose.desktop { unit public.mime-type - application/x-autodev-unit + application/zip diff --git a/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.android.kt b/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.android.kt index 960f1a6d1b..129ddaee3f 100644 --- a/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.android.kt +++ b/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.android.kt @@ -17,6 +17,7 @@ import cc.unitmesh.agent.ArtifactAgent actual fun ArtifactPreviewPanel( artifact: ArtifactAgent.Artifact, onConsoleLog: (String, String) -> Unit, + onFixRequest: ((ArtifactAgent.Artifact, String) -> Unit)?, modifier: Modifier ) { val htmlContent = remember(artifact.content) { diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactAgentViewModel.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactAgentViewModel.kt index cf689da1a3..b09d34cb1d 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactAgentViewModel.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactAgentViewModel.kt @@ -99,7 +99,8 @@ class ArtifactAgentViewModel( // Generation complete - set final artifact if (result.success && result.artifacts.isNotEmpty()) { - lastArtifact = result.artifacts.first() + // Select the best artifact (avoids package.json for Node.js) + lastArtifact = cc.unitmesh.agent.artifact.ArtifactBundle.selectBestArtifact(result.artifacts) streamingArtifact = null // Clear streaming state } else { result.error?.let { errorMsg -> @@ -126,6 +127,66 @@ class ArtifactAgentViewModel( } } + /** + * Fix a failed artifact based on execution error + * + * @param artifact The artifact that failed to execute + * @param errorMessage The error message from execution + */ + fun fixArtifact(artifact: ArtifactAgent.Artifact, errorMessage: String) { + if (isExecuting) return + + if (llmService == null) { + renderer.renderError("WARNING: LLM model is not configured. Please configure your model to continue.") + return + } + + val agent = getArtifactAgent() ?: return + + isExecuting = true + renderer.clearError() + + // Add a message indicating we're fixing the artifact + renderer.addUserMessage("🔧 Fixing artifact due to execution error:\n```\n${errorMessage.take(500)}\n```") + streamingArtifact = null + + currentExecutionJob = scope.launch { + val contentBuilder = StringBuilder() + + try { + val result = agent.fix(artifact, errorMessage) { chunk -> + contentBuilder.append(chunk) + updateStreamingArtifact(contentBuilder.toString()) + } + + if (result.success && result.artifacts.isNotEmpty()) { + lastArtifact = cc.unitmesh.agent.artifact.ArtifactBundle.selectBestArtifact(result.artifacts) + streamingArtifact = null + } else { + result.error?.let { errorMsg -> + renderer.renderError(errorMsg) + } + } + + isExecuting = false + currentExecutionJob = null + } catch (e: kotlinx.coroutines.CancellationException) { + renderer.forceStop() + renderer.renderError("Fix cancelled by user") + streamingArtifact = null + isExecuting = false + currentExecutionJob = null + } catch (e: Exception) { + renderer.renderError(e.message ?: "Unknown error during fix") + streamingArtifact = null + isExecuting = false + currentExecutionJob = null + } finally { + saveConversationHistory() + } + } + } + /** * Parse streaming content and update artifact preview in real-time */ @@ -256,6 +317,7 @@ class ArtifactAgentViewModel( val artifactType = when (bundle.type) { ArtifactType.HTML -> ArtifactAgent.Artifact.ArtifactType.HTML ArtifactType.REACT -> ArtifactAgent.Artifact.ArtifactType.REACT + ArtifactType.NODEJS -> ArtifactAgent.Artifact.ArtifactType.NODEJS ArtifactType.PYTHON -> ArtifactAgent.Artifact.ArtifactType.PYTHON ArtifactType.SVG -> ArtifactAgent.Artifact.ArtifactType.SVG ArtifactType.MERMAID -> ArtifactAgent.Artifact.ArtifactType.MERMAID diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPage.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPage.kt index f4dca949b0..17195bf5c7 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPage.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPage.kt @@ -217,6 +217,9 @@ fun ArtifactPage( onConsoleLog = { level, message -> consoleLogs = appendConsoleLog(consoleLogs, level, message) }, + onFixRequest = { art, error -> + viewModel.fixArtifact(art, error) + }, modifier = Modifier.fillMaxSize() ) }, @@ -242,6 +245,9 @@ fun ArtifactPage( onConsoleLog = { level, message -> consoleLogs = appendConsoleLog(consoleLogs, level, message) }, + onFixRequest = { art, error -> + viewModel.fixArtifact(art, error) + }, modifier = Modifier.weight(0.7f).fillMaxWidth() ) ConsolePanel( @@ -277,12 +283,14 @@ private fun ArtifactPreviewPanelWithStreaming( artifact: ArtifactAgent.Artifact, isStreaming: Boolean, onConsoleLog: (String, String) -> Unit, + onFixRequest: ((ArtifactAgent.Artifact, String) -> Unit)? = null, modifier: Modifier = Modifier ) { Box(modifier = modifier) { ArtifactPreviewPanel( artifact = artifact, onConsoleLog = onConsoleLog, + onFixRequest = onFixRequest, modifier = Modifier.fillMaxSize() ) @@ -431,11 +439,17 @@ private fun ArtifactTopBar( /** * Artifact preview panel - shows WebView with generated HTML * This is an expect/actual pattern - JVM implementation uses KCEF + * + * @param artifact The artifact to preview + * @param onConsoleLog Callback for console log messages + * @param onFixRequest Callback when user requests to fix a failed artifact (artifact, errorMessage) + * @param modifier Modifier for the panel */ @Composable expect fun ArtifactPreviewPanel( artifact: ArtifactAgent.Artifact, onConsoleLog: (String, String) -> Unit, + onFixRequest: ((ArtifactAgent.Artifact, String) -> Unit)? = null, modifier: Modifier = Modifier ) diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/SketchRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/SketchRenderer.kt index a45429ce2c..8692074fee 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/SketchRenderer.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/SketchRenderer.kt @@ -2,13 +2,14 @@ package cc.unitmesh.devins.ui.compose.sketch import androidx.compose.foundation.layout.* import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cc.unitmesh.agent.Platform import cc.unitmesh.devins.parser.CodeFence import cc.unitmesh.devins.ui.compose.sketch.chart.ChartBlockRenderer import cc.unitmesh.devins.ui.compose.sketch.letsplot.LetsPlotBlockRenderer +import kotlinx.datetime.Clock /** * Sketch 渲染器 - 主渲染器 @@ -19,8 +20,26 @@ import cc.unitmesh.devins.ui.compose.sketch.letsplot.LetsPlotBlockRenderer * - Diff -> DiffSketchRenderer * - Thinking -> ThinkingBlockRenderer * - Walkthrough -> WalkthroughBlockRenderer + * + * Streaming Optimization: + * - Uses throttled rendering to reduce recomposition frequency during streaming + * - Caches parsed code fences to avoid redundant parsing + * - Only re-renders when content changes significantly (new lines or blocks) */ object SketchRenderer : BaseContentRenderer() { + /** + * Throttle interval for streaming content updates (in milliseconds). + * Lower values = more responsive but more recompositions. + * Higher values = less flickering but delayed updates. + */ + private const val STREAMING_THROTTLE_MS = 100L + + /** + * Minimum character change threshold to trigger re-render during streaming. + * Helps reduce flickering by batching small updates. + */ + private const val MIN_CHAR_CHANGE_THRESHOLD = 20 + /** * 渲染 LLM 响应内容(向后兼容的方法) * @@ -49,6 +68,10 @@ object SketchRenderer : BaseContentRenderer() { /** * 实现 ContentRenderer 接口的渲染方法 + * + * Optimized for streaming with throttled updates: + * - During streaming (isComplete=false): throttle updates to reduce flickering + * - After completion (isComplete=true): render immediately with full formatting */ @Composable override fun Render( @@ -59,9 +82,20 @@ object SketchRenderer : BaseContentRenderer() { ) { val blockSpacing = if (Platform.isJvm && !Platform.isAndroid) 4.dp else 8.dp + // Throttled content for streaming - reduces recomposition frequency + val throttledContent = rememberThrottledContent( + content = content, + isComplete = isComplete, + throttleMs = STREAMING_THROTTLE_MS, + minCharChange = MIN_CHAR_CHANGE_THRESHOLD + ) + + // Cache parsed code fences to avoid redundant parsing + val codeFences = remember(throttledContent) { + CodeFence.parseAll(throttledContent) + } + Column(modifier = modifier) { - // Parse and render main content - val codeFences = CodeFence.parseAll(content) // 通知外层当前渲染的块数量和最后一个块类型 if (codeFences.isNotEmpty()) { @@ -186,3 +220,72 @@ object SketchRenderer : BaseContentRenderer() { } } } + +/** + * Composable that throttles content updates during streaming to reduce flickering. + * + * During streaming (isComplete=false): + * - Only updates when content changes significantly (new lines or exceeds char threshold) + * - Uses time-based throttling to batch rapid updates + * + * After completion (isComplete=true): + * - Returns content immediately without throttling + * + * @param content The raw content string + * @param isComplete Whether streaming is complete + * @param throttleMs Minimum time between updates in milliseconds + * @param minCharChange Minimum character change to trigger update + * @return Throttled content string + */ +@Composable +private fun rememberThrottledContent( + content: String, + isComplete: Boolean, + throttleMs: Long, + minCharChange: Int +): String { + // When complete, always return the full content immediately + if (isComplete) { + return content + } + + // Use a data class to hold throttle state for cleaner updates + data class ThrottleState( + val renderedContent: String = "", + val updateTime: Long = 0L, + val lineCount: Int = 0 + ) + + var throttleState by remember { mutableStateOf(ThrottleState()) } + + // Calculate current metrics using kotlinx-datetime for KMP compatibility + val currentTime = Clock.System.now().toEpochMilliseconds() + val currentLineCount = content.count { it == '\n' } + val charDiff = content.length - throttleState.renderedContent.length + + // Determine if we should update based on: + // 1. New line added (important for markdown structure) + // 2. Significant character change + // 3. Time threshold exceeded + // 4. Content became shorter (user edit or reset) + // 5. First render (empty state) + val newLineAdded = currentLineCount > throttleState.lineCount + val significantChange = charDiff >= minCharChange || charDiff < 0 + val timeThresholdMet = (currentTime - throttleState.updateTime) >= throttleMs + val shouldUpdate = newLineAdded || + (significantChange && timeThresholdMet) || + throttleState.renderedContent.isEmpty() + + // Use SideEffect to update state after composition + SideEffect { + if (shouldUpdate) { + throttleState = ThrottleState( + renderedContent = content, + updateTime = currentTime, + lineCount = currentLineCount + ) + } + } + + return throttleState.renderedContent.ifEmpty { content } +} diff --git a/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.ios.kt b/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.ios.kt index 5b9bc69671..3dd08463a4 100644 --- a/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.ios.kt +++ b/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.ios.kt @@ -22,6 +22,7 @@ import cc.unitmesh.agent.ArtifactAgent actual fun ArtifactPreviewPanel( artifact: ArtifactAgent.Artifact, onConsoleLog: (String, String) -> Unit, + onFixRequest: ((ArtifactAgent.Artifact, String) -> Unit)?, modifier: Modifier ) { // For iOS, show source code view diff --git a/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.js.kt b/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.js.kt index 44e1b4a612..5fc8678bfe 100644 --- a/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.js.kt +++ b/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.js.kt @@ -31,6 +31,7 @@ import org.w3c.files.BlobPropertyBag actual fun ArtifactPreviewPanel( artifact: ArtifactAgent.Artifact, onConsoleLog: (String, String) -> Unit, + onFixRequest: ((ArtifactAgent.Artifact, String) -> Unit)?, modifier: Modifier ) { var showSource by remember { mutableStateOf(false) } diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/Main.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/Main.kt index c0755a8742..c6e9bf4360 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/Main.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/Main.kt @@ -27,6 +27,8 @@ import cc.unitmesh.devins.ui.desktop.DesktopWindowLayout import cc.unitmesh.devins.ui.desktop.UnitFileHandler import cc.unitmesh.devins.ui.desktop.FileOpenHandler import cc.unitmesh.agent.artifact.ArtifactBundle +import cc.unitmesh.devins.ui.compose.state.DesktopUiState +import cc.unitmesh.devins.ui.platform.createFileChooser import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.delay @@ -207,6 +209,11 @@ fun main(args: Array) { triggerFileChooser = true AutoDevLogger.info("AutoDevMain") { "Open File menu clicked" } }, + onOpenUnitBundle = { + appScope.launch { + openUnitBundleFile(uiState) + } + }, onExit = ::exitApplication ) @@ -245,3 +252,37 @@ fun main(args: Array) { } } } + +/** + * Open Unit Bundle file from file chooser + */ +private suspend fun openUnitBundleFile(uiState: DesktopUiState) { + try { + val fileChooser = createFileChooser() + val selectedPath = fileChooser.chooseFile( + title = "Open Unit Bundle", + fileExtensions = listOf("unit") + ) + + selectedPath?.let { path -> + AutoDevLogger.info("AutoDevMain") { "📦 Opening Unit Bundle: $path" } + + // Switch to Artifact mode + uiState.updateAgentType(AgentType.ARTIFACT) + + // Load the bundle + val success = withContext(Dispatchers.IO) { + UnitFileHandler.loadUnitFile(path) + } + + if (success) { + AutoDevLogger.info("AutoDevMain") { "✅ Unit Bundle loaded successfully: $path" } + } else { + AutoDevLogger.error("AutoDevMain") { "❌ Failed to load Unit Bundle: $path" } + } + } + } catch (e: Exception) { + AutoDevLogger.error("AutoDevMain") { "❌ Error opening Unit Bundle: ${e.message}" } + e.printStackTrace() + } +} diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.jvm.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.jvm.kt index be0c110368..2ef8ce389d 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.jvm.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.jvm.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll @@ -17,6 +18,9 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.agent.ArtifactAgent +import cc.unitmesh.agent.artifact.executor.ArtifactExecutorFactory +import cc.unitmesh.agent.artifact.executor.ExecutionResult +import cc.unitmesh.agent.logging.AutoDevLogger import cc.unitmesh.viewer.web.KcefInitState import cc.unitmesh.viewer.web.KcefManager import com.multiplatform.webview.jsbridge.IJsMessageHandler @@ -45,9 +49,11 @@ import javax.swing.filechooser.FileNameExtensionFilter actual fun ArtifactPreviewPanel( artifact: ArtifactAgent.Artifact, onConsoleLog: (String, String) -> Unit, + onFixRequest: ((ArtifactAgent.Artifact, String) -> Unit)?, modifier: Modifier ) { val scope = rememberCoroutineScope() + val logger = AutoDevLogger // Check KCEF initialization state val kcefInitState by KcefManager.initState.collectAsState() @@ -55,6 +61,15 @@ actual fun ArtifactPreviewPanel( // Toggle between preview and source view var showSource by remember { mutableStateOf(false) } + // Node.js execution state + var isNodeJsType by remember { mutableStateOf(artifact.type == ArtifactAgent.Artifact.ArtifactType.NODEJS) } + var isExecuting by remember { mutableStateOf(false) } + var isServerRunning by remember { mutableStateOf(false) } + var runningProcessId by remember { mutableStateOf(null) } + var serverUrl by remember { mutableStateOf(null) } + var executionOutput by remember { mutableStateOf("") } + var executionError by remember { mutableStateOf(null) } + // Prepare HTML with console.log interception script val htmlWithConsole = remember(artifact.content) { ArtifactConsoleBridgeJvm.injectConsoleCapture(artifact.content) @@ -82,6 +97,77 @@ actual fun ArtifactPreviewPanel( ) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + // Execute/Stop button for Node.js artifacts + if (isNodeJsType) { + if (isServerRunning && runningProcessId != null) { + // Stop button when server is running + IconButton( + onClick = { + runningProcessId?.let { pid -> + scope.launch { + cc.unitmesh.agent.artifact.executor.NodeJsArtifactExecutor.stopProcess(pid) + isServerRunning = false + runningProcessId = null + serverUrl = null + executionOutput += "\n🛑 Server stopped\n" + onConsoleLog("info", "Server stopped") + } + } + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = "Stop Server", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error + ) + } + } else { + // Execute button + IconButton( + onClick = { + if (!isExecuting) { + isExecuting = true + executionOutput = "" + executionError = null + serverUrl = null + scope.launch { + try { + executeNodeJsArtifact(artifact, onConsoleLog) { output, error, processId, url -> + executionOutput = output + executionError = error + if (processId != null && processId > 0) { + runningProcessId = processId + isServerRunning = true + serverUrl = url + } + } + } finally { + isExecuting = false + } + } + } + }, + modifier = Modifier.size(32.dp), + enabled = !isExecuting + ) { + if (isExecuting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Execute", + modifier = Modifier.size(18.dp) + ) + } + } + } + } + // Toggle source/preview IconButton( onClick = { showSource = !showSource }, @@ -94,20 +180,22 @@ actual fun ArtifactPreviewPanel( ) } - // Open in browser - IconButton( - onClick = { - scope.launch { - openInBrowser(artifact.content, artifact.title) - } - }, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.OpenInBrowser, - contentDescription = "Open in Browser", - modifier = Modifier.size(18.dp) - ) + // Open in browser (only for HTML artifacts) + if (!isNodeJsType) { + IconButton( + onClick = { + scope.launch { + openInBrowser(artifact.content, artifact.title) + } + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = "Open in Browser", + modifier = Modifier.size(18.dp) + ) + } } // Save file @@ -138,6 +226,32 @@ actual fun ArtifactPreviewPanel( ) } + isNodeJsType -> { + // Node.js execution view + NodeJsExecutionView( + artifact = artifact, + isExecuting = isExecuting, + isServerRunning = isServerRunning, + serverUrl = serverUrl, + output = executionOutput, + error = executionError, + onConsoleLog = onConsoleLog, + onFixRequest = onFixRequest, + onStopServer = { + runningProcessId?.let { pid -> + scope.launch { + cc.unitmesh.agent.artifact.executor.NodeJsArtifactExecutor.stopProcess(pid) + isServerRunning = false + runningProcessId = null + serverUrl = null + executionOutput += "\n🛑 Server stopped\n" + } + } + }, + modifier = Modifier.fillMaxSize() + ) + } + kcefInitState !is KcefInitState.Initialized -> { // KCEF not ready - show loading or fallback KcefStatusView( @@ -549,3 +663,234 @@ actual fun exportArtifactBundle( onNotification("error", "Failed to export: ${e.message}") } } + +/** + * Execute artifact (supports Node.js, Python, Web artifacts) + * + * @param artifact The artifact to execute + * @param onConsoleLog Callback for console log messages + * @param onResult Callback with (output, error, processId, serverUrl) + * processId > 0 means a server is running + */ +private suspend fun executeNodeJsArtifact( + artifact: ArtifactAgent.Artifact, + onConsoleLog: (String, String) -> Unit, + onResult: (String, String?, Long?, String?) -> Unit +) { + try { + // First, export artifact to a temporary .unit file + val tempUnitFile = File.createTempFile("artifact-${artifact.identifier}", ".unit") + // Note: Don't deleteOnExit for server processes - they need the files + + val bundle = cc.unitmesh.agent.artifact.ArtifactBundle.fromArtifact( + artifact = artifact, + conversationHistory = emptyList(), + modelInfo = null + ) + + val packer = cc.unitmesh.agent.artifact.ArtifactBundlePacker() + when (val packResult = packer.pack(bundle, tempUnitFile.absolutePath)) { + is cc.unitmesh.agent.artifact.PackResult.Success -> { + // Execute the .unit file using the factory (supports all artifact types) + when (val execResult = ArtifactExecutorFactory.executeArtifact( + unitFilePath = tempUnitFile.absolutePath, + onOutput = { line -> + onConsoleLog("info", line) + } + )) { + is ExecutionResult.Success -> { + val output = buildString { + append(execResult.output) + execResult.serverUrl?.let { url -> + append("\n\n🌐 Server URL: $url") + } + execResult.processId?.let { pid -> + if (pid > 0) { + append("\n📋 Process ID: $pid") + } + } + } + onResult(output, null, execResult.processId, execResult.serverUrl) + } + is ExecutionResult.Error -> { + onResult("", execResult.message, null, null) + } + } + } + is cc.unitmesh.agent.artifact.PackResult.Error -> { + onResult("", "Failed to create .unit file: ${packResult.message}", null, null) + } + } + } catch (e: Exception) { + onResult("", "Execution error: ${e.message}", null, null) + } +} + +/** + * Node.js execution view - shows terminal output with auto-fix and stop support + */ +@Composable +private fun NodeJsExecutionView( + artifact: ArtifactAgent.Artifact, + isExecuting: Boolean, + isServerRunning: Boolean = false, + serverUrl: String? = null, + output: String, + error: String?, + onConsoleLog: (String, String) -> Unit, + onFixRequest: ((ArtifactAgent.Artifact, String) -> Unit)? = null, + onStopServer: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp) + ) { + // Header with status + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Node.js Application", + style = MaterialTheme.typography.titleMedium + ) + // Server URL if available + if (serverUrl != null && isServerRunning) { + Text( + text = "🌐 $serverUrl", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when { + isExecuting -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Text( + text = "Starting...", + style = MaterialTheme.typography.bodySmall + ) + } + isServerRunning -> { + // Green dot indicator + Box( + modifier = Modifier + .size(8.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape + ) + ) + Text( + text = "Running", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + // Stop button + if (onStopServer != null) { + OutlinedButton( + onClick = onStopServer, + modifier = Modifier.height(28.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = "Stop", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Stop", + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Console output (main area) + Surface( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp) + ) { + SelectionContainer { + Box( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()) + .padding(12.dp) + ) { + Column { + if (output.isNotEmpty()) { + Text( + text = output, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 18.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + } else if (error != null) { + Text( + text = "Error: $error", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.error + ) + } else { + Text( + text = "Click the ▶ button to execute the Node.js application.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + // Fix button when there's an error + if (error != null && onFixRequest != null && !isExecuting && !isServerRunning) { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { onFixRequest(artifact, error) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Auto Fix with AI") + } + } + } +} diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/desktop/AutoDevMenuBar.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/desktop/AutoDevMenuBar.kt index 0304f04928..4d5a7a539a 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/desktop/AutoDevMenuBar.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/desktop/AutoDevMenuBar.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.launch @Composable fun FrameWindowScope.AutoDevMenuBar( onOpenFile: () -> Unit, + onOpenUnitBundle: () -> Unit, onExit: () -> Unit ) { val scope = rememberCoroutineScope() @@ -35,6 +36,13 @@ fun FrameWindowScope.AutoDevMenuBar( mnemonic = 'O' ) + Item( + "Open Unit Bundle...", + onClick = onOpenUnitBundle, + shortcut = Keymap.openUnitBundle, + mnemonic = 'U' + ) + Separator() Item( diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/desktop/Keymap.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/desktop/Keymap.kt index e93e9d6b61..3a08f9f6c2 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/desktop/Keymap.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/desktop/Keymap.kt @@ -34,6 +34,16 @@ object Keymap { ctrl = !isMac ) + /** + * Open Unit Bundle: Command+Shift+U (Mac) / Ctrl+Shift+U (Windows/Linux) + */ + val openUnitBundle: KeyShortcut = KeyShortcut( + Key.U, + meta = isMac, + ctrl = !isMac, + shift = true + ) + /** * Exit Application: Command+Q (Mac) / Ctrl+Q (Windows/Linux) */ diff --git a/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.wasmJs.kt b/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.wasmJs.kt index 5fa3ac3b74..2822a38e08 100644 --- a/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.wasmJs.kt +++ b/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifact/ArtifactPreviewPanel.wasmJs.kt @@ -24,6 +24,7 @@ import cc.unitmesh.agent.ArtifactAgent actual fun ArtifactPreviewPanel( artifact: ArtifactAgent.Artifact, onConsoleLog: (String, String) -> Unit, + onFixRequest: ((ArtifactAgent.Artifact, String) -> Unit)?, modifier: Modifier ) { Column(modifier = modifier) {