feat: 完善 AI 对话界面与交互
This commit is contained in:
14 files changed
+605
-251
No files matched your search
@@ -127,7 +127,7 @@
|
|||||||
android:name=".ui.main.MainActivity"
|
android:name=".ui.main.MainActivity"
|
||||||
android:alwaysRetainTaskState="true"
|
android:alwaysRetainTaskState="true"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:windowSoftInputMode="adjustPan"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -76,4 +76,10 @@ interface AiChatDao {
|
|||||||
|
|
||||||
@Query("UPDATE ai_chat_conversations SET updatedAt = :updatedAt WHERE id = :conversationId")
|
@Query("UPDATE ai_chat_conversations SET updatedAt = :updatedAt WHERE id = :conversationId")
|
||||||
suspend fun touchConversation(conversationId: String, updatedAt: Long)
|
suspend fun touchConversation(conversationId: String, updatedAt: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM ai_chat_conversations WHERE id = :conversationId")
|
||||||
|
suspend fun deleteConversation(conversationId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM ai_chat_messages WHERE conversationId = :conversationId")
|
||||||
|
suspend fun deleteMessagesByConversation(conversationId: String)
|
||||||
}
|
}
|
||||||
@@ -126,5 +126,10 @@ class AiChatRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteConversation(conversationId: String) = withContext(Dispatchers.IO) {
|
||||||
|
aiChatDao.deleteMessagesByConversation(conversationId)
|
||||||
|
aiChatDao.deleteConversation(conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
|
private fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
|
||||||
}
|
}
|
||||||
@@ -30,4 +30,5 @@ interface AiChatGateway {
|
|||||||
suspend fun getBranchCounts(conversationId: String): Map<String, Int>
|
suspend fun getBranchCounts(conversationId: String): Map<String, Int>
|
||||||
suspend fun updateConversationTitle(conversationId: String, title: String)
|
suspend fun updateConversationTitle(conversationId: String, title: String)
|
||||||
suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String)
|
suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String)
|
||||||
|
suspend fun deleteConversation(conversationId: String)
|
||||||
}
|
}
|
||||||
@@ -116,6 +116,29 @@ class AiChatGenerationUseCase(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun generateTitle(
|
||||||
|
userContent: String,
|
||||||
|
assistantContent: String,
|
||||||
|
reasoningLevel: AiReasoningLevel
|
||||||
|
): String {
|
||||||
|
val preset = aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
|
||||||
|
?: error("No AI model configured")
|
||||||
|
val prompt = """
|
||||||
|
请根据以下对话内容,生成一个简短的中文标题(不超过20个字)。
|
||||||
|
只输出标题,不要任何解释或前缀。
|
||||||
|
|
||||||
|
用户:${userContent.take(500)}
|
||||||
|
助手:${assistantContent.take(500)}
|
||||||
|
""".trimIndent()
|
||||||
|
val request = AiGenerateRequest(
|
||||||
|
model = preset.model,
|
||||||
|
messages = listOf(AiMessage(AiMessageRole.USER, prompt)),
|
||||||
|
params = preset.params.copy(reasoningLevel = reasoningLevel)
|
||||||
|
)
|
||||||
|
val result = aiTextGateway.generate(request)
|
||||||
|
return result.getOrNull()?.text?.trim()?.take(30) ?: userContent.take(20)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun buildRequestMessages(
|
private suspend fun buildRequestMessages(
|
||||||
newContent: String,
|
newContent: String,
|
||||||
history: List<AiChatMessageUi>,
|
history: List<AiChatMessageUi>,
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ sealed interface AiChatIntent {
|
|||||||
data class UpdateReasoningLevel(val level: AiReasoningLevel) : AiChatIntent
|
data class UpdateReasoningLevel(val level: AiReasoningLevel) : AiChatIntent
|
||||||
data class RegenerateMessage(val messageId: String) : AiChatIntent
|
data class RegenerateMessage(val messageId: String) : AiChatIntent
|
||||||
data class SwitchBranch(val messageId: String) : AiChatIntent
|
data class SwitchBranch(val messageId: String) : AiChatIntent
|
||||||
|
data class DeleteConversation(val id: String) : AiChatIntent
|
||||||
|
data class RenameConversation(val id: String, val title: String) : AiChatIntent
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface AiChatEffect {
|
sealed interface AiChatEffect {
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
package io.legado.app.ui.ai.chat
|
package io.legado.app.ui.ai.chat
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.scrollBy
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -23,15 +24,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.ime
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -72,7 +75,12 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -96,12 +104,9 @@ import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
|||||||
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
|
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
|
||||||
import io.legado.app.ui.widget.components.text.AppText
|
import io.legado.app.ui.widget.components.text.AppText
|
||||||
import io.legado.app.ui.widget.components.text.MarkdownBlock
|
import io.legado.app.ui.widget.components.text.MarkdownBlock
|
||||||
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
|
|
||||||
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
|
|
||||||
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
|
|
||||||
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -133,13 +138,55 @@ fun AiChatScreen(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onOpenBookInfo: (AiChatBookResultUi) -> Unit
|
onOpenBookInfo: (AiChatBookResultUi) -> Unit
|
||||||
) {
|
) {
|
||||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
var draft by rememberSaveable { mutableStateOf("") }
|
var draft by rememberSaveable { mutableStateOf("") }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
var topOverlayHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
var bottomOverlayHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
var initiallyPositionedConversationId by remember { mutableStateOf<String?>(null) }
|
||||||
|
val currentConversation = state.conversations.firstOrNull {
|
||||||
|
it.id == state.currentConversationId
|
||||||
|
} ?: state.conversations.firstOrNull { it.isSelected }
|
||||||
|
val conversationTitle = currentConversation?.title?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.ai_chat)
|
||||||
|
val modelName = currentConversation?.modelName?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.ai_select_model)
|
||||||
|
val assistantLabel = when {
|
||||||
|
currentConversation == null -> ""
|
||||||
|
currentConversation.providerName.isNotBlank() &&
|
||||||
|
currentConversation.modelName.isNotBlank() -> {
|
||||||
|
"${currentConversation.providerName} ${currentConversation.modelName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
currentConversation.modelName.isNotBlank() -> currentConversation.modelName
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
val generationGradientProgress = if (state.isSending) {
|
||||||
|
val transition = androidx.compose.animation.core.rememberInfiniteTransition(
|
||||||
|
label = "AiChatGeneratingGradient"
|
||||||
|
)
|
||||||
|
val progress by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1800, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "AiChatGeneratingGradientProgress"
|
||||||
|
)
|
||||||
|
progress
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
val bottomGradientColor = lerp(
|
||||||
|
LegadoTheme.colorScheme.surface,
|
||||||
|
LegadoTheme.colorScheme.secondaryContainer,
|
||||||
|
generationGradientProgress
|
||||||
|
)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
effects.collectLatest { effect ->
|
effects.collectLatest { effect ->
|
||||||
@@ -166,37 +213,10 @@ fun AiChatScreen(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
AppScaffold(
|
AppScaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
alwaysDrawBehindBars = true,
|
||||||
GlassMediumFlexibleTopAppBar(
|
contentWindowInsets = WindowInsets(0)
|
||||||
title = stringResource(R.string.ai_chat),
|
) {
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
navigationIcon = {
|
|
||||||
TopBarNavigationButton(onClick = onBackClick)
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
TopBarActionButton(
|
|
||||||
onClick = { scope.launch { drawerState.open() } },
|
|
||||||
imageVector = Icons.Default.Menu,
|
|
||||||
contentDescription = stringResource(R.string.ai_recent_chats)
|
|
||||||
)
|
|
||||||
TopBarActionButton(
|
|
||||||
onClick = { onIntent(AiChatIntent.NewConversation) },
|
|
||||||
imageVector = Icons.Default.Add,
|
|
||||||
contentDescription = stringResource(R.string.ai_new_chat)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
val inputBarReservedHeight = 96.dp + WindowInsets.navigationBars
|
|
||||||
.asPaddingValues()
|
|
||||||
.calculateBottomPadding()
|
|
||||||
|
|
||||||
// Keyboard-aware auto-scroll
|
|
||||||
ImeLazyListAutoScroller(lazyListState = listState)
|
|
||||||
|
|
||||||
// Track whether user is near the bottom
|
// Track whether user is near the bottom
|
||||||
val isNearBottom by remember {
|
val isNearBottom by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -216,6 +236,28 @@ fun AiChatScreen(
|
|||||||
// Stick-to-bottom: tracks whether user has scrolled away
|
// Stick-to-bottom: tracks whether user has scrolled away
|
||||||
var shouldStickToBottom by remember { mutableStateOf(true) }
|
var shouldStickToBottom by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
// Position immediately at the bottom once this conversation is laid out.
|
||||||
|
LaunchedEffect(
|
||||||
|
state.currentConversationId,
|
||||||
|
state.messages.size,
|
||||||
|
state.streamingMessage?.id
|
||||||
|
) {
|
||||||
|
val conversationId = state.currentConversationId ?: currentConversation?.id
|
||||||
|
if (conversationId == null ||
|
||||||
|
initiallyPositionedConversationId == conversationId ||
|
||||||
|
(state.messages.isEmpty() && state.streamingMessage == null)
|
||||||
|
) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val bottomAnchorIndex = state.messages.size +
|
||||||
|
if (state.streamingMessage != null) 1 else 0
|
||||||
|
snapshotFlow { listState.layoutInfo.totalItemsCount }
|
||||||
|
.first { it > bottomAnchorIndex }
|
||||||
|
listState.scrollToItem(bottomAnchorIndex)
|
||||||
|
initiallyPositionedConversationId = conversationId
|
||||||
|
shouldStickToBottom = true
|
||||||
|
}
|
||||||
|
|
||||||
// Update stick-to-bottom when user scrolls
|
// Update stick-to-bottom when user scrolls
|
||||||
LaunchedEffect(listState) {
|
LaunchedEffect(listState) {
|
||||||
snapshotFlow { listState.isScrollInProgress to isNearBottom }
|
snapshotFlow { listState.isScrollInProgress to isNearBottom }
|
||||||
@@ -250,20 +292,28 @@ fun AiChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues)
|
|
||||||
) {
|
) {
|
||||||
|
val systemBottomPadding = maxOf(
|
||||||
|
WindowInsets.ime.asPaddingValues().calculateBottomPadding(),
|
||||||
|
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
|
val topContentPadding = with(density) {
|
||||||
|
topOverlayHeightPx.toDp()
|
||||||
|
}
|
||||||
|
val bottomContentPadding = with(density) {
|
||||||
|
bottomOverlayHeightPx.toDp()
|
||||||
|
} + systemBottomPadding
|
||||||
|
|
||||||
// Message list
|
// Message list
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
top = 8.dp,
|
start = 8.dp,
|
||||||
bottom = inputBarReservedHeight
|
top = topContentPadding + 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
bottom = bottomContentPadding + 8.dp
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
@@ -273,14 +323,6 @@ fun AiChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(state.messages, key = { it.id }) { message ->
|
items(state.messages, key = { it.id }) { message ->
|
||||||
val currentConversation = state.conversations.find { it.isSelected }
|
|
||||||
val assistantLabel = if (currentConversation != null && currentConversation.providerName.isNotBlank() && currentConversation.modelName.isNotBlank()) {
|
|
||||||
"${currentConversation.providerName} ${currentConversation.modelName}"
|
|
||||||
} else if (currentConversation?.modelName?.isNotBlank() == true) {
|
|
||||||
currentConversation.modelName
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
ChatMessageItem(
|
ChatMessageItem(
|
||||||
message = message,
|
message = message,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
@@ -313,14 +355,6 @@ fun AiChatScreen(
|
|||||||
// Streaming message
|
// Streaming message
|
||||||
val streaming = state.streamingMessage
|
val streaming = state.streamingMessage
|
||||||
if (streaming != null) {
|
if (streaming != null) {
|
||||||
val currentConversation = state.conversations.find { it.isSelected }
|
|
||||||
val assistantLabel = if (currentConversation != null && currentConversation.providerName.isNotBlank() && currentConversation.modelName.isNotBlank()) {
|
|
||||||
"${currentConversation.providerName} ${currentConversation.modelName}"
|
|
||||||
} else if (currentConversation?.modelName?.isNotBlank() == true) {
|
|
||||||
currentConversation.modelName
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
item(key = "streaming") {
|
item(key = "streaming") {
|
||||||
ChatMessageItem(
|
ChatMessageItem(
|
||||||
message = streaming,
|
message = streaming,
|
||||||
@@ -337,80 +371,211 @@ fun AiChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom: tool confirmation + floating input
|
// Draw behind both the floating input and the system navigation/IME area.
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.navigationBarsPadding()
|
|
||||||
) {
|
) {
|
||||||
// Scroll-to-bottom button
|
Spacer(
|
||||||
if (!isAtBottom && (state.messages.isNotEmpty() || state.streamingMessage != null)) {
|
modifier = Modifier
|
||||||
Box(
|
.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.height(bottomContentPadding - systemBottomPadding)
|
||||||
contentAlignment = Alignment.Center
|
.background(
|
||||||
) {
|
Brush.verticalGradient(
|
||||||
SmallPlainButton(
|
colorStops = arrayOf(
|
||||||
onClick = {
|
0f to bottomGradientColor.copy(alpha = 0f),
|
||||||
scope.launch {
|
0.58f to bottomGradientColor.copy(alpha = 0f),
|
||||||
val lastIndex = listState.layoutInfo.totalItemsCount - 1
|
0.82f to bottomGradientColor.copy(alpha = 0.24f),
|
||||||
if (lastIndex >= 0) {
|
1f to bottomGradientColor.copy(alpha = 0.68f)
|
||||||
listState.animateScrollToItem(lastIndex)
|
)
|
||||||
}
|
)
|
||||||
shouldStickToBottom = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon = Icons.Default.KeyboardArrowDown,
|
|
||||||
contentDescription = stringResource(R.string.ai_scroll_to_bottom),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(RoundedCornerShape(18.dp))
|
|
||||||
.background(LegadoTheme.colorScheme.surfaceVariant)
|
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(
|
||||||
}
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
state.pendingToolConfirmation?.let { confirmation ->
|
.height(systemBottomPadding)
|
||||||
PendingToolConfirmationCard(
|
.background(bottomGradientColor.copy(alpha = 0.68f))
|
||||||
confirmation = confirmation,
|
|
||||||
onConfirm = { onIntent(AiChatIntent.ConfirmPendingTool) },
|
|
||||||
onReject = { onIntent(AiChatIntent.RejectPendingTool) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ChatInputBar(
|
|
||||||
value = draft,
|
|
||||||
isSending = state.isSending,
|
|
||||||
reasoningLevel = state.reasoningLevel,
|
|
||||||
onValueChange = { draft = it },
|
|
||||||
onSend = {
|
|
||||||
val text = draft
|
|
||||||
draft = ""
|
|
||||||
onIntent(AiChatIntent.SendMessage(text))
|
|
||||||
},
|
|
||||||
onStop = { onIntent(AiChatIntent.StopGenerating) },
|
|
||||||
onUpdateReasoningLevel = { onIntent(AiChatIntent.UpdateReasoningLevel(it)) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Bottom controls consume IME/navigation insets exactly once.
|
||||||
* Tracks keyboard (IME) height changes and scrolls the LazyList to compensate,
|
Box(
|
||||||
* so the list stays in place when the keyboard appears/disappears.
|
modifier = Modifier
|
||||||
*/
|
.align(Alignment.BottomCenter)
|
||||||
@Composable
|
.fillMaxWidth()
|
||||||
private fun ImeLazyListAutoScroller(lazyListState: LazyListState) {
|
.imePadding()
|
||||||
val ime = WindowInsets.ime
|
.navigationBarsPadding()
|
||||||
val density = LocalDensity.current
|
) {
|
||||||
var imeHeight by remember { mutableIntStateOf(0) }
|
Box(
|
||||||
LaunchedEffect(Unit) {
|
modifier = Modifier
|
||||||
snapshotFlow { ime.getBottom(density) }.collect { keyboardHeight ->
|
.fillMaxWidth()
|
||||||
if (keyboardHeight > 0) {
|
.onSizeChanged { bottomOverlayHeightPx = it.height }
|
||||||
lazyListState.scrollBy((keyboardHeight - imeHeight).toFloat())
|
) {
|
||||||
imeHeight = keyboardHeight
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 32.dp)
|
||||||
|
) {
|
||||||
|
if (!isAtBottom &&
|
||||||
|
(state.messages.isNotEmpty() || state.streamingMessage != null)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
SmallPlainButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val lastIndex =
|
||||||
|
listState.layoutInfo.totalItemsCount - 1
|
||||||
|
if (lastIndex >= 0) {
|
||||||
|
listState.animateScrollToItem(lastIndex)
|
||||||
|
}
|
||||||
|
shouldStickToBottom = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
R.string.ai_scroll_to_bottom
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(LegadoTheme.colorScheme.surfaceVariant)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pendingToolConfirmation?.let { confirmation ->
|
||||||
|
PendingToolConfirmationCard(
|
||||||
|
confirmation = confirmation,
|
||||||
|
onConfirm = {
|
||||||
|
onIntent(AiChatIntent.ConfirmPendingTool)
|
||||||
|
},
|
||||||
|
onReject = {
|
||||||
|
onIntent(AiChatIntent.RejectPendingTool)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ChatInputBar(
|
||||||
|
value = draft,
|
||||||
|
isSending = state.isSending,
|
||||||
|
reasoningLevel = state.reasoningLevel,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
onSend = {
|
||||||
|
val text = draft
|
||||||
|
draft = ""
|
||||||
|
onIntent(AiChatIntent.SendMessage(text))
|
||||||
|
},
|
||||||
|
onStop = { onIntent(AiChatIntent.StopGenerating) },
|
||||||
|
onUpdateReasoningLevel = {
|
||||||
|
onIntent(AiChatIntent.UpdateReasoningLevel(it))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top controls draw above the list while keeping actions below the status bar.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { topOverlayHeightPx = it.height }
|
||||||
|
) {
|
||||||
|
Spacer(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0f to LegadoTheme.colorScheme.surface.copy(alpha = 0.66f),
|
||||||
|
0.38f to LegadoTheme.colorScheme.surface.copy(alpha = 0.46f),
|
||||||
|
0.72f to LegadoTheme.colorScheme.surface.copy(alpha = 0.12f),
|
||||||
|
1f to LegadoTheme.colorScheme.surface.copy(alpha = 0f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 24.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
MediumTonalButton(
|
||||||
|
onClick = onBackClick,
|
||||||
|
icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||||
|
contentDescription = stringResource(R.string.back),
|
||||||
|
modifier = Modifier.shadow(
|
||||||
|
elevation = 2.dp,
|
||||||
|
shape = CircleShape,
|
||||||
|
clip = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.height(40.dp),
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = LegadoTheme.colorScheme.surfaceContainerLow,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
shadowElevation = 2.dp
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
AppText(
|
||||||
|
text = conversationTitle,
|
||||||
|
style = LegadoTheme.typography.labelMediumEmphasized,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
AppText(
|
||||||
|
text = modelName,
|
||||||
|
style = LegadoTheme.typography.labelSmall,
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
MediumTonalButton(
|
||||||
|
onClick = { scope.launch { drawerState.open() } },
|
||||||
|
icon = Icons.Default.Menu,
|
||||||
|
contentDescription = stringResource(R.string.ai_recent_chats),
|
||||||
|
modifier = Modifier.shadow(
|
||||||
|
elevation = 2.dp,
|
||||||
|
shape = CircleShape,
|
||||||
|
clip = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
MediumTonalButton(
|
||||||
|
onClick = { onIntent(AiChatIntent.NewConversation) },
|
||||||
|
icon = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.ai_new_chat),
|
||||||
|
modifier = Modifier.shadow(
|
||||||
|
elevation = 2.dp,
|
||||||
|
shape = CircleShape,
|
||||||
|
clip = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,7 +636,9 @@ private fun RecentChatsDrawer(
|
|||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
onSelectConversation: (String) -> Unit
|
onSelectConversation: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
var searchQuery by remember { mutableStateOf("") }
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
val filteredConversations = remember(conversations, searchQuery) {
|
val filteredConversations = remember(conversations, searchQuery) {
|
||||||
if (searchQuery.isBlank()) {
|
if (searchQuery.isBlank()) {
|
||||||
conversations
|
conversations
|
||||||
@@ -482,6 +649,12 @@ private fun RecentChatsDrawer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(showSearch) {
|
||||||
|
if (showSearch) {
|
||||||
|
searchFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ModalDrawerSheet(
|
ModalDrawerSheet(
|
||||||
modifier = Modifier.width(280.dp),
|
modifier = Modifier.width(280.dp),
|
||||||
drawerContainerColor = LegadoTheme.colorScheme.surfaceContainerLow
|
drawerContainerColor = LegadoTheme.colorScheme.surfaceContainerLow
|
||||||
@@ -496,24 +669,41 @@ private fun RecentChatsDrawer(
|
|||||||
text = stringResource(R.string.ai_recent_chats),
|
text = stringResource(R.string.ai_recent_chats),
|
||||||
style = LegadoTheme.typography.titleMedium,
|
style = LegadoTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
SmallPlainButton(
|
MediumTonalButton(
|
||||||
|
onClick = {
|
||||||
|
showSearch = !showSearch
|
||||||
|
if (!showSearch) {
|
||||||
|
searchQuery = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = Icons.Default.Search,
|
||||||
|
selected = showSearch,
|
||||||
|
contentDescription = stringResource(R.string.search)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
MediumTonalButton(
|
||||||
onClick = onNewChat,
|
onClick = onNewChat,
|
||||||
icon = Icons.Default.Add,
|
icon = Icons.Default.Add,
|
||||||
contentDescription = stringResource(R.string.ai_new_chat)
|
contentDescription = stringResource(R.string.ai_new_chat)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AppTextField(
|
AnimatedVisibility(visible = showSearch) {
|
||||||
value = searchQuery,
|
AppTextField(
|
||||||
onValueChange = { searchQuery = it },
|
value = searchQuery,
|
||||||
modifier = Modifier
|
onValueChange = { searchQuery = it },
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
.fillMaxWidth()
|
||||||
backgroundColor = LegadoTheme.colorScheme.surface,
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
label = stringResource(R.string.search),
|
.focusRequester(searchFocusRequester),
|
||||||
singleLine = true
|
backgroundColor = LegadoTheme.colorScheme.surface,
|
||||||
)
|
label = stringResource(R.string.search),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||||
@@ -647,6 +837,13 @@ private fun ChatMessageContent(
|
|||||||
val groupedParts = remember(message.parts, message.thinkingDuration) {
|
val groupedParts = remember(message.parts, message.thinkingDuration) {
|
||||||
message.parts.groupMessageParts(message.thinkingDuration)
|
message.parts.groupMessageParts(message.thinkingDuration)
|
||||||
}
|
}
|
||||||
|
val reasoningSteps = groupedParts
|
||||||
|
.filterIsInstance<AiMessagePartBlock.ThinkingBlock>()
|
||||||
|
.flatMap { block -> block.steps.filterIsInstance<AiThinkingStep.ReasoningStep>() }
|
||||||
|
val toolSteps = groupedParts
|
||||||
|
.filterIsInstance<AiMessagePartBlock.ThinkingBlock>()
|
||||||
|
.flatMap { block -> block.steps.filterIsInstance<AiThinkingStep.ToolStep>() }
|
||||||
|
val contentBlocks = groupedParts.filterIsInstance<AiMessagePartBlock.ContentBlock>()
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
AppText(
|
AppText(
|
||||||
@@ -660,64 +857,57 @@ private fun ChatMessageContent(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Render parts in order: thinking blocks first, then content blocks
|
// Presentation order is stable regardless of persistence order:
|
||||||
groupedParts.forEach { block ->
|
// reasoning, tool calls, then the assistant response.
|
||||||
when (block) {
|
if (reasoningSteps.isNotEmpty()) {
|
||||||
is AiMessagePartBlock.ThinkingBlock -> {
|
AiThinkingCard(
|
||||||
if (block.steps.isNotEmpty()) {
|
steps = reasoningSteps,
|
||||||
AiThinkingCard(
|
isStreaming = isStreaming,
|
||||||
steps = block.steps,
|
durationSeconds = message.thinkingDuration,
|
||||||
isStreaming = isStreaming,
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
durationSeconds = block.durationSeconds,
|
)
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
}
|
||||||
)
|
if (toolSteps.isNotEmpty()) {
|
||||||
}
|
AiThinkingCard(
|
||||||
|
steps = toolSteps,
|
||||||
|
isStreaming = isStreaming,
|
||||||
|
durationSeconds = message.thinkingDuration,
|
||||||
|
autoExpandWhileStreaming = false,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
contentBlocks.forEach { block ->
|
||||||
|
when (val part = block.part) {
|
||||||
|
is AiMessagePart.Text -> {
|
||||||
|
MessageTextContent(
|
||||||
|
text = part.text,
|
||||||
|
isUser = isUser,
|
||||||
|
isStreaming = isStreaming,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is AiMessagePartBlock.ContentBlock -> {
|
is AiMessagePart.BookResult -> {
|
||||||
when (val part = block.part) {
|
BookResultsList(
|
||||||
is AiMessagePart.Text -> {
|
books = listOf(
|
||||||
MessageTextContent(
|
AiChatBookResultUi(
|
||||||
text = part.text,
|
bookUrl = part.bookUrl,
|
||||||
isUser = isUser,
|
name = part.name,
|
||||||
isStreaming = isStreaming,
|
author = part.author,
|
||||||
|
origin = part.origin,
|
||||||
|
coverPath = part.coverPath,
|
||||||
|
latestChapterTitle = part.latestChapterTitle,
|
||||||
|
currentChapterTitle = part.currentChapterTitle,
|
||||||
|
intro = part.intro
|
||||||
)
|
)
|
||||||
}
|
),
|
||||||
is AiMessagePart.BookResult -> {
|
onOpenBookInfo = onOpenBookInfo
|
||||||
BookResultsList(
|
)
|
||||||
books = listOf(
|
|
||||||
AiChatBookResultUi(
|
|
||||||
bookUrl = part.bookUrl,
|
|
||||||
name = part.name,
|
|
||||||
author = part.author,
|
|
||||||
origin = part.origin,
|
|
||||||
coverPath = part.coverPath,
|
|
||||||
latestChapterTitle = part.latestChapterTitle,
|
|
||||||
currentChapterTitle = part.currentChapterTitle,
|
|
||||||
intro = part.intro
|
|
||||||
)
|
|
||||||
),
|
|
||||||
onOpenBookInfo = onOpenBookInfo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> { /* skip */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else -> { /* skip */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fallback: if no grouped parts but there's content, show it directly
|
// Legacy fallback keeps the same reasoning -> tools -> content order.
|
||||||
if (groupedParts.isEmpty()) {
|
if (groupedParts.isEmpty()) {
|
||||||
val displayContent = message.parts.filterIsInstance<AiMessagePart.Text>()
|
|
||||||
.joinToString("\n\n") { it.text }.trim()
|
|
||||||
.ifBlank { message.content }
|
|
||||||
if (displayContent.isNotBlank()) {
|
|
||||||
MessageTextContent(
|
|
||||||
text = displayContent,
|
|
||||||
isUser = isUser,
|
|
||||||
isStreaming = isStreaming,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Legacy reasoning fallback
|
|
||||||
val displayReasoning = message.parts.filterIsInstance<AiMessagePart.Reasoning>()
|
val displayReasoning = message.parts.filterIsInstance<AiMessagePart.Reasoning>()
|
||||||
.joinToString("\n\n") { it.text }.trim()
|
.joinToString("\n\n") { it.text }.trim()
|
||||||
.takeIf { it.isNotBlank() } ?: message.reasoning
|
.takeIf { it.isNotBlank() } ?: message.reasoning
|
||||||
@@ -729,7 +919,6 @@ private fun ChatMessageContent(
|
|||||||
modifier = Modifier.padding(bottom = 8.dp)
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Legacy tool trace fallback
|
|
||||||
val displayToolTrace = message.parts.filterIsInstance<AiMessagePart.Tool>()
|
val displayToolTrace = message.parts.filterIsInstance<AiMessagePart.Tool>()
|
||||||
.joinToString("\n\n") { tool ->
|
.joinToString("\n\n") { tool ->
|
||||||
buildString {
|
buildString {
|
||||||
@@ -745,7 +934,16 @@ private fun ChatMessageContent(
|
|||||||
content = displayToolTrace
|
content = displayToolTrace
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Legacy book results fallback
|
val displayContent = message.parts.filterIsInstance<AiMessagePart.Text>()
|
||||||
|
.joinToString("\n\n") { it.text }.trim()
|
||||||
|
.ifBlank { message.content }
|
||||||
|
if (displayContent.isNotBlank()) {
|
||||||
|
MessageTextContent(
|
||||||
|
text = displayContent,
|
||||||
|
isUser = isUser,
|
||||||
|
isStreaming = isStreaming,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (message.bookResults.isNotEmpty()) {
|
if (message.bookResults.isNotEmpty()) {
|
||||||
BookResultsList(books = message.bookResults, onOpenBookInfo = onOpenBookInfo)
|
BookResultsList(books = message.bookResults, onOpenBookInfo = onOpenBookInfo)
|
||||||
}
|
}
|
||||||
@@ -996,6 +1194,18 @@ private fun ChatInputBar(
|
|||||||
AiReasoningLevel.XHIGH -> "Max"
|
AiReasoningLevel.XHIGH -> "Max"
|
||||||
}
|
}
|
||||||
val isThinkingOn = reasoningLevel != AiReasoningLevel.OFF
|
val isThinkingOn = reasoningLevel != AiReasoningLevel.OFF
|
||||||
|
val isKeyboardVisible =
|
||||||
|
WindowInsets.ime.asPaddingValues().calculateBottomPadding() > 0.dp
|
||||||
|
val horizontalPadding by animateDpAsState(
|
||||||
|
targetValue = if (isKeyboardVisible) 16.dp else 46.dp,
|
||||||
|
animationSpec = tween(durationMillis = 250),
|
||||||
|
label = "AiChatInputHorizontalPadding"
|
||||||
|
)
|
||||||
|
val bottomPadding by animateDpAsState(
|
||||||
|
targetValue = if (isKeyboardVisible) 16.dp else 32.dp,
|
||||||
|
animationSpec = tween(durationMillis = 250),
|
||||||
|
label = "AiChatInputBottomPadding"
|
||||||
|
)
|
||||||
|
|
||||||
// Thinking mode bottom sheet
|
// Thinking mode bottom sheet
|
||||||
AppModalBottomSheet(
|
AppModalBottomSheet(
|
||||||
@@ -1071,17 +1281,22 @@ private fun ChatInputBar(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
.padding(
|
||||||
shape = RoundedCornerShape(28.dp),
|
start = horizontalPadding,
|
||||||
|
end = horizontalPadding,
|
||||||
|
bottom = bottomPadding
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(32.dp),
|
||||||
color = LegadoTheme.colorScheme.surfaceContainerHigh,
|
color = LegadoTheme.colorScheme.surfaceContainerHigh,
|
||||||
tonalElevation = 2.dp
|
tonalElevation = 2.dp,
|
||||||
|
shadowElevation = 8.dp
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 6.dp, vertical = 6.dp),
|
.padding(all = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
// Thinking mode button
|
// Thinking mode button
|
||||||
MediumTonalButton(
|
MediumTonalButton(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import io.legado.app.domain.model.AiMessagePart
|
|||||||
import io.legado.app.domain.model.AiMessagePartJson
|
import io.legado.app.domain.model.AiMessagePartJson
|
||||||
import io.legado.app.domain.model.AiMessageRole
|
import io.legado.app.domain.model.AiMessageRole
|
||||||
import io.legado.app.domain.model.AiReasoningLevel
|
import io.legado.app.domain.model.AiReasoningLevel
|
||||||
|
import io.legado.app.domain.model.AiTaskType
|
||||||
import io.legado.app.domain.model.AiToolApprovalState
|
import io.legado.app.domain.model.AiToolApprovalState
|
||||||
import io.legado.app.domain.model.reasoningContent
|
import io.legado.app.domain.model.reasoningContent
|
||||||
import io.legado.app.domain.model.textContent
|
import io.legado.app.domain.model.textContent
|
||||||
@@ -17,6 +18,7 @@ import io.legado.app.domain.usecase.AiChatGenerationUseCase
|
|||||||
import io.legado.app.domain.usecase.PendingToolRun
|
import io.legado.app.domain.usecase.PendingToolRun
|
||||||
import io.legado.app.domain.usecase.ToolTraceBuilder
|
import io.legado.app.domain.usecase.ToolTraceBuilder
|
||||||
import io.legado.app.utils.GSON
|
import io.legado.app.utils.GSON
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -66,6 +68,8 @@ class AiChatViewModel(
|
|||||||
is AiChatIntent.UpdateReasoningLevel -> updateReasoningLevel(intent.level)
|
is AiChatIntent.UpdateReasoningLevel -> updateReasoningLevel(intent.level)
|
||||||
is AiChatIntent.RegenerateMessage -> regenerateMessage(intent.messageId)
|
is AiChatIntent.RegenerateMessage -> regenerateMessage(intent.messageId)
|
||||||
is AiChatIntent.SwitchBranch -> switchBranch(intent.messageId)
|
is AiChatIntent.SwitchBranch -> switchBranch(intent.messageId)
|
||||||
|
is AiChatIntent.DeleteConversation -> deleteConversation(intent.id)
|
||||||
|
is AiChatIntent.RenameConversation -> renameConversation(intent.id, intent.title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,13 +80,17 @@ class AiChatViewModel(
|
|||||||
aiChatGateway.observeConversations().collect { conversations ->
|
aiChatGateway.observeConversations().collect { conversations ->
|
||||||
val selectedId = currentConversationId.value
|
val selectedId = currentConversationId.value
|
||||||
_uiState.update { current ->
|
_uiState.update { current ->
|
||||||
|
val previousConversations = current.conversations.associateBy { it.id }
|
||||||
current.copy(
|
current.copy(
|
||||||
conversations = conversations.map {
|
conversations = conversations.map {
|
||||||
|
val previous = previousConversations[it.id]
|
||||||
AiChatConversationUi(
|
AiChatConversationUi(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
title = it.title,
|
title = it.title,
|
||||||
updatedAt = it.updatedAt,
|
updatedAt = it.updatedAt,
|
||||||
isSelected = it.id == selectedId
|
isSelected = it.id == selectedId,
|
||||||
|
providerName = previous?.providerName.orEmpty(),
|
||||||
|
modelName = previous?.modelName.orEmpty()
|
||||||
)
|
)
|
||||||
}.toImmutableList()
|
}.toImmutableList()
|
||||||
)
|
)
|
||||||
@@ -152,18 +160,10 @@ class AiChatViewModel(
|
|||||||
currentConversationId.value = id
|
currentConversationId.value = id
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val conversation = aiChatGateway.getConversation(id)
|
val conversation = aiChatGateway.getConversation(id)
|
||||||
|
val preset = aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
|
||||||
// Load provider/model info for the conversation
|
?: aiProfileGateway.getTaskPreset(AiTaskType.TRANSLATE_CHAPTER)
|
||||||
var providerName = ""
|
val providerName = preset?.model?.provider?.name.orEmpty()
|
||||||
var modelName = ""
|
val modelName = preset?.model?.displayName.orEmpty()
|
||||||
conversation?.modelProfileId?.let { profileId ->
|
|
||||||
val modelProfile = aiProfileGateway.getModel(profileId)
|
|
||||||
if (modelProfile != null) {
|
|
||||||
modelName = modelProfile.displayName
|
|
||||||
val provider = aiProfileGateway.getProvider(modelProfile.providerId)
|
|
||||||
providerName = provider?.name ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.update { current ->
|
_uiState.update { current ->
|
||||||
current.copy(
|
current.copy(
|
||||||
@@ -268,6 +268,42 @@ class AiChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun deleteConversation(conversationId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
aiChatGateway.deleteConversation(conversationId)
|
||||||
|
if (currentConversationId.value == conversationId) {
|
||||||
|
currentConversationId.value = null
|
||||||
|
_uiState.update { it.copy(messages = persistentListOf(), currentConversationId = null) }
|
||||||
|
createConversation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renameConversation(conversationId: String, title: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
aiChatGateway.updateConversationTitle(conversationId, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateConversationTitle(conversationId: String, userContent: String, assistantContent: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val state = _uiState.value
|
||||||
|
val shortTitle = generationUseCase.generateTitle(
|
||||||
|
userContent = userContent,
|
||||||
|
assistantContent = assistantContent,
|
||||||
|
reasoningLevel = state.reasoningLevel
|
||||||
|
)
|
||||||
|
if (shortTitle.isNotBlank()) {
|
||||||
|
aiChatGateway.updateConversationTitle(conversationId, shortTitle)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fallback to truncated user message
|
||||||
|
aiChatGateway.updateConversationTitle(conversationId, userContent.take(24))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun regenerateMessage(messageId: String) {
|
private fun regenerateMessage(messageId: String) {
|
||||||
val message = lastMessages.find { it.id == messageId }
|
val message = lastMessages.find { it.id == messageId }
|
||||||
?: return
|
?: return
|
||||||
@@ -390,9 +426,6 @@ class AiChatViewModel(
|
|||||||
)
|
)
|
||||||
conversationIdForMsg = userMessage.conversationId
|
conversationIdForMsg = userMessage.conversationId
|
||||||
parentMessageId = userMessage.id
|
parentMessageId = userMessage.id
|
||||||
if (historySnapshot.none { it.role == AiMessageRole.USER }) {
|
|
||||||
aiChatGateway.updateConversationTitle(conversationId, content.take(24))
|
|
||||||
}
|
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val request = generationUseCase.buildRequest(
|
val request = generationUseCase.buildRequest(
|
||||||
userContent = content,
|
userContent = content,
|
||||||
@@ -448,6 +481,10 @@ class AiChatViewModel(
|
|||||||
thinkingDuration = duration
|
thinkingDuration = duration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Generate AI title for first conversation
|
||||||
|
if (conversationIdForMsg != null && historySnapshot.none { it.role == AiMessageRole.ASSISTANT }) {
|
||||||
|
generateConversationTitle(conversationIdForMsg, content, assistantContent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
if (waitingForToolConfirmation) it
|
if (waitingForToolConfirmation) it
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ import kotlinx.coroutines.delay
|
|||||||
/**
|
/**
|
||||||
* Unified thinking/tool card.
|
* Unified thinking/tool card.
|
||||||
*
|
*
|
||||||
* - Expands only while actively thinking or calling tools
|
* - Reasoning can expand while streaming
|
||||||
|
* - Tool calls can opt out and expand only after a user click
|
||||||
* - Auto-collapses when streaming ends
|
* - Auto-collapses when streaming ends
|
||||||
* - Header: left icon + title | right TextCard (time, reasoning only) + expand arrow
|
* - Header: left icon + title | right TextCard (time, reasoning only) + expand arrow
|
||||||
*/
|
*/
|
||||||
@@ -50,6 +51,7 @@ fun AiThinkingCard(
|
|||||||
isStreaming: Boolean,
|
isStreaming: Boolean,
|
||||||
durationSeconds: Int = 0,
|
durationSeconds: Int = 0,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
autoExpandWhileStreaming: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val hasTools = steps.any { it is AiThinkingStep.ToolStep }
|
val hasTools = steps.any { it is AiThinkingStep.ToolStep }
|
||||||
val hasReasoning = steps.any { it is AiThinkingStep.ReasoningStep }
|
val hasReasoning = steps.any { it is AiThinkingStep.ReasoningStep }
|
||||||
@@ -74,10 +76,14 @@ fun AiThinkingCard(
|
|||||||
}
|
}
|
||||||
val displaySeconds = if (durationSeconds > 0) durationSeconds else liveSeconds
|
val displaySeconds = if (durationSeconds > 0) durationSeconds else liveSeconds
|
||||||
|
|
||||||
// Expand while streaming, collapse when done
|
// Tool cards disable this so only an explicit user click can expand them.
|
||||||
var expanded by remember { mutableStateOf(isStreaming) }
|
var expanded by remember {
|
||||||
LaunchedEffect(isStreaming) {
|
mutableStateOf(isStreaming && autoExpandWhileStreaming)
|
||||||
expanded = isStreaming
|
}
|
||||||
|
LaunchedEffect(isStreaming, autoExpandWhileStreaming) {
|
||||||
|
if (autoExpandWhileStreaming) {
|
||||||
|
expanded = isStreaming
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show time only for reasoning (not tool-only)
|
// Show time only for reasoning (not tool-only)
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ fun AiConfigRouteScreen(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onNavigateToProviderEdit: (providerId: String?) -> Unit,
|
onNavigateToProviderEdit: (providerId: String?) -> Unit,
|
||||||
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
|
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
|
||||||
onNavigateToChat: () -> Unit,
|
|
||||||
onNavigateToTranslation: () -> Unit,
|
onNavigateToTranslation: () -> Unit,
|
||||||
viewModel: AiConfigViewModel = koinViewModel()
|
viewModel: AiConfigViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
@@ -45,7 +44,6 @@ fun AiConfigRouteScreen(
|
|||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
onNavigateToProviderEdit = onNavigateToProviderEdit,
|
onNavigateToProviderEdit = onNavigateToProviderEdit,
|
||||||
onNavigateToModelEdit = onNavigateToModelEdit,
|
onNavigateToModelEdit = onNavigateToModelEdit,
|
||||||
onNavigateToChat = onNavigateToChat,
|
|
||||||
onNavigateToTranslation = onNavigateToTranslation
|
onNavigateToTranslation = onNavigateToTranslation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -59,7 +57,6 @@ fun AiConfigScreen(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onNavigateToProviderEdit: (providerId: String?) -> Unit,
|
onNavigateToProviderEdit: (providerId: String?) -> Unit,
|
||||||
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
|
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
|
||||||
onNavigateToChat: () -> Unit,
|
|
||||||
onNavigateToTranslation: () -> Unit
|
onNavigateToTranslation: () -> Unit
|
||||||
) {
|
) {
|
||||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||||
@@ -128,10 +125,6 @@ fun AiConfigScreen(
|
|||||||
|
|
||||||
item {
|
item {
|
||||||
SplicedColumnGroup(title = stringResource(R.string.ai_tasks)) {
|
SplicedColumnGroup(title = stringResource(R.string.ai_tasks)) {
|
||||||
ClickableSettingItem(
|
|
||||||
title = stringResource(R.string.ai_chat),
|
|
||||||
onClick = onNavigateToChat
|
|
||||||
)
|
|
||||||
ClickableSettingItem(
|
ClickableSettingItem(
|
||||||
title = stringResource(R.string.translation_config),
|
title = stringResource(R.string.translation_config),
|
||||||
onClick = onNavigateToTranslation
|
onClick = onNavigateToTranslation
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ fun MainActivity.mainEntryProvider(
|
|||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
onNavigateToRoute(MainRouteSettings)
|
onNavigateToRoute(MainRouteSettings)
|
||||||
},
|
},
|
||||||
|
onNavigateToChat = {
|
||||||
|
onNavigateToRoute(MainRouteAiChat)
|
||||||
|
},
|
||||||
onNavigateToSearch = { key ->
|
onNavigateToSearch = { key ->
|
||||||
onNavigateToRoute(
|
onNavigateToRoute(
|
||||||
MainRouteSearch(
|
MainRouteSearch(
|
||||||
@@ -227,7 +230,6 @@ fun MainActivity.mainEntryProvider(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onNavigateToChat = { backStack.add(MainRouteAiChat) },
|
|
||||||
onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
|
onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ fun MainScreen(
|
|||||||
viewModel: MainViewModel = koinViewModel(),
|
viewModel: MainViewModel = koinViewModel(),
|
||||||
useRail: Boolean,
|
useRail: Boolean,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onNavigateToChat: () -> Unit,
|
||||||
onNavigateToSearch: (String?) -> Unit,
|
onNavigateToSearch: (String?) -> Unit,
|
||||||
onNavigateToRemoteImport: () -> Unit,
|
onNavigateToRemoteImport: () -> Unit,
|
||||||
onNavigateToLocalImport: () -> Unit,
|
onNavigateToLocalImport: () -> Unit,
|
||||||
@@ -424,6 +425,7 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
MainDestination.My -> MyScreen(
|
MainDestination.My -> MyScreen(
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onNavigateToChat = onNavigateToChat,
|
||||||
onNavigate = { event ->
|
onNavigate = { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage()
|
PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
|||||||
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
||||||
import androidx.compose.material.icons.automirrored.filled.Rule
|
import androidx.compose.material.icons.automirrored.filled.Rule
|
||||||
import androidx.compose.material.icons.filled.Bookmark
|
import androidx.compose.material.icons.filled.Bookmark
|
||||||
|
import androidx.compose.material.icons.filled.AutoAwesome
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.icons.filled.FindReplace
|
import androidx.compose.material.icons.filled.FindReplace
|
||||||
@@ -67,6 +68,7 @@ import org.koin.androidx.compose.koinViewModel
|
|||||||
fun MyScreen(
|
fun MyScreen(
|
||||||
viewModel: MyViewModel = koinViewModel(),
|
viewModel: MyViewModel = koinViewModel(),
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onNavigateToChat: () -> Unit,
|
||||||
onNavigate: (PrefClickEvent) -> Unit
|
onNavigate: (PrefClickEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -170,6 +172,11 @@ fun MyScreen(
|
|||||||
SplicedColumnGroup(
|
SplicedColumnGroup(
|
||||||
title = stringResource(R.string.other)
|
title = stringResource(R.string.other)
|
||||||
) {
|
) {
|
||||||
|
ClickableSettingItem(
|
||||||
|
title = stringResource(R.string.ai_chat),
|
||||||
|
imageVector = Icons.Default.AutoAwesome,
|
||||||
|
onClick = onNavigateToChat
|
||||||
|
)
|
||||||
ClickableSettingItem(
|
ClickableSettingItem(
|
||||||
title = stringResource(R.string.setting),
|
title = stringResource(R.string.setting),
|
||||||
imageVector = Icons.Default.Settings,
|
imageVector = Icons.Default.Settings,
|
||||||
|
|||||||
@@ -5,16 +5,22 @@ import androidx.compose.foundation.BorderStroke
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.ProvideTextStyle
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -30,6 +36,7 @@ import androidx.compose.ui.draw.drawWithContent
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -45,6 +52,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.em
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import io.legado.app.R
|
||||||
import io.legado.app.ui.theme.LegadoTheme
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -52,6 +60,7 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
|
import splitties.systemservices.clipboardManager
|
||||||
import org.intellij.markdown.IElementType
|
import org.intellij.markdown.IElementType
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
import org.intellij.markdown.MarkdownElementTypes
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
import org.intellij.markdown.MarkdownTokenTypes
|
||||||
@@ -541,6 +550,13 @@ private fun MarkdownCodeBlock(
|
|||||||
language: String?,
|
language: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val collapsedLines = 10
|
||||||
|
val codeLines = remember(code) { code.lines() }
|
||||||
|
var isExpanded by remember(code) { mutableStateOf(codeLines.size <= collapsedLines) }
|
||||||
|
val canCollapse = codeLines.size > collapsedLines
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -552,24 +568,63 @@ private fun MarkdownCodeBlock(
|
|||||||
RoundedCornerShape(8.dp)
|
RoundedCornerShape(8.dp)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (!language.isNullOrBlank()) {
|
Row(
|
||||||
AppText(
|
|
||||||
text = language,
|
|
||||||
style = LegadoTheme.typography.labelSmall,
|
|
||||||
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AppText(
|
|
||||||
text = code,
|
|
||||||
style = LegadoTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 0.85.em),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||||
)
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (!language.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = language,
|
||||||
|
style = LegadoTheme.typography.labelSmall,
|
||||||
|
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_copy),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.clickable {
|
||||||
|
val clip = android.content.ClipData.newPlainText("code", code)
|
||||||
|
clipboardManager.setPrimaryClip(clip)
|
||||||
|
}
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(16.dp),
|
||||||
|
tint = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.then(
|
||||||
|
if (true) Modifier else Modifier.horizontalScroll(scrollState)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val displayCode = if (isExpanded) code else codeLines.take(collapsedLines).joinToString("\n")
|
||||||
|
AppText(
|
||||||
|
text = displayCode,
|
||||||
|
style = LegadoTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canCollapse) {
|
||||||
|
Text(
|
||||||
|
text = if (isExpanded) "▲ 收起" else "▼ 展开 (${codeLines.size} 行)",
|
||||||
|
style = LegadoTheme.typography.labelSmall,
|
||||||
|
color = LegadoTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { isExpanded = !isExpanded }
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,13 +673,17 @@ private fun MarkdownTable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Rows
|
// Rows
|
||||||
rows.forEach { row ->
|
rows.forEachIndexed { index, row ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
if (index % 2 == 0) LegadoTheme.colorScheme.surface
|
||||||
|
else LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f)
|
||||||
|
)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
row.forEachIndexed { index, cell ->
|
row.forEach { cell ->
|
||||||
AppText(
|
AppText(
|
||||||
text = cell,
|
text = cell,
|
||||||
style = LegadoTheme.typography.bodySmall,
|
style = LegadoTheme.typography.bodySmall,
|
||||||
@@ -634,10 +693,6 @@ private fun MarkdownTable(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 0.25.dp,
|
|
||||||
color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user