Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Changelog

## [Unreleased]
- Initial multi-module setup with LLM interface and TAK plugin stub.
- Replaced TAK plugin stub with an offline-first local assistant service that can parse and execute create/edit marker operations.
- Added local chat UI workflow that binds to the plugin service and returns actionable responses.
- Added unit tests for operation planning parser.
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
# Tactical App

Multi-module Android project for an offline-first tactical AI assistant.
Multi-module Android project for an offline-first tactical AI assistant and ATAK plugin integration.

## Modules
- `app`: main Android application with basic home screen.
- `llm`: common LLM interface, router, and stub implementations.
- `tak-plugin`: CivTAK plugin stub for future CoT integration.
- `app`: main Android application with local assistant chat UI.
- `llm`: common LLM interface, router, and deterministic offline local model.
- `tak-plugin`: ATAK-facing assistant service that can parse restricted natural-language operations and execute local marker create/edit actions over CoT.
- `core`: CoT interoperability primitives and beacon support.

## Building
Ensure JDK 17 and Android SDK are installed, then run:
```
./gradlew assembleDebug
```
(If `gradlew` is missing in your clone, use your local Gradle install with `gradle assembleDebug`.)

## Development
A simple edge LLM stub server is available:
```
./scripts/dev/run_edge_stub.sh
```

### Local ATAK assistant operation format
From the local chat UI you can issue:
- `create marker uid=<id> lat=<x> lon=<y> type=<cot-type>`
- `edit marker uid=<id> lat=<x?> lon=<y?> type=<cot-type?>`

See `CONTRIBUTING.md` for details.
48 changes: 48 additions & 0 deletions app/src/main/java/com/example/tacticalapp/LocalChatActivity.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,63 @@
package com.example.tacticalapp

import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import androidx.appcompat.app.AppCompatActivity
import com.example.tacticalapp.databinding.ActivityLocalChatBinding
import com.example.takplugin.TakPluginService

class LocalChatActivity : AppCompatActivity() {
private lateinit var binding: ActivityLocalChatBinding
private var pluginService: TakPluginService? = null
private var isBound: Boolean = false

private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
pluginService = (service as? TakPluginService.LocalBinder)?.service()
isBound = pluginService != null
}

override fun onServiceDisconnected(name: ComponentName?) {
pluginService = null
isBound = false
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLocalChatBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.btnSend.setOnClickListener {
val prompt = binding.editPrompt.text?.toString().orEmpty()
val service = pluginService
if (prompt.isBlank() || service == null) {
binding.tvResponse.text = "Assistant unavailable. Ensure plugin service is bound."
return@setOnClickListener
}
binding.tvResponse.text = "Working..."
service.ask(prompt) { reply ->
runOnUiThread {
binding.tvResponse.text = reply.answer
}
}
}
}

override fun onStart() {
super.onStart()
isBound = bindService(Intent(this, TakPluginService::class.java), connection, Context.BIND_AUTO_CREATE)
}

override fun onStop() {
if (isBound) {
unbindService(connection)
isBound = false
}
super.onStop()
}
}
19 changes: 17 additions & 2 deletions app/src/main/res/layout/activity_local_chat.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,24 @@
android:orientation="vertical"
android:padding="16dp">

<EditText
android:id="@+id/editPrompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Ask local ATAK assistant or run: create marker uid=... lat=... lon=..."
android:inputType="textMultiLine" />

<Button
android:id="@+id/btnSend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Send" />

<TextView
android:id="@+id/tvPlaceholder"
android:id="@+id/tvResponse"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/local_chat_placeholder" />
android:layout_marginTop="16dp"
android:text="Response appears here" />
</LinearLayout>
15 changes: 13 additions & 2 deletions llm/src/main/java/com/example/llm/LocalLlm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,21 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

