diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..ca16a99
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 7505d8d..cdbc250 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -5,6 +5,12 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index c2b3ddc..a4f09e2 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.kotlin/errors/errors-1784164186560.log b/.kotlin/errors/errors-1784164186560.log
new file mode 100644
index 0000000..23bb2c8
--- /dev/null
+++ b/.kotlin/errors/errors-1784164186560.log
@@ -0,0 +1,69 @@
+kotlin version: 2.2.10
+error message: Daemon compilation failed: Connection to the Kotlin daemon has been unexpectedly lost. This might be caused by the daemon being killed by another process or the operating system, or by JVM crash.
+org.jetbrains.kotlin.gradle.tasks.DaemonCrashedException: Connection to the Kotlin daemon has been unexpectedly lost. This might be caused by the daemon being killed by another process or the operating system, or by JVM crash.
+ at org.jetbrains.kotlin.gradle.tasks.TasksUtilsKt.wrapCompilationExceptionIfNeeded(tasksUtils.kt:54)
+ at org.jetbrains.kotlin.gradle.tasks.TasksUtilsKt.wrapAndRethrowCompilationException(tasksUtils.kt:65)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:243)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
+ at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
+ at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61)
+ at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61)
+ at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
+ at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
+ at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58)
+ at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176)
+ at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
+ at org.gradle.internal.Factories$1.create(Factories.java:30)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$withLocksAcquired$0(DefaultWorkerLeaseService.java:275)
+ at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:43)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:273)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:265)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:128)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:133)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
+ at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
+ at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
+ at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
+ at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
+ at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
+ at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
+ at java.base/java.lang.Thread.run(Unknown Source)
+Caused by: java.rmi.UnmarshalException: Error unmarshaling return header; nested exception is:
+ java.net.SocketException: Connection reset
+ at java.rmi/sun.rmi.transport.StreamRemoteCall.executeCall(Unknown Source)
+ at java.rmi/sun.rmi.server.UnicastRef.invoke(Unknown Source)
+ at java.rmi/java.rmi.server.RemoteObjectInvocationHandler.invokeRemoteMethod(Unknown Source)
+ at java.rmi/java.rmi.server.RemoteObjectInvocationHandler.invoke(Unknown Source)
+ at jdk.proxy4/jdk.proxy4.$Proxy134.compile(Unknown Source)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.incrementalCompilationWithDaemon(GradleKotlinCompilerWork.kt:328)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:235)
+ ... 40 more
+Caused by: java.net.SocketException: Connection reset
+ at java.base/sun.nio.ch.NioSocketImpl.implRead(Unknown Source)
+ at java.base/sun.nio.ch.NioSocketImpl.read(Unknown Source)
+ at java.base/sun.nio.ch.NioSocketImpl$1.read(Unknown Source)
+ at java.base/java.net.Socket$SocketInputStream.read(Unknown Source)
+ at java.base/java.io.BufferedInputStream.fill(Unknown Source)
+ at java.base/java.io.BufferedInputStream.implRead(Unknown Source)
+ at java.base/java.io.BufferedInputStream.read(Unknown Source)
+ at java.base/java.io.DataInputStream.readUnsignedByte(Unknown Source)
+ at java.base/java.io.DataInputStream.readByte(Unknown Source)
+ ... 47 more
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f7352f8..b425c56 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,51 +1,69 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "cn.flyzxf.agentapp"
- compileSdk {
- version = release(36) {
- minorApiLevel = 1
- }
- }
+ compileSdk = 36
defaultConfig {
applicationId = "cn.flyzxf.agentapp"
minSdk = 28
- targetSdk = 36
+ targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
}
buildTypes {
release {
- optimization {
- enable = false
- }
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.okhttp)
+ implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@@ -53,4 +71,4 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
-}
\ No newline at end of file
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..870de40
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,3 @@
+# Add project specific ProGuard rules here.
+-keep class cn.flyzxf.agentapp.data.** { *; }
+-keepclassmembers class kotlinx.serialization.json.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 56250e7..17780e2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,9 @@
+
+
+
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding)
- )
+ val viewModel: ChatViewModel = viewModel()
+ val state by viewModel.ui.collectAsStateWithLifecycle()
+ AppTheme(darkTheme = state.isDarkTheme) {
+ Surface(
+ modifier = Modifier.fillMaxSize().background(LocalAppColors.current.bg),
+ ) {
+ ChatScreen(viewModel = viewModel)
}
}
}
}
}
-
-@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
- Text(
- text = "Hello $name!",
- modifier = modifier
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun GreetingPreview() {
- AgentAppTheme {
- Greeting("Android")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/cn/flyzxf/agentapp/data/ApiClient.kt b/app/src/main/java/cn/flyzxf/agentapp/data/ApiClient.kt
new file mode 100644
index 0000000..1fc48e2
--- /dev/null
+++ b/app/src/main/java/cn/flyzxf/agentapp/data/ApiClient.kt
@@ -0,0 +1,233 @@
+package cn.flyzxf.agentapp.data
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.coroutineContext
+
+class ApiClient {
+
+ private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(60, TimeUnit.SECONDS)
+ .readTimeout(0, TimeUnit.MILLISECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .build()
+
+ // ---- safe JSON helpers (handle JsonNull without throwing) ----
+ private fun JsonElement?.objOrNull(): JsonObject? = this as? JsonObject
+ private fun JsonElement?.arrOrNull(): JsonArray? = this as? JsonArray
+ private fun JsonElement?.strOrNull(): String? {
+ val p = this as? JsonPrimitive ?: return null
+ if (p === JsonNull || p is JsonNull) return null
+ return p.content
+ }
+ private fun JsonElement?.longOrZero(): Long {
+ val p = this as? JsonPrimitive ?: return 0L
+ if (p === JsonNull || p is JsonNull) return 0L
+ return p.content.toLongOrNull() ?: 0L
+ }
+
+ private fun normalizeBaseURL(baseURL: String): String {
+ var url = baseURL.trim().trimEnd('/')
+ if (url.endsWith("/chat/completions")) {
+ url = url.removeSuffix("/chat/completions")
+ }
+ return url
+ }
+
+ private fun chatURL(baseURL: String) = normalizeBaseURL(baseURL) + "/chat/completions"
+ private fun modelsURL(baseURL: String) = normalizeBaseURL(baseURL) + "/models"
+
+ suspend fun fetchModels(baseURL: String, apiKey: String): List = withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url(modelsURL(baseURL))
+ .get()
+ .addHeader("Authorization", "Bearer $apiKey")
+ .build()
+ val call = client.newCall(request)
+ val parentJob = coroutineContext[Job]
+ val handle = parentJob?.invokeOnCompletion { call.cancel() }
+ try {
+ call.execute().use { res ->
+ if (!res.isSuccessful) {
+ val body = res.body?.string().orEmpty()
+ throw IOException("HTTP ${res.code}: ${body.take(400)}")
+ }
+ val root = json.parseToJsonElement(res.body!!.string()).objOrNull()
+ ?: return@withContext emptyList()
+ val data = root["data"].arrOrNull() ?: root["models"].arrOrNull() ?: emptyList()
+ data.mapNotNull { el ->
+ when (el) {
+ is JsonPrimitive -> if (el is JsonNull) null else el.content
+ is JsonObject -> el["id"].strOrNull() ?: el["name"].strOrNull()
+ else -> null
+ }
+ }.filter { it.isNotBlank() }
+ }
+ } finally {
+ handle?.dispose()
+ }
+ }
+
+ suspend fun streamChat(
+ params: StreamParams,
+ onChunk: (content: String, reasoning: String) -> Unit,
+ onUsage: (Usage) -> Unit,
+ ): Pair = withContext(Dispatchers.IO) {
+ val parentJob = coroutineContext[Job]!!
+ var currentMessages = params.messages
+ var accumulated = ""
+ val maxIter = 16
+ val continuePrompt = "继续,请从刚才中断的地方直接接着输出,不要重复已经输出的任何内容。"
+
+ for (iter in 0 until maxIter) {
+ coroutineContext.ensureActive()
+ val body = buildRequestBody(params, currentMessages)
+ val request = Request.Builder()
+ .url(chatURL(params.baseURL))
+ .post(body.toRequestBody(JSON))
+ .addHeader("Authorization", "Bearer ${params.apiKey}")
+ .addHeader("Accept", "text/event-stream")
+ .build()
+
+ val call = client.newCall(request)
+ val handle = parentJob.invokeOnCompletion { runCatching { call.cancel() } }
+
+ try {
+ val response = call.execute()
+ if (!response.isSuccessful) {
+ val errBody = response.body?.string().orEmpty()
+ response.close()
+ throw IOException("HTTP ${response.code}: ${errBody.take(500)}")
+ }
+ val source = response.body!!.byteStream().bufferedReader()
+ var finishReason: String? = null
+ var sawDone = false
+ var gotContentThisIter = false
+
+ try {
+ while (true) {
+ if (!parentJob.isActive) {
+ response.close()
+ return@withContext StreamOutcome.ABORTED to accumulated
+ }
+ val line = source.readLine() ?: break
+ val data = line.removePrefix("data:").trim()
+ if (data.isEmpty()) continue
+ if (data == "[DONE]") { sawDone = true; break }
+ val el = runCatching { json.parseToJsonElement(data) }.getOrNull() ?: continue
+ val obj = el.objOrNull() ?: continue
+
+ // usage
+ val usageEl = obj["usage"]
+ if (usageEl != null && usageEl !is JsonNull) {
+ val u = parseUsage(usageEl)
+ if (u.totalTokens > 0) onUsage(u)
+ }
+
+ // choices
+ val choices = obj["choices"].arrOrNull()
+ val choice = choices?.firstOrNull().objOrNull()
+ if (choice != null) {
+ val delta = choice["delta"].objOrNull()
+ val content = delta?.get("content").strOrNull().orEmpty()
+ val reasoning = (delta?.get("reasoning_content").strOrNull()
+ ?: delta?.get("reasoning").strOrNull()).orEmpty()
+ if (content.isNotEmpty()) {
+ accumulated += content
+ gotContentThisIter = true
+ onChunk(content, "")
+ }
+ if (reasoning.isNotEmpty()) {
+ onChunk("", reasoning)
+ }
+ val fr = choice["finish_reason"].strOrNull()
+ if (fr != null && fr != "null") finishReason = fr
+ }
+ }
+ } finally {
+ response.close()
+ }
+ handle.dispose()
+
+ if (!parentJob.isActive) return@withContext StreamOutcome.ABORTED to accumulated
+
+ val truncated = finishReason == "length"
+
+ if (truncated && iter < maxIter - 1 && accumulated.isNotEmpty()) {
+ currentMessages = params.messages + listOf(
+ mapOf("role" to "assistant", "content" to accumulated),
+ mapOf("role" to "user", "content" to continuePrompt),
+ )
+ onChunk("\n\n", "")
+ continue
+ }
+ break
+ } catch (e: IOException) {
+ handle.dispose()
+ if (!parentJob.isActive) {
+ return@withContext StreamOutcome.ABORTED to accumulated
+ }
+ throw e
+ }
+ }
+ StreamOutcome.DONE to accumulated
+ }
+
+ private fun buildRequestBody(params: StreamParams, messages: List