完成功能
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>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<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" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectType">
|
<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
|
||||||
|
|
||||||
|
|
||||||
+29
-11
@@ -1,51 +1,69 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "cn.flyzxf.agentapp"
|
namespace = "cn.flyzxf.agentapp"
|
||||||
compileSdk {
|
compileSdk = 36
|
||||||
version = release(36) {
|
|
||||||
minorApiLevel = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "cn.flyzxf.agentapp"
|
applicationId = "cn.flyzxf.agentapp"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
optimization {
|
isMinifyEnabled = false
|
||||||
enable = false
|
proguardFiles(
|
||||||
}
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|||||||
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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -10,6 +13,8 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@style/Theme.AgentApp">
|
android:theme="@style/Theme.AgentApp">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -2,46 +2,38 @@ package cn.flyzxf.agentapp
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import cn.flyzxf.agentapp.ui.theme.AgentAppTheme
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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 {
|
setContent {
|
||||||
AgentAppTheme {
|
val viewModel: ChatViewModel = viewModel()
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
val state by viewModel.ui.collectAsStateWithLifecycle()
|
||||||
Greeting(
|
AppTheme(darkTheme = state.isDarkTheme) {
|
||||||
name = "Android",
|
Surface(
|
||||||
modifier = Modifier.padding(innerPadding)
|
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
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
// ============ Light (default) — clean, high-contrast, tech ============
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
val LightBg = Color(0xFFEFF1F8)
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
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)
|
// ============ Dark — soft navy, NOT pure black ============
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val DarkBg = Color(0xFF15172A)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
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
|
package cn.flyzxf.agentapp.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
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(
|
@Immutable
|
||||||
primary = Purple80,
|
data class AppColors(
|
||||||
secondary = PurpleGrey80,
|
val bg: Color,
|
||||||
tertiary = Pink80
|
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(
|
val LightAppColors = AppColors(
|
||||||
primary = Purple40,
|
bg = LightBg, bg2 = LightBg2, bg3 = LightBg3, bg4 = LightBg4,
|
||||||
secondary = PurpleGrey40,
|
surface = LightSurface, border = LightBorder, borderSoft = LightBorderSoft,
|
||||||
tertiary = Pink40
|
text = LightText, textDim = LightTextDim, textFaint = LightTextFaint,
|
||||||
|
accent = LightAccent, accent2 = LightAccent2, danger = LightDanger,
|
||||||
/* Other default colors to override
|
userBubble = LightUserBubble, codeBg = LightCodeBg, reasoningText = LightReasoning,
|
||||||
background = Color(0xFFFFFBFE),
|
|
||||||
surface = Color(0xFFFFFBFE),
|
|
||||||
onPrimary = Color.White,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
onTertiary = Color.White,
|
|
||||||
onBackground = Color(0xFF1C1B1F),
|
|
||||||
onSurface = Color(0xFF1C1B1F),
|
|
||||||
*/
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AgentAppTheme(
|
fun AppTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean,
|
||||||
// Dynamic color is available on Android 12+
|
content: @Composable () -> Unit,
|
||||||
dynamicColor: Boolean = true,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val appColors = if (darkTheme) DarkAppColors else LightAppColors
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
val scheme = if (darkTheme) {
|
||||||
val context = LocalContext.current
|
darkColorScheme(
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
primary = appColors.accent,
|
||||||
}
|
onPrimary = Color.White,
|
||||||
|
secondary = appColors.accent2,
|
||||||
darkTheme -> DarkColorScheme
|
background = appColors.bg,
|
||||||
else -> LightColorScheme
|
onBackground = appColors.text,
|
||||||
}
|
surface = appColors.surface,
|
||||||
|
onSurface = appColors.text,
|
||||||
MaterialTheme(
|
surfaceVariant = appColors.bg3,
|
||||||
colorScheme = colorScheme,
|
onSurfaceVariant = appColors.textDim,
|
||||||
typography = Typography,
|
outline = appColors.border,
|
||||||
content = content
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
// Set of Material typography styles to start with
|
val AppTypography = Typography(
|
||||||
val Typography = Typography(
|
|
||||||
bodyLarge = TextStyle(
|
bodyLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp,
|
fontSize = 15.sp,
|
||||||
lineHeight = 24.sp,
|
lineHeight = 23.sp,
|
||||||
letterSpacing = 0.5.sp
|
),
|
||||||
)
|
bodyMedium = TextStyle(
|
||||||
/* Other default text styles to override
|
|
||||||
titleLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 22.sp,
|
fontSize = 14.sp,
|
||||||
lineHeight = 28.sp,
|
lineHeight = 21.sp,
|
||||||
letterSpacing = 0.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(
|
labelSmall = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
lineHeight = 16.sp,
|
lineHeight = 15.sp,
|
||||||
letterSpacing = 0.5.sp
|
),
|
||||||
)
|
|
||||||
*/
|
|
||||||
)
|
)
|
||||||
@@ -1,170 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
android:fillColor="#3DDC84"
|
<aapt:attr name="android:fillColor">
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
<gradient
|
||||||
<path
|
android:endX="108"
|
||||||
android:fillColor="#00000000"
|
android:endY="108"
|
||||||
android:pathData="M9,0L9,108"
|
android:startX="0"
|
||||||
android:strokeWidth="0.8"
|
android:startY="0"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:type="linear">
|
||||||
<path
|
<item android:color="#FF6C5CE7" android:offset="0.0" />
|
||||||
android:fillColor="#00000000"
|
<item android:color="#FF00B4D8" android:offset="1.0" />
|
||||||
android:pathData="M19,0L19,108"
|
</gradient>
|
||||||
android:strokeWidth="0.8"
|
</aapt:attr>
|
||||||
android:strokeColor="#33FFFFFF" />
|
</path>
|
||||||
<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" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="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">
|
<!-- 4-pointed spark star -->
|
||||||
<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>
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:fillType="nonZero"
|
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" />
|
||||||
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"
|
<!-- small center circle -->
|
||||||
android:strokeWidth="1"
|
<path
|
||||||
android:strokeColor="#00000000" />
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,48 m6,0 a6,6 0 1,0 -12,0 a6,6 0 1,0 12,0" />
|
||||||
</vector>
|
</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="purple_200">#FFBB86FC</color>
|
<color name="window_bg">#FFEFF1F8</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="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">agentApp</string>
|
<string name="app_name">FengAI</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<style name="Theme.AgentApp" parent="android:Theme.DeviceDefault.NoActionBar">
|
||||||
<style name="Theme.AgentApp" parent="android:Theme.Material.Light.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>
|
</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,4 +2,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
+7
-2
@@ -6,7 +6,7 @@
|
|||||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
# Specifies the JVM arguments used for the daemon process.
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
# 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.
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
# This option should only be used with decoupled projects. For more details, visit
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
# 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)
|
# phase entirely if nothing that affects the build configuration (such as build scripts)
|
||||||
# has changed. Additionally, Gradle applies performance optimizations to task execution.
|
# has changed. Additionally, Gradle applies performance optimizations to task execution.
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
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]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
coreKtx = "1.10.1"
|
coreKtx = "1.15.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
junitVersion = "1.1.5"
|
||||||
espressoCore = "3.5.1"
|
espressoCore = "3.5.1"
|
||||||
lifecycleRuntimeKtx = "2.6.1"
|
lifecycleRuntimeKtx = "2.8.7"
|
||||||
activityCompose = "1.8.0"
|
activityCompose = "1.9.3"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
kotlinxSerializationJson = "1.7.3"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
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-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-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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
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-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 {
|
pluginManagement {
|
||||||
repositories {
|
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 {
|
google {
|
||||||
content {
|
content {
|
||||||
includeGroupByRegex("com\\.android.*")
|
includeGroupByRegex("com\\.android.*")
|
||||||
@@ -11,12 +14,12 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins {
|
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
|
||||||
}
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
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()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user