diff --git a/app/src/main/java/com/nastechresearch/andcode/data/connection/ConnectionProfile.kt b/app/src/main/java/com/nastechresearch/andcode/data/connection/ConnectionProfile.kt index b9ff6f3..03b12c3 100644 --- a/app/src/main/java/com/nastechresearch/andcode/data/connection/ConnectionProfile.kt +++ b/app/src/main/java/com/nastechresearch/andcode/data/connection/ConnectionProfile.kt @@ -13,11 +13,16 @@ data class ConnectionProfile( @SerialName("baseUrl") val baseUrl: String, @SerialName("username") val username: String = "opencode", @SerialName("password") val password: String? = null, + /** Bearer credential used by Nastech-Agent's API server. */ + @SerialName("apiKey") val apiKey: String? = null, + @SerialName("dashboardUrl") val dashboardUrl: String? = null, + /** Connection protocol owned by the remote runtime. Defaults to OpenCode for old profiles. */ + @SerialName("runtime") val runtime: String = "opencode", @SerialName("allowInsecureLan") val allowInsecureLan: Boolean = false, @SerialName("pinSha256") val pinSha256: String? = null, ) { override fun toString(): String = - "ConnectionProfile(id=$id, name=$name, baseUrl=$baseUrl, username=$username, password=, allowInsecureLan=$allowInsecureLan, pinSha256=)" + "ConnectionProfile(id=$id, name=$name, baseUrl=$baseUrl, username=$username, password=, apiKey=, runtime=$runtime, allowInsecureLan=$allowInsecureLan, pinSha256=)" } object ConnectionProfileCodec { diff --git a/app/src/main/java/com/nastechresearch/andcode/feature/settings/ServerInfoScreen.kt b/app/src/main/java/com/nastechresearch/andcode/feature/settings/ServerInfoScreen.kt index 79cb1f9..6aa6c1b 100644 --- a/app/src/main/java/com/nastechresearch/andcode/feature/settings/ServerInfoScreen.kt +++ b/app/src/main/java/com/nastechresearch/andcode/feature/settings/ServerInfoScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.nastechresearch.andcode.R import com.nastechresearch.andcode.runtime.RuntimeRegistry +import com.nastechresearch.andcode.runtime.nastech.NastechRuntimeControl import com.nastechresearch.andcode.ui.ViewModelFactory import com.nastechresearch.andcode.ui.components.StatusChip @@ -61,6 +62,7 @@ import com.nastechresearch.andcode.ui.components.StatusChip @Composable fun ServerInfoScreen( registry: RuntimeRegistry, + onOpenDashboard: (String) -> Unit = {}, onBack: () -> Unit, ) { val viewModel: ServerInfoViewModel = @@ -69,6 +71,7 @@ fun ServerInfoScreen( factory = ViewModelFactory { ServerInfoViewModel(registry) }, ) val state by viewModel.state.collectAsState() + val selectedTarget by registry.selected.collectAsState() var selectedTab by remember { mutableIntStateOf(0) } val snackbarHostState = remember { SnackbarHostState() } val configSavedMessage = stringResource(R.string.server_info_config_saved) @@ -90,6 +93,11 @@ fun ServerInfoScreen( } }, actions = { + (selectedTarget as? NastechRuntimeControl)?.dashboardUrl?.let { url -> + TextButton(onClick = { onOpenDashboard(url) }) { + Text("Dashboard") + } + } IconButton(onClick = viewModel::refresh) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.refresh)) } @@ -120,6 +128,13 @@ fun ServerInfoScreen( onClick = { selectedTab = 3 }, text = { Text(stringResource(R.string.server_info_tab_skills)) }, ) + if (state.toolsetsJson != null) { + Tab( + selected = selectedTab == 4, + onClick = { selectedTab = 4 }, + text = { Text("Toolsets") }, + ) + } } if (state.isLoading) { @@ -136,6 +151,7 @@ fun ServerInfoScreen( 1 -> ProvidersTab(state) 2 -> CommandsTab(state) 3 -> SkillsTab(state) + 4 -> ToolsetsTab(state) } } } @@ -370,3 +386,18 @@ private fun SkillsTab(state: ServerInfoUiState) { } } } + +@Composable +private fun ToolsetsTab(state: ServerInfoUiState) { + Surface( + modifier = Modifier.fillMaxSize().padding(16.dp), + shape = MaterialTheme.shapes.medium, + tonalElevation = 1.dp, + ) { + Text( + text = state.toolsetsJson ?: "{}", + modifier = Modifier.padding(12.dp).horizontalScroll(rememberScrollState()), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp), + ) + } +} diff --git a/app/src/main/java/com/nastechresearch/andcode/feature/settings/ServerInfoViewModel.kt b/app/src/main/java/com/nastechresearch/andcode/feature/settings/ServerInfoViewModel.kt index 15e40fe..9560d71 100644 --- a/app/src/main/java/com/nastechresearch/andcode/feature/settings/ServerInfoViewModel.kt +++ b/app/src/main/java/com/nastechresearch/andcode/feature/settings/ServerInfoViewModel.kt @@ -6,6 +6,7 @@ import com.nastechresearch.andcode.core.api.ConfiguredProvider import com.nastechresearch.andcode.core.api.OpenCodeCommand import com.nastechresearch.andcode.core.api.OpenCodeSkill import com.nastechresearch.andcode.runtime.RuntimeRegistry +import com.nastechresearch.andcode.runtime.nastech.NastechRuntimeControl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,6 +20,7 @@ data class ServerInfoUiState( val configProviders: List = emptyList(), val commands: List = emptyList(), val skills: List = emptyList(), + val toolsetsJson: String? = null, val isLoading: Boolean = false, val error: String? = null, val configEditDraft: String? = null, @@ -26,6 +28,13 @@ data class ServerInfoUiState( val saveSuccess: Boolean = false, ) +private data class ServerInfoPayload( + val providers: List, + val commands: List, + val skills: List, + val toolsets: String?, +) + class ServerInfoViewModel( private val registry: RuntimeRegistry, ) : ViewModel() { @@ -50,15 +59,17 @@ class ServerInfoViewModel( val providers = runCatching { backend.configProviders() }.getOrDefault(emptyList()) val commands = runCatching { backend.commands() }.getOrDefault(emptyList()) val skills = runCatching { backend.skills() }.getOrDefault(emptyList()) - config to Triple(providers, commands, skills) + val toolsets = (backend as? NastechRuntimeControl)?.let { runCatching { it.toolsets() }.getOrNull() } + config to ServerInfoPayload(providers, commands, skills, toolsets?.toString()) }.onSuccess { (config, data) -> - val (providers, commands, skills) = data + val (providers, commands, skills, toolsets) = data _state.update { it.copy( configJson = config.toString(), configProviders = providers, commands = commands, skills = skills, + toolsetsJson = toolsets, isLoading = false, ) } diff --git a/app/src/main/java/com/nastechresearch/andcode/feature/workspace/ConnectionDialog.kt b/app/src/main/java/com/nastechresearch/andcode/feature/workspace/ConnectionDialog.kt index 90ef064..c77e616 100644 --- a/app/src/main/java/com/nastechresearch/andcode/feature/workspace/ConnectionDialog.kt +++ b/app/src/main/java/com/nastechresearch/andcode/feature/workspace/ConnectionDialog.kt @@ -74,6 +74,19 @@ internal fun ConnectionDialog( modifier = Modifier.fillMaxWidth(), singleLine = true, ) + OutlinedButton( + onClick = { + form = + form.copy( + runtime = if (form.runtime == "nastech") "opencode" else "nastech", + testSucceeded = false, + testMessage = null, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (form.runtime == "nastech") "Runtime: Nastech-Agent" else "Runtime: OpenCode") + } OutlinedTextField( value = form.baseUrl, onValueChange = { form = form.copy(baseUrl = it, testSucceeded = false, testMessage = null) }, @@ -91,22 +104,44 @@ internal fun ConnectionDialog( null }, ) - OutlinedTextField( - value = form.username, - onValueChange = { form = form.copy(username = it) }, - label = { Text(stringResource(R.string.username)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - OutlinedTextField( - value = form.password, - onValueChange = { form = form.copy(password = it, testSucceeded = false, testMessage = null) }, - label = { Text(stringResource(R.string.password)) }, - leadingIcon = { Icon(Icons.Default.Key, contentDescription = stringResource(R.string.cd_password)) }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) + if (form.runtime == "nastech") { + OutlinedTextField( + value = form.dashboardUrl, + onValueChange = { form = form.copy(dashboardUrl = it, testSucceeded = false, testMessage = null) }, + label = { Text("Nastech dashboard URL (optional)") }, + leadingIcon = { Icon(Icons.Default.Link, contentDescription = "Nastech dashboard URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + if (form.runtime == "nastech") { + OutlinedTextField( + value = form.apiKey, + onValueChange = { form = form.copy(apiKey = it, testSucceeded = false, testMessage = null) }, + label = { Text("Nastech API key") }, + leadingIcon = { Icon(Icons.Default.Key, contentDescription = "Nastech API key") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } else { + OutlinedTextField( + value = form.username, + onValueChange = { form = form.copy(username = it) }, + label = { Text(stringResource(R.string.username)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = form.password, + onValueChange = { form = form.copy(password = it, testSucceeded = false, testMessage = null) }, + label = { Text(stringResource(R.string.password)) }, + leadingIcon = { Icon(Icons.Default.Key, contentDescription = stringResource(R.string.cd_password)) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } // No cleartext opt-in here: OpenCodeUrl.normalize already limits plain HTTP to // loopback, RFC1918, link-local, Tailscale CGNAT and .local hosts, and anything // beyond that has to be https. A checkbox would only add a step in front of the diff --git a/app/src/main/java/com/nastechresearch/andcode/feature/workspace/ConnectionFormState.kt b/app/src/main/java/com/nastechresearch/andcode/feature/workspace/ConnectionFormState.kt index 5e0b59b..2b2f51d 100644 --- a/app/src/main/java/com/nastechresearch/andcode/feature/workspace/ConnectionFormState.kt +++ b/app/src/main/java/com/nastechresearch/andcode/feature/workspace/ConnectionFormState.kt @@ -8,8 +8,11 @@ data class ConnectionFormState( val id: String = UUID.randomUUID().toString(), val name: String = "", val baseUrl: String = "", + val runtime: String = "opencode", val username: String = "opencode", val password: String = "", + val apiKey: String = "", + val dashboardUrl: String = "", val allowInsecureLan: Boolean = false, val isTesting: Boolean = false, val testMessage: String? = null, @@ -35,8 +38,11 @@ data class ConnectionFormState( id = id, name = name.trim(), baseUrl = url.toString(), + runtime = runtime, username = username.trim().ifBlank { "opencode" }, password = password.takeIf { it.isNotBlank() }, + apiKey = apiKey.takeIf { it.isNotBlank() }, + dashboardUrl = dashboardUrl.takeIf { it.isNotBlank() }, // `opencode serve` on a PC is plain HTTP on the LAN. normalize() has already limited // cleartext to private address space, so record the allowance here instead of asking // the user to tick a box before the connection can be saved at all. @@ -50,8 +56,11 @@ data class ConnectionFormState( id = profile.id, name = profile.name, baseUrl = profile.baseUrl, + runtime = profile.runtime, username = profile.username, password = profile.password.orEmpty(), + apiKey = profile.apiKey.orEmpty(), + dashboardUrl = profile.dashboardUrl.orEmpty(), allowInsecureLan = profile.allowInsecureLan, testSucceeded = true, ) diff --git a/app/src/main/java/com/nastechresearch/andcode/feature/workspace/RemoteConnectionScreen.kt b/app/src/main/java/com/nastechresearch/andcode/feature/workspace/RemoteConnectionScreen.kt index 4fd132f..e0d60b7 100644 --- a/app/src/main/java/com/nastechresearch/andcode/feature/workspace/RemoteConnectionScreen.kt +++ b/app/src/main/java/com/nastechresearch/andcode/feature/workspace/RemoteConnectionScreen.kt @@ -145,7 +145,7 @@ fun RemoteConnectionScreen( testSucceeded = health.healthy, testMessage = if (health.healthy) { - "OpenCode ${health.version}" + "${if (form.runtime == "nastech") "Nastech-Agent" else "OpenCode"} ${health.version}" } else { context.getString(R.string.remote_connection_unhealthy) }, @@ -276,6 +276,19 @@ fun RemoteConnectionScreen( singleLine = true, shape = RoundedCornerShape(14.dp), ) + OutlinedButton( + onClick = { + form = + form.copy( + runtime = if (form.runtime == "nastech") "opencode" else "nastech", + testSucceeded = false, + testMessage = null, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (form.runtime == "nastech") "Runtime: Nastech-Agent" else "Runtime: OpenCode") + } val urlInvalid = form.baseUrl.isNotBlank() && form.normalizedUrl == null OutlinedTextField( value = form.baseUrl, @@ -297,41 +310,69 @@ fun RemoteConnectionScreen( }, shape = RoundedCornerShape(14.dp), ) - OutlinedTextField( - value = form.username, - onValueChange = { - form = form.copy(username = it, testSucceeded = false, testMessage = null) - }, - label = { Text(stringResource(R.string.username)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(14.dp), - ) - OutlinedTextField( - value = form.password, - onValueChange = { - form = form.copy(password = it, testSucceeded = false, testMessage = null) - }, - label = { Text(stringResource(R.string.password)) }, - leadingIcon = { Icon(Icons.Default.Key, contentDescription = stringResource(R.string.cd_password)) }, - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = stringResource(R.string.cd_toggle_password), - ) - } - }, - visualTransformation = - if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() + if (form.runtime == "nastech") { + OutlinedTextField( + value = form.dashboardUrl, + onValueChange = { + form = form.copy(dashboardUrl = it, testSucceeded = false, testMessage = null) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(14.dp), - ) + label = { Text("Nastech dashboard URL (optional)") }, + leadingIcon = { Icon(Icons.Default.Link, contentDescription = "Nastech dashboard URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + ) + } + if (form.runtime == "nastech") { + OutlinedTextField( + value = form.apiKey, + onValueChange = { + form = form.copy(apiKey = it, testSucceeded = false, testMessage = null) + }, + label = { Text("Nastech API key") }, + leadingIcon = { Icon(Icons.Default.Key, contentDescription = "Nastech API key") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + ) + } else { + OutlinedTextField( + value = form.username, + onValueChange = { + form = form.copy(username = it, testSucceeded = false, testMessage = null) + }, + label = { Text(stringResource(R.string.username)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + ) + OutlinedTextField( + value = form.password, + onValueChange = { + form = form.copy(password = it, testSucceeded = false, testMessage = null) + }, + label = { Text(stringResource(R.string.password)) }, + leadingIcon = { Icon(Icons.Default.Key, contentDescription = stringResource(R.string.cd_password)) }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = stringResource(R.string.cd_toggle_password), + ) + } + }, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + ) + } } Row( diff --git a/app/src/main/java/com/nastechresearch/andcode/feature/workspace/WorkspaceViewModel.kt b/app/src/main/java/com/nastechresearch/andcode/feature/workspace/WorkspaceViewModel.kt index b1384fa..80d3a7b 100644 --- a/app/src/main/java/com/nastechresearch/andcode/feature/workspace/WorkspaceViewModel.kt +++ b/app/src/main/java/com/nastechresearch/andcode/feature/workspace/WorkspaceViewModel.kt @@ -20,6 +20,7 @@ import com.nastechresearch.andcode.runtime.local.ClaudeCodeUiState import com.nastechresearch.andcode.runtime.local.ClaudePermissionMode import com.nastechresearch.andcode.runtime.local.LocalRuntimeManager import com.nastechresearch.andcode.runtime.local.LocalRuntimeServiceController +import com.nastechresearch.andcode.runtime.nastech.NastechApiClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -330,7 +331,10 @@ class WorkspaceViewModel( if (!form.canSave) { return Result.failure(IllegalArgumentException(incompleteConnectionMessage)) } - return runCatching { OpenCodeApiClient(form.toProfile()).health() } + return runCatching { + val profile = form.toProfile() + if (profile.runtime == "nastech") NastechApiClient(profile).health() else OpenCodeApiClient(profile).health() + } } /** [agents] is the setup guide's selection; every other caller means OpenCode alone. */ diff --git a/app/src/main/java/com/nastechresearch/andcode/runtime/RuntimeRegistry.kt b/app/src/main/java/com/nastechresearch/andcode/runtime/RuntimeRegistry.kt index 2b40310..fb28fa0 100644 --- a/app/src/main/java/com/nastechresearch/andcode/runtime/RuntimeRegistry.kt +++ b/app/src/main/java/com/nastechresearch/andcode/runtime/RuntimeRegistry.kt @@ -1,6 +1,7 @@ package com.nastechresearch.andcode.runtime import com.nastechresearch.andcode.data.connection.ConnectionProfile +import com.nastechresearch.andcode.runtime.nastech.NastechRuntimeTarget import com.nastechresearch.andcode.runtime.remote.RemoteRuntimeTarget import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -11,7 +12,7 @@ class RuntimeRegistry( private val localTarget: RuntimeTarget, private val additionalTargets: List = emptyList(), private val remoteFactory: (ConnectionProfile) -> RuntimeTarget = { profile -> - RemoteRuntimeTarget(profile) + if (profile.runtime == "nastech") NastechRuntimeTarget(profile) else RemoteRuntimeTarget(profile) }, ) { /** diff --git a/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechApiClient.kt b/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechApiClient.kt new file mode 100644 index 0000000..a6fa81c --- /dev/null +++ b/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechApiClient.kt @@ -0,0 +1,282 @@ +package com.nastechresearch.andcode.runtime.nastech + +import com.nastechresearch.andcode.core.api.OpenCodeHealth +import com.nastechresearch.andcode.core.api.OpenCodeMessage +import com.nastechresearch.andcode.core.api.OpenCodeMessageInfo +import com.nastechresearch.andcode.core.api.OpenCodeModel +import com.nastechresearch.andcode.core.api.OpenCodePart +import com.nastechresearch.andcode.core.api.OpenCodeProvider +import com.nastechresearch.andcode.core.api.OpenCodeSession +import com.nastechresearch.andcode.core.api.OpenCodeSkill +import com.nastechresearch.andcode.core.api.OpenCodeTime +import com.nastechresearch.andcode.core.api.PromptRequest +import com.nastechresearch.andcode.core.api.ProviderCatalog +import com.nastechresearch.andcode.core.security.OpenCodeUrl +import com.nastechresearch.andcode.data.connection.ConnectionProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** Thin adapter over Nastech-Agent's public API. Nastech remains the source of truth. */ +class NastechApiClient( + private val profile: ConnectionProfile, + private val httpClient: OkHttpClient = defaultClient(), + private val json: Json = + Json { + ignoreUnknownKeys = true + isLenient = true + }, +) { + private val baseUrl by lazy { OpenCodeUrl.normalize(profile.baseUrl).getOrThrow() } + private val activeRuns = ConcurrentHashMap() + + fun activeRunSnapshot(): Map = activeRuns.toMap() + + fun forgetRun(sessionId: String) { + activeRuns.remove(sessionId) + } + + suspend fun health(): OpenCodeHealth { + val root = get("health") + return OpenCodeHealth( + healthy = root["status"]?.jsonPrimitive?.contentOrNull == "ok", + version = root["version"]?.jsonPrimitive?.contentOrNull ?: "unknown", + ) + } + + suspend fun sessions(): List = get("api/sessions")["data"]?.jsonArray?.map(::sessionFromJson).orEmpty() + + suspend fun createSession(title: String?): OpenCodeSession { + val body = buildJsonObject { title?.takeIf(String::isNotBlank)?.let { put("title", it) } } + return sessionFromJson(post("api/sessions", body)["session"] ?: error("Nastech did not return a session")) + } + + suspend fun messages(sessionId: String): List = + get("api/sessions/${encode(sessionId)}/messages")["data"]?.jsonArray?.map(::messageFromJson).orEmpty() + + suspend fun renameSession( + sessionId: String, + title: String, + ): OpenCodeSession = + sessionFromJson( + request("PATCH", "api/sessions/${encode(sessionId)}", buildJsonObject { put("title", title) }) + .jsonObject["session"] ?: error("Nastech did not return the renamed session"), + ) + + suspend fun deleteSession(sessionId: String): Boolean { + request("DELETE", "api/sessions/${encode(sessionId)}", null) + return true + } + + /** Starts a durable Nastech run and returns immediately so AndCode can poll the transcript. */ + suspend fun send( + sessionId: String, + request: PromptRequest, + ) { + val body = + buildJsonObject { + put("input", request.text) + put("session_id", sessionId) + request.modelId?.takeIf(String::isNotBlank)?.let { put("model", it) } + request.agent?.takeIf(String::isNotBlank)?.let { put("agent_ref", it) } + } + val runId = + post("v1/runs", body)["run_id"]?.jsonPrimitive?.contentOrNull + ?: error("Nastech did not return a run id") + activeRuns[sessionId] = runId + } + + suspend fun abort(sessionId: String): Boolean { + val runId = activeRuns[sessionId] ?: return false + return runCatching { + post("v1/runs/${encode(runId)}/stop", buildJsonObject {}) + activeRuns.remove(sessionId, runId) + true + }.getOrDefault(false) + } + + suspend fun providers(): ProviderCatalog { + val models = + get("v1/models")["data"]?.jsonArray.orEmpty() + .mapNotNull { model -> model.jsonObject["id"]?.jsonPrimitive?.contentOrNull } + .associateWith { id -> OpenCodeModel(id = id, providerId = "nastech", name = id) } + return ProviderCatalog( + all = + listOf( + OpenCodeProvider(id = "nastech", name = "Nastech-Agent", models = models), + ), + default = + models.keys.firstOrNull()?.let { + mapOf("nastech" to it) + }.orEmpty(), + connected = listOf("nastech"), + ) + } + + suspend fun capabilities(): JsonObject = get("v1/capabilities") + + suspend fun detailedHealth(): JsonObject = get("health/detailed") + + suspend fun skills(): List = + get("v1/skills")["data"]?.jsonArray?.mapNotNull { value -> + val skill = value.jsonObject + skill["name"]?.jsonPrimitive?.contentOrNull?.let { name -> + OpenCodeSkill( + name = name, + description = skill["description"]?.jsonPrimitive?.contentOrNull, + location = skill["location"]?.jsonPrimitive?.contentOrNull, + ) + } + }.orEmpty() + + suspend fun toolsets(): JsonObject = get("v1/toolsets") + + suspend fun runStatus(runId: String): JsonObject = get("v1/runs/${encode(runId)}") + + fun runEvents(runId: String): Flow = + channelFlow { + withContext(Dispatchers.IO) { + val builder = Request.Builder().url(baseUrl.newBuilder().addPathSegments("v1/runs/${encode(runId)}/events").build()) + if (!profile.apiKey.isNullOrBlank()) builder.header("Authorization", "Bearer ${profile.apiKey}") + httpClient.newCall(builder.get().build()).execute().use { response -> + if (!response.isSuccessful) error("Nastech events ${response.code}") + val source = response.body?.source() ?: return@withContext + var data = StringBuilder() + while (!source.exhausted()) { + val line = source.readUtf8Line() ?: break + if (line.startsWith("data:")) data.append(line.removePrefix("data:").trim()) + if (line.isEmpty() && data.isNotEmpty()) { + send(Json.parseToJsonElement(data.toString()).jsonObject) + data = StringBuilder() + } + } + } + } + } + + suspend fun steer( + sessionId: String, + message: String, + ): Boolean { + val runId = activeRuns[sessionId] ?: return false + post("v1/runs/${encode(runId)}/steer", buildJsonObject { put("input", message) }) + return true + } + + suspend fun approve( + sessionId: String, + approvalId: String, + approved: Boolean, + ): Boolean { + val runId = activeRuns[sessionId] ?: return false + post( + "v1/runs/${encode(runId)}/approval", + buildJsonObject { + put("approval_id", approvalId) + put("approved", approved) + }, + ) + return true + } + + private suspend fun get(path: String): JsonObject = request("GET", path, null).jsonObject + + private suspend fun post( + path: String, + body: JsonObject, + ): JsonObject = request("POST", path, body).jsonObject + + private suspend fun request( + method: String, + path: String, + body: JsonObject?, + ): JsonElement = + withContext(Dispatchers.IO) { + val builder = Request.Builder().url(baseUrl.newBuilder().addPathSegments(path).build()) + if (!profile.apiKey.isNullOrBlank()) { + builder.header("Authorization", "Bearer ${profile.apiKey}") + } else if (!profile.password.isNullOrBlank()) { + builder.header("Authorization", okhttp3.Credentials.basic(profile.username, profile.password)) + } + if (body != null) { + builder.header("Content-Type", JSON_MEDIA_TYPE.toString()) + builder.method(method, body.toString().toRequestBody(JSON_MEDIA_TYPE)) + } else { + builder.method(method, null) + } + httpClient.newCall(builder.build()).execute().use { response -> + val text = response.body?.string().orEmpty() + if (!response.isSuccessful) error("Nastech ${response.code}: ${text.take(300)}") + if (text.isBlank()) JsonObject(emptyMap()) else json.parseToJsonElement(text) + } + } + + private fun sessionFromJson(element: JsonElement): OpenCodeSession { + val value = element.jsonObject + val started = value.long("started_at") + val updated = value.long("last_active") ?: started + return OpenCodeSession( + id = value.string("id") ?: error("Nastech session has no id"), + title = value.string("title") ?: "Nastech session", + time = OpenCodeTime(created = epochMs(started ?: 0L), updated = updated?.let(::epochMs)), + version = "nastech-agent", + ) + } + + private fun messageFromJson(element: JsonElement): OpenCodeMessage { + val value = element.jsonObject + val id = value.string("id") ?: "nastech-message-${value.hashCode()}" + val role = value.string("role") ?: "assistant" + return OpenCodeMessage( + info = + OpenCodeMessageInfo( + id = id, + sessionId = value.string("session_id") ?: "", + role = role, + time = OpenCodeTime(created = epochMs(value.long("timestamp") ?: 0L)), + ), + parts = listOf(OpenCodePart(id = id, type = "text", text = content(value["content"]))), + ) + } + + private fun content(element: JsonElement?): String = + when (element) { + is JsonPrimitive -> element.contentOrNull.orEmpty() + is JsonArray -> element.joinToString("\n") { content(it.jsonObject["text"] ?: it.jsonObject["content"]) } + is JsonObject -> content(element["text"] ?: element["content"]).ifBlank { element.toString() } + else -> "" + } + + private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull + + private fun JsonObject.long(key: String): Long? = string(key)?.toLongOrNull() + + private fun epochMs(value: Long): Long = if (value in 1 until 1_000_000_000_000L) value * 1000L else value + + private fun encode(value: String): String = java.net.URLEncoder.encode(value, Charsets.UTF_8.name()) + + private companion object { + val JSON_MEDIA_TYPE = "application/json".toMediaType() + + fun defaultClient(): OkHttpClient = + OkHttpClient.Builder().connectTimeout(15, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build() + } +} diff --git a/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechRuntimeControl.kt b/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechRuntimeControl.kt new file mode 100644 index 0000000..aedf7e2 --- /dev/null +++ b/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechRuntimeControl.kt @@ -0,0 +1,20 @@ +package com.nastechresearch.andcode.runtime.nastech + +import com.nastechresearch.andcode.core.api.OpenCodeSkill +import kotlinx.serialization.json.JsonObject + +/** Optional management surface implemented by Nastech-backed runtime targets. */ +interface NastechRuntimeControl { + val dashboardUrl: String? + + suspend fun capabilities(): JsonObject + + suspend fun toolsets(): JsonObject + + suspend fun skills(): List + + suspend fun steer( + sessionId: String, + message: String, + ): Boolean +} diff --git a/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechRuntimeTarget.kt b/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechRuntimeTarget.kt new file mode 100644 index 0000000..accdfca --- /dev/null +++ b/app/src/main/java/com/nastechresearch/andcode/runtime/nastech/NastechRuntimeTarget.kt @@ -0,0 +1,198 @@ +package com.nastechresearch.andcode.runtime.nastech + +import com.nastechresearch.andcode.core.api.ConfiguredProvider +import com.nastechresearch.andcode.core.api.OpenCodeAgent +import com.nastechresearch.andcode.core.api.OpenCodeCommand +import com.nastechresearch.andcode.core.api.OpenCodeEvent +import com.nastechresearch.andcode.core.api.OpenCodeHealth +import com.nastechresearch.andcode.core.api.OpenCodeMessage +import com.nastechresearch.andcode.core.api.OpenCodeSession +import com.nastechresearch.andcode.core.api.PermissionRequest +import com.nastechresearch.andcode.core.api.PromptRequest +import com.nastechresearch.andcode.core.api.ProviderCatalog +import com.nastechresearch.andcode.core.api.QuestionRequest +import com.nastechresearch.andcode.data.connection.ConnectionProfile +import com.nastechresearch.andcode.runtime.BackendKind +import com.nastechresearch.andcode.runtime.PermissionResponse +import com.nastechresearch.andcode.runtime.RuntimeCapabilities +import com.nastechresearch.andcode.runtime.RuntimeState +import com.nastechresearch.andcode.runtime.RuntimeTarget +import com.nastechresearch.andcode.runtime.RuntimeType +import com.nastechresearch.andcode.runtime.WorkspaceRef +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** AndCode adapter for the complete Nastech-Agent product. It does not embed Nastech's brain. */ +class NastechRuntimeTarget( + val profile: ConnectionProfile, + private val api: NastechApiClient = NastechApiClient(profile), +) : RuntimeTarget, NastechRuntimeControl { + override val id: String = profile.id + override val displayName: String = profile.name + override val type: RuntimeType = RuntimeType.REMOTE + override val kind: BackendKind = BackendKind.REMOTE + override val dashboardUrl: String? = profile.dashboardUrl + override val capabilities = + RuntimeCapabilities( + permissions = true, + toolEvents = true, + providerModelList = true, + resume = true, + abortsBeforeInterrupt = true, + ) + + private val mutableState = MutableStateFlow(RuntimeState.Disconnected) + override val state: StateFlow = mutableState.asStateFlow() + + override suspend fun connect(): Result { + mutableState.value = RuntimeState.Connecting + return runCatching { api.health() } + .onSuccess { health -> + mutableState.value = + if (health.healthy) { + RuntimeState.Connected( + health.version, + ) + } else { + RuntimeState.Failed("Nastech-Agent reported unhealthy") + } + } + .onFailure { error -> mutableState.value = RuntimeState.Failed(error.message ?: "Nastech connection failed") } + } + + override fun disconnect() { + mutableState.value = RuntimeState.Disconnected + } + + override suspend fun listWorkspaces(): List = emptyList() + + override suspend fun health(): OpenCodeHealth = api.health() + + override suspend fun listSessions(directory: String?): List = api.sessions() + + override suspend fun createSession( + title: String?, + directory: String?, + ): OpenCodeSession = api.createSession(title) + + override suspend fun listMessages(sessionId: String): List = api.messages(sessionId) + + override suspend fun renameSession( + sessionId: String, + title: String, + ): OpenCodeSession = api.renameSession(sessionId, title) + + override suspend fun deleteSession(sessionId: String): Boolean = api.deleteSession(sessionId) + + override suspend fun listProviders(): ProviderCatalog = api.providers() + + override suspend fun configProviders(): List = + api.providers().all.map { provider -> + ConfiguredProvider( + id = provider.id, + name = provider.name, + defaultModel = provider.models.values.firstOrNull()?.id, + connected = provider.id in api.providers().connected, + ) + } + + override suspend fun skills() = api.skills() + + override suspend fun capabilities() = api.capabilities() + + override suspend fun toolsets() = api.toolsets() + + override suspend fun steer( + sessionId: String, + message: String, + ): Boolean = api.steer(sessionId, message) + + override suspend fun config(): JsonElement = + buildJsonObject { + put("capabilities", api.capabilities()) + put("gateway", api.detailedHealth()) + put("toolsets", api.toolsets()) + } + + override suspend fun commands(): List = + listOf( + OpenCodeCommand("setup", "Configure Nastech-Agent providers, terminal, gateway, and tools"), + OpenCodeCommand("doctor", "Run Nastech-Agent diagnostics"), + OpenCodeCommand("gateway", "Configure Telegram and other gateway platforms"), + OpenCodeCommand("tools", "Configure Nastech toolsets"), + OpenCodeCommand("update", "Update the Nastech-Agent runtime"), + ) + + override suspend fun listAgents(): List = listOf(OpenCodeAgent("nastech", "Nastech-Agent", "primary", true)) + + override suspend fun sendMessage( + sessionId: String, + request: PromptRequest, + ) = api.send(sessionId, request) + + override suspend fun abortSession(sessionId: String): Boolean = api.abort(sessionId) + + override suspend fun respondToPermission( + sessionId: String, + permissionId: String, + response: PermissionResponse, + remember: Boolean, + ): Boolean = api.approve(sessionId, permissionId, response != PermissionResponse.REJECT) + + override suspend fun answerQuestion( + requestId: String, + answers: List>, + directory: String?, + ): Boolean = unsupported("Nastech questions") + + override suspend fun rejectQuestion( + requestId: String, + directory: String?, + ): Boolean = unsupported("Nastech question rejection") + + override suspend fun pendingQuestions(directory: String?): List = emptyList() + + override fun events(): Flow = + channelFlow { + api.activeRunSnapshot().forEach { (sessionId, runId) -> + launch { + api.runEvents(runId).collect { event -> + val type = event["event"]?.toString()?.trim('"') ?: return@collect + when (type) { + "approval.request" -> { + val requestId = + event["request_id"]?.toString()?.trim('"') + ?: event["id"]?.toString()?.trim('"') + ?: runId + send( + OpenCodeEvent.PermissionAsked( + PermissionRequest( + id = requestId, + sessionId = sessionId, + permission = event["permission"]?.toString()?.trim('"') ?: "approval", + patterns = event["command"]?.toString()?.trim('"')?.let(::listOf).orEmpty(), + metadata = event, + ), + ), + ) + } + "run.completed", "run.cancelled", "run.failed" -> { + send(OpenCodeEvent.SessionStatusChanged(sessionId, type.removePrefix("run."))) + api.forgetRun(sessionId) + } + else -> send(OpenCodeEvent.SessionStatusChanged(sessionId, type)) + } + } + } + } + } + + private fun unsupported(capability: String): Nothing = throw UnsupportedOperationException(capability) +} diff --git a/app/src/main/java/com/nastechresearch/andcode/ui/navigation/SettingsNavGraph.kt b/app/src/main/java/com/nastechresearch/andcode/ui/navigation/SettingsNavGraph.kt index 349bc0b..3f578f0 100644 --- a/app/src/main/java/com/nastechresearch/andcode/ui/navigation/SettingsNavGraph.kt +++ b/app/src/main/java/com/nastechresearch/andcode/ui/navigation/SettingsNavGraph.kt @@ -471,6 +471,7 @@ fun NavGraphBuilder.settingsNavGraph( composable(ROUTE_SETTINGS_SERVER_INFO) { com.nastechresearch.andcode.feature.settings.ServerInfoScreen( registry = runtimeRegistry, + onOpenDashboard = { url -> UrlLauncher.openUrl(context, url) }, onBack = { navController.popBackStack() }, ) } diff --git a/app/src/test/java/com/nastechresearch/andcode/runtime/nastech/NastechApiClientTest.kt b/app/src/test/java/com/nastechresearch/andcode/runtime/nastech/NastechApiClientTest.kt new file mode 100644 index 0000000..492d569 --- /dev/null +++ b/app/src/test/java/com/nastechresearch/andcode/runtime/nastech/NastechApiClientTest.kt @@ -0,0 +1,66 @@ +package com.nastechresearch.andcode.runtime.nastech + +import com.nastechresearch.andcode.core.api.PromptRequest +import com.nastechresearch.andcode.data.connection.ConnectionProfile +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class NastechApiClientTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `health reads Nastech version and sends bearer credential`() = + runBlocking { + server.enqueue(MockResponse().setBody("{\"status\":\"ok\",\"platform\":\"nastech-agent\",\"version\":\"0.21.0\"}")) + val client = NastechApiClient(profile()) + + val health = client.health() + val request = server.takeRequest() + + assertTrue(health.healthy) + assertEquals("0.21.0", health.version) + assertEquals("Bearer test-key", request.getHeader("Authorization")) + assertEquals("/health", request.path) + } + + @Test + fun `send starts a durable session run and records the session id`() = + runBlocking { + server.enqueue(MockResponse().setBody("{\"run_id\":\"run_123\",\"status\":\"queued\"}")) + val client = NastechApiClient(profile()) + + client.send("session_1", PromptRequest(text = "hello", modelId = "nastech/default")) + val request = server.takeRequest() + val body = request.body.readUtf8() + + assertEquals("/v1/runs", request.path) + assertTrue(body.contains("\"session_id\":\"session_1\"")) + assertTrue(body.contains("\"input\":\"hello\"")) + assertTrue(body.contains("\"model\":\"nastech/default\"")) + } + + private fun profile() = + ConnectionProfile( + name = "Nastech test", + baseUrl = server.url("/").toString(), + apiKey = "test-key", + runtime = "nastech", + ) +}