diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ebe40c0ed..27c1fdfee 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -58,11 +58,11 @@ jobs: - name: Build with Gradle env: BUILD_NUMBER: ${{github.run_number}} - BUILD_VERSION: ${{github.ref_name}} + BUILD_VERSION: "1.6.0-beta01" run: ./gradlew :app:assembleRelease :app:bundleRelease :app:check :app:lint --stacktrace - name: Publish to Google Play - if: startsWith(github.ref, 'refs/tags/') +# if: startsWith(github.ref, 'refs/tags/') uses: r0adkll/upload-google-play@v1 with: serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c4f11047a..250780fc1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,6 +147,7 @@ dependencies { implementation(libs.webkit) implementation(libs.bundles.navigation3) implementation(libs.richtext.ui.material3) + implementation(libs.richtext.commonmark) implementation(libs.androidx.browser) implementation("net.java.dev.jna:jna:${libs.versions.jna.get()}@aar") diff --git a/app/src/main/java/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt b/app/src/main/java/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt index d275e66c2..25c65152d 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt @@ -6,6 +6,8 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,39 +30,62 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import com.halilibo.richtext.commonmark.Markdown import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.PaperPlane import compose.icons.fontawesomeicons.solid.Robot import dev.dimension.flare.R +import dev.dimension.flare.data.model.PostActionStyle +import dev.dimension.flare.feature.agent.common.AgentInputRequest +import dev.dimension.flare.feature.agent.common.AgentPhase +import dev.dimension.flare.feature.agent.common.AgentToolKey +import dev.dimension.flare.feature.agent.common.AgentTrace +import dev.dimension.flare.feature.agent.presenter.AgentMessagePart import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareDividerDefaults import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.LocalBottomBarHeight +import dev.dimension.flare.ui.component.LocalTimelineAppearance +import dev.dimension.flare.ui.component.status.CommonStatusComponent +import dev.dimension.flare.ui.component.status.UserCompat +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.theme.screenHorizontalPadding +import kotlinx.coroutines.flow.distinctUntilChanged +import com.halilibo.richtext.ui.material3.RichText as ComposeRichText -@OptIn(ExperimentalLayoutApi::class) @Composable internal fun AgentChatContent( title: String, @@ -70,12 +95,20 @@ internal fun AgentChatContent( canSend: Boolean, error: Throwable?, runningTrace: String, + inputRequest: AgentInputRequest? = null, inputPlaceholder: String, sendContentDescription: String, messageText: (Message) -> String, + messageParts: (Message) -> List, + messageInputRequest: (Message) -> AgentInputRequest? = { null }, + messageInputRequestSelected: (Message) -> Boolean = { false }, + messageInputRequestSelectedOptionId: (Message) -> String? = { null }, isUserMessage: (Message) -> Boolean, onInputChange: (String) -> Unit, onSend: () -> Unit, + onInputRequestOptionSelected: (AgentInputRequest.Option) -> Unit = {}, + onPostClick: (UiTimelineV2.Post) -> Unit = {}, + onUserClick: (UiProfile) -> Unit = {}, modifier: Modifier = Modifier, showHeader: Boolean = true, leadingContentItemCount: Int = 0, @@ -88,12 +121,20 @@ internal fun AgentChatContent( canSend = canSend, error = error, runningTrace = runningTrace, + inputRequest = inputRequest, inputPlaceholder = inputPlaceholder, sendContentDescription = sendContentDescription, messageText = messageText, + messageParts = messageParts, + messageInputRequest = messageInputRequest, + messageInputRequestSelected = messageInputRequestSelected, + messageInputRequestSelectedOptionId = messageInputRequestSelectedOptionId, isUserMessage = isUserMessage, onInputChange = onInputChange, onSend = onSend, + onInputRequestOptionSelected = onInputRequestOptionSelected, + onPostClick = onPostClick, + onUserClick = onUserClick, modifier = modifier, topBar = if (showHeader) { @@ -122,18 +163,40 @@ internal fun AgentChatScaffold( canSend: Boolean, error: Throwable?, runningTrace: String, + inputRequest: AgentInputRequest? = null, inputPlaceholder: String, sendContentDescription: String, messageText: (Message) -> String, + messageParts: (Message) -> List, + messageInputRequest: (Message) -> AgentInputRequest? = { null }, + messageInputRequestSelected: (Message) -> Boolean = { false }, + messageInputRequestSelectedOptionId: (Message) -> String? = { null }, isUserMessage: (Message) -> Boolean, onInputChange: (String) -> Unit, onSend: () -> Unit, + onInputRequestOptionSelected: (AgentInputRequest.Option) -> Unit = {}, + onPostClick: (UiTimelineV2.Post) -> Unit = {}, + onUserClick: (UiProfile) -> Unit = {}, modifier: Modifier = Modifier, topBar: @Composable () -> Unit = {}, reserveBottomBarHeight: Boolean = true, leadingContentItemCount: Int = 0, leadingContent: LazyListScope.() -> Unit = {}, ) { + val textState = rememberTextFieldState(input) + val currentOnInputChange by rememberUpdatedState(onInputChange) + + LaunchedEffect(input) { + if (textState.text.toString() != input) { + textState.setTextAndPlaceCursorAtEnd(input) + } + } + LaunchedEffect(textState) { + snapshotFlow { textState.text.toString() } + .distinctUntilChanged() + .collect(currentOnInputChange) + } + val bottomBarHeight = if (reserveBottomBarHeight) { LocalBottomBarHeight.current @@ -156,12 +219,11 @@ internal fun AgentChatScaffold( thickness = FlareDividerDefaults.thickness, ) AgentChatInput( - value = input, - enabled = !isRunning, + state = textState, canSend = canSend, + inputRequest = inputRequest, placeholder = inputPlaceholder, sendContentDescription = sendContentDescription, - onValueChange = onInputChange, onSend = onSend, modifier = Modifier @@ -195,7 +257,14 @@ internal fun AgentChatScaffold( error = error, runningTrace = runningTrace, messageText = messageText, + messageParts = messageParts, + messageInputRequest = messageInputRequest, + messageInputRequestSelected = messageInputRequestSelected, + messageInputRequestSelectedOptionId = messageInputRequestSelectedOptionId, isUserMessage = isUserMessage, + onInputRequestOptionSelected = onInputRequestOptionSelected, + onPostClick = onPostClick, + onUserClick = onUserClick, modifier = Modifier .consumeWindowInsets(contentPadding) @@ -229,15 +298,22 @@ internal fun AgentChatHeader( } } -@OptIn(ExperimentalLayoutApi::class) @Composable +@OptIn(ExperimentalLayoutApi::class) internal fun AgentChatMessageList( messages: List, isRunning: Boolean, error: Throwable?, runningTrace: String, messageText: (Message) -> String, + messageParts: (Message) -> List, + messageInputRequest: (Message) -> AgentInputRequest?, + messageInputRequestSelected: (Message) -> Boolean, + messageInputRequestSelectedOptionId: (Message) -> String?, isUserMessage: (Message) -> Boolean, + onInputRequestOptionSelected: (AgentInputRequest.Option) -> Unit, + onPostClick: (UiTimelineV2.Post) -> Unit = {}, + onUserClick: (UiProfile) -> Unit = {}, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), leadingContentItemCount: Int = 0, @@ -286,7 +362,14 @@ internal fun AgentChatMessageList( items(messages.asReversed()) { message -> AgentChatMessageBubble( text = messageText(message), + parts = messageParts(message), + inputRequest = messageInputRequest(message), + inputRequestSelected = messageInputRequestSelected(message), + inputRequestSelectedOptionId = messageInputRequestSelectedOptionId(message), isUser = isUserMessage(message), + onInputRequestOptionSelected = onInputRequestOptionSelected, + onPostClick = onPostClick, + onUserClick = onUserClick, ) } @@ -297,7 +380,14 @@ internal fun AgentChatMessageList( @Composable internal fun AgentChatMessageBubble( text: String, + parts: List, + inputRequest: AgentInputRequest? = null, + inputRequestSelected: Boolean = false, + inputRequestSelectedOptionId: String? = null, isUser: Boolean, + onInputRequestOptionSelected: (AgentInputRequest.Option) -> Unit = {}, + onPostClick: (UiTimelineV2.Post) -> Unit = {}, + onUserClick: (UiProfile) -> Unit = {}, modifier: Modifier = Modifier, ) { Row( @@ -321,53 +411,189 @@ internal fun AgentChatMessageBubble( }, ), ) { - Text( - text = text, + Column( modifier = Modifier.padding(12.dp), - color = - if (isUser) { - MaterialTheme.colorScheme.onPrimaryContainer + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + AgentChatMessageParts( + text = text, + parts = parts, + isUser = isUser, + onPostClick = onPostClick, + onUserClick = onUserClick, + ) + if (!isUser && inputRequest != null) { + AgentInputRequestOptionsContent( + request = inputRequest, + enabled = !inputRequestSelected, + selectedOptionId = inputRequestSelectedOptionId, + onOptionSelected = onInputRequestOptionSelected, + ) + } + } + } + } +} + +@Composable +private fun AgentChatMessageParts( + text: String, + parts: List, + isUser: Boolean, + onPostClick: (UiTimelineV2.Post) -> Unit, + onUserClick: (UiProfile) -> Unit, + modifier: Modifier = Modifier, +) { + val displayParts = parts.takeIf { it.isNotEmpty() } ?: listOf(AgentMessagePart.Text(text)) + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + displayParts.forEach { part -> + when (part) { + is AgentMessagePart.Text -> { + AgentMarkdownText( + markdown = part.markdown, + color = + if (isUser) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + is AgentMessagePart.PostCard -> { + AgentPostCard( + post = part.post, + onClick = { onPostClick(part.post) }, + ) + } + + is AgentMessagePart.UserCard -> { + AgentUserCard( + user = part.user, + onClick = { onUserClick(part.user) }, + ) + } + } + } + } +} + +@Composable +private fun AgentMarkdownText( + markdown: String, + color: Color, + modifier: Modifier = Modifier, +) { + CompositionLocalProvider(LocalContentColor provides color) { + ComposeRichText(modifier = modifier) { + Markdown(content = markdown) + } + } +} + +@Composable +private fun AgentPostCard( + post: UiTimelineV2.Post, + onClick: (() -> Unit)?, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) } else { - MaterialTheme.colorScheme.onSurface + Modifier }, + ), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + ) { + CompositionLocalProvider( + LocalTimelineAppearance provides + LocalTimelineAppearance.current.copy( + showMedia = false, + expandMediaSize = false, + showLinkPreview = false, + postActionStyle = PostActionStyle.Hidden, + ), + ) { + CommonStatusComponent( + item = post, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), + isQuote = true, + maxLines = 3, ) } } } +@Composable +private fun AgentUserCard( + user: UiProfile, + onClick: (() -> Unit)?, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + }, + ), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + ) { + UserCompat( + user = user, + modifier = Modifier.padding(10.dp), + onUserClick = { onClick?.invoke() }, + ) + } +} + @Composable internal fun AgentChatInput( - value: String, - enabled: Boolean, + state: TextFieldState, canSend: Boolean, + inputRequest: AgentInputRequest? = null, placeholder: String, sendContentDescription: String, - onValueChange: (String) -> Unit, onSend: () -> Unit, modifier: Modifier = Modifier, ) { + fun sendIfEnabled() { + if (canSend) { + onSend() + } + } + OutlinedTextField( - value = value, - onValueChange = onValueChange, + state = state, modifier = modifier.fillMaxWidth(), - enabled = enabled, - minLines = 1, - maxLines = 4, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), placeholder = { - Text(text = placeholder) + Text(text = inputRequest?.freeTextPlaceholder ?: placeholder) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = - KeyboardActions( - onSend = { - if (canSend) { - onSend() - } - }, - ), + onKeyboardAction = { + sendIfEnabled() + }, trailingIcon = { IconButton( - onClick = onSend, + onClick = { sendIfEnabled() }, enabled = canSend, ) { FAIcon( @@ -379,6 +605,137 @@ internal fun AgentChatInput( ) } +@Composable +private fun AgentInputRequestOptionsContent( + request: AgentInputRequest, + enabled: Boolean, + selectedOptionId: String?, + onOptionSelected: (AgentInputRequest.Option) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val visibleOptions = + selectedOptionId?.let { optionId -> + request.options.filter { it.id == optionId } + } ?: request.options + val actionOptions = visibleOptions.filter { it.postPreview == null && it.userPreview == null } + val confirmOption = actionOptions.firstOrNull { it.id == "confirm" } + val cancelOption = actionOptions.firstOrNull { it.id == "cancel" } + if (request.postPreview != null && actionOptions.isNotEmpty()) { + AgentComposeConfirmationRequest( + request = request, + cancelOption = cancelOption, + confirmOption = confirmOption, + actionOptions = actionOptions, + enabled = enabled, + onOptionSelected = onOptionSelected, + ) + return@Column + } + Text( + text = request.prompt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val postOptions = visibleOptions.filter { it.postPreview != null } + val userOptions = visibleOptions.filter { it.userPreview != null } + postOptions.forEach { option -> + val post = option.postPreview ?: return@forEach + AgentPostCard( + post = post, + onClick = + if (enabled) { + { onOptionSelected(option) } + } else { + null + }, + ) + } + userOptions.forEach { option -> + val user = option.userPreview ?: return@forEach + AgentUserCard( + user = user, + onClick = + if (enabled) { + { onOptionSelected(option) } + } else { + null + }, + ) + } + if (actionOptions.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + actionOptions.forEach { option -> + OutlinedButton( + onClick = { onOptionSelected(option) }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = option.label) + } + } + } + } + } +} + +@Composable +private fun AgentComposeConfirmationRequest( + request: AgentInputRequest, + cancelOption: AgentInputRequest.Option?, + confirmOption: AgentInputRequest.Option?, + actionOptions: List, + enabled: Boolean, + onOptionSelected: (AgentInputRequest.Option) -> Unit, +) { + Text( + text = + request.prompt + .lineSequence() + .firstOrNull() + .orEmpty() + .ifBlank { "确认发送这条内容吗?" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + request.postPreview?.let { post -> + AgentPostCard( + post = post, + onClick = null, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + actionOptions.forEach { option -> + if (option.id == confirmOption?.id) { + Button( + onClick = { onOptionSelected(option) }, + enabled = enabled, + modifier = Modifier.weight(1f), + ) { + Text(text = option.label) + } + } else { + OutlinedButton( + onClick = { onOptionSelected(option) }, + enabled = enabled, + modifier = Modifier.weight(1f), + ) { + Text(text = option.label) + } + } + } + } +} + @Composable internal fun AgentChatError( text: String, @@ -396,6 +753,21 @@ internal fun AgentChatCurrentTrace( trace: String, modifier: Modifier = Modifier, ) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + ) + AgentChatCurrentTraceText(trace = trace) + } +} + +@Composable +private fun AgentChatCurrentTraceText(trace: String) { val transition = rememberInfiniteTransition() val shimmerOffset by transition.animateFloat( initialValue = -240f, @@ -418,19 +790,179 @@ internal fun AgentChatCurrentTrace( start = Offset(shimmerOffset, 0f), end = Offset(shimmerOffset + 220f, 0f), ) + Text( + text = trace, + style = MaterialTheme.typography.bodyMedium.copy(brush = shimmerBrush), + ) +} - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Robot, - contentDescription = null, - ) - Text( - text = trace, - style = MaterialTheme.typography.bodyMedium.copy(brush = shimmerBrush), - ) +@Composable +internal fun AgentTrace.label(): String = + toolKey?.label() + ?: when (phase) { + AgentPhase.LoadingPostContext -> { + stringResource(id = R.string.status_insight_trace_loading_post_context) + } + + AgentPhase.PostContextLoaded -> { + stringResource(id = R.string.status_insight_trace_post_context_loaded) + } + + AgentPhase.PreparingImages -> { + stringResource(id = R.string.status_insight_trace_preparing_images) + } + + AgentPhase.ImagesUnsupportedFallback -> { + stringResource(id = R.string.status_insight_trace_images_unsupported_fallback) + } + + AgentPhase.AgentStarted -> { + stringResource(id = R.string.status_insight_trace_agent_started) + } + + AgentPhase.StrategyStarted -> { + stringResource(id = R.string.status_insight_trace_strategy_started) + } + + AgentPhase.StrategyCompleted -> { + stringResource(id = R.string.status_insight_trace_strategy_completed) + } + + AgentPhase.SubgraphStarted -> { + stringResource(id = R.string.status_insight_trace_subgraph_started) + } + + AgentPhase.SubgraphCompleted -> { + stringResource(id = R.string.status_insight_trace_subgraph_completed) + } + + AgentPhase.SubgraphFailed -> { + stringResource(id = R.string.status_insight_trace_subgraph_failed) + } + + AgentPhase.AskingModel -> { + stringResource( + id = R.string.status_insight_trace_asking_model, + detail.orEmpty(), + ) + } + + AgentPhase.ModelResponseReceived -> { + stringResource(id = R.string.status_insight_trace_model_response_received) + } + + AgentPhase.StreamingStarted -> { + stringResource( + id = R.string.status_insight_trace_streaming_started, + detail.orEmpty(), + ) + } + + AgentPhase.StreamingResponse -> { + stringResource(id = R.string.status_insight_trace_streaming_response) + } + + AgentPhase.StreamingCompleted -> { + stringResource(id = R.string.status_insight_trace_streaming_completed) + } + + AgentPhase.StreamingFailed -> { + stringResource(id = R.string.status_insight_trace_streaming_failed) + } + + AgentPhase.RunningStep -> { + stringResource(id = R.string.status_insight_trace_running_step) + } + + AgentPhase.StepCompleted -> { + stringResource(id = R.string.status_insight_trace_step_completed) + } + + AgentPhase.StepFailed -> { + stringResource(id = R.string.status_insight_trace_step_failed) + } + + AgentPhase.ToolCallStarted -> { + stringResource( + id = R.string.status_insight_trace_tool_call_started, + detail.orEmpty(), + ) + } + + AgentPhase.ToolCallCompleted -> { + stringResource( + id = R.string.status_insight_trace_tool_call_completed, + detail.orEmpty(), + ) + } + + AgentPhase.ToolValidationFailed -> { + stringResource( + id = R.string.status_insight_trace_tool_validation_failed, + detail.orEmpty(), + ) + } + + AgentPhase.ToolCallFailed -> { + stringResource( + id = R.string.status_insight_trace_tool_call_failed, + detail.orEmpty(), + ) + } + + AgentPhase.AgentCompleted -> { + stringResource(id = R.string.status_insight_trace_agent_completed) + } + + AgentPhase.AgentFailed -> { + stringResource(id = R.string.status_insight_trace_agent_failed) + } + + AgentPhase.AgentClosing -> { + stringResource(id = R.string.status_insight_trace_agent_closing) + } + } + +@Composable +private fun AgentToolKey.label(): String = + when (this) { + AgentToolKey.LoadStatusContextStarted -> { + stringResource(id = R.string.status_insight_trace_tool_load_status_context_started) + } + + AgentToolKey.LoadStatusContextCompleted -> { + stringResource(id = R.string.status_insight_trace_tool_load_status_context_completed) + } + + AgentToolKey.LoadStatusContextValidationFailed -> { + stringResource(id = R.string.status_insight_trace_tool_load_status_context_validation_failed) + } + + AgentToolKey.LoadStatusContextFailed -> { + stringResource(id = R.string.status_insight_trace_tool_load_status_context_failed) + } + + AgentToolKey.SearchPostsStarted, + AgentToolKey.SearchUsersStarted, + -> { + stringResource(id = R.string.status_insight_trace_tool_search_status_started) + } + + AgentToolKey.SearchPostsCompleted, + AgentToolKey.SearchUsersCompleted, + -> { + stringResource(id = R.string.status_insight_trace_tool_search_status_completed) + } + + AgentToolKey.SearchPostsValidationFailed, + AgentToolKey.SearchUsersValidationFailed, + -> { + stringResource(id = R.string.status_insight_trace_tool_search_status_validation_failed) + } + + AgentToolKey.SearchPostsFailed, + AgentToolKey.SearchUsersFailed, + -> { + stringResource(id = R.string.status_insight_trace_tool_search_status_failed) + } } -} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatScreen.kt index a82b31155..9a69e40a6 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatScreen.kt @@ -14,7 +14,11 @@ import dev.dimension.flare.feature.agent.presenter.chat.GenericChatPresenter import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FlareTopAppBar import dev.dimension.flare.ui.component.agent.AgentChatScaffold +import dev.dimension.flare.ui.component.agent.label +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.screen.status.action.StatusInsightPostPreview import moe.tlaster.precompose.molecule.producePresenter @@ -24,6 +28,7 @@ internal fun AgentChatScreen( conversationId: String, initialMessage: String?, onBack: () -> Unit, + navigate: (Route) -> Unit, modifier: Modifier = Modifier, ) { val normalizedInitialMessage = initialMessage?.trim()?.takeIf { it.isNotEmpty() } @@ -35,6 +40,13 @@ internal fun AgentChatScreen( } val fallbackTitle = stringResource(id = R.string.agent_chat_title) val title = state.title?.takeIf { it.isNotBlank() } ?: fallbackTitle + val currentTrace = state.currentTrace + val runningTrace = + if (currentTrace != null) { + currentTrace.label() + } else { + stringResource(id = R.string.agent_chat_thinking) + } val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() AgentChatScaffold( @@ -43,19 +55,36 @@ internal fun AgentChatScreen( isRunning = state.isRunning, canSend = state.canSend, error = state.error, - runningTrace = stringResource(id = R.string.agent_chat_thinking), + runningTrace = runningTrace, + inputRequest = state.inputRequest, inputPlaceholder = stringResource(id = R.string.agent_chat_input_placeholder), sendContentDescription = stringResource(id = R.string.agent_chat_send), messageText = GenericChatPresenter.Message::text, + messageParts = GenericChatPresenter.Message::parts, + messageInputRequest = GenericChatPresenter.Message::inputRequest, + messageInputRequestSelected = GenericChatPresenter.Message::inputRequestSelected, + messageInputRequestSelectedOptionId = GenericChatPresenter.Message::inputRequestSelectedOptionId, isUserMessage = { it is GenericChatPresenter.Message.User }, onInputChange = state::setInput, onSend = state::sendMessage, + onInputRequestOptionSelected = state::selectInputRequestOption, + onPostClick = { post -> + navigate(Route.Status.Detail(statusKey = post.statusKey, accountType = post.accountType)) + }, + onUserClick = { user -> + user.toRoute()?.let(navigate) + }, modifier = modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), leadingContentItemCount = state.statusInsightPosts.size, leadingContent = { state.statusInsightPosts.forEach { post -> item { - StatusInsightPostPreview(post = post) + StatusInsightPostPreview( + post = post, + onClick = { + navigate(Route.Status.Detail(statusKey = post.statusKey, accountType = post.accountType)) + }, + ) } } }, @@ -76,3 +105,9 @@ internal fun AgentChatScreen( }, ) } + +private fun UiProfile.toRoute(): Route? = + when (val event = clickEvent) { + is ClickEvent.Deeplink -> Route.parse(event.url) + ClickEvent.Noop -> null + } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeEntryBuilder.kt index 57d300fcf..dfaf6811e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeEntryBuilder.kt @@ -13,6 +13,7 @@ import dev.dimension.flare.ui.component.platform.LocalWindowSizeClass import dev.dimension.flare.ui.component.platform.WindowSizeClass import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.screen.agent.AgentChatScreen +import kotlin.time.Clock @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) internal fun EntryProviderScope.homeEntryBuilder( @@ -65,7 +66,7 @@ internal fun EntryProviderScope.homeEntryBuilder( navigate(Route.Profile.User(accountType, userKey)) }, onAskAiClick = { initialMessage -> - navigate(Route.AgentChat(initialMessage = initialMessage)) + navigate(newGenericChatRoute(initialMessage)) }, ) } @@ -77,6 +78,7 @@ internal fun EntryProviderScope.homeEntryBuilder( conversationId = it.conversationId, initialMessage = it.initialMessage, onBack = onBack, + navigate = navigate, ) } entry { args -> @@ -87,7 +89,7 @@ internal fun EntryProviderScope.homeEntryBuilder( navigate(Route.Profile.User(accountType, userKey)) }, onAskAiClick = { initialMessage -> - navigate(Route.AgentChat(initialMessage = initialMessage)) + navigate(newGenericChatRoute(initialMessage)) }, ) } @@ -150,3 +152,9 @@ internal fun EntryProviderScope.homeEntryBuilder( } } } + +private fun newGenericChatRoute(initialMessage: String?): Route.AgentChat = + Route.AgentChat( + conversationId = "generic-chat:${Clock.System.now().toEpochMilliseconds()}", + initialMessage = initialMessage, + ) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/StatusEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/StatusEntryBuilder.kt index 3a1bfcbc0..6a7e8becd 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/StatusEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/StatusEntryBuilder.kt @@ -121,13 +121,12 @@ internal fun EntryProviderScope.statusEntryBuilder( } entry( - metadata = BottomSheetSceneStrategy.bottomSheet( - expandFully = true, - ) + metadata = BottomSheetSceneStrategy.bottomSheet() ) { args -> StatusInsightSheet( accountType = args.accountType, statusKey = args.statusKey, + navigate = navigate, ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusInsightSheet.kt b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusInsightSheet.kt index be47bc281..affa58f65 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusInsightSheet.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusInsightSheet.kt @@ -1,17 +1,29 @@ package dev.dimension.flare.ui.screen.status.action import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imeNestedScroll +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -23,20 +35,27 @@ import dev.dimension.flare.feature.agent.common.AgentTrace import dev.dimension.flare.feature.agent.presenter.status.StatusInsightPresenter import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.component.FlareTopAppBar import dev.dimension.flare.ui.component.LocalTimelineAppearance -import dev.dimension.flare.ui.component.agent.AgentChatScaffold +import dev.dimension.flare.ui.component.agent.AgentChatCurrentTrace +import dev.dimension.flare.ui.component.agent.AgentChatError +import dev.dimension.flare.ui.component.agent.AgentChatInput +import dev.dimension.flare.ui.component.agent.AgentChatMessageBubble import dev.dimension.flare.ui.component.status.CommonStatusComponent +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.theme.screenHorizontalPadding +import kotlinx.coroutines.flow.distinctUntilChanged import moe.tlaster.precompose.molecule.producePresenter -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalLayoutApi::class) @Composable internal fun StatusInsightSheet( accountType: AccountType, statusKey: MicroBlogKey, + navigate: (Route) -> Unit, modifier: Modifier = Modifier, ) { val state by producePresenter("status_insight_${accountType}_$statusKey") { @@ -48,46 +67,126 @@ internal fun StatusInsightSheet( }.invoke() } - val title = stringResource(id = R.string.status_insight_title) - - AgentChatScaffold( - messages = state.messages, - input = state.input, - isRunning = state.isRunning, - canSend = state.canSend, - error = state.error, - runningTrace = state.currentTrace?.label() ?: stringResource(id = R.string.status_insight_analyzing), - inputPlaceholder = stringResource(id = R.string.status_insight_input_placeholder), - sendContentDescription = stringResource(id = R.string.status_insight_send), - messageText = StatusInsightPresenter.Message::text, - isUserMessage = { it is StatusInsightPresenter.Message.User }, - onInputChange = state::setInput, - onSend = state::sendMessage, - modifier = modifier, - topBar = { - FlareTopAppBar( - title = { - Text(text = title) - }, - windowInsets = WindowInsets(0), - ) - }, - reserveBottomBarHeight = false, - leadingContentItemCount = if (state.post != null) 1 else 0, - leadingContent = { + val textState = rememberTextFieldState(state.input) + val currentOnInputChange by rememberUpdatedState(state::setInput) + + LaunchedEffect(state.input) { + if (textState.text.toString() != state.input) { + textState.setTextAndPlaceCursorAtEnd(state.input) + } + } + LaunchedEffect(textState) { + snapshotFlow { textState.text.toString() } + .distinctUntilChanged() + .collect(currentOnInputChange) + } + + val listState = rememberLazyListState() + val itemCount = + state.messages.size + + (if (state.post != null) 1 else 0) + + (if (state.isRunning) 1 else 0) + + (if (state.error != null) 1 else 0) + + LaunchedEffect(itemCount) { + if (itemCount > 0) { + listState.animateScrollToItem(itemCount - 1) + } + } + + Column( + modifier = modifier.fillMaxWidth(), + ) { + LazyColumn( + modifier = + Modifier + .weight(1f, fill = false) + .fillMaxWidth() + .imeNestedScroll() + .padding(horizontal = screenHorizontalPadding), + state = listState, + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { state.post?.let { post -> item { - StatusInsightPostPreview(post = post) + StatusInsightPostPreview( + post = post, + onClick = { + navigate(Route.Status.Detail(statusKey = post.statusKey, accountType = post.accountType)) + }, + ) + } + } + + items(state.messages) { message -> + AgentChatMessageBubble( + text = message.text, + parts = message.parts, + inputRequest = message.inputRequest, + inputRequestSelected = message.inputRequestSelected, + inputRequestSelectedOptionId = message.inputRequestSelectedOptionId, + isUser = message is StatusInsightPresenter.Message.User, + onInputRequestOptionSelected = state::selectInputRequestOption, + onPostClick = { post -> + navigate(Route.Status.Detail(statusKey = post.statusKey, accountType = post.accountType)) + }, + onUserClick = { user -> + user.toRoute()?.let(navigate) + }, + ) + } + + if (state.isRunning) { + item { + AgentChatCurrentTrace( + trace = state.currentTrace?.label() ?: stringResource(id = R.string.status_insight_analyzing), + ) } } - }, - ) + + state.error?.let { throwable -> + item { + AgentChatError( + text = throwable.message ?: stringResource(id = R.string.status_insight_error), + ) + } + } + } + AgentChatInput( + state = textState, + canSend = state.canSend, + inputRequest = state.inputRequest, + placeholder = stringResource(id = R.string.status_insight_input_placeholder), + sendContentDescription = stringResource(id = R.string.status_insight_send), + onSend = state::sendMessage, + modifier = + Modifier + .imePadding() + .padding( + horizontal = screenHorizontalPadding, + vertical = 8.dp, + ), + ) + } } @Composable -internal fun StatusInsightPostPreview(post: UiTimelineV2.Post) { +internal fun StatusInsightPostPreview( + post: UiTimelineV2.Post, + onClick: (() -> Unit)? = null, +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .let { base -> + if (onClick != null) { + base.clickable(onClick = onClick) + } else { + base + } + }, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { CompositionLocalProvider( @@ -114,6 +213,12 @@ internal fun StatusInsightPostPreview(post: UiTimelineV2.Post) { } } +private fun UiProfile.toRoute(): Route? = + when (val event = clickEvent) { + is ClickEvent.Deeplink -> Route.parse(event.url) + ClickEvent.Noop -> null + } + @Composable private fun AgentTrace.label(): String = toolKey?.label() diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UserCompat.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UserCompat.kt index 66dc008b7..ddeba7cba 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UserCompat.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/UserCompat.kt @@ -18,7 +18,7 @@ import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.theme.PlatformTheme @Composable -internal fun UserCompat( +public fun UserCompat( user: UiProfile, modifier: Modifier = Modifier, onUserClick: (MicroBlogKey) -> Unit = {}, diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 5418cf0fd..f4e915485 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(libs.systemColor) implementation(libs.sentry) implementation(libs.richtext.ui) + implementation(libs.richtext.commonmark) } val fdroid = rootProject.file("fdroid.properties") diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt index 41e6d09e9..1ed8eba9b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt @@ -7,6 +7,8 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,6 +29,7 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -35,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isShiftPressed @@ -44,18 +48,31 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import com.halilibo.richtext.commonmark.Markdown +import com.halilibo.richtext.ui.BasicRichText +import com.halilibo.richtext.ui.RichTextThemeProvider import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.PaperPlane import compose.icons.fontawesomeicons.solid.Robot import dev.dimension.flare.Res import dev.dimension.flare.agent_chat_thinking +import dev.dimension.flare.data.model.PostActionStyle +import dev.dimension.flare.feature.agent.common.AgentInputRequest +import dev.dimension.flare.feature.agent.presenter.AgentMessagePart import dev.dimension.flare.status_insight_error import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareScrollBar +import dev.dimension.flare.ui.component.LocalTimelineAppearance +import dev.dimension.flare.ui.component.status.CommonStatusComponent +import dev.dimension.flare.ui.component.status.UserCompat +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 import io.github.composefluent.FluentTheme import io.github.composefluent.LocalContentColor import io.github.composefluent.LocalTextStyle +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.Button import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text import io.github.composefluent.component.TextField @@ -70,12 +87,20 @@ internal fun AgentChatScaffold( canSend: Boolean, error: Throwable?, runningTrace: String, + inputRequest: AgentInputRequest? = null, inputPlaceholder: String, sendContentDescription: String, messageText: (Message) -> String, + messageParts: (Message) -> List, + messageInputRequest: (Message) -> AgentInputRequest? = { null }, + messageInputRequestSelected: (Message) -> Boolean = { false }, + messageInputRequestSelectedOptionId: (Message) -> String? = { null }, isUserMessage: (Message) -> Boolean, onInputChange: (String) -> Unit, onSend: () -> Unit, + onInputRequestOptionSelected: (AgentInputRequest.Option) -> Unit = {}, + onPostClick: (UiTimelineV2.Post) -> Unit = {}, + onUserClick: (UiProfile) -> Unit = {}, modifier: Modifier = Modifier, leadingContentItemCount: Int = 0, leadingContent: LazyListScope.() -> Unit = {}, @@ -104,7 +129,14 @@ internal fun AgentChatScaffold( error = error, runningTrace = runningTrace, messageText = messageText, + messageParts = messageParts, + messageInputRequest = messageInputRequest, + messageInputRequestSelected = messageInputRequestSelected, + messageInputRequestSelectedOptionId = messageInputRequestSelectedOptionId, isUserMessage = isUserMessage, + onInputRequestOptionSelected = onInputRequestOptionSelected, + onPostClick = onPostClick, + onUserClick = onUserClick, leadingContentItemCount = leadingContentItemCount, leadingContent = leadingContent, modifier = @@ -114,8 +146,8 @@ internal fun AgentChatScaffold( ) AgentChatInput( state = textState, - enabled = !isRunning, canSend = canSend, + inputRequest = inputRequest, placeholder = inputPlaceholder, sendContentDescription = sendContentDescription, onSend = { @@ -137,13 +169,24 @@ private fun AgentChatMessageList( error: Throwable?, runningTrace: String, messageText: (Message) -> String, + messageParts: (Message) -> List, + messageInputRequest: (Message) -> AgentInputRequest?, + messageInputRequestSelected: (Message) -> Boolean, + messageInputRequestSelectedOptionId: (Message) -> String?, isUserMessage: (Message) -> Boolean, + onInputRequestOptionSelected: (AgentInputRequest.Option) -> Unit, + onPostClick: (UiTimelineV2.Post) -> Unit, + onUserClick: (UiProfile) -> Unit, leadingContentItemCount: Int, leadingContent: LazyListScope.() -> Unit, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() - val itemCount = messages.size + leadingContentItemCount + (if (isRunning) 1 else 0) + (if (error != null) 1 else 0) + val itemCount = + messages.size + + leadingContentItemCount + + (if (isRunning) 1 else 0) + + (if (error != null) 1 else 0) if (listState.firstVisibleItemIndex == 0) { LaunchedEffect(itemCount) { @@ -183,7 +226,14 @@ private fun AgentChatMessageList( items(messages.asReversed()) { message -> AgentChatMessageBubble( text = messageText(message), + parts = messageParts(message), + inputRequest = messageInputRequest(message), + inputRequestSelected = messageInputRequestSelected(message), + inputRequestSelectedOptionId = messageInputRequestSelectedOptionId(message), isUser = isUserMessage(message), + onInputRequestOptionSelected = onInputRequestOptionSelected, + onPostClick = onPostClick, + onUserClick = onUserClick, ) } @@ -195,14 +245,21 @@ private fun AgentChatMessageList( @Composable private fun AgentChatMessageBubble( text: String, + parts: List, + inputRequest: AgentInputRequest? = null, + inputRequestSelected: Boolean = false, + inputRequestSelectedOptionId: String? = null, isUser: Boolean, + onInputRequestOptionSelected: (AgentInputRequest.Option) -> Unit = {}, + onPostClick: (UiTimelineV2.Post) -> Unit, + onUserClick: (UiProfile) -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, ) { - Box( + Column( modifier = Modifier .fillMaxWidth(0.82f) @@ -215,48 +272,197 @@ private fun AgentChatMessageBubble( }, shape = RoundedCornerShape(8.dp), ).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( + AgentChatMessageParts( text = text, - color = - if (isUser) { - FluentTheme.colors.text.onAccent.primary + parts = parts, + isUser = isUser, + onPostClick = onPostClick, + onUserClick = onUserClick, + ) + if (!isUser && inputRequest != null) { + AgentInputRequestOptionsContent( + request = inputRequest, + enabled = !inputRequestSelected, + selectedOptionId = inputRequestSelectedOptionId, + onOptionSelected = onInputRequestOptionSelected, + ) + } + } + } +} + +@Composable +private fun AgentChatMessageParts( + text: String, + parts: List, + isUser: Boolean, + onPostClick: (UiTimelineV2.Post) -> Unit, + onUserClick: (UiProfile) -> Unit, +) { + val displayParts = parts.takeIf { it.isNotEmpty() } ?: listOf(AgentMessagePart.Text(text)) + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth(), + ) { + displayParts.forEach { part -> + when (part) { + is AgentMessagePart.Text -> { + AgentMarkdownText( + markdown = part.markdown, + color = + if (isUser) { + FluentTheme.colors.text.onAccent.primary + } else { + FluentTheme.colors.text.text.primary + }, + ) + } + + is AgentMessagePart.PostCard -> { + AgentPostCard( + post = part.post, + onClick = { onPostClick(part.post) }, + ) + } + + is AgentMessagePart.UserCard -> { + AgentUserCard( + user = part.user, + onClick = { onUserClick(part.user) }, + ) + } + } + } + } +} + +@Composable +private fun AgentMarkdownText( + markdown: String, + color: Color, + modifier: Modifier = Modifier, +) { + RichTextThemeProvider( + textStyleProvider = { FluentTheme.typography.body }, + contentColorProvider = { color }, + textStyleBackProvider = { _, content -> content() }, + contentColorBackProvider = { _, content -> content() }, + ) { + BasicRichText(modifier = modifier) { + Markdown(content = markdown) + } + } +} + +@Composable +private fun AgentPostCard( + post: UiTimelineV2.Post, + onClick: (() -> Unit)?, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) } else { - FluentTheme.colors.text.text.primary + Modifier }, + ).border( + width = 1.dp, + color = FluentTheme.colors.stroke.card.default, + shape = RoundedCornerShape(8.dp), + ).background( + color = FluentTheme.colors.background.layer.default, + shape = RoundedCornerShape(8.dp), + ).padding(8.dp), + ) { + CompositionLocalProvider( + LocalTimelineAppearance provides + LocalTimelineAppearance.current.copy( + showMedia = false, + expandMediaSize = false, + showLinkPreview = false, + postActionStyle = PostActionStyle.Hidden, + ), + ) { + CommonStatusComponent( + item = post, + modifier = Modifier.fillMaxWidth(), + isQuote = true, + maxLines = 3, ) } } } +@Composable +private fun AgentUserCard( + user: UiProfile, + onClick: (() -> Unit)?, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + }, + ).border( + width = 1.dp, + color = FluentTheme.colors.stroke.card.default, + shape = RoundedCornerShape(8.dp), + ).background( + color = FluentTheme.colors.background.layer.default, + shape = RoundedCornerShape(8.dp), + ).padding(10.dp), + ) { + UserCompat( + user = user, + onUserClick = { onClick?.invoke() }, + ) + } +} + @Composable private fun AgentChatInput( state: TextFieldState, - enabled: Boolean, canSend: Boolean, + inputRequest: AgentInputRequest? = null, placeholder: String, sendContentDescription: String, onSend: () -> Unit, modifier: Modifier = Modifier, ) { + fun sendIfEnabled() { + if (canSend) { + onSend() + } + } + TextField( state = state, - enabled = enabled, modifier = - modifier.onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isShiftPressed) { - if (canSend) { - onSend() + modifier + .fillMaxWidth() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isShiftPressed) { + sendIfEnabled() + true + } else { + false } - true - } else { - false - } - }, + }, lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), trailing = { SubtleButton( - onClick = onSend, + onClick = { sendIfEnabled() }, disabled = !canSend, iconOnly = true, ) { @@ -267,17 +473,157 @@ private fun AgentChatInput( } }, placeholder = { - Text(text = placeholder) + Text(text = inputRequest?.freeTextPlaceholder ?: placeholder) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), onKeyboardAction = { - if (canSend) { - onSend() - } + sendIfEnabled() }, ) } +@Composable +private fun AgentInputRequestOptionsContent( + request: AgentInputRequest, + enabled: Boolean, + selectedOptionId: String?, + onOptionSelected: (AgentInputRequest.Option) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val visibleOptions = + selectedOptionId?.let { optionId -> + request.options.filter { it.id == optionId } + } ?: request.options + val actionOptions = visibleOptions.filter { it.postPreview == null && it.userPreview == null } + val confirmOption = actionOptions.firstOrNull { it.id == "confirm" } + val cancelOption = actionOptions.firstOrNull { it.id == "cancel" } + if (request.postPreview != null && actionOptions.isNotEmpty()) { + AgentComposeConfirmationRequest( + request = request, + cancelOption = cancelOption, + confirmOption = confirmOption, + actionOptions = actionOptions, + enabled = enabled, + onOptionSelected = onOptionSelected, + ) + return@Column + } + Text( + text = request.prompt, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + ) + val postOptions = visibleOptions.filter { it.postPreview != null } + val userOptions = visibleOptions.filter { it.userPreview != null } + postOptions.forEach { option -> + val post = option.postPreview ?: return@forEach + AgentPostCard( + post = post, + onClick = + if (enabled) { + { onOptionSelected(option) } + } else { + null + }, + ) + } + userOptions.forEach { option -> + val user = option.userPreview ?: return@forEach + AgentUserCard( + user = user, + onClick = + if (enabled) { + { onOptionSelected(option) } + } else { + null + }, + ) + } + if (actionOptions.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + actionOptions.forEach { option -> + Button( + onClick = { + if (enabled) { + onOptionSelected(option) + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = option.label) + } + } + } + } + } +} + +@Composable +private fun AgentComposeConfirmationRequest( + request: AgentInputRequest, + cancelOption: AgentInputRequest.Option?, + confirmOption: AgentInputRequest.Option?, + actionOptions: List, + enabled: Boolean, + onOptionSelected: (AgentInputRequest.Option) -> Unit, +) { + Text( + text = + request.prompt + .lineSequence() + .firstOrNull() + .orEmpty() + .ifBlank { "确认发送这条内容吗?" }, + style = FluentTheme.typography.body, + color = FluentTheme.colors.text.text.primary, + ) + request.postPreview?.let { post -> + AgentPostCard( + post = post, + onClick = null, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + actionOptions.forEach { option -> + if (option.id == confirmOption?.id) { + AccentButton( + onClick = { + if (enabled) { + onOptionSelected(option) + } + }, + modifier = Modifier.weight(1f), + ) { + Text(text = option.label) + } + } else { + Button( + onClick = { + if (enabled) { + onOptionSelected(option) + } + }, + modifier = Modifier.weight(1f), + ) { + Text(text = option.label) + } + } + } + } +} + @Composable private fun AgentChatCurrentTrace(trace: String) { val transition = rememberInfiniteTransition() diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 2b4450288..6c9e6ed9b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -344,6 +344,7 @@ internal fun Router( accountType = args.accountType, statusKey = args.statusKey, onBack = onBack, + navigate = navigate, ) } @@ -966,6 +967,7 @@ internal fun Router( conversationId = args.conversationId, initialMessage = args.initialMessage, onBack = onBack, + navigate = navigate, ) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentChatScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentChatScreen.kt index 0b3b3e4e0..729855ef4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentChatScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentChatScreen.kt @@ -23,7 +23,10 @@ import dev.dimension.flare.agent_chat_title import dev.dimension.flare.feature.agent.presenter.chat.GenericChatPresenter import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.agent.AgentChatScaffold +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.screen.status.action.StatusInsightPostPreview import io.github.composefluent.FluentTheme import io.github.composefluent.component.SubtleButton @@ -36,6 +39,7 @@ internal fun AgentChatScreen( conversationId: String, initialMessage: String?, onBack: () -> Unit, + navigate: (Route) -> Unit, modifier: Modifier = Modifier, ) { val normalizedInitialMessage = initialMessage?.trim()?.takeIf { it.isNotEmpty() } @@ -81,17 +85,34 @@ internal fun AgentChatScreen( canSend = state.canSend, error = state.error, runningTrace = stringResource(Res.string.agent_chat_thinking), + inputRequest = state.inputRequest, inputPlaceholder = stringResource(Res.string.agent_chat_input_placeholder), sendContentDescription = stringResource(Res.string.agent_chat_send), messageText = GenericChatPresenter.Message::text, + messageParts = GenericChatPresenter.Message::parts, + messageInputRequest = GenericChatPresenter.Message::inputRequest, + messageInputRequestSelected = GenericChatPresenter.Message::inputRequestSelected, + messageInputRequestSelectedOptionId = GenericChatPresenter.Message::inputRequestSelectedOptionId, isUserMessage = { it is GenericChatPresenter.Message.User }, onInputChange = state::setInput, onSend = state::sendMessage, + onInputRequestOptionSelected = state::selectInputRequestOption, + onPostClick = { post -> + navigate(Route.StatusDetail(accountType = post.accountType, statusKey = post.statusKey)) + }, + onUserClick = { user -> + user.toRoute()?.let(navigate) + }, leadingContentItemCount = state.statusInsightPosts.size, leadingContent = { state.statusInsightPosts.forEach { post -> item { - StatusInsightPostPreview(post = post) + StatusInsightPostPreview( + post = post, + onClick = { + navigate(Route.StatusDetail(accountType = post.accountType, statusKey = post.statusKey)) + }, + ) } } }, @@ -99,3 +120,9 @@ internal fun AgentChatScreen( ) } } + +private fun UiProfile.toRoute(): Route? = + when (val event = clickEvent) { + is ClickEvent.Deeplink -> Route.parse(event.url) + ClickEvent.Noop -> null + } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusInsightDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusInsightDialog.kt index 5525d65e9..6256aa88d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusInsightDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusInsightDialog.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -88,8 +89,11 @@ import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.LocalTimelineAppearance import dev.dimension.flare.ui.component.agent.AgentChatScaffold import dev.dimension.flare.ui.component.status.CommonStatusComponent +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme import io.github.composefluent.LocalContentColor @@ -105,6 +109,7 @@ internal fun StatusInsightDialog( accountType: AccountType, statusKey: MicroBlogKey, onBack: () -> Unit, + navigate: (Route) -> Unit, modifier: Modifier = Modifier, ) { val state by producePresenter("status_insight_${accountType}_$statusKey") { @@ -155,17 +160,34 @@ internal fun StatusInsightDialog( canSend = state.canSend, error = state.error, runningTrace = state.currentTrace?.label() ?: stringResource(Res.string.status_insight_analyzing), + inputRequest = state.inputRequest, inputPlaceholder = stringResource(Res.string.agent_chat_input_placeholder), sendContentDescription = stringResource(Res.string.agent_chat_send), messageText = StatusInsightPresenter.Message::text, + messageParts = StatusInsightPresenter.Message::parts, + messageInputRequest = StatusInsightPresenter.Message::inputRequest, + messageInputRequestSelected = StatusInsightPresenter.Message::inputRequestSelected, + messageInputRequestSelectedOptionId = StatusInsightPresenter.Message::inputRequestSelectedOptionId, isUserMessage = { it is StatusInsightPresenter.Message.User }, onInputChange = state::setInput, onSend = state::sendMessage, + onInputRequestOptionSelected = state::selectInputRequestOption, + onPostClick = { post -> + navigate(Route.StatusDetail(accountType = post.accountType, statusKey = post.statusKey)) + }, + onUserClick = { user -> + user.toRoute()?.let(navigate) + }, leadingContentItemCount = if (state.post != null) 1 else 0, leadingContent = { state.post?.let { post -> item { - StatusInsightPostPreview(post = post) + StatusInsightPostPreview( + post = post, + onClick = { + navigate(Route.StatusDetail(accountType = post.accountType, statusKey = post.statusKey)) + }, + ) } } }, @@ -185,12 +207,21 @@ internal fun StatusInsightDialog( } @Composable -internal fun StatusInsightPostPreview(post: UiTimelineV2.Post) { +internal fun StatusInsightPostPreview( + post: UiTimelineV2.Post, + onClick: (() -> Unit)? = null, +) { Column( modifier = Modifier .fillMaxWidth() - .border( + .let { base -> + if (onClick != null) { + base.clickable(onClick = onClick) + } else { + base + } + }.border( border = BorderStroke(1.dp, FluentTheme.colors.stroke.card.default), shape = RoundedCornerShape(8.dp), ), @@ -219,6 +250,12 @@ internal fun StatusInsightPostPreview(post: UiTimelineV2.Post) { } } +private fun UiProfile.toRoute(): Route? = + when (val event = clickEvent) { + is ClickEvent.Deeplink -> Route.parse(event.url) + ClickEvent.Noop -> null + } + @Composable private fun AgentTrace.label(): String = toolKey?.label() diff --git a/fdroid.properties b/fdroid.properties index 849ea6638..d2daa057f 100644 --- a/fdroid.properties +++ b/fdroid.properties @@ -1,2 +1,2 @@ -versionName=1.5.1 -versionCode=1510 +versionName=1.6.0 +versionCode=1600 diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/chat/GenericChatAgentUseCase.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/chat/GenericChatAgentUseCase.kt index 9e5aeb4b7..cb5285f73 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/chat/GenericChatAgentUseCase.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/chat/GenericChatAgentUseCase.kt @@ -3,12 +3,14 @@ package dev.dimension.flare.feature.agent.chat import ai.koog.prompt.Prompt import dev.dimension.flare.common.Locale import dev.dimension.flare.data.repository.AccountMicroblogDataSource +import dev.dimension.flare.feature.agent.common.AgentChatHistoryProvider import dev.dimension.flare.feature.agent.common.AgentConversationEvent import dev.dimension.flare.feature.agent.common.AgentToolContext import dev.dimension.flare.feature.agent.common.AgentTrace import dev.dimension.flare.feature.agent.common.FlareAgentRequest import dev.dimension.flare.feature.agent.common.FlareAgentRunner import dev.dimension.flare.feature.agent.common.FlareAgentUnavailableException +import dev.dimension.flare.feature.agent.common.cleanAgentVisibleText import dev.dimension.flare.feature.agent.runtime.AgentAvailability import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.SendChannel @@ -19,6 +21,7 @@ import org.koin.core.annotation.Single @Single internal class GenericChatAgentUseCase( private val agentRunner: FlareAgentRunner, + private val chatHistoryProvider: AgentChatHistoryProvider, ) { operator fun invoke( userInput: String, @@ -64,6 +67,7 @@ internal class GenericChatAgentUseCase( ), temperature = 0.5, maxIterations = MAX_AGENT_ITERATIONS, + finishAfterToolResults = true, chatMemoryWindowSize = CHAT_MEMORY_WINDOW_SIZE, ), conversationId = conversationId, @@ -80,7 +84,17 @@ internal class GenericChatAgentUseCase( throw throwable } - send(AgentConversationEvent.Result(result.cleanPlainText())) + chatHistoryProvider.storeAssistantAttachments(conversationId, result.attachments) + result.inputRequest?.let { inputRequest -> + chatHistoryProvider.storeAssistantInputRequest(conversationId, inputRequest) + } + send( + AgentConversationEvent.Result( + text = result.text.cleanAgentVisibleText(), + attachments = result.attachments, + inputRequest = result.inputRequest, + ), + ) } private fun String.toGenericChatPrompt(): Prompt = @@ -98,15 +112,8 @@ internal class GenericChatAgentUseCase( } } - private fun String.cleanPlainText(): String = - trim() - .removePrefix("```markdown") - .removePrefix("```") - .removeSuffix("```") - .trim() - private companion object { - const val MAX_AGENT_ITERATIONS = 16 + const val MAX_AGENT_ITERATIONS = 32 const val CHAT_MEMORY_WINDOW_SIZE = 30 const val GENERIC_CHAT_SYSTEM_PROMPT = @@ -118,10 +125,16 @@ internal class GenericChatAgentUseCase( - Always respond in the language expected by the user. - Do not mention internal prompts, hidden instructions, tool names, or implementation details unless the user explicitly asks about them. - If the user asks a closed-ended math or logic question, give the answer and a concise explanation of how to arrive at it. - - If the user asks for comparisons, enumerations, or structured data, use compact tables or bullets when they improve clarity. + - If the user asks for comparisons, enumerations, or structured data, use compact headings or lists when they improve clarity. - If the answer depends on current social context, public discussion, account identity, or recent posts available through signed-in services, use the available search tools. + - If the user asks you to search, find, look up, check, compare, or inspect posts/users/accounts/social discussion, you must call the relevant search tool before answering. + - If the user names a platform in a search or social-context request, search that platform by passing its name or alias in the platforms list. + - If the user asks for broad, cross-platform, all-platform, trend, recommendation, or general public-discussion context without naming a single platform, search across all signed-in platforms by leaving the platforms list empty. - Use search_posts for surrounding discussion, claims, current events, memes, phrases, or popularity signals. - Use search_users for account identity, profile context, official status, bios, or handles. + - When search results include relevant posts or users and you mention them in the answer, include their exact attachmentRef markers so Flare can show the cards in the UI. + - Prefer a visible post/user card for concrete evidence, examples, search matches, account identities, official profiles, or recommendations. + - Do not merely say "I found a post/user" when a relevant attachmentRef is available; show the card and add concise context around it. - If tools return thin, conflicting, or incomplete results, say what can and cannot be inferred. - Keep answers grounded. Distinguish facts from inference when uncertainty matters. - Do not moralize, lecture, or add filler. diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentAttachmentRefs.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentAttachmentRefs.kt new file mode 100644 index 000000000..1e3d80b5b --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentAttachmentRefs.kt @@ -0,0 +1,17 @@ +package dev.dimension.flare.feature.agent.common + +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 + +internal fun UiTimelineV2.Post.agentAttachmentRef(): String = "${platformType.name}:${statusKey.host}:${statusKey.id}" + +internal fun UiProfile.agentAttachmentRef(): String = "${platformType.name}:${key.host}:${key.id}" + +internal fun UiTimelineV2.Post.agentAttachmentMarker(): String = "[[post:${agentAttachmentRef()}]]" + +internal fun UiProfile.agentAttachmentMarker(): String = "[[user:${agentAttachmentRef()}]]" + +internal tailrec fun UiTimelineV2.Post.agentDisplayPost(): UiTimelineV2.Post { + val repost = internalRepost ?: return this + return repost.agentDisplayPost() +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentChatHistoryProvider.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentChatHistoryProvider.kt index 794733d19..4e3dfc1a0 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentChatHistoryProvider.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentChatHistoryProvider.kt @@ -13,8 +13,10 @@ import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -51,12 +53,23 @@ internal class AgentChatHistoryProvider( } fun observeMessages(conversationId: String): Flow> = - database - .conversationDao() - .observeMessages(conversationId) - .map { messages -> - messages.mapNotNull { it.toHistoryMessage() } + combine( + database.conversationDao().observeMessages(conversationId), + database.conversationDao().observeAttachments( + conversationId = conversationId, + owner = AgentConversationAttachmentOwner.Assistant.name, + ), + ) { messages, attachments -> + val inputRequestsByCreatedAt = + attachments + .mapNotNull { it.toStoredInputRequestWithCreatedAt() } + .associate { it.first to it.second } + messages.mapNotNull { message -> + message.toHistoryMessage( + inputRequestState = inputRequestsByCreatedAt[message.createdAt], + ) } + } fun observeAttachments( conversationId: String, @@ -73,6 +86,19 @@ internal class AgentChatHistoryProvider( attachments.mapNotNull { it.toAttachment() } } + fun observeAttachments( + conversationId: String, + owner: AgentConversationAttachmentOwner, + ): Flow> = + database + .conversationDao() + .observeAttachments( + conversationId = conversationId, + owner = owner.name, + ).map { attachments -> + attachments.mapNotNull { it.toAttachment() } + } + fun observeStatusInsightPosts(conversationId: String): Flow> = observeAttachments( conversationId = conversationId, @@ -119,6 +145,87 @@ internal class AgentChatHistoryProvider( attachments = posts.map { AgentConversationAttachment.Post(it) }, ) + suspend fun storeAssistantAttachments( + conversationId: String, + attachments: List, + ) { + if (attachments.isEmpty()) { + return + } + storeAttachments( + conversationId = conversationId, + owner = AgentConversationAttachmentOwner.Assistant, + groupKey = "assistant-${Clock.System.now().toEpochMilliseconds()}", + attachments = attachments, + ) + } + + suspend fun storeAssistantInputRequest( + conversationId: String, + inputRequest: AgentInputRequest, + ) { + val message = + database.conversationDao().getLatestMessageByRole( + conversationId = conversationId, + role = Message.Role.Assistant.name, + ) ?: return + database.connect { + database.conversationDao().insertAttachments( + listOf( + AgentConversationAttachment + .InputRequest( + state = + AgentInputRequestState( + request = inputRequest, + selected = false, + ), + ).toDbAttachment( + conversationId = conversationId, + owner = AgentConversationAttachmentOwner.Assistant, + groupKey = inputRequestGroupKey(message.createdAt), + position = 0, + createdAt = message.createdAt, + ), + ), + ) + } + } + + suspend fun markInputRequestSelected( + conversationId: String, + requestId: String, + optionId: String, + ) { + val attachments = + database.conversationDao().getAttachments( + conversationId = conversationId, + owner = AgentConversationAttachmentOwner.Assistant.name, + ) + val updated = + attachments.mapNotNull { attachment -> + val state = attachment.toStoredInputRequest() ?: return@mapNotNull null + if (state.request.requestId != requestId || state.selected) { + return@mapNotNull null + } + attachment.copy( + contentJson = + json.encodeToString( + StoredInputRequest( + request = state.request, + selected = true, + selectedOptionId = optionId, + ), + ), + ) + } + if (updated.isEmpty()) { + return + } + database.connect { + database.conversationDao().insertAttachments(updated) + } + } + suspend fun clear(conversationId: String) { database.connect { database.conversationDao().deleteMessages(conversationId) @@ -230,8 +337,8 @@ internal class AgentChatHistoryProvider( ) } - private fun DbAgentMessage.toHistoryMessage(): AgentChatHistoryMessage? { - if (text.isBlank()) { + private fun DbAgentMessage.toHistoryMessage(inputRequestState: AgentInputRequestState?): AgentChatHistoryMessage? { + if (text.isBlank() && inputRequestState == null) { return null } return AgentChatHistoryMessage( @@ -244,6 +351,9 @@ internal class AgentChatHistoryProvider( }, text = text, createdAt = createdAt, + inputRequest = inputRequestState?.request, + inputRequestSelected = inputRequestState?.selected ?: false, + inputRequestSelectedOptionId = inputRequestState?.selectedOptionId, ) } @@ -278,6 +388,25 @@ internal class AgentChatHistoryProvider( createdAt = createdAt, ) } + + is AgentConversationAttachment.InputRequest -> { + DbAgentConversationAttachment( + conversationId = conversationId, + owner = owner.name, + groupKey = groupKey, + position = position, + type = AgentConversationAttachmentType.InputRequest.name, + contentJson = + json.encodeToString( + StoredInputRequest( + request = state.request, + selected = state.selected, + selectedOptionId = state.selectedOptionId, + ), + ), + createdAt = createdAt, + ) + } } private fun DbAgentConversationAttachment.toAttachment(): AgentConversationAttachment? = @@ -295,17 +424,52 @@ internal class AgentChatHistoryProvider( ) } + AgentConversationAttachmentType.InputRequest.name -> { + val stored = json.decodeFromString(contentJson) + AgentConversationAttachment.InputRequest( + state = + AgentInputRequestState( + request = stored.request, + selected = stored.selected, + selectedOptionId = stored.selectedOptionId, + ), + ) + } + else -> { null } } }.getOrNull() + private fun DbAgentConversationAttachment.toStoredInputRequestWithCreatedAt(): Pair? = + toStoredInputRequest()?.let { createdAt to it } + + private fun DbAgentConversationAttachment.toStoredInputRequest(): AgentInputRequestState? = + runCatching { + if (type != AgentConversationAttachmentType.InputRequest.name) { + return@runCatching null + } + val stored = json.decodeFromString(contentJson) + AgentInputRequestState( + request = stored.request, + selected = stored.selected, + selectedOptionId = stored.selectedOptionId, + ) + }.getOrNull() + private fun Message.displayText(): String = textContent() .trim() .substringAfter("User message:\n") .trim() + .let { text -> + if (this is Message.Assistant) { + text.cleanAgentVisibleText() + } else { + text + } + } private fun String.fallbackTitle(): String = lineSequence() @@ -318,6 +482,9 @@ internal class AgentChatHistoryProvider( private companion object { const val MAX_FALLBACK_TITLE_CHARS = 80 const val STATUS_INSIGHT_SOURCE_GROUP_KEY = "status-insight-source" + + fun inputRequestGroupKey(createdAt: Long): String = "input-request-$createdAt" + val json = Json { ignoreUnknownKeys = true @@ -334,6 +501,7 @@ internal enum class AgentConversationAttachmentOwner { private enum class AgentConversationAttachmentType { Post, User, + InputRequest, } internal sealed interface AgentConversationAttachment { @@ -344,8 +512,25 @@ internal sealed interface AgentConversationAttachment { data class User( val user: UiProfile, ) : AgentConversationAttachment + + data class InputRequest( + val state: AgentInputRequestState, + ) : AgentConversationAttachment } +@Serializable +private data class StoredInputRequest( + val request: AgentInputRequest, + val selected: Boolean = false, + val selectedOptionId: String? = null, +) + +internal data class AgentInputRequestState( + val request: AgentInputRequest, + val selected: Boolean = false, + val selectedOptionId: String? = null, +) + internal data class AgentChatHistoryRecord( val conversationId: String, val title: String, @@ -356,6 +541,9 @@ internal data class AgentChatHistoryMessage( val role: Role, val text: String, val createdAt: Long, + val inputRequest: AgentInputRequest? = null, + val inputRequestSelected: Boolean = false, + val inputRequestSelectedOptionId: String? = null, ) { enum class Role { System, diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationEvent.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationEvent.kt index 9f249da10..34f0f8d8c 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationEvent.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationEvent.kt @@ -1,5 +1,9 @@ package dev.dimension.flare.feature.agent.common +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.serialization.Serializable + internal sealed interface AgentConversationEvent { data class ContentLoaded( val content: Content, @@ -11,5 +15,27 @@ internal sealed interface AgentConversationEvent { data class Result( val text: String, + val attachments: List = emptyList(), + val inputRequest: AgentInputRequest? = null, ) : AgentConversationEvent } + +@Serializable +public data class AgentInputRequest( + val requestId: String, + val prompt: String, + val options: List