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