完成功能

This commit is contained in:
2026-07-16 15:22:25 +08:00
parent 792adf7a54
commit 83c031930b
38 changed files with 2968 additions and 313 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+6
View File
@@ -5,6 +5,12 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+69
View File
@@ -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
+29 -11
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
# Add project specific ProGuard rules here.
-keep class cn.flyzxf.agentapp.data.** { *; }
-keepclassmembers class kotlinx.serialization.json.** { *; }
+5
View File
@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,6 +13,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.AgentApp">
<activity
android:name=".MainActivity"
@@ -2,46 +2,38 @@ package cn.flyzxf.agentapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import cn.flyzxf.agentapp.ui.theme.AgentAppTheme
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import cn.flyzxf.agentapp.ui.ChatScreen
import cn.flyzxf.agentapp.ui.ChatViewModel
import cn.flyzxf.agentapp.ui.theme.AppTheme
import cn.flyzxf.agentapp.ui.theme.LocalAppColors
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.auto(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT),
)
setContent {
AgentAppTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
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")
}
}
@@ -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<String> = 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<StreamOutcome, String> = 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<Map<String, String>>): String {
val msgs = buildJsonArray {
messages.forEach { m ->
add(buildJsonObject {
m.forEach { (k, v) -> put(k, v) }
})
}
}
val root = buildJsonObject {
put("model", params.model)
put("messages", msgs)
put("stream", true)
if (params.enableThinking) {
put("thinking", buildJsonObject { put("type", "enabled") })
if (params.reasoningEffort != null) put("reasoning_effort", params.reasoningEffort)
}
if (params.maxTokens > 0) put("max_tokens", params.maxTokens)
}
return root.toString()
}
private fun parseUsage(el: JsonElement): Usage {
val obj = el.objOrNull() ?: return Usage()
fun getLong(key: String): Long = obj[key].longOrZero()
val input = getLong("prompt_tokens").let { if (it > 0) it else getLong("input_tokens") }
val completion = getLong("completion_tokens").let { if (it > 0) it else getLong("output_tokens") }
val details = obj["completion_tokens_details"].objOrNull()
val reasoning = (details?.get("reasoning_tokens").longOrZero()).let {
if (it > 0) it else getLong("reasoning_tokens")
}
val total = getLong("total_tokens").let { if (it > 0) it else input + completion }
return Usage(input, completion, reasoning, total)
}
companion object {
private val JSON = "application/json; charset=utf-8".toMediaType()
}
}
@@ -0,0 +1,65 @@
package cn.flyzxf.agentapp.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Provider(
val id: String = "",
val name: String = "",
val baseURL: String = "",
val apiKey: String = "",
val models: List<String> = emptyList(),
val maxTokens: Long = 0,
val visionModels: List<String> = emptyList(),
)
@Serializable
data class Usage(
val promptTokens: Long = 0,
val completionTokens: Long = 0,
val reasoningTokens: Long = 0,
val totalTokens: Long = 0,
)
@Serializable
data class Message(
val role: String,
val content: String = "",
val reasoning: String = "",
val usage: Usage? = null,
val streaming: Boolean = false,
)
@Serializable
data class Conversation(
val id: String,
val title: String = "新对话",
val messages: List<Message> = emptyList(),
val providerId: String? = null,
val model: String? = null,
val createdAt: Long = 0L,
val updatedAt: Long = 0L,
)
data class ConversationMeta(
val id: String,
val title: String,
val providerId: String?,
val model: String?,
val createdAt: Long,
val updatedAt: Long,
val messageCount: Int,
)
data class StreamParams(
val baseURL: String,
val apiKey: String,
val model: String,
val messages: List<Map<String, String>>,
val enableThinking: Boolean,
val reasoningEffort: String?,
val maxTokens: Long,
)
enum class StreamOutcome { DONE, ABORTED, ERROR }
@@ -0,0 +1,95 @@
package cn.flyzxf.agentapp.data
import android.content.Context
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.util.UUID
class Store(context: Context) {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
prettyPrint = true
}
private val rootDir = File(context.filesDir, "agentapp").apply { mkdirs() }
private val convDir = File(rootDir, "conversations").apply { mkdirs() }
private val providersFile = File(rootDir, "providers.json")
// ---------------- providers ----------------
fun getProviders(): List<Provider> {
if (!providersFile.exists()) return emptyList()
return runCatching {
json.decodeFromString<List<Provider>>(providersFile.readText())
}.getOrDefault(emptyList())
}
fun saveProvider(provider: Provider): Provider {
val list = getProviders().toMutableList()
val p = if (provider.id.isBlank()) provider.copy(id = UUID.randomUUID().toString()) else provider
val idx = list.indexOfFirst { it.id == p.id }
if (idx >= 0) list[idx] = p else list.add(p)
writeProviders(list)
return p
}
fun deleteProvider(id: String) {
writeProviders(getProviders().filter { it.id != id })
}
private fun writeProviders(list: List<Provider>) {
providersFile.writeText(json.encodeToString(list))
}
// ---------------- conversations ----------------
fun getConversationList(): List<ConversationMeta> {
val files = convDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: emptyArray()
return files.mapNotNull { f ->
runCatching {
val c = json.decodeFromString<Conversation>(f.readText())
ConversationMeta(
id = c.id,
title = c.title,
providerId = c.providerId,
model = c.model,
createdAt = c.createdAt,
updatedAt = c.updatedAt,
messageCount = c.messages.size,
)
}.getOrNull()
}.sortedByDescending { it.updatedAt }
}
fun getConversation(id: String): Conversation? {
val f = File(convDir, "$id.json")
if (!f.exists()) return null
return runCatching { json.decodeFromString<Conversation>(f.readText()) }.getOrNull()
}
fun saveConversation(conv: Conversation): Conversation {
val now = System.currentTimeMillis()
val saved = conv.copy(
createdAt = if (conv.createdAt == 0L) now else conv.createdAt,
updatedAt = now,
)
File(convDir, "${saved.id}.json").writeText(json.encodeToString(saved))
return saved
}
fun deleteConversation(id: String) {
File(convDir, "$id.json").delete()
}
fun clearAllConversations() {
convDir.listFiles { f -> f.isFile && f.name.endsWith(".json") }?.forEach { it.delete() }
}
fun renameConversation(id: String, title: String) {
val conv = getConversation(id) ?: return
saveConversation(conv.copy(title = title))
}
}
@@ -0,0 +1,941 @@
package cn.flyzxf.agentapp.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.flyzxf.agentapp.data.Message
import cn.flyzxf.agentapp.data.Usage
import cn.flyzxf.agentapp.ui.components.MarkdownText
import cn.flyzxf.agentapp.ui.theme.LocalAppColors
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val state by viewModel.ui.collectAsStateWithLifecycle()
val c = LocalAppColors.current
val context = LocalContext.current
val drawerState = rememberDrawerState(initialValue = androidx.compose.material3.DrawerValue.Closed)
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.toast) {
state.toast?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearToast()
}
}
LaunchedEffect(state.drawerOpen) {
if (state.drawerOpen) scope.launch { drawerState.open() } else scope.launch { drawerState.close() }
}
LaunchedEffect(drawerState.currentValue) {
if (drawerState.currentValue == androidx.compose.material3.DrawerValue.Closed && state.drawerOpen) {
viewModel.setDrawerOpen(false)
}
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
SidebarContent(
state = state,
onNewChat = { viewModel.newConversation() },
onOpen = { viewModel.openConversation(it); scope.launch { drawerState.close() } },
onDelete = { viewModel.deleteConversation(it) },
onClearAll = { viewModel.clearAll() },
onSettings = { viewModel.setShowSettings(true); scope.launch { drawerState.close() } },
onSearch = { viewModel.setSearchQuery(it) },
onToggleTheme = { viewModel.toggleTheme() },
)
},
gesturesEnabled = true,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(c.bg)
.windowInsetsPadding(WindowInsets.systemBars)
) {
Column(modifier = Modifier.fillMaxSize()) {
ChatTopBar(
state = state,
onMenu = { scope.launch { drawerState.open() } },
onSettings = { viewModel.setShowSettings(true) },
onToggleTheme = { viewModel.toggleTheme() },
onSelectModel = { pid, m -> viewModel.selectModel(pid, m) },
onRename = { viewModel.renameConversation(state.currentConversation!!.id, it) },
onClearCurrent = { viewModel.clearCurrent() },
)
HorizontalDivider(thickness = 1.dp, color = c.border)
ControlsBar(state = state, viewModel = viewModel)
HorizontalDivider(thickness = 1.dp, color = c.borderSoft)
MessagesArea(
modifier = Modifier.weight(1f),
state = state,
onCopy = { copyText(it, context) },
onRegenerate = { viewModel.regenerate() },
)
ComposerBar(
state = state,
onSend = { viewModel.sendMessage(it) },
onStop = { viewModel.stopStreaming() },
)
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding(),
)
}
}
if (state.showSettings) {
SettingsScreen(viewModel = viewModel, onDismiss = { viewModel.setShowSettings(false) })
}
}
// ===================== Top Bar =====================
@Composable
private fun ChatTopBar(
state: ChatUiState,
onMenu: () -> Unit,
onSettings: () -> Unit,
onToggleTheme: () -> Unit,
onSelectModel: (String, String) -> Unit,
onRename: (String) -> Unit,
onClearCurrent: () -> Unit,
) {
val c = LocalAppColors.current
var menuOpen by remember { mutableStateOf(false) }
var showRename by remember { mutableStateOf(false) }
var renameText by remember { mutableStateOf("") }
Row(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onMenu) {
Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "菜单", tint = c.text)
}
Text(
text = state.currentConversation?.title ?: "FengAI",
style = MaterialTheme.typography.titleMedium,
color = c.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
)
// model dropdown
ModelSelector(state = state, onSelect = onSelectModel)
// overflow menu
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "更多", tint = c.textDim)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("重命名对话") },
onClick = {
menuOpen = false
state.currentConversation?.let { renameText = it.title; showRename = true }
},
leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
DropdownMenuItem(
text = { Text("清空当前对话") },
onClick = { menuOpen = false; onClearCurrent() },
leadingIcon = { Icon(Icons.Filled.Clear, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
DropdownMenuItem(
text = { Text("设置") },
onClick = { menuOpen = false; onSettings() },
leadingIcon = { Icon(Icons.Filled.Settings, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
}
}
if (showRename) {
AlertDialog(
onDismissRequest = { showRename = false },
title = { Text("重命名对话") },
text = {
OutlinedTextField(
value = renameText,
onValueChange = { renameText = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(onClick = {
if (renameText.isNotBlank()) onRename(renameText.trim())
showRename = false
}) { Text("确定") }
},
dismissButton = { TextButton(onClick = { showRename = false }) { Text("取消") } },
)
}
}
@Composable
private fun ModelSelector(state: ChatUiState, onSelect: (String, String) -> Unit) {
val c = LocalAppColors.current
var expanded by remember { mutableStateOf(false) }
val allModels = remember(state.providers) {
state.providers.flatMap { p -> p.models.map { m -> p to m } }
}
Box {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(c.bg3)
.border(1.dp, c.borderSoft, RoundedCornerShape(8.dp))
.clickable(enabled = allModels.isNotEmpty()) { expanded = true }
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${state.selectedModel ?: "选择模型"}",
style = MaterialTheme.typography.labelLarge,
color = if (allModels.isEmpty()) c.textFaint else c.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 130.dp),
)
Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = c.textDim, modifier = Modifier.size(18.dp))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
allModels.forEach { (p, m) ->
DropdownMenuItem(
text = { Text("${p.name} / $m", style = MaterialTheme.typography.bodyMedium) },
onClick = {
onSelect(p.id, m)
expanded = false
},
)
}
if (allModels.isEmpty()) {
DropdownMenuItem(text = { Text("请先配置供应商") }, onClick = { expanded = false })
}
}
}
}
// ===================== Controls Bar =====================
@Composable
private fun ControlsBar(state: ChatUiState, viewModel: ChatViewModel) {
val c = LocalAppColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// context stepper
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
Text("上下文", style = MaterialTheme.typography.labelSmall, color = c.textDim)
StepBtn("-") { viewModel.setContextLimit(state.contextLimit - 1) }
Text(
"${state.contextLimit}",
style = MaterialTheme.typography.labelLarge,
color = c.text,
modifier = Modifier.padding(horizontal = 6.dp),
)
StepBtn("+") { viewModel.setContextLimit(state.contextLimit + 1) }
}
HorizontalDivider(modifier = Modifier.width(1.dp).height(16.dp), color = c.borderSoft)
// thinking effort
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
Text("思考", style = MaterialTheme.typography.labelSmall, color = c.textDim)
val efforts = listOf("off" to "关闭", "high" to "增强", "max" to "深度")
efforts.forEach { (v, label) ->
val active = state.thinkingEffort == v
Box(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(if (active) c.accentBrush else SolidColor(Color.Transparent))
.clickable { viewModel.setThinkingEffort(v) }
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = if (active) Color.White else c.textDim,
)
}
}
}
}
}
@Composable
private fun StepBtn(label: String, onClick: () -> Unit) {
val c = LocalAppColors.current
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(c.bg3)
.border(1.dp, c.borderSoft, RoundedCornerShape(6.dp))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.labelLarge, color = c.text)
}
}
// ===================== Messages =====================
@Composable
private fun MessagesArea(
modifier: Modifier = Modifier,
state: ChatUiState,
onCopy: (String) -> Unit,
onRegenerate: () -> Unit,
) {
val c = LocalAppColors.current
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val conv = state.currentConversation
val messages = conv?.messages ?: emptyList()
val totalItems = messages.size + (if (state.isStreaming) 1 else 0)
var autoScroll by remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow {
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
val total = listState.layoutInfo.totalItemsCount
total > 0 && last >= total - 2
}.collect { nearBottom -> autoScroll = nearBottom }
}
LaunchedEffect(state.streamContent, state.streamReasoning, messages.size, state.isStreaming) {
if (autoScroll && totalItems > 0) {
listState.animateScrollToItem(totalItems - 1)
}
}
if (messages.isEmpty() && !state.isStreaming) {
EmptyState(modifier = modifier)
return
}
Box(modifier = modifier) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
items(messages) { msg ->
MessageItem(
msg = msg,
isLast = msg == messages.last(),
isStreaming = false,
onCopy = onCopy,
onRegenerate = onRegenerate,
)
}
if (state.isStreaming) {
item {
StreamingMessage(
content = state.streamContent,
reasoning = state.streamReasoning,
onCopy = { onCopy(state.streamContent) },
)
}
}
if (state.error != null) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(9.dp))
.background(c.danger.copy(alpha = 0.1f))
.border(1.dp, c.danger, RoundedCornerShape(9.dp))
.padding(12.dp),
) {
Text(state.error, style = MaterialTheme.typography.bodySmall, color = c.danger)
}
}
}
}
AnimatedVisibility(
visible = !autoScroll,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(c.bg2)
.border(1.dp, c.border, CircleShape)
.clickable {
autoScroll = true
scope.launch { listState.animateScrollToItem(totalItems - 1) }
},
contentAlignment = Alignment.Center,
) {
Text("", color = c.text, fontSize = 18.sp)
}
}
}
}
@Composable
private fun EmptyState(modifier: Modifier = Modifier) {
val c = LocalAppColors.current
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(contentAlignment = Alignment.Center) {
// glow layer
Box(modifier = Modifier.size(108.dp).clip(CircleShape).background(c.accent.copy(alpha = 0.12f)))
// main icon
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(c.accentBrush),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Color.White, modifier = Modifier.size(40.dp))
}
}
Spacer(Modifier.height(16.dp))
Text("FengAI", style = MaterialTheme.typography.titleLarge, color = c.text, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text("选择模型,输入消息开始对话", style = MaterialTheme.typography.bodyMedium, color = c.textFaint)
}
}
}
@Composable
private fun MessageItem(
msg: Message,
isLast: Boolean,
isStreaming: Boolean,
onCopy: (String) -> Unit,
onRegenerate: () -> Unit,
) {
val c = LocalAppColors.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
// header
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(7.dp))
.background(if (msg.role == "user") SolidColor(c.accent2) else c.accentBrush),
contentAlignment = Alignment.Center,
) {
Icon(
if (msg.role == "user") Icons.Filled.Person else Icons.Filled.AutoAwesome,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp),
)
}
Text(
if (msg.role == "user") "" else "FengAI",
style = MaterialTheme.typography.labelSmall,
color = c.textDim,
)
if (msg.role == "assistant" && isLast && !isStreaming) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniBtn("复制") { onCopy(msg.content) }
MiniBtn("重新生成") { onRegenerate() }
}
}
}
Spacer(Modifier.height(6.dp))
if (msg.reasoning.isNotBlank()) {
ReasoningCard(reasoning = msg.reasoning, isStreaming = false)
Spacer(Modifier.height(8.dp))
}
if (msg.role == "user") {
Box(
modifier = Modifier
.clip(RoundedCornerShape(14.dp))
.background(c.userBubble)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
SelectionContainer {
Text(msg.content, style = MaterialTheme.typography.bodyLarge, color = c.text)
}
}
} else {
if (msg.content.isNotBlank()) {
MarkdownText(text = msg.content, modifier = Modifier.fillMaxWidth())
}
}
msg.usage?.let {
if (it.totalTokens > 0) {
Spacer(Modifier.height(6.dp))
Text(
"📊 输入 ${fmtTokens(it.promptTokens)} · 思考 ${fmtTokens(it.reasoningTokens)} · 输出 ${fmtTokens(it.completionTokens)} · 合计 ${fmtTokens(it.totalTokens)}",
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = c.textFaint,
)
}
}
}
}
@Composable
private fun StreamingMessage(content: String, reasoning: String, onCopy: () -> Unit) {
val c = LocalAppColors.current
val blink by rememberInfiniteTransition().animateFloat(
initialValue = 1f, targetValue = 0f,
animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse),
)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(7.dp))
.background(c.accentBrush),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
}
Text("FengAI", style = MaterialTheme.typography.labelSmall, color = c.textDim)
}
Spacer(Modifier.height(6.dp))
if (reasoning.isNotBlank()) {
ReasoningCard(reasoning = reasoning, isStreaming = true)
Spacer(Modifier.height(8.dp))
}
if (content.isNotBlank()) {
MarkdownText(text = content, modifier = Modifier.fillMaxWidth())
}
Text("", color = c.accent, modifier = Modifier.alpha(blink), fontSize = 16.sp)
}
}
@Composable
private fun ReasoningCard(reasoning: String, isStreaming: Boolean) {
val c = LocalAppColors.current
var collapsed by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(9.dp))
.background(c.bg3.copy(alpha = 0.5f))
.border(1.dp, c.borderSoft, RoundedCornerShape(9.dp)),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { collapsed = !collapsed }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(CircleShape)
.background(if (isStreaming) c.accent else c.textFaint)
.alpha(if (isStreaming) blinkPulse() else 1f),
)
Text(if (isStreaming) "思考中…" else "思考过程", style = MaterialTheme.typography.labelSmall, color = c.reasoningText)
Text(if (collapsed) "" else "", style = MaterialTheme.typography.labelSmall, color = c.reasoningText, fontSize = 9.sp)
}
AnimatedVisibility(visible = !collapsed, enter = expandVertically(), exit = shrinkVertically()) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
SelectionContainer {
Text(reasoning, style = MaterialTheme.typography.bodySmall.copy(lineHeight = 19.sp), color = c.textDim)
}
}
}
}
}
@Composable
private fun blinkPulse(): Float {
return rememberInfiniteTransition().animateFloat(
initialValue = 0.3f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1_400), RepeatMode.Reverse),
).value
}
@Composable
private fun MiniBtn(label: String, onClick: () -> Unit) {
val c = LocalAppColors.current
Box(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(c.bg3)
.border(1.dp, c.borderSoft, RoundedCornerShape(6.dp))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(label, style = MaterialTheme.typography.labelSmall, color = c.textDim)
}
}
// ===================== Composer =====================
@Composable
private fun ComposerBar(state: ChatUiState, onSend: (String) -> Unit, onStop: () -> Unit) {
val c = LocalAppColors.current
var input by remember { mutableStateOf("") }
val sending = state.isStreaming
Row(
modifier = Modifier
.fillMaxWidth()
.background(c.bg)
.padding(horizontal = 12.dp, vertical = 10.dp)
.navigationBarsPadding()
.imePadding(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.background(c.bg3)
.border(1.dp, c.borderSoft, RoundedCornerShape(16.dp)),
) {
BasicTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp, max = 180.dp)
.padding(horizontal = 14.dp, vertical = 10.dp),
textStyle = TextStyle(
color = c.text,
fontSize = 15.sp,
lineHeight = 22.sp,
),
cursorBrush = SolidColor(c.accent),
enabled = !sending,
keyboardOptions = KeyboardOptions.Default,
keyboardActions = KeyboardActions(onSend = {
if (input.isNotBlank()) { onSend(input); input = "" }
}),
decorationBox = { inner ->
if (input.isEmpty()) {
Text("输入消息…", color = c.textFaint, fontSize = 15.sp)
}
inner()
},
)
}
if (sending) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(12.dp))
.background(c.danger)
.clickable(onClick = onStop),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Stop, contentDescription = "停止", tint = Color.White)
}
} else {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(12.dp))
.background(c.accentBrush)
.shadow(6.dp, RoundedCornerShape(12.dp), clip = false)
.clickable(enabled = input.isNotBlank()) {
onSend(input); input = ""
},
contentAlignment = Alignment.Center,
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送", tint = Color.White)
}
}
}
}
// ===================== Sidebar =====================
@Composable
private fun SidebarContent(
state: ChatUiState,
onNewChat: () -> Unit,
onOpen: (String) -> Unit,
onDelete: (String) -> Unit,
onClearAll: () -> Unit,
onSettings: () -> Unit,
onSearch: (String) -> Unit,
onToggleTheme: () -> Unit,
) {
val c = LocalAppColors.current
ModalDrawerSheet(
drawerContainerColor = c.bg2,
drawerShape = RoundedCornerShape(0.dp),
) {
Column(modifier = Modifier.fillMaxSize()) {
// brand
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier.size(28.dp).clip(CircleShape).background(c.accentBrush),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
Text("FengAI", style = MaterialTheme.typography.titleLarge, color = c.text, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
IconButton(onClick = onToggleTheme) {
Icon(
if (state.isDarkTheme) Icons.Filled.LightMode else Icons.Filled.DarkMode,
contentDescription = "切换主题",
tint = c.text,
)
}
}
// new chat
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(9.dp))
.background(c.accentBrush)
.clickable(onClick = onNewChat)
.padding(vertical = 11.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(Icons.Filled.Add, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
Text("新建对话", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
}
}
// search
OutlinedTextField(
value = state.searchQuery,
onValueChange = onSearch,
placeholder = { Text("搜索对话…", style = MaterialTheme.typography.bodySmall) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
singleLine = true,
shape = RoundedCornerShape(9.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
// list
val q = state.searchQuery.trim().lowercase()
val filtered = state.conversations.filter { it.title.lowercase().contains(q) }
LazyColumn(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {
items(filtered, key = { it.id }) { meta ->
val active = state.currentConversation?.id == meta.id
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(9.dp))
.background(if (active) c.accent.copy(alpha = 0.12f) else Color.Transparent)
.clickable { onOpen(meta.id) }
.padding(horizontal = 10.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
meta.title,
style = MaterialTheme.typography.bodyMedium,
color = c.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
timeAgo(meta.updatedAt),
style = MaterialTheme.typography.labelSmall,
color = c.textFaint,
)
}
IconButton(onClick = { onDelete(meta.id) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.DeleteOutline, contentDescription = "删除", tint = c.textFaint, modifier = Modifier.size(16.dp))
}
}
}
if (filtered.isEmpty()) {
item {
Text(
if (state.conversations.isEmpty()) "暂无对话" else "无匹配结果",
style = MaterialTheme.typography.bodySmall,
color = c.textFaint,
modifier = Modifier.padding(16.dp),
)
}
}
}
HorizontalDivider(color = c.border)
// footer
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
FooterBtn("清空全部对话", Icons.Filled.DeleteOutline, danger = true, onClick = onClearAll)
FooterBtn("供应商与模型", Icons.Filled.Settings, onClick = onSettings)
}
}
}
}
@Composable
private fun FooterBtn(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, danger: Boolean = false, onClick: () -> Unit) {
val c = LocalAppColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(9.dp))
.border(1.dp, c.border, RoundedCornerShape(9.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(icon, contentDescription = null, tint = if (danger) c.danger else c.textDim, modifier = Modifier.size(16.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, color = if (danger) c.danger else c.textDim)
}
}
// ===================== helpers =====================
private fun fmtTokens(n: Long): String = if (n >= 10000) "%.1fk".format(n / 1000.0) else n.toString()
private fun timeAgo(ts: Long): String {
if (ts == 0L) return ""
val d = System.currentTimeMillis() - ts
val m = d / 60000
if (m < 1) return "刚刚"
if (m < 60) return "${m}分钟前"
val h = m / 60
if (h < 24) return "${h}小时前"
val day = h / 24
if (day < 7) return "${day}天前"
val cal = java.util.Calendar.getInstance().apply { timeInMillis = ts }
return "${cal.get(java.util.Calendar.MONTH) + 1}/${cal.get(java.util.Calendar.DAY_OF_MONTH)}"
}
private fun copyText(text: String, context: Context) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
}
@@ -0,0 +1,358 @@
package cn.flyzxf.agentapp.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cn.flyzxf.agentapp.data.ApiClient
import cn.flyzxf.agentapp.data.Conversation
import cn.flyzxf.agentapp.data.Message
import cn.flyzxf.agentapp.data.Provider
import cn.flyzxf.agentapp.data.Store
import cn.flyzxf.agentapp.data.StreamOutcome
import cn.flyzxf.agentapp.data.StreamParams
import cn.flyzxf.agentapp.data.Usage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
data class ChatUiState(
val providers: List<Provider> = emptyList(),
val conversations: List<cn.flyzxf.agentapp.data.ConversationMeta> = emptyList(),
val currentConversation: Conversation? = null,
val selectedProviderId: String? = null,
val selectedModel: String? = null,
val isStreaming: Boolean = false,
val streamContent: String = "",
val streamReasoning: String = "",
val streamUsage: Usage? = null,
val contextLimit: Int = 6,
val thinkingEffort: String = "off",
val systemPrompt: String = "",
val isDarkTheme: Boolean = true,
val error: String? = null,
val toast: String? = null,
val searchQuery: String = "",
val showSettings: Boolean = false,
val drawerOpen: Boolean = false,
)
class ChatViewModel(app: Application) : AndroidViewModel(app) {
private val store = Store(app)
private val api = ApiClient()
private val prefs = app.getSharedPreferences("agentapp_prefs", android.content.Context.MODE_PRIVATE)
private val _ui = MutableStateFlow(ChatUiState())
val ui: StateFlow<ChatUiState> = _ui.asStateFlow()
private var streamJob: Job? = null
init {
loadPrefs()
loadAll()
}
// ---------- prefs ----------
private fun loadPrefs() {
_ui.update {
it.copy(
isDarkTheme = prefs.getBoolean("dark", false),
contextLimit = prefs.getInt("ctx", 6),
thinkingEffort = prefs.getString("effort", "off") ?: "off",
systemPrompt = prefs.getString("system", "") ?: "",
)
}
}
fun toggleTheme() {
val next = !_ui.value.isDarkTheme
prefs.edit().putBoolean("dark", next).apply()
_ui.update { it.copy(isDarkTheme = next) }
}
fun setContextLimit(v: Int) {
val clamped = v.coerceIn(0, 50)
prefs.edit().putInt("ctx", clamped).apply()
_ui.update { it.copy(contextLimit = clamped) }
}
fun setThinkingEffort(v: String) {
prefs.edit().putString("effort", v).apply()
_ui.update { it.copy(thinkingEffort = v) }
}
fun setSystemPrompt(v: String) {
prefs.edit().putString("system", v).apply()
_ui.update { it.copy(systemPrompt = v) }
}
fun setSearchQuery(v: String) = _ui.update { it.copy(searchQuery = v) }
fun setDrawerOpen(v: Boolean) = _ui.update { it.copy(drawerOpen = v) }
fun setShowSettings(v: Boolean) = _ui.update { it.copy(showSettings = v) }
fun clearToast() = _ui.update { it.copy(toast = null) }
fun clearError() = _ui.update { it.copy(error = null) }
private fun toast(msg: String) = _ui.update { it.copy(toast = msg) }
// ---------- load ----------
fun loadAll() {
val providers = store.getProviders()
val convs = store.getConversationList()
val selPid = _ui.value.selectedProviderId ?: providers.firstOrNull()?.id
val selModel = _ui.value.selectedModel
?: providers.firstOrNull()?.models?.firstOrNull()
_ui.update {
it.copy(
providers = providers,
conversations = convs,
selectedProviderId = selPid,
selectedModel = selModel,
)
}
}
fun refreshConversations() {
_ui.update { it.copy(conversations = store.getConversationList()) }
}
// ---------- conversations ----------
fun newConversation() {
if (_ui.value.isStreaming) stopStreaming()
val conv = Conversation(
id = UUID.randomUUID().toString(),
title = "新对话",
messages = emptyList(),
)
_ui.update {
it.copy(
currentConversation = conv,
drawerOpen = false,
)
}
}
fun openConversation(id: String) {
val conv = store.getConversation(id) ?: return
if (conv.providerId != null && conv.model != null) {
_ui.update {
it.copy(
currentConversation = conv,
selectedProviderId = conv.providerId,
selectedModel = conv.model,
drawerOpen = false,
)
}
} else {
_ui.update { it.copy(currentConversation = conv, drawerOpen = false) }
}
}
fun deleteConversation(id: String) {
store.deleteConversation(id)
if (_ui.value.currentConversation?.id == id) {
_ui.update { it.copy(currentConversation = null) }
}
refreshConversations()
}
fun renameConversation(id: String, title: String) {
store.renameConversation(id, title)
_ui.value.currentConversation?.let {
if (it.id == id) _ui.update { s -> s.copy(currentConversation = it.copy(title = title)) }
}
refreshConversations()
}
fun clearAll() {
if (_ui.value.conversations.isEmpty()) { toast("暂无对话"); return }
if (_ui.value.isStreaming) stopStreaming()
store.clearAllConversations()
_ui.update { it.copy(currentConversation = null) }
refreshConversations()
toast("已清空全部对话")
}
fun clearCurrent() {
val conv = _ui.value.currentConversation ?: return
store.saveConversation(conv.copy(title = "新对话", messages = emptyList()))
_ui.update { it.copy(currentConversation = conv.copy(title = "新对话", messages = emptyList())) }
refreshConversations()
toast("已清空当前对话")
}
// ---------- model selection ----------
fun selectModel(providerId: String, model: String) {
_ui.update { it.copy(selectedProviderId = providerId, selectedModel = model) }
_ui.value.currentConversation?.let { conv ->
val updated = conv.copy(providerId = providerId, model = model)
store.saveConversation(updated)
_ui.update { it.copy(currentConversation = updated) }
}
}
// ---------- send / stream ----------
fun sendMessage(text: String) {
if (_ui.value.isStreaming) return
val trimmedText = text.trim()
if (trimmedText.isEmpty()) return
val sel = getSelectedProvider()
if (sel == null) { toast("请先配置供应商和模型"); return }
val (provider, model) = sel
var conv = _ui.value.currentConversation ?: Conversation(
id = UUID.randomUUID().toString(),
title = "新对话",
)
val userMsg = Message(role = "user", content = trimmedText)
conv = conv.copy(
messages = conv.messages + userMsg,
providerId = provider.id,
model = model,
)
if (conv.messages.count { it.role == "user" } == 1) {
conv = conv.copy(title = makeTitle(trimmedText))
}
store.saveConversation(conv)
_ui.update {
it.copy(
currentConversation = conv,
isStreaming = true,
streamContent = "",
streamReasoning = "",
streamUsage = null,
error = null,
conversations = store.getConversationList(),
)
}
startStream(conv, provider, model)
}
private fun startStream(conv: Conversation, provider: Provider, model: String) {
streamJob = viewModelScope.launch {
val s = _ui.value
val sys = s.systemPrompt.trim().let { if (it.isNotEmpty()) listOf(mapOf("role" to "system", "content" to it)) else emptyList() }
val history = conv.messages
val limited = if (s.contextLimit > 0) history.takeLast(s.contextLimit) else history
val apiMessages = sys + limited.map { mapOf("role" to it.role, "content" to it.content) }
val params = StreamParams(
baseURL = provider.baseURL,
apiKey = provider.apiKey,
model = model,
messages = apiMessages,
enableThinking = s.thinkingEffort != "off",
reasoningEffort = if (s.thinkingEffort != "off") s.thinkingEffort else null,
maxTokens = provider.maxTokens,
)
try {
api.streamChat(
params = params,
onChunk = { content, reasoning ->
if (content.isNotEmpty()) _ui.update { it.copy(streamContent = it.streamContent + content) }
if (reasoning.isNotEmpty()) _ui.update { it.copy(streamReasoning = it.streamReasoning + reasoning) }
},
onUsage = { u -> _ui.update { it.copy(streamUsage = u) } },
)
finalizeStream(aborted = false, conv)
} catch (e: kotlinx.coroutines.CancellationException) {
withContext(Dispatchers.IO + NonCancellable) { finalizeStream(aborted = true, conv) }
throw e
} catch (e: Exception) {
finalizeStream(aborted = true, conv)
_ui.update { it.copy(error = e.message ?: "请求失败", toast = "请求失败:${e.message ?: "未知错误"}") }
}
}
}
private fun finalizeStream(aborted: Boolean, conv: Conversation) {
val s = _ui.value
val content = s.streamContent
val reasoning = s.streamReasoning
val usage = s.streamUsage
val updated = if (content.isBlank() && reasoning.isBlank()) {
conv
} else {
conv.copy(messages = conv.messages + Message(role = "assistant", content = content, reasoning = reasoning, usage = usage))
}
store.saveConversation(updated)
_ui.update {
it.copy(
currentConversation = updated,
isStreaming = false,
streamContent = "",
streamReasoning = "",
streamUsage = null,
conversations = store.getConversationList(),
)
}
}
fun stopStreaming() {
streamJob?.cancel()
}
fun regenerate() {
if (_ui.value.isStreaming) return
val conv = _ui.value.currentConversation ?: return
if (conv.messages.isEmpty()) return
val sel = getSelectedProvider() ?: run { toast("请先配置供应商和模型"); return }
val (provider, model) = sel
var msgs = conv.messages
if (msgs.lastOrNull()?.role == "assistant") msgs = msgs.dropLast(1)
if (msgs.isEmpty() || msgs.last().role != "user") { toast("没有可重新生成的消息"); return }
val updated = conv.copy(messages = msgs)
store.saveConversation(updated)
_ui.update { it.copy(currentConversation = updated, isStreaming = true, streamContent = "", streamReasoning = "", streamUsage = null) }
startStream(updated, provider, model)
}
// ---------- providers ----------
fun saveProvider(p: Provider): Provider {
val saved = store.saveProvider(p)
loadAll()
return saved
}
fun deleteProvider(id: String) {
store.deleteProvider(id)
loadAll()
}
fun testProvider(provider: Provider, onResult: (ok: Boolean, models: List<String>, error: String) -> Unit) {
viewModelScope.launch {
try {
val models = api.fetchModels(provider.baseURL, provider.apiKey)
onResult(true, models, "")
} catch (e: Exception) {
onResult(false, emptyList(), e.message ?: "连接失败")
}
}
}
// ---------- helpers ----------
private fun getSelectedProvider(): Pair<Provider, String>? {
val s = _ui.value
val pid = s.selectedProviderId ?: return null
val model = s.selectedModel ?: return null
val provider = s.providers.firstOrNull { it.id == pid } ?: return null
return provider to model
}
private fun makeTitle(text: String): String {
val t = text.replace(Regex("\\s+"), " ").trim()
return if (t.length > 18) t.take(18) else (if (t.isEmpty()) "新对话" else t)
}
override fun onCleared() {
super.onCleared()
streamJob?.cancel()
}
}
@@ -0,0 +1,367 @@
package cn.flyzxf.agentapp.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.flyzxf.agentapp.data.Provider
import cn.flyzxf.agentapp.ui.theme.LocalAppColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: ChatViewModel, onDismiss: () -> Unit) {
val state by viewModel.ui.collectAsStateWithLifecycle()
val c = LocalAppColors.current
var editingId by remember { mutableStateOf(state.providers.firstOrNull()?.id ?: "") }
var name by remember { mutableStateOf("") }
var baseURL by remember { mutableStateOf("") }
var apiKey by remember { mutableStateOf("") }
var models by remember { mutableStateOf("") }
var maxTokens by remember { mutableStateOf("") }
var systemPrompt by remember { mutableStateOf(state.systemPrompt) }
var showKey by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf("") }
var testOk by remember { mutableStateOf<Boolean?>(null) }
var showDeleteConfirm by remember { mutableStateOf(false) }
fun loadProvider(id: String) {
val p = state.providers.firstOrNull { it.id == id }
name = p?.name ?: ""
baseURL = p?.baseURL ?: ""
apiKey = p?.apiKey ?: ""
models = p?.models?.joinToString("\n") ?: ""
maxTokens = if (p != null && p.maxTokens > 0) p.maxTokens.toString() else ""
testMsg = ""
testOk = null
}
LaunchedEffect(editingId) { if (editingId.isNotEmpty()) loadProvider(editingId) }
LaunchedEffect(state.providers) {
if (editingId.isEmpty() && state.providers.isNotEmpty()) {
editingId = state.providers.first().id
}
}
fun buildProvider(): Provider = Provider(
id = editingId,
name = name.trim().ifEmpty { "(未命名)" },
baseURL = baseURL.trim(),
apiKey = apiKey.trim(),
models = models.split("\n", ",").map { it.trim() }.filter { it.isNotEmpty() },
maxTokens = maxTokens.trim().toLongOrNull() ?: 0,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(c.bg)
.windowInsetsPadding(WindowInsets.systemBars)
.imePadding(),
) {
Column(modifier = Modifier.fillMaxSize()) {
// top bar
Row(
modifier = Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDismiss) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回", tint = c.text)
}
Text("供应商与模型", style = MaterialTheme.typography.titleMedium, color = c.text, modifier = Modifier.weight(1f))
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
// provider selector row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ProviderDropdown(
providers = state.providers,
selectedId = editingId,
onSelect = { editingId = it },
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(9.dp))
.background(c.bg3)
.border(1.dp, c.borderSoft, RoundedCornerShape(9.dp))
.clickable {
editingId = ""
name = ""; baseURL = ""; apiKey = ""; models = ""; maxTokens = ""
testMsg = ""; testOk = null
},
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Add, contentDescription = "新建", tint = c.text)
}
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(9.dp))
.background(c.bg3)
.border(1.dp, c.borderSoft, RoundedCornerShape(9.dp))
.clickable(enabled = editingId.isNotEmpty()) { showDeleteConfirm = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Delete, contentDescription = "删除", tint = if (editingId.isNotEmpty()) c.danger else c.textFaint)
}
}
Spacer(Modifier.height(16.dp))
FieldLabel("供应商名称")
FieldInput(name, { name = it }, "GLM5.1")
Spacer(Modifier.height(12.dp))
FieldLabel("API 地址(自动拼接 /chat/completions")
FieldInput(baseURL, { baseURL = it }, "https://ark.cn-beijing.volces.com/api/coding/v3")
Spacer(Modifier.height(12.dp))
FieldLabel("密钥")
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(9.dp),
textStyle = MaterialTheme.typography.bodyMedium,
placeholder = { Text("sk-…", style = MaterialTheme.typography.bodyMedium, color = c.textFaint) },
visualTransformation = if (showKey) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
TextButton(onClick = { showKey = !showKey }) {
Text(if (showKey) "隐藏" else "显示", style = MaterialTheme.typography.labelSmall)
}
},
)
Spacer(Modifier.height(12.dp))
// test & fetch
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(9.dp))
.background(c.bg3)
.border(1.dp, c.borderSoft, RoundedCornerShape(9.dp))
.clickable {
testMsg = "测试中…"; testOk = null
viewModel.testProvider(buildProvider()) { ok, modelsList, err ->
if (ok) {
testOk = true
testMsg = if (modelsList.isNotEmpty()) {
"连接成功,发现 ${modelsList.size} 个模型"
} else "连接成功,未返回模型列表"
if (modelsList.isNotEmpty()) models = modelsList.joinToString("\n")
} else {
testOk = false
testMsg = "失败:$err"
}
}
}
.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, tint = c.text, modifier = Modifier.size(16.dp))
Text("测试并获取模型", style = MaterialTheme.typography.labelLarge, color = c.text)
}
testMsg.takeIf { it.isNotEmpty() }?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = when (testOk) {
true -> Color(0xFF3EC77A)
false -> c.danger
else -> c.textDim
},
modifier = Modifier.weight(1f),
)
}
}
Spacer(Modifier.height(12.dp))
FieldLabel("模型列表(每行一个,或逗号分隔)")
FieldInput(
models, { models = it }, "glm-5.2\nkimi-k2.6\ndeepseek-v4-pro",
singleLine = false, minLines = 4,
)
Spacer(Modifier.height(12.dp))
FieldLabel("最大输出 tokens(留空=不限制;GLM-5.2 可填 131072")
FieldInput(maxTokens, { maxTokens = it.filter { ch -> ch.isDigit() } }, "131072", keyboardType = KeyboardType.Number)
Spacer(Modifier.height(12.dp))
// save button
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(9.dp))
.background(c.accentBrush)
.clickable {
val saved = viewModel.saveProvider(buildProvider())
editingId = saved.id
testMsg = "已保存"; testOk = true
}
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text("保存供应商", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
}
Spacer(Modifier.height(20.dp))
FieldLabel("系统提示词(可选,对所有对话生效)")
FieldInput(
systemPrompt,
{ systemPrompt = it; viewModel.setSystemPrompt(it) },
"你是一个乐于助人的助手…",
singleLine = false, minLines = 3,
)
Spacer(Modifier.height(24.dp))
}
}
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("删除供应商") },
text = { Text("确定删除该供应商配置吗?") },
confirmButton = {
TextButton(onClick = {
if (editingId.isNotEmpty()) viewModel.deleteProvider(editingId)
showDeleteConfirm = false
editingId = state.providers.firstOrNull()?.id ?: ""
}) { Text("删除") }
},
dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("取消") } },
)
}
}
@Composable
private fun ProviderDropdown(providers: List<Provider>, selectedId: String, onSelect: (String) -> Unit, modifier: Modifier = Modifier) {
val c = LocalAppColors.current
var expanded by remember { mutableStateOf(false) }
val selected = providers.firstOrNull { it.id == selectedId }
Box(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(9.dp))
.background(c.bg3)
.border(1.dp, c.borderSoft, RoundedCornerShape(9.dp))
.clickable { expanded = true }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
selected?.name ?: "选择供应商",
style = MaterialTheme.typography.bodyMedium,
color = if (selected == null) c.textFaint else c.text,
modifier = Modifier.weight(1f),
)
Text("", color = c.textDim, fontSize = 12.sp)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
providers.forEach { p ->
DropdownMenuItem(text = { Text(p.name) }, onClick = { onSelect(p.id); expanded = false })
}
if (providers.isEmpty()) {
DropdownMenuItem(text = { Text("暂无供应商") }, onClick = { expanded = false })
}
}
}
}
@Composable
private fun FieldLabel(text: String) {
val c = LocalAppColors.current
Text(text, style = MaterialTheme.typography.labelSmall, color = c.textDim, modifier = Modifier.padding(bottom = 4.dp))
}
@Composable
private fun FieldInput(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
singleLine: Boolean = true,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
) {
val c = LocalAppColors.current
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
singleLine = singleLine,
minLines = minLines,
shape = RoundedCornerShape(9.dp),
textStyle = MaterialTheme.typography.bodyMedium,
placeholder = { Text(placeholder, style = MaterialTheme.typography.bodyMedium, color = c.textFaint) },
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
)
}
@@ -0,0 +1,278 @@
package cn.flyzxf.agentapp.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cn.flyzxf.agentapp.ui.theme.AppColors
import cn.flyzxf.agentapp.ui.theme.LocalAppColors
private sealed class MdBlock {
data class Paragraph(val text: String) : MdBlock()
data class Header(val level: Int, val text: String) : MdBlock()
data class Code(val lang: String, val code: String) : MdBlock()
data class ListItem(val ordered: Boolean, val index: Int, val text: String) : MdBlock()
data class Quote(val text: String) : MdBlock()
data object Hr : MdBlock()
}
private enum class Inline { NORMAL, BOLD, ITALIC, CODE, STRIKE, LINK }
private val inlineRegex = Regex(
"""`([^`]+)`|\*\*(.+?)\*\*|\*([^*\n]+?)\*|__(.+?)__|~~(.+?)~~|\[([^\]]+)\]\(([^)]+)\)"""
)
private fun tokenizeInline(input: String): List<Pair<String, Inline>> {
val result = mutableListOf<Pair<String, Inline>>()
var lastEnd = 0
for (m in inlineRegex.findAll(input)) {
if (m.range.first > lastEnd) result.add(input.substring(lastEnd, m.range.first) to Inline.NORMAL)
val g = m.groupValues
when {
g[1].isNotEmpty() -> result.add(g[1] to Inline.CODE)
g[2].isNotEmpty() -> result.add(g[2] to Inline.BOLD)
g[3].isNotEmpty() -> result.add(g[3] to Inline.ITALIC)
g[4].isNotEmpty() -> result.add(g[4] to Inline.ITALIC)
g[5].isNotEmpty() -> result.add(g[5] to Inline.STRIKE)
g[6].isNotEmpty() -> result.add(g[6] to Inline.LINK)
}
lastEnd = m.range.last + 1
}
if (lastEnd < input.length) result.add(input.substring(lastEnd) to Inline.NORMAL)
if (result.isEmpty()) result.add(input to Inline.NORMAL)
return result
}
private fun buildInline(text: String, c: AppColors): AnnotatedString = buildAnnotatedString {
for ((chunk, style) in tokenizeInline(text)) {
when (style) {
Inline.NORMAL -> append(chunk)
Inline.BOLD -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(chunk) }
Inline.ITALIC -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(chunk) }
Inline.CODE -> withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
background = c.codeBg,
color = c.accent2,
fontSize = 13.sp,
)
) { append(" $chunk ") }
Inline.STRIKE -> withStyle(SpanStyle(textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough)) { append(chunk) }
Inline.LINK -> {
pushStringAnnotation(tag = "URL", annotation = chunk)
withStyle(SpanStyle(color = c.accent2, fontWeight = FontWeight.Medium)) { append(chunk) }
pop()
}
}
}
}
private fun parseMarkdown(raw: String): List<MdBlock> {
val blocks = mutableListOf<MdBlock>()
val lines = raw.split("\n")
var i = 0
while (i < lines.size) {
val line = lines[i]
val trimmed = line.trim()
when {
trimmed.startsWith("```") -> {
val lang = trimmed.removePrefix("```").trim()
val sb = StringBuilder()
i++
while (i < lines.size && !lines[i].trim().startsWith("```")) {
sb.appendLine(lines[i])
i++
}
if (i < lines.size) i++
blocks.add(MdBlock.Code(lang, sb.toString().trimEnd('\n')))
}
trimmed.matches(Regex("^#{1,6}\\s.+")) -> {
val level = trimmed.takeWhile { it == '#' }.length
val txt = trimmed.drop(level).trim()
blocks.add(MdBlock.Header(level, txt))
i++
}
trimmed.matches(Regex("^([-*]){3,}$")) -> {
blocks.add(MdBlock.Hr)
i++
}
trimmed.startsWith("- ") || trimmed.startsWith("* ") -> {
blocks.add(MdBlock.ListItem(false, 0, trimmed.drop(2).trim()))
i++
}
trimmed.matches(Regex("^\\d+\\..+")) -> {
val num = trimmed.takeWhile { it.isDigit() }.toIntOrNull() ?: 1
val txt = trimmed.substringAfter(".").trim()
blocks.add(MdBlock.ListItem(true, num, txt))
i++
}
trimmed.startsWith("> ") -> {
val sb = StringBuilder(trimmed.drop(2))
i++
while (i < lines.size && lines[i].trim().startsWith("> ")) {
sb.append("\n").append(lines[i].trim().drop(2))
i++
}
blocks.add(MdBlock.Quote(sb.toString()))
}
trimmed.isEmpty() -> i++
else -> {
val sb = StringBuilder(line)
i++
while (i < lines.size && lines[i].trim().isNotEmpty() &&
!lines[i].trimStart().startsWith("#") &&
!lines[i].trimStart().startsWith("- ") &&
!lines[i].trimStart().startsWith("* ") &&
!lines[i].trimStart().startsWith("```") &&
!lines[i].trimStart().startsWith("> ") &&
!lines[i].trim().matches(Regex("^\\d+\\..+")) &&
!lines[i].trim().matches(Regex("^([-*]){3,}$"))
) {
sb.append("\n").append(lines[i])
i++
}
blocks.add(MdBlock.Paragraph(sb.toString()))
}
}
}
return blocks
}
@Composable
fun MarkdownText(
text: String,
modifier: Modifier = Modifier,
) {
val c = LocalAppColors.current
val blocks = remember(text) { parseMarkdown(text) }
val uriHandler = LocalUriHandler.current
SelectionContainer {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (block in blocks) {
when (block) {
is MdBlock.Paragraph -> {
val styled = remember(block.text) { buildInline(block.text, c) }
ClickableText(
text = styled,
modifier = Modifier.fillMaxWidth(),
onClick = { offset ->
styled.getStringAnnotations("URL", offset, offset)
.firstOrNull()?.let { uriHandler.openUri(it.item) }
},
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge.copy(
color = c.text, lineHeight = 24.sp
),
)
}
is MdBlock.Header -> {
val styled = remember(block.text) { buildInline(block.text, c) }
val size = when {
block.level <= 1 -> 24.sp
block.level == 2 -> 21.sp
block.level == 3 -> 18.sp
else -> 16.sp
}
Text(
text = styled,
fontSize = size,
fontWeight = FontWeight.Bold,
color = c.text,
modifier = Modifier.padding(top = 4.dp),
)
}
is MdBlock.Code -> {
Box(
modifier = Modifier
.fillMaxWidth()
.background(c.codeBg, RoundedCornerShape(10.dp))
.border(1.dp, c.borderSoft, RoundedCornerShape(10.dp))
.padding(14.dp)
) {
Text(
text = block.code,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
lineHeight = 18.sp,
color = c.text,
)
}
}
is MdBlock.ListItem -> {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = if (block.ordered) "${block.index}. " else "",
color = c.accent,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(22.dp),
)
val styled = remember(block.text) { buildInline(block.text, c) }
Text(
text = styled,
color = c.text,
modifier = Modifier.weight(1f),
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge.copy(lineHeight = 24.sp),
)
}
}
is MdBlock.Quote -> {
val styled = remember(block.text) { buildInline(block.text, c) }
Box(
modifier = Modifier
.fillMaxWidth()
.background(c.bg3, RoundedCornerShape(0.dp, 8.dp, 8.dp, 0.dp))
.padding(start = 14.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
) {
Row {
Box(
modifier = Modifier
.width(3.dp)
.height(18.dp)
.background(c.accent, RoundedCornerShape(3.dp))
.align(Alignment.CenterVertically)
)
Text(
text = styled,
color = c.textDim,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
is MdBlock.Hr -> HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
thickness = 1.dp,
color = c.border,
)
}
}
}
}
}
@@ -2,10 +2,42 @@ package cn.flyzxf.agentapp.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
// ============ Light (default) — clean, high-contrast, tech ============
val LightBg = Color(0xFFEFF1F8)
val LightBg2 = Color(0xFFFFFFFF)
val LightBg3 = Color(0xFFE4E8F4)
val LightBg4 = Color(0xFFD8DCEC)
val LightSurface = Color(0xFFFFFFFF)
val LightBorder = Color(0xFFD0D5E5)
val LightBorderSoft = Color(0xFFE4E8F4)
val LightText = Color(0xFF1A1D2E)
val LightTextDim = Color(0xFF545978)
val LightTextFaint = Color(0xFF9095AC)
val LightAccent = Color(0xFF6C5CE7)
val LightAccent2 = Color(0xFF00B4D8)
val LightDanger = Color(0xFFFF3B5C)
val LightUserBubble = Color(0xFFEEF0FF)
val LightCodeBg = Color(0xFFF2F4FA)
val LightReasoning = Color(0xFF6C5CE7)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// ============ Dark — soft navy, NOT pure black ============
val DarkBg = Color(0xFF15172A)
val DarkBg2 = Color(0xFF1D1F36)
val DarkBg3 = Color(0xFF272A45)
val DarkBg4 = Color(0xFF313555)
val DarkSurface = Color(0xFF1D1F36)
val DarkBorder = Color(0xFF353960)
val DarkBorderSoft = Color(0xFF272A45)
val DarkText = Color(0xFFEDF0F8)
val DarkTextDim = Color(0xFFA8AECF)
val DarkTextFaint = Color(0xFF6D7295)
val DarkAccent = Color(0xFF8B7FF2)
val DarkAccent2 = Color(0xFF2ED3F7)
val DarkDanger = Color(0xFFFF5B7A)
val DarkUserBubble = Color(0xFF2C2F50)
val DarkCodeBg = Color(0xFF111220)
val DarkReasoning = Color(0xFFB4A7FF)
// ============ Gradient (shared) ============
val AccentGradientStart = Color(0xFF6C5CE7)
val AccentGradientEnd = Color(0xFF00B4D8)
@@ -1,58 +1,117 @@
package cn.flyzxf.agentapp.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
@Immutable
data class AppColors(
val bg: Color,
val bg2: Color,
val bg3: Color,
val bg4: Color,
val surface: Color,
val border: Color,
val borderSoft: Color,
val text: Color,
val textDim: Color,
val textFaint: Color,
val accent: Color,
val accent2: Color,
val danger: Color,
val userBubble: Color,
val codeBg: Color,
val reasoningText: Color,
) {
val accentBrush: Brush
get() = Brush.linearGradient(listOf(AccentGradientStart, AccentGradientEnd))
}
val LocalAppColors = compositionLocalOf<AppColors> {
error("AppColors not provided")
}
val DarkAppColors = AppColors(
bg = DarkBg, bg2 = DarkBg2, bg3 = DarkBg3, bg4 = DarkBg4,
surface = DarkSurface, border = DarkBorder, borderSoft = DarkBorderSoft,
text = DarkText, textDim = DarkTextDim, textFaint = DarkTextFaint,
accent = DarkAccent, accent2 = DarkAccent2, danger = DarkDanger,
userBubble = DarkUserBubble, codeBg = DarkCodeBg, reasoningText = DarkReasoning,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
val LightAppColors = AppColors(
bg = LightBg, bg2 = LightBg2, bg3 = LightBg3, bg4 = LightBg4,
surface = LightSurface, border = LightBorder, borderSoft = LightBorderSoft,
text = LightText, textDim = LightTextDim, textFaint = LightTextFaint,
accent = LightAccent, accent2 = LightAccent2, danger = LightDanger,
userBubble = LightUserBubble, codeBg = LightCodeBg, reasoningText = LightReasoning,
)
@Composable
fun AgentAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
fun AppTheme(
darkTheme: Boolean,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
val appColors = if (darkTheme) DarkAppColors else LightAppColors
val scheme = if (darkTheme) {
darkColorScheme(
primary = appColors.accent,
onPrimary = Color.White,
secondary = appColors.accent2,
background = appColors.bg,
onBackground = appColors.text,
surface = appColors.surface,
onSurface = appColors.text,
surfaceVariant = appColors.bg3,
onSurfaceVariant = appColors.textDim,
outline = appColors.border,
error = appColors.danger,
)
} else {
lightColorScheme(
primary = appColors.accent,
onPrimary = Color.White,
secondary = appColors.accent2,
background = appColors.bg,
onBackground = appColors.text,
surface = appColors.surface,
onSurface = appColors.text,
surfaceVariant = appColors.bg3,
onSurfaceVariant = appColors.textDim,
outline = appColors.border,
error = appColors.danger,
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
val controller = WindowCompat.getInsetsController(window, view)
controller.isAppearanceLightStatusBars = !darkTheme
controller.isAppearanceLightNavigationBars = !darkTheme
}
}
CompositionLocalProvider(LocalAppColors provides appColors) {
MaterialTheme(
colorScheme = scheme,
typography = AppTypography,
content = content,
)
}
}
@@ -6,29 +6,47 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
val AppTypography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontSize = 15.sp,
lineHeight = 23.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
fontSize = 14.sp,
lineHeight = 21.sp,
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 17.sp,
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 24.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
lineHeight = 15.sp,
),
)
@@ -1,170 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="108"
android:endY="108"
android:startX="0"
android:startY="0"
android:type="linear">
<item android:color="#FF6C5CE7" android:offset="0.0" />
<item android:color="#FF00B4D8" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
</vector>
@@ -1,30 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- 4-pointed spark star -->
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
android:pathData="M54,26 C57,42 66,51 82,54 C66,57 57,66 54,82 C51,66 42,57 26,54 C42,51 51,42 54,26 Z" />
<!-- small center circle -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,48 m6,0 a6,6 0 1,0 -12,0 a6,6 0 1,0 12,0" />
</vector>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="window_bg">#FF15172A</color>
</resources>
+1 -5
View File
@@ -1,10 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="window_bg">#FFEFF1F8</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">agentApp</string>
<string name="app_name">FengAI</string>
</resources>
+6 -2
View File
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AgentApp" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.AgentApp" parent="android:Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">@color/window_bg</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+46
View File
@@ -0,0 +1,46 @@
Starting a Gradle Daemon (subsequent builds will be faster)
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:processDebugNavigationResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:generateDebugRFile UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:generateDebugGlobalSynthetics UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:compileDebugNavigationResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs UP-TO-DATE
> Task :app:stripDebugDebugSymbols UP-TO-DATE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:compileDebugKotlin
> Task :app:compileDebugJavaWithJavac NO-SOURCE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:dexBuilderDebug
> Task :app:mergeProjectDexDebug
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect UP-TO-DATE
> Task :app:assembleDebug
BUILD SUCCESSFUL in 21s
36 actionable tasks: 4 executed, 32 up-to-date
+45
View File
@@ -0,0 +1,45 @@
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:processDebugNavigationResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:generateDebugRFile UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:generateDebugGlobalSynthetics UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:compileDebugNavigationResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs UP-TO-DATE
> Task :app:stripDebugDebugSymbols UP-TO-DATE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:compileDebugKotlin
> Task :app:compileDebugJavaWithJavac NO-SOURCE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:dexBuilderDebug
> Task :app:mergeProjectDexDebug
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect UP-TO-DATE
> Task :app:assembleDebug
BUILD SUCCESSFUL in 6s
36 actionable tasks: 4 executed, 32 up-to-date
+45
View File
@@ -0,0 +1,45 @@
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:processDebugNavigationResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:generateDebugRFile UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:generateDebugGlobalSynthetics UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:compileDebugNavigationResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs UP-TO-DATE
> Task :app:stripDebugDebugSymbols UP-TO-DATE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:compileDebugKotlin
> Task :app:compileDebugJavaWithJavac NO-SOURCE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:dexBuilderDebug
> Task :app:mergeProjectDexDebug
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect UP-TO-DATE
> Task :app:assembleDebug
BUILD SUCCESSFUL in 2s
36 actionable tasks: 4 executed, 32 up-to-date
+45
View File
@@ -0,0 +1,45 @@
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:processDebugNavigationResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:generateDebugRFile UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:compileDebugJavaWithJavac NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:generateDebugGlobalSynthetics UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:compileDebugNavigationResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:dexBuilderDebug UP-TO-DATE
> Task :app:mergeProjectDexDebug UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs UP-TO-DATE
> Task :app:stripDebugDebugSymbols UP-TO-DATE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:mergeExtDexDebug
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect
> Task :app:assembleDebug
BUILD SUCCESSFUL in 5s
36 actionable tasks: 3 executed, 33 up-to-date
+45
View File
@@ -0,0 +1,45 @@
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:processDebugNavigationResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:generateDebugRFile UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:compileDebugJavaWithJavac NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:compressDebugAssets UP-TO-DATE
> Task :app:generateDebugGlobalSynthetics UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:desugarDebugFileDependencies UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeLibDexDebug UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:compileDebugNavigationResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest
> Task :app:processDebugManifest
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:processDebugManifestForPackage
> Task :app:mergeDebugNativeLibs UP-TO-DATE
> Task :app:stripDebugDebugSymbols UP-TO-DATE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:processDebugResources
> Task :app:dexBuilderDebug UP-TO-DATE
> Task :app:mergeProjectDexDebug UP-TO-DATE
> Task :app:packageDebug
> Task :app:createDebugApkListingFileRedirect UP-TO-DATE
> Task :app:assembleDebug
BUILD SUCCESSFUL in 6s
36 actionable tasks: 5 executed, 31 up-to-date
+1
View File
@@ -2,4 +2,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
+7 -2
View File
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
@@ -15,5 +15,10 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# phase entirely if nothing that affects the build configuration (such as build scripts)
# has changed. Additionally, Gradle applies performance optimizations to task execution.
org.gradle.configuration-cache=true
# Kotlin code style for this project: "official" or "obsolete":
# Kotlin code style for "official" or "obsolete":
kotlin.code.style=official
# Use Android Studio's bundled JDK 21 (AGP 9 requires JDK 21)
org.gradle.java.home=C:\\Program Files\\Android\\Android Studio\\jbr
# Register the JBR as a known JDK installation and disable auto-download
org.gradle.java.installations.paths=C:\\Program Files\\Android\\Android Studio\\jbr
org.gradle.java.installations.auto-download=false
+14 -5
View File
@@ -1,20 +1,25 @@
[versions]
agp = "9.2.1"
coreKtx = "1.10.1"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
kotlin = "2.2.10"
composeBom = "2026.02.01"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.7.3"
lifecycle = "2.8.7"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
@@ -24,8 +29,12 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
View File
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

+6 -3
View File
@@ -1,5 +1,8 @@
pluginManagement {
repositories {
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google {
content {
includeGroupByRegex("com\\.android.*")
@@ -11,12 +14,12 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google()
mavenCentral()
}