feat: 完善 AI 对话界面与交互
This commit is contained in:
@@ -127,7 +127,7 @@
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:alwaysRetainTaskState="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -76,4 +76,10 @@ interface AiChatDao {
|
||||
|
||||
@Query("UPDATE ai_chat_conversations SET updatedAt = :updatedAt WHERE id = :conversationId")
|
||||
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("-", "")}"
|
||||
}
|
||||
|
||||
@@ -30,4 +30,5 @@ interface AiChatGateway {
|
||||
suspend fun getBranchCounts(conversationId: String): Map<String, Int>
|
||||
suspend fun updateConversationTitle(conversationId: String, title: 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(
|
||||
newContent: String,
|
||||
history: List<AiChatMessageUi>,
|
||||
|
||||
@@ -71,6 +71,8 @@ sealed interface AiChatIntent {
|
||||
data class UpdateReasoningLevel(val level: AiReasoningLevel) : AiChatIntent
|
||||
data class RegenerateMessage(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 {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package io.legado.app.ui.ai.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -72,7 +75,12 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.LocalDensity
|
||||
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.text.AppText
|
||||
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.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -133,13 +138,55 @@ fun AiChatScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onOpenBookInfo: (AiChatBookResultUi) -> Unit
|
||||
) {
|
||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var draft by rememberSaveable { mutableStateOf("") }
|
||||
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) {
|
||||
effects.collectLatest { effect ->
|
||||
@@ -166,37 +213,10 @@ fun AiChatScreen(
|
||||
}
|
||||
) {
|
||||
AppScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
GlassMediumFlexibleTopAppBar(
|
||||
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)
|
||||
|
||||
alwaysDrawBehindBars = true,
|
||||
contentWindowInsets = WindowInsets(0)
|
||||
) {
|
||||
// Track whether user is near the bottom
|
||||
val isNearBottom by remember {
|
||||
derivedStateOf {
|
||||
@@ -216,6 +236,28 @@ fun AiChatScreen(
|
||||
// Stick-to-bottom: tracks whether user has scrolled away
|
||||
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
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.isScrollInProgress to isNearBottom }
|
||||
@@ -250,20 +292,28 @@ fun AiChatScreen(
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
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
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 8.dp,
|
||||
bottom = inputBarReservedHeight
|
||||
start = 8.dp,
|
||||
top = topContentPadding + 8.dp,
|
||||
end = 8.dp,
|
||||
bottom = bottomContentPadding + 8.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
@@ -273,14 +323,6 @@ fun AiChatScreen(
|
||||
}
|
||||
}
|
||||
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(
|
||||
message = message,
|
||||
isStreaming = false,
|
||||
@@ -313,14 +355,6 @@ fun AiChatScreen(
|
||||
// Streaming message
|
||||
val streaming = state.streamingMessage
|
||||
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") {
|
||||
ChatMessageItem(
|
||||
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(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
// Scroll-to-bottom button
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.height(bottomContentPadding - systemBottomPadding)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0f to bottomGradientColor.copy(alpha = 0f),
|
||||
0.58f to bottomGradientColor.copy(alpha = 0f),
|
||||
0.82f to bottomGradientColor.copy(alpha = 0.24f),
|
||||
1f to bottomGradientColor.copy(alpha = 0.68f)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
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)) }
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(systemBottomPadding)
|
||||
.background(bottomGradientColor.copy(alpha = 0.68f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks keyboard (IME) height changes and scrolls the LazyList to compensate,
|
||||
* so the list stays in place when the keyboard appears/disappears.
|
||||
*/
|
||||
@Composable
|
||||
private fun ImeLazyListAutoScroller(lazyListState: LazyListState) {
|
||||
val ime = WindowInsets.ime
|
||||
val density = LocalDensity.current
|
||||
var imeHeight by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { ime.getBottom(density) }.collect { keyboardHeight ->
|
||||
if (keyboardHeight > 0) {
|
||||
lazyListState.scrollBy((keyboardHeight - imeHeight).toFloat())
|
||||
imeHeight = keyboardHeight
|
||||
// Bottom controls consume IME/navigation insets exactly once.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { bottomOverlayHeightPx = it.height }
|
||||
) {
|
||||
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,
|
||||
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) {
|
||||
if (searchQuery.isBlank()) {
|
||||
conversations
|
||||
@@ -482,6 +649,12 @@ private fun RecentChatsDrawer(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showSearch) {
|
||||
if (showSearch) {
|
||||
searchFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.width(280.dp),
|
||||
drawerContainerColor = LegadoTheme.colorScheme.surfaceContainerLow
|
||||
@@ -496,24 +669,41 @@ private fun RecentChatsDrawer(
|
||||
text = stringResource(R.string.ai_recent_chats),
|
||||
style = LegadoTheme.typography.titleMedium,
|
||||
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,
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.ai_new_chat)
|
||||
)
|
||||
}
|
||||
AppTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
backgroundColor = LegadoTheme.colorScheme.surface,
|
||||
label = stringResource(R.string.search),
|
||||
singleLine = true
|
||||
)
|
||||
AnimatedVisibility(visible = showSearch) {
|
||||
AppTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.focusRequester(searchFocusRequester),
|
||||
backgroundColor = LegadoTheme.colorScheme.surface,
|
||||
label = stringResource(R.string.search),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
@@ -647,6 +837,13 @@ private fun ChatMessageContent(
|
||||
val groupedParts = remember(message.parts, 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) {
|
||||
AppText(
|
||||
@@ -660,64 +857,57 @@ private fun ChatMessageContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Render parts in order: thinking blocks first, then content blocks
|
||||
groupedParts.forEach { block ->
|
||||
when (block) {
|
||||
is AiMessagePartBlock.ThinkingBlock -> {
|
||||
if (block.steps.isNotEmpty()) {
|
||||
AiThinkingCard(
|
||||
steps = block.steps,
|
||||
isStreaming = isStreaming,
|
||||
durationSeconds = block.durationSeconds,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
// Presentation order is stable regardless of persistence order:
|
||||
// reasoning, tool calls, then the assistant response.
|
||||
if (reasoningSteps.isNotEmpty()) {
|
||||
AiThinkingCard(
|
||||
steps = reasoningSteps,
|
||||
isStreaming = isStreaming,
|
||||
durationSeconds = message.thinkingDuration,
|
||||
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 -> {
|
||||
when (val part = block.part) {
|
||||
is AiMessagePart.Text -> {
|
||||
MessageTextContent(
|
||||
text = part.text,
|
||||
isUser = isUser,
|
||||
isStreaming = isStreaming,
|
||||
is AiMessagePart.BookResult -> {
|
||||
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
|
||||
)
|
||||
}
|
||||
is AiMessagePart.BookResult -> {
|
||||
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 */ }
|
||||
}
|
||||
),
|
||||
onOpenBookInfo = onOpenBookInfo
|
||||
)
|
||||
}
|
||||
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()) {
|
||||
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>()
|
||||
.joinToString("\n\n") { it.text }.trim()
|
||||
.takeIf { it.isNotBlank() } ?: message.reasoning
|
||||
@@ -729,7 +919,6 @@ private fun ChatMessageContent(
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
// Legacy tool trace fallback
|
||||
val displayToolTrace = message.parts.filterIsInstance<AiMessagePart.Tool>()
|
||||
.joinToString("\n\n") { tool ->
|
||||
buildString {
|
||||
@@ -745,7 +934,16 @@ private fun ChatMessageContent(
|
||||
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()) {
|
||||
BookResultsList(books = message.bookResults, onOpenBookInfo = onOpenBookInfo)
|
||||
}
|
||||
@@ -996,6 +1194,18 @@ private fun ChatInputBar(
|
||||
AiReasoningLevel.XHIGH -> "Max"
|
||||
}
|
||||
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
|
||||
AppModalBottomSheet(
|
||||
@@ -1071,17 +1281,22 @@ private fun ChatInputBar(
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
.padding(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
bottom = bottomPadding
|
||||
),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
color = LegadoTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 2.dp
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
.padding(all = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// Thinking mode button
|
||||
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.AiMessageRole
|
||||
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.reasoningContent
|
||||
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.ToolTraceBuilder
|
||||
import io.legado.app.utils.GSON
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -66,6 +68,8 @@ class AiChatViewModel(
|
||||
is AiChatIntent.UpdateReasoningLevel -> updateReasoningLevel(intent.level)
|
||||
is AiChatIntent.RegenerateMessage -> regenerateMessage(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 ->
|
||||
val selectedId = currentConversationId.value
|
||||
_uiState.update { current ->
|
||||
val previousConversations = current.conversations.associateBy { it.id }
|
||||
current.copy(
|
||||
conversations = conversations.map {
|
||||
val previous = previousConversations[it.id]
|
||||
AiChatConversationUi(
|
||||
id = it.id,
|
||||
title = it.title,
|
||||
updatedAt = it.updatedAt,
|
||||
isSelected = it.id == selectedId
|
||||
isSelected = it.id == selectedId,
|
||||
providerName = previous?.providerName.orEmpty(),
|
||||
modelName = previous?.modelName.orEmpty()
|
||||
)
|
||||
}.toImmutableList()
|
||||
)
|
||||
@@ -152,18 +160,10 @@ class AiChatViewModel(
|
||||
currentConversationId.value = id
|
||||
viewModelScope.launch {
|
||||
val conversation = aiChatGateway.getConversation(id)
|
||||
|
||||
// Load provider/model info for the conversation
|
||||
var providerName = ""
|
||||
var modelName = ""
|
||||
conversation?.modelProfileId?.let { profileId ->
|
||||
val modelProfile = aiProfileGateway.getModel(profileId)
|
||||
if (modelProfile != null) {
|
||||
modelName = modelProfile.displayName
|
||||
val provider = aiProfileGateway.getProvider(modelProfile.providerId)
|
||||
providerName = provider?.name ?: ""
|
||||
}
|
||||
}
|
||||
val preset = aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
|
||||
?: aiProfileGateway.getTaskPreset(AiTaskType.TRANSLATE_CHAPTER)
|
||||
val providerName = preset?.model?.provider?.name.orEmpty()
|
||||
val modelName = preset?.model?.displayName.orEmpty()
|
||||
|
||||
_uiState.update { current ->
|
||||
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) {
|
||||
val message = lastMessages.find { it.id == messageId }
|
||||
?: return
|
||||
@@ -390,9 +426,6 @@ class AiChatViewModel(
|
||||
)
|
||||
conversationIdForMsg = userMessage.conversationId
|
||||
parentMessageId = userMessage.id
|
||||
if (historySnapshot.none { it.role == AiMessageRole.USER }) {
|
||||
aiChatGateway.updateConversationTitle(conversationId, content.take(24))
|
||||
}
|
||||
val state = _uiState.value
|
||||
val request = generationUseCase.buildRequest(
|
||||
userContent = content,
|
||||
@@ -448,6 +481,10 @@ class AiChatViewModel(
|
||||
thinkingDuration = duration
|
||||
)
|
||||
}
|
||||
// Generate AI title for first conversation
|
||||
if (conversationIdForMsg != null && historySnapshot.none { it.role == AiMessageRole.ASSISTANT }) {
|
||||
generateConversationTitle(conversationIdForMsg, content, assistantContent)
|
||||
}
|
||||
}
|
||||
_uiState.update {
|
||||
if (waitingForToolConfirmation) it
|
||||
|
||||
@@ -40,7 +40,8 @@ import kotlinx.coroutines.delay
|
||||
/**
|
||||
* 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
|
||||
* - Header: left icon + title | right TextCard (time, reasoning only) + expand arrow
|
||||
*/
|
||||
@@ -50,6 +51,7 @@ fun AiThinkingCard(
|
||||
isStreaming: Boolean,
|
||||
durationSeconds: Int = 0,
|
||||
modifier: Modifier = Modifier,
|
||||
autoExpandWhileStreaming: Boolean = true,
|
||||
) {
|
||||
val hasTools = steps.any { it is AiThinkingStep.ToolStep }
|
||||
val hasReasoning = steps.any { it is AiThinkingStep.ReasoningStep }
|
||||
@@ -74,10 +76,14 @@ fun AiThinkingCard(
|
||||
}
|
||||
val displaySeconds = if (durationSeconds > 0) durationSeconds else liveSeconds
|
||||
|
||||
// Expand while streaming, collapse when done
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
LaunchedEffect(isStreaming) {
|
||||
expanded = isStreaming
|
||||
// Tool cards disable this so only an explicit user click can expand them.
|
||||
var expanded by remember {
|
||||
mutableStateOf(isStreaming && autoExpandWhileStreaming)
|
||||
}
|
||||
LaunchedEffect(isStreaming, autoExpandWhileStreaming) {
|
||||
if (autoExpandWhileStreaming) {
|
||||
expanded = isStreaming
|
||||
}
|
||||
}
|
||||
|
||||
// Show time only for reasoning (not tool-only)
|
||||
|
||||
@@ -34,7 +34,6 @@ fun AiConfigRouteScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onNavigateToProviderEdit: (providerId: String?) -> Unit,
|
||||
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
|
||||
onNavigateToChat: () -> Unit,
|
||||
onNavigateToTranslation: () -> Unit,
|
||||
viewModel: AiConfigViewModel = koinViewModel()
|
||||
) {
|
||||
@@ -45,7 +44,6 @@ fun AiConfigRouteScreen(
|
||||
onBackClick = onBackClick,
|
||||
onNavigateToProviderEdit = onNavigateToProviderEdit,
|
||||
onNavigateToModelEdit = onNavigateToModelEdit,
|
||||
onNavigateToChat = onNavigateToChat,
|
||||
onNavigateToTranslation = onNavigateToTranslation
|
||||
)
|
||||
}
|
||||
@@ -59,7 +57,6 @@ fun AiConfigScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onNavigateToProviderEdit: (providerId: String?) -> Unit,
|
||||
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
|
||||
onNavigateToChat: () -> Unit,
|
||||
onNavigateToTranslation: () -> Unit
|
||||
) {
|
||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||
@@ -128,10 +125,6 @@ fun AiConfigScreen(
|
||||
|
||||
item {
|
||||
SplicedColumnGroup(title = stringResource(R.string.ai_tasks)) {
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.ai_chat),
|
||||
onClick = onNavigateToChat
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.translation_config),
|
||||
onClick = onNavigateToTranslation
|
||||
|
||||
@@ -95,6 +95,9 @@ fun MainActivity.mainEntryProvider(
|
||||
onOpenSettings = {
|
||||
onNavigateToRoute(MainRouteSettings)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
onNavigateToRoute(MainRouteAiChat)
|
||||
},
|
||||
onNavigateToSearch = { key ->
|
||||
onNavigateToRoute(
|
||||
MainRouteSearch(
|
||||
@@ -227,7 +230,6 @@ fun MainActivity.mainEntryProvider(
|
||||
)
|
||||
)
|
||||
},
|
||||
onNavigateToChat = { backStack.add(MainRouteAiChat) },
|
||||
onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ fun MainScreen(
|
||||
viewModel: MainViewModel = koinViewModel(),
|
||||
useRail: Boolean,
|
||||
onOpenSettings: () -> Unit,
|
||||
onNavigateToChat: () -> Unit,
|
||||
onNavigateToSearch: (String?) -> Unit,
|
||||
onNavigateToRemoteImport: () -> Unit,
|
||||
onNavigateToLocalImport: () -> Unit,
|
||||
@@ -424,6 +425,7 @@ fun MainScreen(
|
||||
)
|
||||
MainDestination.My -> MyScreen(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onNavigateToChat = onNavigateToChat,
|
||||
onNavigate = { event ->
|
||||
when (event) {
|
||||
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.Rule
|
||||
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.Download
|
||||
import androidx.compose.material.icons.filled.FindReplace
|
||||
@@ -67,6 +68,7 @@ import org.koin.androidx.compose.koinViewModel
|
||||
fun MyScreen(
|
||||
viewModel: MyViewModel = koinViewModel(),
|
||||
onOpenSettings: () -> Unit,
|
||||
onNavigateToChat: () -> Unit,
|
||||
onNavigate: (PrefClickEvent) -> Unit
|
||||
) {
|
||||
|
||||
@@ -170,6 +172,11 @@ fun MyScreen(
|
||||
SplicedColumnGroup(
|
||||
title = stringResource(R.string.other)
|
||||
) {
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.ai_chat),
|
||||
imageVector = Icons.Default.AutoAwesome,
|
||||
onClick = onNavigateToChat
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.setting),
|
||||
imageVector = Icons.Default.Settings,
|
||||
|
||||
@@ -5,16 +5,22 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
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.sp
|
||||
import androidx.core.net.toUri
|
||||
import io.legado.app.R
|
||||
import io.legado.app.ui.theme.LegadoTheme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -52,6 +60,7 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import splitties.systemservices.clipboardManager
|
||||
import org.intellij.markdown.IElementType
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
@@ -541,6 +550,13 @@ private fun MarkdownCodeBlock(
|
||||
language: String?,
|
||||
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(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -552,24 +568,63 @@ private fun MarkdownCodeBlock(
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
if (!language.isNullOrBlank()) {
|
||||
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),
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.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.forEach { row ->
|
||||
rows.forEachIndexed { index, row ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (index % 2 == 0) LegadoTheme.colorScheme.surface
|
||||
else LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
row.forEachIndexed { index, cell ->
|
||||
row.forEach { cell ->
|
||||
AppText(
|
||||
text = cell,
|
||||
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