/**
* Placeholder local LLM implementation.
* Offline deterministic local assistant intended for constrained tactical nodes.
*/
class LocalLlm : Llm {
override suspend fun chat(messages: List<Llm.Message>): Flow<Llm.TokenChunk> = flow {
emit(Llm.TokenChunk(text = "local-response", done = true))
val prompt = messages.lastOrNull()?.content?.trim().orEmpty()
val guidance = buildString {
append("Local ATAK assistant (offline mode). ")
append("I can answer planning questions and run local actions with the formats: ")
append("'create marker uid=<id> lat=<x> lon=<y> type=<cot-type>' or ")
append("'edit marker uid=<id> lat=<x?> lon=<y?> type=<cot-type?>'. ")
if (prompt.isNotBlank()) {
append("Received: ")
append(prompt)
}
}
emit(Llm.TokenChunk(text = guidance, done = true))
}
}
3 changes: 3 additions & 0 deletions tak-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ android {

dependencies {
implementation(project(":llm"))
implementation(project(":core"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// CivTAK SDK placeholder

testImplementation("junit:junit:4.13.2")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.example.takplugin

import android.content.Context
import com.example.core.interop.Interop
import com.example.core.interop.MarkerEntity
import java.util.concurrent.ConcurrentHashMap

/** Executes restricted local operations against ATAK/CoT interop. */
class TakOperationExecutor(context: Context) {
private val markerCache = ConcurrentHashMap<String, MarkerEntity>()

init {
// default local target for ATAK server running on same device/network.
if (Interop.host.isBlank()) {
Interop.host = "127.0.0.1"
}
Interop.enabled = true
Interop.startSelfBeacon(context)
}

fun execute(op: TakOperation): String = when (op) {
is TakOperation.CreateMarker -> create(op)
is TakOperation.EditMarker -> edit(op)
}

private fun create(op: TakOperation.CreateMarker): String {
val marker = MarkerEntity(uid = op.uid, lat = op.lat, lon = op.lon, type = op.type)
markerCache[op.uid] = marker
Interop.publishMarker(marker)
return "Created marker ${op.uid} at ${op.lat},${op.lon} type=${op.type}."
}

private fun edit(op: TakOperation.EditMarker): String {
val existing = markerCache[op.uid] ?: return "Cannot edit marker ${op.uid}: marker not found in local cache."
val updated = existing.copy(
lat = op.lat ?: existing.lat,
lon = op.lon ?: existing.lon,
type = op.type ?: existing.type,
)
markerCache[op.uid] = updated
Interop.publishMarker(updated)
return "Updated marker ${op.uid} to ${updated.lat},${updated.lon} type=${updated.type}."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.example.takplugin

import java.util.Locale

/** Converts free text into constrained local ATAK server operations. */
class TakOperationPlanner {
fun plan(prompt: String): TakOperation? {
val line = prompt.lowercase(Locale.US).trim()
return when {
line.startsWith("create marker") -> parseCreate(prompt)
line.startsWith("edit marker") -> parseEdit(prompt)
else -> null
}
}

private fun parseCreate(prompt: String): TakOperation? {
val uid = value(prompt, "uid") ?: return null
val lat = value(prompt, "lat")?.toDoubleOrNull() ?: return null
val lon = value(prompt, "lon")?.toDoubleOrNull() ?: return null
val type = value(prompt, "type") ?: "b-m-p"
return TakOperation.CreateMarker(uid = uid, lat = lat, lon = lon, type = type)
}

private fun parseEdit(prompt: String): TakOperation? {
val uid = value(prompt, "uid") ?: return null
val lat = value(prompt, "lat")?.toDoubleOrNull()
val lon = value(prompt, "lon")?.toDoubleOrNull()
val type = value(prompt, "type")
if (lat == null && lon == null && type == null) return null
return TakOperation.EditMarker(uid = uid, lat = lat, lon = lon, type = type)
}

private fun value(prompt: String, key: String): String? {
val regex = Regex("""\b$key\s*=\s*([^\s,;]+)""", RegexOption.IGNORE_CASE)
return regex.find(prompt)?.groupValues?.getOrNull(1)
}
}

sealed class TakOperation {
data class CreateMarker(
val uid: String,
val lat: Double,
val lon: Double,
val type: String,
) : TakOperation()

data class EditMarker(
val uid: String,
val lat: Double?,
val lon: Double?,
val type: String?,
) : TakOperation()
}
58 changes: 56 additions & 2 deletions tak-plugin/src/main/java/com/example/takplugin/TakPluginService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,65 @@ package com.example.takplugin

import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import com.example.llm.Llm
import com.example.llm.LocalLlm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch

/**
* Placeholder TAK plugin service.
* ATAK-facing local assistant service.
*
* This service is intentionally offline-first and can execute restricted,
* local server operations such as marker create/edit from natural language.
*/
class TakPluginService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val llm: Llm = LocalLlm()
private val planner = TakOperationPlanner()
private val executor by lazy { TakOperationExecutor(applicationContext) }

inner class LocalBinder : Binder() {
fun service(): TakPluginService = this@TakPluginService
}

private val binder = LocalBinder()

override fun onBind(intent: Intent?): IBinder = binder

fun ask(prompt: String, onResult: (TakAssistantReply) -> Unit) {
serviceScope.launch {
val baseReply = llm.chat(listOf(Llm.Message("user", prompt))).toList().joinToString("") { it.text }
val operation = planner.plan(prompt)
val operationMessage = operation?.let { executor.execute(it) }
onResult(
TakAssistantReply(
answer = buildString {
append(baseReply)
if (!operationMessage.isNullOrBlank()) {
append("\n\n")
append(operationMessage)
}
},
operationExecuted = operation != null,
)
)
}
}

override fun onDestroy() {
serviceScope.cancel()
super.onDestroy()
}
}


data class TakAssistantReply(
val answer: String,
val operationExecuted: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.example.takplugin

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class TakOperationPlannerTest {
private val planner = TakOperationPlanner()

@Test
fun parsesCreateMarker() {
val op = planner.plan("create marker uid=alpha lat=10.5 lon=-20.25 type=a-f-G-U-C-I")
assertTrue(op is TakOperation.CreateMarker)
op as TakOperation.CreateMarker
assertEquals("alpha", op.uid)
assertEquals(10.5, op.lat, 0.0)
assertEquals(-20.25, op.lon, 0.0)
assertEquals("a-f-G-U-C-I", op.type)
}

@Test
fun ignoresNonActionPrompt() {
val op = planner.plan("what can you do?")
assertEquals(null, op)
}
}
Loading