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