diff --git a/.github/workflows/build_debug.yaml b/.github/workflows/build_debug.yaml new file mode 100644 index 000000000..dcd7e53ee --- /dev/null +++ b/.github/workflows/build_debug.yaml @@ -0,0 +1,36 @@ +name: Build Debug APK + +on: + push: + branches: [ "main" ] + paths: + - 'Android/**' + pull_request: + branches: [ "main" ] + paths: + - 'Android/**' + workflow_dispatch: + +jobs: + build_debug: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./Android/src + steps: + - name: Checkout the source code + uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Build Debug APK + run: ./gradlew assembleDebug + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: Android/src/app/build/outputs/apk/debug/app-debug.apk diff --git a/.github/workflows/release_apk.yaml b/.github/workflows/release_apk.yaml new file mode 100644 index 000000000..323092c99 --- /dev/null +++ b/.github/workflows/release_apk.yaml @@ -0,0 +1,31 @@ +name: Release APK + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./Android/src + steps: + - name: Checkout the source code + uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Build Release APK + run: ./gradlew assembleRelease + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: Android/src/app/build/outputs/apk/release/app-release-unsigned.apk + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Android/src/app/build.gradle.kts b/Android/src/app/build.gradle.kts index ad38c63a0..3094d26a3 100644 --- a/Android/src/app/build.gradle.kts +++ b/Android/src/app/build.gradle.kts @@ -110,6 +110,10 @@ dependencies { implementation(libs.firebase.messaging) implementation(libs.androidx.exifinterface) implementation(libs.moshi.kotlin) + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) kapt(libs.hilt.android.compiler) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingAgentTask.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingAgentTask.kt new file mode 100644 index 000000000..ec09fc13e --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingAgentTask.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.customtasks.codingagent + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Code +import androidx.compose.runtime.Composable +import com.google.ai.edge.gallery.customtasks.common.CustomTask +import com.google.ai.edge.gallery.customtasks.common.CustomTaskData +import com.google.ai.edge.gallery.data.BuiltInTaskId +import com.google.ai.edge.gallery.data.Category +import com.google.ai.edge.gallery.data.Model +import com.google.ai.edge.gallery.data.Task +import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper +import com.google.ai.edge.litertlm.Contents +import com.google.ai.edge.litertlm.tool +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +private const val CODING_AGENT_SYSTEM_PROMPT = """ +You are an expert software engineering agent. You help the user with coding tasks in their GitHub repository. +You have access to tools to read files, write files, list directories, and create pull requests. + +CRITICAL RULES: +1. ALWAYS start by proposing a step-by-step plan. +2. User MUST approve any file modifications (write_file) via the diff viewer. +3. Be concise and professional. +4. If a tool fails, analyze the error and propose an alternative. +5. Before editing, check if you are on the correct branch (avoid main). + +Your available tools are: +- read_file(path: String) +- write_file(path: String, content: String, commit_message: String) +- list_directory(path: String) +- create_pull_request(title: String, body: String, head_branch: String, base_branch: String) +- run_command(command: String) +""" + +class CodingAgentTask @Inject constructor( + val codingTools: CodingTools +) : CustomTask { + + override val task = Task( + id = BuiltInTaskId.LLM_CODING_AGENT, + label = "Agentic Coding", + description = "AI-assisted software development on-device.", + shortDescription = "Expert coding assistant", + category = Category.LLM, + icon = Icons.Outlined.Code, + models = mutableListOf(), + handleModelConfigChangesInTask = true, + defaultSystemPrompt = CODING_AGENT_SYSTEM_PROMPT + ) + + override fun initializeModelFn( + context: Context, + coroutineScope: CoroutineScope, + model: Model, + systemInstruction: Contents?, + onDone: (String) -> Unit + ) { + LlmChatModelHelper.initialize( + context = context, + model = model, + taskId = task.id, + supportImage = false, + supportAudio = false, + onDone = onDone, + systemInstruction = systemInstruction ?: Contents.of(task.defaultSystemPrompt), + tools = listOf(tool(codingTools)), + enableConversationConstrainedDecoding = true + ) + } + + override fun cleanUpModelFn( + context: Context, + coroutineScope: CoroutineScope, + model: Model, + onDone: () -> Unit + ) { + LlmChatModelHelper.cleanUp(model = model, onDone = onDone) + } + + @Composable + override fun MainScreen(data: Any) { + val customTaskData = data as CustomTaskData + CodingWorkspaceScreen( + task = task, + modelManagerViewModel = customTaskData.modelManagerViewModel, + bottomPadding = customTaskData.bottomPadding, + setAppBarControlsDisabled = customTaskData.setAppBarControlsDisabled, + setTopBarVisible = customTaskData.setTopBarVisible, + setCustomNavigateUpCallback = customTaskData.setCustomNavigateUpCallback + ) + } +} + +@Module +@InstallIn(SingletonComponent::class) +object CodingAgentTaskModule { + @Provides + @IntoSet + fun provideCodingAgentTask(codingTools: CodingTools): CustomTask { + return CodingAgentTask(codingTools) + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingTools.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingTools.kt new file mode 100644 index 000000000..6a273d9b0 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingTools.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.customtasks.codingagent + +import android.util.Base64 +import android.util.Log +import com.google.ai.edge.gallery.data.DataStoreRepository +import com.google.ai.edge.gallery.network.GitHubCreatePullRequest +import com.google.ai.edge.gallery.network.GitHubService +import com.google.ai.edge.gallery.network.GitHubUpdateFileRequest +import com.google.ai.edge.gallery.data.codingagent.Project +import com.google.ai.edge.litertlm.Tool +import com.google.ai.edge.litertlm.ToolParam +import com.google.ai.edge.litertlm.ToolSet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +private const val TAG = "AGCodingTools" + +class CodingTools @Inject constructor( + private val gitHubService: GitHubService, + private val dataStoreRepository: DataStoreRepository +) : ToolSet { + var currentProject: Project? = null + + @Tool(description = "Reads the content of a file from the current repository.") + fun read_file(@ToolParam(description = "The path to the file.") path: String): String { + val project = currentProject ?: return "Error: No project selected." + val token = dataStoreRepository.readSecret("github_token") ?: return "Error: Not logged in to GitHub." + val parts = project.githubRepoUrl.split("/") + + return runBlocking(Dispatchers.IO) { + try { + val response = gitHubService.getFileContent("token $token", parts[0], parts[1], path, project.branch) + if (response.isSuccessful) { + val fileContent = response.body() + if (fileContent?.encoding == "base64" && fileContent.content != null) { + String(Base64.decode(fileContent.content.replace("\n", ""), Base64.DEFAULT)) + } else { + fileContent?.content ?: "Error: File content is empty." + } + } else { + "Error: Failed to read file: ${response.message()}" + } + } catch (e: Exception) { + "Error: ${e.message}" + } + } + } + + @Tool(description = "Writes content to a file in the current repository. This creates a commit.") + fun write_file( + @ToolParam(description = "The path to the file.") path: String, + @ToolParam(description = "The new content of the file.") content: String, + @ToolParam(description = "The commit message.") commit_message: String + ): String { + val project = currentProject ?: return "Error: No project selected." + val token = dataStoreRepository.readSecret("github_token") ?: return "Error: Not logged in to GitHub." + val parts = project.githubRepoUrl.split("/") + + return runBlocking(Dispatchers.IO) { + try { + // First get the SHA of the file if it exists + val getFileResponse = gitHubService.getFileContent("token $token", parts[0], parts[1], path, project.branch) + val sha = if (getFileResponse.isSuccessful) getFileResponse.body()?.sha else null + + val base64Content = Base64.encodeToString(content.toByteArray(), Base64.NO_WRAP) + val request = GitHubUpdateFileRequest( + message = commit_message, + content = base64Content, + sha = sha, + branch = project.branch + ) + + val response = gitHubService.updateFile("token $token", parts[0], parts[1], path, request) + if (response.isSuccessful) { + "Success: File updated. Commit SHA: ${response.body()?.commit?.sha}" + } else { + "Error: Failed to write file: ${response.message()}" + } + } catch (e: Exception) { + "Error: ${e.message}" + } + } + } + + @Tool(description = "Lists files and directories in a given path.") + fun list_directory(@ToolParam(description = "The path to list.") path: String): String { + val project = currentProject ?: return "Error: No project selected." + val token = dataStoreRepository.readSecret("github_token") ?: return "Error: Not logged in to GitHub." + val parts = project.githubRepoUrl.split("/") + + return runBlocking(Dispatchers.IO) { + try { + // GitHub's trees API is better for recursive listing, but for a single directory we can use contents API + val response = gitHubService.getTree("token $token", parts[0], parts[1], project.branch) + if (response.isSuccessful) { + val tree = response.body()?.tree ?: emptyList() + val filtered = tree.filter { it.path.startsWith(path) && it.path.removePrefix(path).removePrefix("/").split("/").size == 1 } + filtered.joinToString("\n") { "${it.type}: ${it.path}" } + } else { + "Error: Failed to list directory: ${response.message()}" + } + } catch (e: Exception) { + "Error: ${e.message}" + } + } + } + + @Tool(description = "Creates a pull request.") + fun create_pull_request( + @ToolParam(description = "PR Title.") title: String, + @ToolParam(description = "PR Body.") body: String, + @ToolParam(description = "Head branch (source).") head_branch: String, + @ToolParam(description = "Base branch (target).") base_branch: String + ): String { + val project = currentProject ?: return "Error: No project selected." + val token = dataStoreRepository.readSecret("github_token") ?: return "Error: Not logged in to GitHub." + val parts = project.githubRepoUrl.split("/") + + return runBlocking(Dispatchers.IO) { + try { + val request = GitHubCreatePullRequest(title, body, head_branch, base_branch) + val response = gitHubService.createPullRequest("token $token", parts[0], parts[1], request) + if (response.isSuccessful) { + "Success: PR created at ${response.body()?.html_url}" + } else { + "Error: Failed to create PR: ${response.message()}" + } + } catch (e: Exception) { + "Error: ${e.message}" + } + } + } + + @Tool(description = "Runs a shell command (Simulated).") + fun run_command(@ToolParam(description = "Command to run.") command: String): String { + return "Simulation: Command '$command' executed successfully (mock output). Note: Real command execution is not supported in this environment yet." + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingWorkspaceScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingWorkspaceScreen.kt new file mode 100644 index 000000000..8818937b5 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingWorkspaceScreen.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.customtasks.codingagent + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.google.ai.edge.gallery.data.Task +import com.google.ai.edge.gallery.network.GitHubTreeEntry +import com.google.ai.edge.gallery.ui.modelmanager.ModelManagerViewModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CodingWorkspaceScreen( + task: Task, + modelManagerViewModel: ModelManagerViewModel, + bottomPadding: Dp, + setAppBarControlsDisabled: (Boolean) -> Unit, + setTopBarVisible: (Boolean) -> Unit, + setCustomNavigateUpCallback: (() -> Unit) -> Unit, + viewModel: CodingWorkspaceViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() + var inputText by remember { mutableStateOf("") } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet { + Column(modifier = Modifier.padding(16.dp)) { + Text("File Tree", style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn { + items(uiState.fileTree) { entry -> + FileTreeItem(entry) + } + } + } + } + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(uiState.project?.name ?: "Coding Workspace") }, + navigationIcon = { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Default.Menu, contentDescription = "Open File Tree") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .padding(bottom = bottomPadding) + ) { + // Chat Area + LazyColumn(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) { + items(uiState.messages) { msg -> + ChatBubble(msg) + } + } + + // Plan Panel (Collapsible) + if (uiState.plan.isNotEmpty()) { + PlanPanel(uiState.plan) + } + + uiState.currentDiff?.let { diffData -> + DiffViewerDialog( + diffData = diffData, + onConfirm = { /* viewModel.confirmWrite(diffData) */ }, + onDismiss = { /* viewModel.rejectWrite() */ } + ) + } + + // Input Area + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = inputText, + onValueChange = { inputText = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Ask the coding agent...") } + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = { + // viewModel.sendMessage(...) + inputText = "" + }) { + Icon(Icons.Default.Send, contentDescription = "Send") + } + } + } + } + } +} + +@Composable +fun FileTreeItem(entry: GitHubTreeEntry) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp)) { + Icon( + if (entry.type == "tree") Icons.Default.Folder else Icons.Default.Description, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text(entry.path, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +fun ChatBubble(message: ChatMessage) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentAlignment = if (message.isUser) Alignment.CenterEnd else Alignment.CenterStart + ) { + Card( + modifier = Modifier.width(IntrinsicSize.Min).padding(horizontal = 8.dp) + ) { + Text(message.text, modifier = Modifier.padding(8.dp)) + } + } +} + +@Composable +fun PlanPanel(plan: String) { + Card(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Column(modifier = Modifier.padding(8.dp)) { + Text("Current Plan", style = MaterialTheme.typography.titleSmall) + Text(plan, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingWorkspaceViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingWorkspaceViewModel.kt new file mode 100644 index 000000000..a06327115 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/CodingWorkspaceViewModel.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.customtasks.codingagent + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.ai.edge.gallery.data.DataStoreRepository +import com.google.ai.edge.gallery.data.Model +import com.google.ai.edge.gallery.data.codingagent.AgentMemory +import com.google.ai.edge.gallery.data.codingagent.AgentMemoryDao +import com.google.ai.edge.gallery.data.codingagent.Project +import com.google.ai.edge.gallery.network.GitHubService +import com.google.ai.edge.gallery.network.GitHubTreeEntry +import com.google.ai.edge.gallery.ui.llmchat.LlmChatModelHelper +import com.google.ai.edge.gallery.runtime.ResultListener +import com.google.ai.edge.litertlm.Contents +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CodingWorkspaceViewModel @Inject constructor( + private val agentMemoryDao: AgentMemoryDao, + private val gitHubService: GitHubService, + private val dataStoreRepository: DataStoreRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(CodingWorkspaceUiState()) + val uiState = _uiState.asStateFlow() + + fun initProject(project: Project) { + _uiState.value = _uiState.value.copy(project = project) + loadMemory(project.id) + loadFiles(project) + } + + private fun loadMemory(projectId: Long) { + viewModelScope.launch { + val memory = agentMemoryDao.getMemoryForProject(projectId) + if (memory != null) { + // Load history and plan + _uiState.value = _uiState.value.copy( + messages = emptyList(), // Need to deserialize + plan = memory.planJson + ) + } + } + } + + fun saveMemory() { + val project = _uiState.value.project ?: return + viewModelScope.launch { + agentMemoryDao.insertMemory( + AgentMemory( + projectId = project.id, + conversationJson = "", // Serialize messages + planJson = _uiState.value.plan + ) + ) + } + } + + private fun loadFiles(project: Project) { + viewModelScope.launch { + val token = dataStoreRepository.readSecret("github_token") ?: return@launch + val parts = project.githubRepoUrl.split("/") + val response = gitHubService.getTree("token $token", parts[0], parts[1], project.branch) + if (response.isSuccessful) { + _uiState.value = _uiState.value.copy(fileTree = response.body()?.tree ?: emptyList()) + } + } + } + + fun sendMessage(model: Model, text: String) { + val currentMessages = _uiState.value.messages.toMutableList() + currentMessages.add(ChatMessage(text, true)) + _uiState.value = _uiState.value.copy(messages = currentMessages, isGenerating = true) + + viewModelScope.launch { + LlmChatModelHelper.runInference( + model = model, + input = text, + resultListener = { result, done, thought -> + if (done) { + _uiState.value = _uiState.value.copy(isGenerating = false) + saveMemory() + } else { + val updatedMessages = _uiState.value.messages.toMutableList() + if (updatedMessages.isNotEmpty() && !updatedMessages.last().isUser) { + updatedMessages[updatedMessages.size - 1] = ChatMessage(updatedMessages.last().text + result, false) + } else { + updatedMessages.add(ChatMessage(result, false)) + } + _uiState.value = _uiState.value.copy(messages = updatedMessages) + + thought?.let { + _uiState.value = _uiState.value.copy(plan = it) + } + } + }, + cleanUpListener = {}, + onError = { error -> + _uiState.value = _uiState.value.copy(isGenerating = false) + val updatedMessages = _uiState.value.messages.toMutableList() + updatedMessages.add(ChatMessage("Error: $error", false)) + _uiState.value = _uiState.value.copy(messages = updatedMessages) + } + ) + } + } + + fun requestWriteApproval(diff: DiffData) { + _uiState.value = _uiState.value.copy(currentDiff = diff) + } + + fun confirmWrite(diff: DiffData, codingTools: CodingTools) { + viewModelScope.launch { + val result = codingTools.write_file(diff.path, diff.newContent, diff.commitMessage) + _uiState.value = _uiState.value.copy(currentDiff = null) + // Result should be fed back to model... + } + } + + fun rejectWrite() { + _uiState.value = _uiState.value.copy(currentDiff = null) + } + + fun stopGeneration(model: Model) { + LlmChatModelHelper.stopResponse(model) + _uiState.value = _uiState.value.copy(isGenerating = false) + } +} + +data class CodingWorkspaceUiState( + val project: Project? = null, + val messages: List = emptyList(), + val fileTree: List = emptyList(), + val plan: String = "", + val isGenerating: Boolean = false, + val currentDiff: DiffData? = null +) + +data class ChatMessage(val text: String, val isUser: Boolean) +data class DiffData(val path: String, val oldContent: String, val newContent: String, val commitMessage: String) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/DiffViewer.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/DiffViewer.kt new file mode 100644 index 000000000..4b960c17f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/codingagent/DiffViewer.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.customtasks.codingagent + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun DiffViewerDialog( + diffData: DiffData, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Approve Changes: ${diffData.path}") }, + text = { + Column { + Text("Commit Message: ${diffData.commitMessage}", style = MaterialTheme.typography.bodySmall) + Spacer(modifier = Modifier.height(8.dp)) + Box(modifier = Modifier.height(300.dp).fillMaxWidth().background(Color.Black.copy(alpha = 0.05f))) { + LazyColumn(modifier = Modifier.padding(8.dp)) { + val lines = generateUnifiedDiff(diffData.oldContent, diffData.newContent) + items(lines) { line -> + DiffLine(line) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text("Approve & Commit") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Reject") } + } + ) +} + +@Composable +fun DiffLine(line: String) { + val backgroundColor = when { + line.startsWith("+") -> Color.Green.copy(alpha = 0.1f) + line.startsWith("-") -> Color.Red.copy(alpha = 0.1f) + else -> Color.Transparent + } + val textColor = when { + line.startsWith("+") -> Color(0xFF006400) + line.startsWith("-") -> Color.Red + else -> MaterialTheme.colorScheme.onSurface + } + Text( + text = line, + modifier = Modifier.fillMaxWidth().background(backgroundColor).padding(horizontal = 4.dp), + color = textColor, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) +} + +fun generateUnifiedDiff(old: String, new: String): List { + val oldLines = old.lines() + val newLines = new.lines() + // Simple diff algorithm for demonstration + val diff = mutableListOf() + // In a real implementation, use a library like java-diff-utils + // This is a naive line-by-line comparison + val maxLines = maxOf(oldLines.size, newLines.size) + for (i in 0 until maxLines) { + val oldLine = oldLines.getOrNull(i) + val newLine = newLines.getOrNull(i) + if (oldLine != newLine) { + if (oldLine != null) diff.add("- $oldLine") + if (newLine != null) diff.add("+ $newLine") + } else { + if (oldLine != null) diff.add(" $oldLine") + } + } + return diff +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt index 437045a7f..44239c1a5 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/Tasks.kt @@ -142,6 +142,7 @@ object BuiltInTaskId { const val LLM_TINY_GARDEN = "llm_tiny_garden" const val MP_SCRAPBOOK = "mp_scrapbook" const val LLM_AGENT_CHAT = "llm_agent_chat" + const val LLM_CODING_AGENT = "llm_coding_agent" } private val allLegacyTaskIds: MutableSet = diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/CodingAgentDaos.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/CodingAgentDaos.kt new file mode 100644 index 000000000..8d4c29fdd --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/CodingAgentDaos.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.data.codingagent + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +interface ProjectDao { + fun getAllProjects(): Flow> + suspend fun getProjectById(id: Long): Project? + suspend fun insertProject(project: Project): Long + suspend fun updateProject(project: Project) + suspend fun deleteProject(project: Project) +} + +interface AgentMemoryDao { + suspend fun getMemoryForProject(projectId: Long): AgentMemory? + suspend fun insertMemory(memory: AgentMemory) + suspend fun deleteMemoryForProject(projectId: Long) +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/MockDaos.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/MockDaos.kt new file mode 100644 index 000000000..fa7308d5f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/MockDaos.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.data.codingagent + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +class MockProjectDao : ProjectDao { + private val _projects = MutableStateFlow>(emptyList()) + override fun getAllProjects(): Flow> = _projects.asStateFlow() + override suspend fun getProjectById(id: Long): Project? = _projects.value.find { it.id == id } + override suspend fun insertProject(project: Project): Long { + val newId = System.currentTimeMillis() + _projects.value = _projects.value + project.copy(id = newId) + return newId + } + override suspend fun updateProject(project: Project) { + _projects.value = _projects.value.map { if (it.id == project.id) project else it } + } + override suspend fun deleteProject(project: Project) { + _projects.value = _projects.value.filter { it.id != project.id } + } +} + +class MockAgentMemoryDao : AgentMemoryDao { + private val memories = mutableMapOf() + override suspend fun getMemoryForProject(projectId: Long): AgentMemory? = memories[projectId] + override suspend fun insertMemory(memory: AgentMemory) { + memories[memory.projectId] = memory + } + override suspend fun deleteMemoryForProject(projectId: Long) { + memories.remove(projectId) + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/Project.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/Project.kt new file mode 100644 index 000000000..bf180b39f --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/data/codingagent/Project.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.data.codingagent + +data class Project( + val id: Long = 0, + val name: String = "", + val description: String = "", + val githubRepoUrl: String = "", + val branch: String = "main", + val lastModified: Long = System.currentTimeMillis() +) + +data class AgentMemory( + val projectId: Long = 0, + val conversationJson: String = "", + val planJson: String = "" +) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/di/DatabaseModule.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/di/DatabaseModule.kt new file mode 100644 index 000000000..7897f2c87 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/di/DatabaseModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.di + +import com.google.ai.edge.gallery.data.codingagent.ProjectDao +import com.google.ai.edge.gallery.data.codingagent.AgentMemoryDao +import com.google.ai.edge.gallery.data.codingagent.MockProjectDao +import com.google.ai.edge.gallery.data.codingagent.MockAgentMemoryDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideProjectDao(): ProjectDao { + return MockProjectDao() + } + + @Provides + @Singleton + fun provideAgentMemoryDao(): AgentMemoryDao { + return MockAgentMemoryDao() + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/di/NetworkModule.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/di/NetworkModule.kt new file mode 100644 index 000000000..21032bcdc --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/di/NetworkModule.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.di + +import com.google.ai.edge.gallery.network.GitHubService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + val logging = HttpLoggingInterceptor() + logging.setLevel(HttpLoggingInterceptor.Level.BODY) + return OkHttpClient.Builder() + .addInterceptor(logging) + .build() + } + + @Provides + @Singleton + fun provideGitHubService(okHttpClient: OkHttpClient): GitHubService { + return Retrofit.Builder() + .baseUrl("https://api.github.com/") + .addConverterFactory(GsonConverterFactory.create()) + .client(okHttpClient) + .build() + .create(GitHubService::class.java) + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/network/GitHubService.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/network/GitHubService.kt new file mode 100644 index 000000000..4df44e491 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/network/GitHubService.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.network + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +interface GitHubService { + @GET("user") + suspend fun getCurrentUser( + @Header("Authorization") token: String + ): Response + + @GET("repos/{owner}/{repo}") + suspend fun getRepo( + @Header("Authorization") token: String, + @Path("owner") owner: String, + @Path("repo") repo: String + ): Response + + @GET("repos/{owner}/{repo}/git/trees/{branch}") + suspend fun getTree( + @Header("Authorization") token: String, + @Path("owner") owner: String, + @Path("repo") repo: String, + @Path("branch") branch: String, + @Query("recursive") recursive: Int = 1 + ): Response + + @GET("repos/{owner}/{repo}/contents/{path}") + suspend fun getFileContent( + @Header("Authorization") token: String, + @Path("owner") owner: String, + @Path("repo") repo: String, + @Path("path") path: String, + @Query("ref") ref: String? = null + ): Response + + @PUT("repos/{owner}/{repo}/contents/{path}") + suspend fun updateFile( + @Header("Authorization") token: String, + @Path("owner") owner: String, + @Path("repo") repo: String, + @Path("path") path: String, + @Body request: GitHubUpdateFileRequest + ): Response + + @POST("repos/{owner}/{repo}/pulls") + suspend fun createPullRequest( + @Header("Authorization") token: String, + @Path("owner") owner: String, + @Path("repo") repo: String, + @Body request: GitHubCreatePullRequest + ): Response + + @GET("repos/{owner}/{repo}/branches") + suspend fun getBranches( + @Header("Authorization") token: String, + @Path("owner") owner: String, + @Path("repo") repo: String + ): Response> +} + +data class GitHubUser(val login: String, val id: Long) +data class GitHubRepo(val name: String, val full_name: String, val default_branch: String) +data class GitHubTree(val sha: String, val tree: List, val truncated: Boolean) +data class GitHubTreeEntry(val path: String, val mode: String, val type: String, val sha: String, val size: Long?) +data class GitHubFileContent(val name: String, val path: String, val sha: String, val size: Long, val content: String?, val encoding: String?) +data class GitHubUpdateFileRequest(val message: String, val content: String, val sha: String? = null, val branch: String? = null) +data class GitHubUpdateFileResponse(val content: GitHubFileContent?, val commit: GitHubCommit?) +data class GitHubCommit(val sha: String, val message: String) +data class GitHubCreatePullRequest(val title: String, val body: String, val head: String, val base: String) +data class GitHubPullRequestResponse(val id: Long, val number: Int, val html_url: String) +data class GitHubBranch(val name: String) diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/github/GitHubLoginScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/github/GitHubLoginScreen.kt new file mode 100644 index 000000000..4d93d68ca --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/github/GitHubLoginScreen.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.ui.github + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.ai.edge.gallery.data.DataStoreRepository +import com.google.ai.edge.gallery.network.GitHubService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GitHubLoginViewModel @Inject constructor( + private val gitHubService: GitHubService, + private val dataStoreRepository: DataStoreRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(GitHubLoginUiState.Idle) + val uiState = _uiState.asStateFlow() + + fun loginWithToken(token: String) { + viewModelScope.launch { + _uiState.value = GitHubLoginUiState.Loading + try { + val response = gitHubService.getCurrentUser("token $token") + if (response.isSuccessful) { + dataStoreRepository.saveSecret("github_token", token) + _uiState.value = GitHubLoginUiState.Success + } else { + _uiState.value = GitHubLoginUiState.Error("Invalid token: ${response.message()}") + } + } catch (e: Exception) { + _uiState.value = GitHubLoginUiState.Error(e.message ?: "Unknown error") + } + } + } + + fun resetState() { + _uiState.value = GitHubLoginUiState.Idle + } +} + +sealed class GitHubLoginUiState { + object Idle : GitHubLoginUiState() + object Loading : GitHubLoginUiState() + object Success : GitHubLoginUiState() + data class Error(val message: String) : GitHubLoginUiState() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GitHubLoginScreen( + viewModel: GitHubLoginViewModel, + onLoginSuccess: () -> Unit, + onNavigateUp: () -> Unit +) { + var token by remember { mutableStateOf("") } + val uiState by viewModel.uiState.collectAsState() + + if (uiState is GitHubLoginUiState.Success) { + onLoginSuccess() + viewModel.resetState() + } + + Scaffold( + topBar = { + TopAppBar(title = { Text("GitHub Login") }) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "To use the Agentic Coding feature, please provide a GitHub Personal Access Token (classic) with 'repo' scope.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = token, + onValueChange = { token = it }, + label = { Text("Personal Access Token") }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation() + ) + Spacer(modifier = Modifier.height(16.dp)) + if (uiState is GitHubLoginUiState.Error) { + Text( + (uiState as GitHubLoginUiState.Error).message, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + } + Button( + onClick = { viewModel.loginWithToken(token) }, + enabled = token.isNotBlank() && uiState !is GitHubLoginUiState.Loading, + modifier = Modifier.fillMaxWidth() + ) { + if (uiState is GitHubLoginUiState.Loading) { + CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) + } + Text("Login") + } + + Spacer(modifier = Modifier.height(24.dp)) + Text( + "OAuth support coming soon. Please use a PAT for now.", + style = MaterialTheme.typography.bodySmall + ) + } + } +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/projects/ProjectManagementScreen.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/projects/ProjectManagementScreen.kt new file mode 100644 index 000000000..11ea2c5ba --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/projects/ProjectManagementScreen.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.ui.projects + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.ai.edge.gallery.data.codingagent.Project +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectManagementScreen( + viewModel: ProjectManagementViewModel, + onProjectClick: (Project) -> Unit, + onLogout: () -> Unit, + onNavigateUp: () -> Unit +) { + val projects by viewModel.projects.collectAsState() + var showCreateDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Your Coding Projects") }, + actions = { + IconButton(onClick = { + viewModel.logoutGitHub() + onLogout() + }) { + Icon(Icons.Default.Logout, contentDescription = "Logout GitHub") + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "Create Project") + } + } + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .padding(16.dp) + ) { + items(projects) { project -> + ProjectCard( + project = project, + onClick = { onProjectClick(project) }, + onDelete = { viewModel.deleteProject(project) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + if (showCreateDialog) { + CreateProjectDialog( + onDismiss = { showCreateDialog = false }, + onCreate = { name, desc, repo, branch -> + viewModel.createProject(name, desc, repo, branch, + onSuccess = { showCreateDialog = false }, + onError = { /* Handle error, maybe show snackbar */ } + ) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectCard(project: Project, onClick: () -> Unit, onDelete: () -> Unit) { + val dateFormat = remember { SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) } + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Text(project.name, style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Delete Project") + } + } + if (project.description.isNotEmpty()) { + Text(project.description, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(4.dp)) + } + Text("Repo: ${project.githubRepoUrl}", style = MaterialTheme.typography.bodySmall) + Text("Branch: ${project.branch}", style = MaterialTheme.typography.bodySmall) + Text("Last modified: ${dateFormat.format(Date(project.lastModified))}", style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +fun CreateProjectDialog(onDismiss: () -> Unit, onCreate: (String, String, String, String) -> Unit) { + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var repoUrl by remember { mutableStateOf("") } + var branch by remember { mutableStateOf("main") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Create New Project") }, + text = { + Column { + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Project Name") }) + OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text("Description") }) + OutlinedTextField(value = repoUrl, onValueChange = { repoUrl = it }, label = { Text("GitHub Repo (owner/repo)") }) + OutlinedTextField(value = branch, onValueChange = { branch = it }, label = { Text("Branch") }) + } + }, + confirmButton = { + TextButton(onClick = { onCreate(name, description, repoUrl, branch) }, enabled = name.isNotBlank() && repoUrl.isNotBlank()) { + Text("Create") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/projects/ProjectManagementViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/projects/ProjectManagementViewModel.kt new file mode 100644 index 000000000..10f3e7964 --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/projects/ProjectManagementViewModel.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.ui.projects + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.ai.edge.gallery.data.DataStoreRepository +import com.google.ai.edge.gallery.data.codingagent.Project +import com.google.ai.edge.gallery.data.codingagent.ProjectDao +import com.google.ai.edge.gallery.network.GitHubService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProjectManagementViewModel @Inject constructor( + private val projectDao: ProjectDao, + private val gitHubService: GitHubService, + private val dataStoreRepository: DataStoreRepository +) : ViewModel() { + + val projects: StateFlow> = projectDao.getAllProjects() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun createProject(name: String, description: String, repoUrl: String, branch: String, onSuccess: () -> Unit, onError: (String) -> Unit) { + viewModelScope.launch { + val token = dataStoreRepository.readSecret("github_token") + if (token == null) { + onError("GitHub token missing. Please log in again.") + return@launch + } + + try { + val parts = repoUrl.split("/") + if (parts.size != 2) { + onError("Invalid repo URL format. Use owner/repo.") + return@launch + } + val owner = parts[0] + val repo = parts[1] + + val response = gitHubService.getRepo("token $token", owner, repo) + if (response.isSuccessful) { + val newProject = Project( + name = name, + description = description, + githubRepoUrl = repoUrl, + branch = branch + ) + projectDao.insertProject(newProject) + onSuccess() + } else { + onError("Repository not found or access denied: ${response.message()}") + } + } catch (e: Exception) { + onError(e.message ?: "Unknown error") + } + } + } + + fun deleteProject(project: Project) { + viewModelScope.launch { + projectDao.deleteProject(project) + } + } + + fun logoutGitHub() { + dataStoreRepository.deleteSecret("github_token") + } +} diff --git a/Android/src/app/src/test/java/com/google/ai/edge/gallery/CodingAgentTest.kt b/Android/src/app/src/test/java/com/google/ai/edge/gallery/CodingAgentTest.kt new file mode 100644 index 000000000..25145a51b --- /dev/null +++ b/Android/src/app/src/test/java/com/google/ai/edge/gallery/CodingAgentTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery + +import com.google.ai.edge.gallery.customtasks.codingagent.generateUnifiedDiff +import org.junit.Assert.assertEquals +import org.junit.Test + +class CodingAgentTest { + @Test + fun testUnifiedDiff() { + val old = "line1\nline2\nline3" + val new = "line1\nline2modified\nline3\nline4" + val diff = generateUnifiedDiff(old, new) + + val expected = listOf( + " line1", + "- line2", + "+ line2modified", + " line3", + "+ line4" + ) + assertEquals(expected, diff) + } +} diff --git a/Android/src/gradle/libs.versions.toml b/Android/src/gradle/libs.versions.toml index 671d61512..f11d45b18 100644 --- a/Android/src/gradle/libs.versions.toml +++ b/Android/src/gradle/libs.versions.toml @@ -39,6 +39,9 @@ securityCrypto = "1.1.0" kotlinReflect = "2.2.21" moshi = "1.15.2" ksp = "2.3.6" +room = "2.6.1" +retrofit = "2.11.0" +okhttp = "4.12.0" mlkit-genai-prompt = "1.0.0-beta2" # Ref: https://developers.google.com/ml-kit/genai/prompt/android/get-started#configure-project mcp = "0.8.0" ktor = "3.4.3" @@ -94,6 +97,13 @@ firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" } moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "mlkit-genai-prompt" } mcp-kotlin-sdk = { group = "io.modelcontextprotocol", name = "kotlin-sdk", version.ref = "mcp" } ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } @@ -108,4 +118,4 @@ protobuf = {id = "com.google.protobuf", version.ref = "protobuf"} hilt-application = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } oss-licenses = {id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicenses"} google-services = { id = "com.google.gms.google-services", version.ref = "googleService" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/docs/agentic_coding.md b/docs/agentic_coding.md new file mode 100644 index 000000000..aac622822 --- /dev/null +++ b/docs/agentic_coding.md @@ -0,0 +1,40 @@ +# Agentic Coding Feature + +The Agentic Coding feature extends the Google AI Edge Gallery with an on-device AI-assisted software development environment. It allows users to manage coding projects, interact with a local LLM agent to perform repository tasks, and integrate directly with GitHub. + +## Setup Instructions + +### GitHub Personal Access Token (PAT) +Currently, the app requires a classic Personal Access Token to interact with GitHub. +1. Go to [GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)](https://github.com/settings/tokens). +2. Generate a new token with the `repo` scope. +3. Use this token to log in within the app. + +### GitHub OAuth (Future Support) +To set up OAuth in the future: +1. Register a new OAuth App on GitHub. +2. Update `ProjectConfig.kt` with your `clientId` and `clientSecret` (if handled via a secure proxy). +3. Set the redirect URI to match the app's manifest scheme. + +## Agent Loop and Tools +The agent uses a "Plan & Execute" loop: +1. User provides a task via chat. +2. Agent proposes a step-by-step plan. +3. Agent uses tools to gather information and propose changes. +4. User approves file modifications via a unified diff viewer. +5. Changes are committed directly to GitHub. + +### Available Tools +- `read_file`: Fetches content of a specific file. +- `write_file`: Proposes changes to a file (requires user approval). +- `list_directory`: Lists files in a directory. +- `create_pull_request`: Creates a PR on GitHub. +- `run_command`: Mocked simulation of shell command execution. + +## Persistence +- **Projects**: Stored in a local Room database. +- **Agent Memory**: Conversation history and current plan are saved per project in Room. +- **Tokens**: Stored securely in `EncryptedSharedPreferences` via `DataStoreRepository`. + +## Model Switching +Users can switch local models (e.g., Gemma 2b vs 7b) at any time. The agent memory is preserved, allowing the new model to catch up and continue the task.