diff --git a/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.android.kt b/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.android.kt new file mode 100644 index 0000000000..5de50641d7 --- /dev/null +++ b/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.android.kt @@ -0,0 +1,6 @@ +package cc.unitmesh.agent.artifact + +/** + * Android implementation for time utilities + */ +actual fun getCurrentTimeMillis(): Long = System.currentTimeMillis() diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/AgentType.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/AgentType.kt index 92fda46de6..238ea40335 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/AgentType.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/AgentType.kt @@ -45,7 +45,12 @@ enum class AgentType { /** * Web edit mode - browse, select DOM elements, and interact with web pages */ - WEB_EDIT; + WEB_EDIT, + + /** + * Artifact Unit mode - create reversible executable artifacts from AI-generated code + */ + ARTIFACT_UNIT; fun getDisplayName(): String = when (this) { LOCAL_CHAT -> "Chat" @@ -55,6 +60,7 @@ enum class AgentType { CHAT_DB -> "ChatDB" REMOTE -> "Remote" WEB_EDIT -> "WebEdit" + ARTIFACT_UNIT -> "Unit" } companion object { @@ -67,6 +73,7 @@ enum class AgentType { "documentreader", "documents" -> KNOWLEDGE "chatdb", "database" -> CHAT_DB "webedit", "web" -> WEB_EDIT + "artifactunit", "artifact", "unit" -> ARTIFACT_UNIT else -> LOCAL_CHAT } } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactAgent.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactAgent.kt new file mode 100644 index 0000000000..273704d2bb --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactAgent.kt @@ -0,0 +1,90 @@ +package cc.unitmesh.agent.artifact + +import cc.unitmesh.agent.model.AgentContext +import cc.unitmesh.llm.LLMMessage + +/** + * Artifact Agent - specialized agent for creating reversible executable artifacts + * + * This agent combines code generation capabilities with artifact building + * to produce standalone executables that contain their own source code + * and generation history. + */ +interface ArtifactAgent { + /** + * Generate code and build an artifact from a user prompt + * + * @param prompt The user's request + * @param context The agent context + * @param type The type of artifact to build + * @return Result of the artifact generation + */ + suspend fun generateArtifact( + prompt: String, + context: AgentContext, + type: ArtifactType = ArtifactType.PYTHON_SCRIPT + ): ArtifactGenerationResult + + /** + * Restore a chat session from an extracted artifact + * + * @param payload The extracted artifact payload + * @return Restored messages for the chat interface + */ + fun restoreChatFromArtifact(payload: ArtifactPayload): List + + /** + * Update an existing artifact with new code changes + * + * @param originalPayload The original artifact payload + * @param newCode The updated code + * @param updateMessage Description of the update + * @return Result of the artifact update + */ + suspend fun updateArtifact( + originalPayload: ArtifactPayload, + newCode: Map, + updateMessage: String + ): ArtifactGenerationResult +} + +/** + * Result of artifact generation + */ +sealed class ArtifactGenerationResult { + /** + * Generation successful + */ + data class Success( + val payload: ArtifactPayload, + val generatedCode: String + ) : ArtifactGenerationResult() + + /** + * Generation failed + */ + data class Error( + val message: String, + val cause: Throwable? = null + ) : ArtifactGenerationResult() + + /** + * Generation in progress + */ + data class Progress( + val stage: GenerationStage, + val progress: Float, + val message: String + ) : ArtifactGenerationResult() +} + +/** + * Generation stages + */ +enum class GenerationStage { + ANALYZING_PROMPT, + GENERATING_CODE, + DETECTING_DEPENDENCIES, + CREATING_PAYLOAD, + COMPLETE +} diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBinaryFormat.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBinaryFormat.kt new file mode 100644 index 0000000000..3c39df5c95 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBinaryFormat.kt @@ -0,0 +1,129 @@ +package cc.unitmesh.agent.artifact + +/** + * Binary format constants for artifact packaging + * + * Defines the structure of the binary artifact file: + * [Runtime Shell] + [MAGIC_DELIMITER] + [Payload ZIP] + [Metadata JSON] + [Footer] + */ +object ArtifactBinaryFormat { + /** + * Magic delimiter to separate runtime shell from payload + * This is a unique byte sequence unlikely to appear in normal executables + */ + const val MAGIC_DELIMITER = "@@AUTODEV_ARTIFACT_DATA@@" + + /** + * Version of the binary format + */ + const val FORMAT_VERSION = "1.0.0" + + /** + * Maximum size of metadata JSON (to prevent reading entire file) + */ + const val MAX_METADATA_SIZE = 10 * 1024 * 1024 // 10 MB + + /** + * Maximum size of payload ZIP + */ + const val MAX_PAYLOAD_SIZE = 100 * 1024 * 1024 // 100 MB + + /** + * Footer size (contains offsets and checksums) + */ + const val FOOTER_SIZE = 128 + + /** + * Checksum algorithm + */ + const val CHECKSUM_ALGORITHM = "SHA-256" +} + +/** + * Footer structure for the artifact binary + * + * Located at the end of the file, contains pointers to locate the data sections + */ +data class ArtifactFooter( + /** + * Format version + */ + val formatVersion: String, + + /** + * Offset of the magic delimiter from start of file + */ + val delimiterOffset: Long, + + /** + * Offset of the payload ZIP from start of file + */ + val payloadOffset: Long, + + /** + * Size of the payload ZIP in bytes + */ + val payloadSize: Long, + + /** + * Offset of the metadata JSON from start of file + */ + val metadataOffset: Long, + + /** + * Size of the metadata JSON in bytes + */ + val metadataSize: Long, + + /** + * SHA-256 checksum of the payload + */ + val payloadChecksum: String, + + /** + * SHA-256 checksum of the metadata + */ + val metadataChecksum: String +) { + /** + * Serialize footer to byte array + */ + fun toBytes(): ByteArray { + // Format: version(16) + delimiterOffset(8) + payloadOffset(8) + payloadSize(8) + + // metadataOffset(8) + metadataSize(8) + payloadChecksum(64) + metadataChecksum(64) + val buffer = StringBuilder() + buffer.append(formatVersion.padEnd(16, '\u0000')) + buffer.append(delimiterOffset.toString().padEnd(8, '0')) + buffer.append(payloadOffset.toString().padEnd(8, '0')) + buffer.append(payloadSize.toString().padEnd(8, '0')) + buffer.append(metadataOffset.toString().padEnd(8, '0')) + buffer.append(metadataSize.toString().padEnd(8, '0')) + buffer.append(payloadChecksum.padEnd(64, '0')) + buffer.append(metadataChecksum.padEnd(64, '0')) + + return buffer.toString().encodeToByteArray() + } + + companion object { + /** + * Parse footer from byte array + */ + fun fromBytes(bytes: ByteArray): ArtifactFooter { + require(bytes.size >= ArtifactBinaryFormat.FOOTER_SIZE) { + "Invalid footer size: ${bytes.size}" + } + + val str = bytes.decodeToString() + return ArtifactFooter( + formatVersion = str.substring(0, 16).trimEnd('\u0000'), + delimiterOffset = str.substring(16, 24).toLong(), + payloadOffset = str.substring(24, 32).toLong(), + payloadSize = str.substring(32, 40).toLong(), + metadataOffset = str.substring(40, 48).toLong(), + metadataSize = str.substring(48, 56).toLong(), + payloadChecksum = str.substring(56, 120).trimEnd('0'), + metadataChecksum = str.substring(120, 184).trimEnd('0') + ) + } + } +} diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBuilder.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBuilder.kt new file mode 100644 index 0000000000..96b2825b37 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactBuilder.kt @@ -0,0 +1,137 @@ +package cc.unitmesh.agent.artifact + +/** + * Interface for building executable artifacts from AI-generated code + * + * This is a platform-independent interface. Platform-specific implementations + * handle the actual binary operations (file I/O, compression, etc.). + */ +interface ArtifactBuilder { + /** + * Build an artifact from a payload + * + * @param payload The artifact payload containing source code and metadata + * @param shellTemplate Path to the runtime shell template (pre-built executable) + * @return Result containing the artifact binary data or error + */ + suspend fun build(payload: ArtifactPayload, shellTemplate: String): ArtifactBuildResult + + /** + * Validate a payload before building + * + * @param payload The payload to validate + * @return Validation result with any errors or warnings + */ + fun validate(payload: ArtifactPayload): ValidationResult + + /** + * Get available runtime shell templates for a given artifact type + * + * @param type The artifact type + * @return List of available shell templates + */ + suspend fun getAvailableShells(type: ArtifactType): List +} + +/** + * Result of an artifact build operation + */ +sealed class ArtifactBuildResult { + /** + * Successful build with binary data + */ + data class Success( + val binaryData: ByteArray, + val fileName: String, + val metadata: ArtifactMetadata + ) : ArtifactBuildResult() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Success + + if (!binaryData.contentEquals(other.binaryData)) return false + if (fileName != other.fileName) return false + if (metadata != other.metadata) return false + + return true + } + + override fun hashCode(): Int { + var result = binaryData.contentHashCode() + result = 31 * result + fileName.hashCode() + result = 31 * result + metadata.hashCode() + return result + } + } + + /** + * Build failed with error + */ + data class Error( + val message: String, + val cause: Throwable? = null + ) : ArtifactBuildResult() + + /** + * Build in progress with status update + */ + data class Progress( + val stage: BuildStage, + val progress: Float, // 0.0 to 1.0 + val message: String + ) : ArtifactBuildResult() +} + +/** + * Build stages for progress reporting + */ +enum class BuildStage { + VALIDATING, + LOADING_SHELL, + COMPRESSING_PAYLOAD, + SERIALIZING_METADATA, + INJECTING_PAYLOAD, + WRITING_FOOTER, + FINALIZING, + COMPLETE +} + +/** + * Validation result + */ +data class ValidationResult( + val isValid: Boolean, + val errors: List = emptyList(), + val warnings: List = emptyList() +) { + companion object { + fun success() = ValidationResult(true) + fun error(vararg errors: String) = ValidationResult(false, errors.toList()) + fun withWarnings(vararg warnings: String) = ValidationResult(true, emptyList(), warnings.toList()) + } +} + +/** + * Runtime shell template information + */ +data class ShellTemplate( + val id: String, + val name: String, + val type: ArtifactType, + val platform: Platform, + val version: String, + val path: String, + val description: String = "" +) { + enum class Platform { + WINDOWS_X64, + WINDOWS_ARM64, + LINUX_X64, + LINUX_ARM64, + MACOS_X64, + MACOS_ARM64, + UNIVERSAL + } +} diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactExtractor.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactExtractor.kt new file mode 100644 index 0000000000..fd5c7b5913 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactExtractor.kt @@ -0,0 +1,113 @@ +package cc.unitmesh.agent.artifact + +/** + * Interface for extracting artifacts back into their components + * + * This enables the "reversible" workflow where users can drag & drop + * a previously generated artifact back into AutoDev to restore the + * chat context and iterate on the code. + */ +interface ArtifactExtractor { + /** + * Extract an artifact from binary data + * + * @param binaryData The artifact binary data + * @return Result containing the extracted payload or error + */ + suspend fun extract(binaryData: ByteArray): ArtifactExtractionResult + + /** + * Extract an artifact from a file path + * + * @param filePath Path to the artifact file + * @return Result containing the extracted payload or error + */ + suspend fun extractFromFile(filePath: String): ArtifactExtractionResult + + /** + * Check if a file is a valid artifact + * + * @param binaryData The binary data to check + * @return True if the data appears to be a valid artifact + */ + fun isValidArtifact(binaryData: ByteArray): Boolean + + /** + * Check if a file is a valid artifact + * + * @param filePath Path to the file to check + * @return True if the file appears to be a valid artifact + */ + suspend fun isValidArtifactFile(filePath: String): Boolean + + /** + * Extract only metadata without decompressing the full payload + * + * @param binaryData The artifact binary data + * @return Result containing just the metadata or error + */ + suspend fun extractMetadata(binaryData: ByteArray): ArtifactMetadataResult +} + +/** + * Result of an artifact extraction operation + */ +sealed class ArtifactExtractionResult { + /** + * Successful extraction with payload + */ + data class Success( + val payload: ArtifactPayload + ) : ArtifactExtractionResult() + + /** + * Extraction failed with error + */ + data class Error( + val message: String, + val cause: Throwable? = null + ) : ArtifactExtractionResult() + + /** + * Extraction in progress with status update + */ + data class Progress( + val stage: ExtractionStage, + val progress: Float, // 0.0 to 1.0 + val message: String + ) : ArtifactExtractionResult() +} + +/** + * Extraction stages for progress reporting + */ +enum class ExtractionStage { + READING_FILE, + LOCATING_DELIMITER, + READING_FOOTER, + VERIFYING_CHECKSUMS, + EXTRACTING_METADATA, + DECOMPRESSING_PAYLOAD, + PARSING_FILES, + COMPLETE +} + +/** + * Result of metadata-only extraction + */ +sealed class ArtifactMetadataResult { + /** + * Successful extraction with metadata + */ + data class Success( + val metadata: ArtifactMetadata + ) : ArtifactMetadataResult() + + /** + * Extraction failed with error + */ + data class Error( + val message: String, + val cause: Throwable? = null + ) : ArtifactMetadataResult() +} diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactMetadata.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactMetadata.kt new file mode 100644 index 0000000000..fc5a8cff63 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactMetadata.kt @@ -0,0 +1,109 @@ +package cc.unitmesh.agent.artifact + +import kotlinx.serialization.Serializable + +/** + * Metadata for an AI-generated artifact + * + * This contains all the information needed to reconstruct the artifact's + * generation context, including chat history, prompts, and configuration. + */ +@Serializable +data class ArtifactMetadata( + /** + * Unique identifier for this artifact + */ + val id: String, + + /** + * Display name for the artifact + */ + val name: String, + + /** + * Type of artifact (PYTHON_SCRIPT, WEB_APP, etc.) + */ + val type: ArtifactType, + + /** + * Original prompt that created this artifact + */ + val originalPrompt: String, + + /** + * Complete chat history used to generate this artifact + */ + val chatHistory: List, + + /** + * User intent description + */ + val userIntent: String = "", + + /** + * Timestamp when artifact was created (Unix timestamp in milliseconds) + */ + val createdAt: Long, + + /** + * Version of the artifact (for tracking iterations) + */ + val version: String = "1.0.0", + + /** + * Tags for categorization + */ + val tags: List = emptyList(), + + /** + * Additional custom metadata + */ + val customMetadata: Map = emptyMap() +) + +/** + * Simple chat message representation for metadata + */ +@Serializable +data class ChatMessage( + val role: String, // "user", "assistant", "system" + val content: String, + val timestamp: Long = 0L +) + +/** + * Type of artifact that can be built + */ +@Serializable +enum class ArtifactType(val displayName: String, val fileExtension: String) { + /** + * Python script with PEP 723 inline dependencies + */ + PYTHON_SCRIPT("Python Script", ".exe"), + + /** + * Web application (HTML/CSS/JS) + */ + WEB_APP("Web Application", ".exe"), + + /** + * Node.js application + */ + NODE_APP("Node.js Application", ".exe"), + + /** + * Generic executable + */ + GENERIC("Generic Executable", ".exe"); + + companion object { + fun fromString(type: String): ArtifactType { + return when (type.lowercase()) { + "python", "python_script" -> PYTHON_SCRIPT + "web", "web_app", "html" -> WEB_APP + "node", "nodejs", "node_app" -> NODE_APP + else -> GENERIC + } + } + } +} diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactPayload.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactPayload.kt new file mode 100644 index 0000000000..07d1c2c54b --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/ArtifactPayload.kt @@ -0,0 +1,125 @@ +package cc.unitmesh.agent.artifact + +import kotlinx.serialization.Serializable + +/** + * Payload structure for an artifact + * + * This contains all the files, dependencies, and configuration + * that make up an executable artifact. + */ +@Serializable +data class ArtifactPayload( + /** + * Metadata about the artifact + */ + val metadata: ArtifactMetadata, + + /** + * Source files (key = relative path, value = content) + */ + val sourceFiles: Map, + + /** + * Main entry point file path + */ + val entryPoint: String, + + /** + * Dependencies specification (PEP 723 for Python, package.json for Node, etc.) + */ + val dependencies: DependencySpec, + + /** + * Asset files (images, data files, etc.) as base64 + */ + val assets: Map = emptyMap(), + + /** + * Runtime configuration + */ + val runtimeConfig: RuntimeConfig = RuntimeConfig() +) + +/** + * Dependency specification + */ +@Serializable +data class DependencySpec( + /** + * Type of dependency specification + */ + val type: DependencyType, + + /** + * Raw dependency content (PEP 723 block, package.json content, etc.) + */ + val content: String, + + /** + * Parsed dependencies (package name -> version) + */ + val parsed: Map = emptyMap() +) + +/** + * Type of dependency specification + */ +@Serializable +enum class DependencyType { + /** + * Python PEP 723 inline script metadata + */ + PEP_723, + + /** + * Node.js package.json + */ + PACKAGE_JSON, + + /** + * Requirements.txt + */ + REQUIREMENTS_TXT, + + /** + * No dependencies + */ + NONE +} + +/** + * Runtime configuration for the artifact + */ +@Serializable +data class RuntimeConfig( + /** + * Python version requirement (e.g., "3.8+") + */ + val pythonVersion: String = "", + + /** + * Node.js version requirement (e.g., "18+") + */ + val nodeVersion: String = "", + + /** + * Environment variables + */ + val envVars: Map = emptyMap(), + + /** + * Command line arguments + */ + val args: List = emptyList(), + + /** + * Working directory + */ + val workingDir: String = ".", + + /** + * Whether to run in windowless mode (for GUI apps) + */ + val windowless: Boolean = false +) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/Pep723Parser.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/Pep723Parser.kt new file mode 100644 index 0000000000..e64a378454 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/Pep723Parser.kt @@ -0,0 +1,168 @@ +package cc.unitmesh.agent.artifact + +/** + * PEP 723 Parser - parses Python inline script metadata + * + * PEP 723 defines a format for embedding metadata in Python scripts: + * # /// script + * # requires-python = ">=3.11" + * # dependencies = [ + * # "requests<3", + * # "rich", + * # ] + * # /// + */ +object Pep723Parser { + private val SCRIPT_BLOCK_START = Regex("""^#\s*///\s*script\s*$""", RegexOption.MULTILINE) + private val SCRIPT_BLOCK_END = Regex("""^#\s*///\s*$""", RegexOption.MULTILINE) + private val DEPENDENCY_LINE = Regex("""^#\s*"([^"]+)"\s*,?\s*$""") + private val REQUIRES_PYTHON = Regex("""^#\s*requires-python\s*=\s*"([^"]+)"\s*$""") + private val DEPENDENCIES_START = Regex("""^#\s*dependencies\s*=\s*\[\s*$""") + private val DEPENDENCIES_END = Regex("""^#\s*]\s*$""") + + /** + * Parse PEP 723 metadata from Python script + * + * @param scriptContent The Python script content + * @return Parsed dependency specification or null if not found + */ + fun parse(scriptContent: String): DependencySpec? { + val lines = scriptContent.lines() + var inScriptBlock = false + var inDependencies = false + val dependencies = mutableListOf() + var requiresPython = "" + val rawMetadata = StringBuilder() + + for (line in lines) { + if (SCRIPT_BLOCK_START.matches(line)) { + inScriptBlock = true + rawMetadata.appendLine(line) + continue + } + + if (SCRIPT_BLOCK_END.matches(line) && inScriptBlock) { + rawMetadata.appendLine(line) + break + } + + if (!inScriptBlock) continue + + rawMetadata.appendLine(line) + + // Check for requires-python + val pythonMatch = REQUIRES_PYTHON.find(line) + if (pythonMatch != null) { + requiresPython = pythonMatch.groupValues[1] + continue + } + + // Check for dependencies array start + if (DEPENDENCIES_START.matches(line)) { + inDependencies = true + continue + } + + // Check for dependencies array end + if (DEPENDENCIES_END.matches(line) && inDependencies) { + inDependencies = false + continue + } + + // Parse dependency line + if (inDependencies) { + val depMatch = DEPENDENCY_LINE.find(line) + if (depMatch != null) { + dependencies.add(depMatch.groupValues[1]) + } + } + } + + if (rawMetadata.isEmpty()) { + return null + } + + // Parse dependencies into name -> version map + val parsed = dependencies.associate { dep -> + val parts = dep.split(Regex("[<>=!~]+"), limit = 2) + val name = parts[0].trim() + val version = if (parts.size > 1) parts[1].trim() else "*" + name to version + } + + return DependencySpec( + type = DependencyType.PEP_723, + content = rawMetadata.toString(), + parsed = parsed + ) + } + + /** + * Generate PEP 723 metadata block + * + * @param dependencies Map of package name to version constraint + * @param requiresPython Python version requirement + * @return PEP 723 formatted string + */ + fun generate( + dependencies: Map, + requiresPython: String = ">=3.8" + ): String { + val sb = StringBuilder() + sb.appendLine("# /// script") + sb.appendLine("# requires-python = \"$requiresPython\"") + + if (dependencies.isNotEmpty()) { + sb.appendLine("# dependencies = [") + dependencies.forEach { (name, version) -> + val versionSpec = if (version == "*") "" else version + sb.appendLine("# \"$name$versionSpec\",") + } + sb.appendLine("# ]") + } + + sb.appendLine("# ///") + return sb.toString() + } + + /** + * Extract PEP 723 block from script + * + * @param scriptContent The Python script content + * @return The PEP 723 block or null if not found + */ + fun extractBlock(scriptContent: String): String? { + val lines = scriptContent.lines() + var inScriptBlock = false + val block = StringBuilder() + + for (line in lines) { + if (SCRIPT_BLOCK_START.matches(line)) { + inScriptBlock = true + block.appendLine(line) + continue + } + + if (SCRIPT_BLOCK_END.matches(line) && inScriptBlock) { + block.appendLine(line) + break + } + + if (inScriptBlock) { + block.appendLine(line) + } + } + + return if (block.isEmpty()) null else block.toString() + } + + /** + * Check if script contains PEP 723 metadata + * + * @param scriptContent The Python script content + * @return True if PEP 723 metadata is present + */ + fun hasPep723Metadata(scriptContent: String): Boolean { + return SCRIPT_BLOCK_START.find(scriptContent) != null + } +} diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.kt new file mode 100644 index 0000000000..6cb46a82e0 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.kt @@ -0,0 +1,6 @@ +package cc.unitmesh.agent.artifact + +/** + * Expect declaration for platform-specific time utilities + */ +expect fun getCurrentTimeMillis(): Long diff --git a/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.ios.kt b/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.ios.kt new file mode 100644 index 0000000000..fb27ad0560 --- /dev/null +++ b/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.ios.kt @@ -0,0 +1,9 @@ +package cc.unitmesh.agent.artifact + +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 + +/** + * iOS implementation for time utilities + */ +actual fun getCurrentTimeMillis(): Long = (NSDate().timeIntervalSince1970() * 1000).toLong() diff --git a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.js.kt b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.js.kt new file mode 100644 index 0000000000..790698a929 --- /dev/null +++ b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.js.kt @@ -0,0 +1,8 @@ +package cc.unitmesh.agent.artifact + +import kotlin.js.Date + +/** + * JS implementation for time utilities + */ +actual fun getCurrentTimeMillis(): Long = Date.now().toLong() diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/JvmArtifactBuilder.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/JvmArtifactBuilder.kt new file mode 100644 index 0000000000..387a072773 --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/JvmArtifactBuilder.kt @@ -0,0 +1,192 @@ +package cc.unitmesh.agent.artifact + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.* +import java.security.MessageDigest +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * JVM implementation of ArtifactBuilder + * + * Builds executable artifacts by appending payload to runtime shells + */ +class JvmArtifactBuilder : ArtifactBuilder { + private val json = Json { prettyPrint = true } + + override suspend fun build( + payload: ArtifactPayload, + shellTemplate: String + ): ArtifactBuildResult = withContext(Dispatchers.IO) { + try { + // Validate payload first + val validation = validate(payload) + if (!validation.isValid) { + return@withContext ArtifactBuildResult.Error( + "Validation failed: ${validation.errors.joinToString(", ")}" + ) + } + + // Load runtime shell + val shellFile = File(shellTemplate) + if (!shellFile.exists()) { + return@withContext ArtifactBuildResult.Error( + "Runtime shell not found: $shellTemplate" + ) + } + + val shellBytes = shellFile.readBytes() + + // Compress payload + val payloadBytes = compressPayload(payload) + + // Serialize metadata + val metadataJson = json.encodeToString(payload.metadata) + val metadataBytes = metadataJson.toByteArray(Charsets.UTF_8) + + // Calculate checksums + val payloadChecksum = calculateSHA256(payloadBytes) + val metadataChecksum = calculateSHA256(metadataBytes) + + // Create footer + val delimiterOffset = shellBytes.size.toLong() + val payloadOffset = delimiterOffset + ArtifactBinaryFormat.MAGIC_DELIMITER.length + val metadataOffset = payloadOffset + payloadBytes.size + + val footer = ArtifactFooter( + formatVersion = ArtifactBinaryFormat.FORMAT_VERSION, + delimiterOffset = delimiterOffset, + payloadOffset = payloadOffset, + payloadSize = payloadBytes.size.toLong(), + metadataOffset = metadataOffset, + metadataSize = metadataBytes.size.toLong(), + payloadChecksum = payloadChecksum, + metadataChecksum = metadataChecksum + ) + + // Assemble binary + val binaryData = ByteArrayOutputStream().use { output -> + // Runtime shell + output.write(shellBytes) + + // Magic delimiter + output.write(ArtifactBinaryFormat.MAGIC_DELIMITER.toByteArray(Charsets.UTF_8)) + + // Payload + output.write(payloadBytes) + + // Metadata + output.write(metadataBytes) + + // Footer + output.write(footer.toBytes()) + + output.toByteArray() + } + + val fileName = "${payload.metadata.name}${payload.metadata.type.fileExtension}" + + ArtifactBuildResult.Success( + binaryData = binaryData, + fileName = fileName, + metadata = payload.metadata + ) + } catch (e: Exception) { + ArtifactBuildResult.Error( + message = "Build failed: ${e.message}", + cause = e + ) + } + } + + override fun validate(payload: ArtifactPayload): ValidationResult { + val errors = mutableListOf() + val warnings = mutableListOf() + + // Check if source files exist + if (payload.sourceFiles.isEmpty()) { + errors.add("No source files provided") + } + + // Check if entry point exists + if (!payload.sourceFiles.containsKey(payload.entryPoint)) { + errors.add("Entry point file not found: ${payload.entryPoint}") + } + + // Warn if no dependencies + if (payload.dependencies.type == DependencyType.NONE) { + warnings.add("No dependencies specified") + } + + // Check file sizes + val totalSize = payload.sourceFiles.values.sumOf { it.length } + if (totalSize > ArtifactBinaryFormat.MAX_PAYLOAD_SIZE) { + errors.add("Payload too large: $totalSize bytes") + } + + return if (errors.isEmpty()) { + ValidationResult(true, warnings = warnings) + } else { + ValidationResult(false, errors = errors, warnings = warnings) + } + } + + override suspend fun getAvailableShells(type: ArtifactType): List { + // In a real implementation, this would scan a directory of runtime shells + // For now, return an empty list as shells need to be provided separately + return emptyList() + } + + /** + * Compress payload to ZIP format + */ + private fun compressPayload(payload: ArtifactPayload): ByteArray { + return ByteArrayOutputStream().use { baos -> + ZipOutputStream(baos).use { zip -> + // Add source files + payload.sourceFiles.forEach { (path, content) -> + val entry = ZipEntry(path) + zip.putNextEntry(entry) + zip.write(content.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + + // Add dependency spec if present + if (payload.dependencies.type != DependencyType.NONE) { + val depFileName = when (payload.dependencies.type) { + DependencyType.PEP_723 -> "requirements.txt" + DependencyType.PACKAGE_JSON -> "package.json" + DependencyType.REQUIREMENTS_TXT -> "requirements.txt" + else -> "dependencies.txt" + } + val entry = ZipEntry(depFileName) + zip.putNextEntry(entry) + zip.write(payload.dependencies.content.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + + // Add assets + payload.assets.forEach { (path, base64Content) -> + val entry = ZipEntry("assets/$path") + zip.putNextEntry(entry) + // Decode base64 and write + zip.write(java.util.Base64.getDecoder().decode(base64Content)) + zip.closeEntry() + } + } + baos.toByteArray() + } + } + + /** + * Calculate SHA-256 checksum + */ + private fun calculateSHA256(data: ByteArray): String { + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(data) + return digest.joinToString("") { "%02x".format(it) } + } +} diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/JvmArtifactExtractor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/JvmArtifactExtractor.kt new file mode 100644 index 0000000000..9fc6dfc263 --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/JvmArtifactExtractor.kt @@ -0,0 +1,285 @@ +package cc.unitmesh.agent.artifact + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.* +import java.security.MessageDigest +import java.util.zip.ZipInputStream + +/** + * JVM implementation of ArtifactExtractor + * + * Extracts artifacts back into their constituent parts for editing + */ +class JvmArtifactExtractor : ArtifactExtractor { + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun extract(binaryData: ByteArray): ArtifactExtractionResult = + withContext(Dispatchers.IO) { + try { + // Read footer from end of file + if (binaryData.size < ArtifactBinaryFormat.FOOTER_SIZE) { + return@withContext ArtifactExtractionResult.Error( + "File too small to be a valid artifact" + ) + } + + val footerBytes = binaryData.sliceArray( + binaryData.size - ArtifactBinaryFormat.FOOTER_SIZE until binaryData.size + ) + val footer = ArtifactFooter.fromBytes(footerBytes) + + // Verify format version + if (footer.formatVersion != ArtifactBinaryFormat.FORMAT_VERSION) { + return@withContext ArtifactExtractionResult.Error( + "Unsupported format version: ${footer.formatVersion}" + ) + } + + // Extract metadata + val metadataBytes = binaryData.sliceArray( + footer.metadataOffset.toInt() until (footer.metadataOffset + footer.metadataSize).toInt() + ) + + // Verify metadata checksum + val metadataChecksum = calculateSHA256(metadataBytes) + if (metadataChecksum != footer.metadataChecksum) { + return@withContext ArtifactExtractionResult.Error( + "Metadata checksum mismatch - file may be corrupted" + ) + } + + val metadataJson = metadataBytes.decodeToString() + val metadata = json.decodeFromString(metadataJson) + + // Extract payload + val payloadBytes = binaryData.sliceArray( + footer.payloadOffset.toInt() until footer.metadataOffset.toInt() + ) + + // Verify payload checksum + val payloadChecksum = calculateSHA256(payloadBytes) + if (payloadChecksum != footer.payloadChecksum) { + return@withContext ArtifactExtractionResult.Error( + "Payload checksum mismatch - file may be corrupted" + ) + } + + // Decompress payload + val sourceFiles = decompressPayload(payloadBytes) + + // Reconstruct dependencies + val dependencies = detectDependencies(sourceFiles, metadata.type) + + val payload = ArtifactPayload( + metadata = metadata, + sourceFiles = sourceFiles, + entryPoint = detectEntryPoint(sourceFiles, metadata.type), + dependencies = dependencies + ) + + ArtifactExtractionResult.Success(payload) + } catch (e: Exception) { + ArtifactExtractionResult.Error( + message = "Extraction failed: ${e.message}", + cause = e + ) + } + } + + override suspend fun extractFromFile(filePath: String): ArtifactExtractionResult = + withContext(Dispatchers.IO) { + try { + val file = File(filePath) + if (!file.exists()) { + return@withContext ArtifactExtractionResult.Error( + "File not found: $filePath" + ) + } + + val binaryData = file.readBytes() + extract(binaryData) + } catch (e: Exception) { + ArtifactExtractionResult.Error( + message = "Failed to read file: ${e.message}", + cause = e + ) + } + } + + override fun isValidArtifact(binaryData: ByteArray): Boolean { + if (binaryData.size < ArtifactBinaryFormat.FOOTER_SIZE) { + return false + } + + try { + // Check for magic delimiter + val delimiterBytes = ArtifactBinaryFormat.MAGIC_DELIMITER.toByteArray(Charsets.UTF_8) + val searchSpace = binaryData.sliceArray( + 0 until minOf(binaryData.size, binaryData.size - ArtifactBinaryFormat.FOOTER_SIZE) + ) + + return searchSpace.contains(delimiterBytes) + } catch (e: Exception) { + return false + } + } + + override suspend fun isValidArtifactFile(filePath: String): Boolean = + withContext(Dispatchers.IO) { + try { + val file = File(filePath) + if (!file.exists()) { + return@withContext false + } + + // Read just enough to check for magic delimiter + file.inputStream().use { input -> + val buffer = ByteArray(1024 * 1024) // Read first 1MB + val bytesRead = input.read(buffer) + if (bytesRead > 0) { + return@withContext isValidArtifact(buffer.sliceArray(0 until bytesRead)) + } + } + + false + } catch (e: Exception) { + false + } + } + + override suspend fun extractMetadata(binaryData: ByteArray): ArtifactMetadataResult = + withContext(Dispatchers.IO) { + try { + if (binaryData.size < ArtifactBinaryFormat.FOOTER_SIZE) { + return@withContext ArtifactMetadataResult.Error( + "File too small to be a valid artifact" + ) + } + + val footerBytes = binaryData.sliceArray( + binaryData.size - ArtifactBinaryFormat.FOOTER_SIZE until binaryData.size + ) + val footer = ArtifactFooter.fromBytes(footerBytes) + + val metadataBytes = binaryData.sliceArray( + footer.metadataOffset.toInt() until (footer.metadataOffset + footer.metadataSize).toInt() + ) + + val metadataJson = metadataBytes.decodeToString() + val metadata = json.decodeFromString(metadataJson) + + ArtifactMetadataResult.Success(metadata) + } catch (e: Exception) { + ArtifactMetadataResult.Error( + message = "Failed to extract metadata: ${e.message}", + cause = e + ) + } + } + + /** + * Decompress ZIP payload + */ + private fun decompressPayload(payloadBytes: ByteArray): Map { + val sourceFiles = mutableMapOf() + + ByteArrayInputStream(payloadBytes).use { bais -> + ZipInputStream(bais).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + if (!entry.isDirectory) { + val content = zip.readBytes().decodeToString() + // Remove 'assets/' prefix if present + val path = entry.name.removePrefix("assets/") + sourceFiles[path] = content + } + entry = zip.nextEntry + } + } + } + + return sourceFiles + } + + /** + * Detect dependencies from extracted files + */ + private fun detectDependencies( + sourceFiles: Map, + type: ArtifactType + ): DependencySpec { + return when (type) { + ArtifactType.PYTHON_SCRIPT -> { + val mainFile = sourceFiles.values.firstOrNull() ?: "" + Pep723Parser.parse(mainFile) ?: DependencySpec( + type = DependencyType.NONE, + content = "" + ) + } + ArtifactType.WEB_APP, ArtifactType.NODE_APP -> { + val packageJson = sourceFiles["package.json"] ?: "" + if (packageJson.isNotEmpty()) { + DependencySpec( + type = DependencyType.PACKAGE_JSON, + content = packageJson + ) + } else { + DependencySpec(type = DependencyType.NONE, content = "") + } + } + else -> DependencySpec(type = DependencyType.NONE, content = "") + } + } + + /** + * Detect entry point from extracted files + */ + private fun detectEntryPoint( + sourceFiles: Map, + type: ArtifactType + ): String { + return when (type) { + ArtifactType.PYTHON_SCRIPT -> { + sourceFiles.keys.firstOrNull { it.endsWith(".py") } ?: "main.py" + } + ArtifactType.WEB_APP -> { + sourceFiles.keys.firstOrNull { it == "index.html" } ?: "index.html" + } + ArtifactType.NODE_APP -> { + sourceFiles.keys.firstOrNull { it == "index.js" || it == "main.js" } ?: "index.js" + } + else -> sourceFiles.keys.firstOrNull() ?: "main" + } + } + + /** + * Calculate SHA-256 checksum + */ + private fun calculateSHA256(data: ByteArray): String { + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(data) + return digest.joinToString("") { "%02x".format(it) } + } + + /** + * Helper to check if byte array contains a subsequence + */ + private fun ByteArray.contains(other: ByteArray): Boolean { + if (other.isEmpty()) return true + if (this.size < other.size) return false + + for (i in 0..this.size - other.size) { + var match = true + for (j in other.indices) { + if (this[i + j] != other[j]) { + match = false + break + } + } + if (match) return true + } + return false + } +} diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.jvm.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.jvm.kt new file mode 100644 index 0000000000..94e85e9ec0 --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.jvm.kt @@ -0,0 +1,6 @@ +package cc.unitmesh.agent.artifact + +/** + * JVM implementation for time utilities + */ +actual fun getCurrentTimeMillis(): Long = System.currentTimeMillis() diff --git a/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.wasmJs.kt b/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.wasmJs.kt new file mode 100644 index 0000000000..ff32a469e7 --- /dev/null +++ b/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/artifact/TimeUtils.wasmJs.kt @@ -0,0 +1,8 @@ +package cc.unitmesh.agent.artifact + +import kotlin.js.Date + +/** + * WASM implementation for time utilities + */ +actual fun getCurrentTimeMillis(): Long = Date.now().toLong() diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentInterfaceRouter.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentInterfaceRouter.kt index 873497e17a..34de13aa4f 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentInterfaceRouter.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentInterfaceRouter.kt @@ -3,6 +3,7 @@ package cc.unitmesh.devins.ui.compose.agent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import cc.unitmesh.agent.AgentType +import cc.unitmesh.devins.ui.compose.agent.artifactunit.ArtifactUnitPage import cc.unitmesh.devins.ui.compose.agent.chatdb.ChatDBPage import cc.unitmesh.devins.ui.compose.agent.codereview.CodeReviewPage import cc.unitmesh.devins.ui.compose.agent.webedit.WebEditPage @@ -141,6 +142,18 @@ fun AgentInterfaceRouter( ) } + AgentType.ARTIFACT_UNIT -> { + ArtifactUnitPage( + workspace = workspace, + llmService = llmService, + modifier = modifier, + onBack = { + onAgentTypeChange(AgentType.CODING) + }, + onNotification = onNotification + ) + } + AgentType.LOCAL_CHAT, AgentType.CODING -> { CodingAgentPage( diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactChatPane.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactChatPane.kt new file mode 100644 index 0000000000..bb7f8b9fa7 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactChatPane.kt @@ -0,0 +1,89 @@ +package cc.unitmesh.devins.ui.compose.agent.artifactunit + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.workspace.Workspace +import cc.unitmesh.llm.KoogLLMService + +/** + * Artifact Chat Pane - Left side chat interface for code generation + * + * This is integrated with the LLM service to generate code based on user prompts. + * The generated code is then displayed in the workbench on the right side. + */ +@Composable +fun ArtifactChatPane( + llmService: KoogLLMService?, + workspace: Workspace?, + onCodeGenerated: (Map) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + Icon( + Icons.Default.Chat, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Chat Interface", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Interact with AI to generate code for your artifact", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + + // Placeholder for future chat integration + // This will be similar to CodingAgentPage but specialized for artifact generation + OutlinedCard( + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Example prompts:", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + ExamplePrompt("Create a Hacker News scraper in Python") + ExamplePrompt("Build a simple TODO list web app") + ExamplePrompt("Generate a markdown to PDF converter") + } + } + } + } +} + +@Composable +private fun ExamplePrompt(text: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text("•", modifier = Modifier.padding(end = 8.dp)) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactUnitPage.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactUnitPage.kt new file mode 100644 index 0000000000..2634e9d8a1 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactUnitPage.kt @@ -0,0 +1,102 @@ +package cc.unitmesh.devins.ui.compose.agent.artifactunit + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import cc.unitmesh.agent.Platform +import cc.unitmesh.devins.ui.base.ResizableSplitPane +import cc.unitmesh.devins.ui.compose.agent.AgentTopAppBar +import cc.unitmesh.devins.ui.compose.agent.AgentTopAppBarActions +import cc.unitmesh.devins.workspace.Workspace +import cc.unitmesh.llm.KoogLLMService +import kotlinx.coroutines.flow.collectLatest + +/** + * Artifact Unit Page - Main page for creating reversible executable artifacts + * + * Left side: Chat interface for code generation + * Right side: Artifact workbench (Source Code / Run Preview / Binary Metadata tabs) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArtifactUnitPage( + workspace: Workspace? = null, + llmService: KoogLLMService?, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onNotification: (String, String) -> Unit = { _, _ -> } +) { + val viewModel = remember { ArtifactUnitViewModel(workspace) } + val state = viewModel.state + + // Collect notifications + LaunchedEffect(viewModel) { + viewModel.notificationEvent.collectLatest { (title, message) -> + onNotification(title, message) + } + } + + // Cleanup on dispose + DisposableEffect(viewModel) { + onDispose { + viewModel.dispose() + } + } + + val notMobile = (Platform.isAndroid || Platform.isIOS).not() + Scaffold( + modifier = modifier, + topBar = { + if (notMobile) { + AgentTopAppBar( + title = "Artifact Unit", + subtitle = workspace?.name, + onBack = onBack, + actions = { + AgentTopAppBarActions.DeleteButton( + onClick = { viewModel.clearArtifact() }, + contentDescription = "Clear Artifact" + ) + } + ) + } + } + ) { paddingValues -> + ResizableSplitPane( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + initialSplitRatio = 0.5f, + minRatio = 0.3f, + maxRatio = 0.7f, + saveKey = "artifact_unit_split_ratio", + first = { + // Left panel - Chat interface for code generation + ArtifactChatPane( + llmService = llmService, + workspace = workspace, + onCodeGenerated = { code -> + viewModel.updateSourceCode(code) + }, + modifier = Modifier.fillMaxSize() + ) + }, + second = { + // Right panel - Artifact workbench + ArtifactWorkbench( + state = state, + onTabSelected = viewModel::selectTab, + onTypeSelected = viewModel::selectArtifactType, + onBuildArtifact = { name, prompt, history -> + // Launch coroutine from parent scope + }, + onImportArtifact = { binaryData -> + // Launch coroutine from parent scope + }, + modifier = Modifier.fillMaxSize() + ) + } + ) + } +} diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactUnitViewModel.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactUnitViewModel.kt new file mode 100644 index 0000000000..e71847ecf1 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactUnitViewModel.kt @@ -0,0 +1,267 @@ +package cc.unitmesh.devins.ui.compose.agent.artifactunit + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import cc.unitmesh.agent.artifact.* +import cc.unitmesh.devins.workspace.Workspace +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +/** + * ViewModel for Artifact Unit page + * + * Manages state for the dual-pane artifact builder: + * - Left: Chat interface for code generation + * - Right: Artifact workbench (source/preview/metadata tabs) + */ +class ArtifactUnitViewModel( + private val workspace: Workspace? +) { + /** + * Current state of the artifact unit + */ + var state by mutableStateOf(ArtifactUnitState()) + private set + + /** + * Notification events (title, message) + */ + private val _notificationEvent = MutableSharedFlow>() + val notificationEvent: SharedFlow> = _notificationEvent + + /** + * Select a workbench tab + */ + fun selectTab(tab: WorkbenchTab) { + state = state.copy(selectedTab = tab) + } + + /** + * Update the generated source code + */ + fun updateSourceCode(code: Map) { + state = state.copy(sourceCode = code) + } + + /** + * Update the selected artifact type + */ + fun selectArtifactType(type: ArtifactType) { + state = state.copy(selectedType = type) + } + + /** + * Start building an artifact + */ + suspend fun buildArtifact( + name: String, + prompt: String, + chatHistory: List + ) { + state = state.copy(isBuildingArtifact = true, buildProgress = 0f) + + try { + // Create metadata + val metadata = ArtifactMetadata( + id = generateId(), + name = name, + type = state.selectedType, + originalPrompt = prompt, + chatHistory = chatHistory, + userIntent = prompt, + createdAt = getCurrentTimeMillis() + ) + + // Detect dependencies from source code + val dependencies = detectDependencies(state.sourceCode, state.selectedType) + + // Create payload + val payload = ArtifactPayload( + metadata = metadata, + sourceFiles = state.sourceCode, + entryPoint = detectEntryPoint(state.sourceCode, state.selectedType), + dependencies = dependencies + ) + + state = state.copy( + currentPayload = payload, + buildProgress = 0.5f + ) + + // Note: Actual building happens in platform-specific code + // Here we just prepare the payload + state = state.copy( + isBuildingArtifact = false, + buildProgress = 1f, + lastBuiltPayload = payload + ) + + _notificationEvent.emit("Build Ready" to "Artifact is ready to export") + } catch (e: Exception) { + state = state.copy( + isBuildingArtifact = false, + buildProgress = 0f, + buildError = e.message + ) + _notificationEvent.emit("Build Failed" to (e.message ?: "Unknown error")) + } + } + + /** + * Import an artifact from binary data + */ + suspend fun importArtifact(binaryData: ByteArray) { + state = state.copy(isImportingArtifact = true) + + try { + // Note: Actual extraction happens in platform-specific code + // For now, we just show a placeholder + _notificationEvent.emit("Import" to "Artifact import not yet implemented") + state = state.copy(isImportingArtifact = false) + } catch (e: Exception) { + state = state.copy(isImportingArtifact = false) + _notificationEvent.emit("Import Failed" to (e.message ?: "Unknown error")) + } + } + + /** + * Clear the current artifact state + */ + fun clearArtifact() { + state = state.copy( + sourceCode = emptyMap(), + currentPayload = null, + lastBuiltPayload = null, + buildError = null, + buildProgress = 0f + ) + } + + /** + * Detect dependencies from source code + */ + private fun detectDependencies( + sourceCode: Map, + type: ArtifactType + ): DependencySpec { + return when (type) { + ArtifactType.PYTHON_SCRIPT -> { + // Check main file for PEP 723 metadata + val mainFile = sourceCode.values.firstOrNull() ?: "" + Pep723Parser.parse(mainFile) ?: DependencySpec( + type = DependencyType.NONE, + content = "" + ) + } + ArtifactType.WEB_APP, ArtifactType.NODE_APP -> { + // Look for package.json + val packageJson = sourceCode["package.json"] ?: "" + if (packageJson.isNotEmpty()) { + DependencySpec( + type = DependencyType.PACKAGE_JSON, + content = packageJson + ) + } else { + DependencySpec(type = DependencyType.NONE, content = "") + } + } + else -> DependencySpec(type = DependencyType.NONE, content = "") + } + } + + /** + * Detect entry point file + */ + private fun detectEntryPoint( + sourceCode: Map, + type: ArtifactType + ): String { + return when (type) { + ArtifactType.PYTHON_SCRIPT -> { + sourceCode.keys.firstOrNull { it.endsWith(".py") } ?: "main.py" + } + ArtifactType.WEB_APP -> { + sourceCode.keys.firstOrNull { it == "index.html" } ?: "index.html" + } + ArtifactType.NODE_APP -> { + sourceCode.keys.firstOrNull { it == "index.js" || it == "main.js" } ?: "index.js" + } + else -> sourceCode.keys.firstOrNull() ?: "main" + } + } + + private fun generateId(): String { + return "artifact-${getCurrentTimeMillis()}" + } + + private fun getCurrentTimeMillis(): Long { + return cc.unitmesh.agent.artifact.getCurrentTimeMillis() + } + + /** + * Cleanup resources + */ + fun dispose() { + // Cleanup if needed + } +} + +/** + * State for the Artifact Unit page + */ +data class ArtifactUnitState( + /** + * Selected workbench tab + */ + val selectedTab: WorkbenchTab = WorkbenchTab.SOURCE_CODE, + + /** + * Selected artifact type + */ + val selectedType: ArtifactType = ArtifactType.PYTHON_SCRIPT, + + /** + * Generated source code (filename -> content) + */ + val sourceCode: Map = emptyMap(), + + /** + * Current payload being built + */ + val currentPayload: ArtifactPayload? = null, + + /** + * Last successfully built payload + */ + val lastBuiltPayload: ArtifactPayload? = null, + + /** + * Whether artifact is being built + */ + val isBuildingArtifact: Boolean = false, + + /** + * Build progress (0.0 to 1.0) + */ + val buildProgress: Float = 0f, + + /** + * Build error message + */ + val buildError: String? = null, + + /** + * Whether artifact is being imported + */ + val isImportingArtifact: Boolean = false +) + +/** + * Workbench tabs + */ +enum class WorkbenchTab(val displayName: String) { + SOURCE_CODE("Source Code"), + RUN_PREVIEW("Run Preview"), + BINARY_METADATA("Binary Metadata") +} diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactWorkbench.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactWorkbench.kt new file mode 100644 index 0000000000..a12b9194b4 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/artifactunit/ArtifactWorkbench.kt @@ -0,0 +1,332 @@ +package cc.unitmesh.devins.ui.compose.agent.artifactunit + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cc.unitmesh.agent.artifact.ArtifactType +import cc.unitmesh.agent.artifact.ChatMessage + +/** + * Artifact Workbench - Tabbed interface for viewing and editing artifacts + */ +@Composable +fun ArtifactWorkbench( + state: ArtifactUnitState, + onTabSelected: (WorkbenchTab) -> Unit, + onTypeSelected: (ArtifactType) -> Unit, + onBuildArtifact: (String, String, List) -> Unit, + onImportArtifact: (ByteArray) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + // Tab bar + TabRow( + selectedTabIndex = state.selectedTab.ordinal, + containerColor = MaterialTheme.colorScheme.surface + ) { + WorkbenchTab.entries.forEach { tab -> + Tab( + selected = state.selectedTab == tab, + onClick = { onTabSelected(tab) }, + text = { Text(tab.displayName) } + ) + } + } + + // Tab content + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + when (state.selectedTab) { + WorkbenchTab.SOURCE_CODE -> { + SourceCodeTab( + sourceCode = state.sourceCode, + selectedType = state.selectedType, + onTypeSelected = onTypeSelected, + modifier = Modifier.fillMaxSize() + ) + } + WorkbenchTab.RUN_PREVIEW -> { + RunPreviewTab( + sourceCode = state.sourceCode, + artifactType = state.selectedType, + modifier = Modifier.fillMaxSize() + ) + } + WorkbenchTab.BINARY_METADATA -> { + BinaryMetadataTab( + payload = state.currentPayload, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } +} + +/** + * Source Code Tab - Display generated code with syntax highlighting + */ +@Composable +fun SourceCodeTab( + sourceCode: Map, + selectedType: ArtifactType, + onTypeSelected: (ArtifactType) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.padding(16.dp)) { + // Artifact type selector + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Artifact Type:", style = MaterialTheme.typography.labelLarge) + + var expanded by remember { mutableStateOf(false) } + + Box { + OutlinedButton(onClick = { expanded = true }) { + Text(selectedType.displayName) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + ArtifactType.entries.forEach { type -> + DropdownMenuItem( + text = { Text(type.displayName) }, + onClick = { + onTypeSelected(type) + expanded = false + } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Source code display + if (sourceCode.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "No source code generated yet", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + sourceCode.forEach { (filename, content) -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + filename, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + content, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.small + ) + .padding(12.dp) + ) + } + } + } + } + } + } +} + +/** + * Run Preview Tab - Preview code execution (Pyodide for Python, iframe for Web) + */ +@Composable +fun RunPreviewTab( + sourceCode: Map, + artifactType: ArtifactType, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Run preview (Pyodide/WASM) - Coming Soon", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Binary Metadata Tab - Visualize binary structure + */ +@Composable +fun BinaryMetadataTab( + payload: cc.unitmesh.agent.artifact.ArtifactPayload?, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.padding(16.dp), + contentAlignment = if (payload == null) Alignment.Center else Alignment.TopStart + ) { + if (payload == null) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "No artifact built yet", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Text( + "Artifact Metadata", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + + // Metadata details + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + MetadataRow("Name", payload.metadata.name) + MetadataRow("Type", payload.metadata.type.displayName) + MetadataRow("Entry Point", payload.entryPoint) + MetadataRow("Files", payload.sourceFiles.size.toString()) + MetadataRow("Dependencies", payload.dependencies.parsed.size.toString()) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Binary structure visualization + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Binary Structure", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + + BinaryStructureItem("Runtime Shell", "Pre-built executable") + BinaryStructureItem("Magic Delimiter", "@@AUTODEV_ARTIFACT_DATA@@") + BinaryStructureItem("Payload (ZIP)", "Source files compressed") + BinaryStructureItem("Metadata (JSON)", "Chat history & context") + BinaryStructureItem("Footer", "Offsets & checksums") + } + } + } + } + } +} + +@Composable +private fun MetadataRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + value, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun BinaryStructureItem(name: String, description: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Icon( + Icons.Default.Description, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +}