From d86342cd319750698cb27fdf3766adc5a1c759ce Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 7 Jun 2026 02:48:09 +0900 Subject: [PATCH 1/6] initial agent implement --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 + .../dev/dimension/flare/ui/AppContainer.kt | 12 +- .../dev/dimension/flare/ui/route/Route.kt | 13 + .../ui/screen/settings/AiConfigScreen.kt | 25 + .../ui/screen/status/StatusEntryBuilder.kt | 10 + .../status/action/StatusInsightSheet.kt | 344 +++++++++ app/src/main/res/values/strings.xml | 39 + .../component/status/CommonStatusComponent.kt | 28 + desktopApp/build.gradle.kts | 1 + .../main/composeResources/values/strings.xml | 39 + .../dev/dimension/flare/ui/route/Route.kt | 12 + .../dev/dimension/flare/ui/route/Router.kt | 11 + .../ui/screen/settings/SettingsScreen.kt | 20 + .../status/action/StatusInsightDialog.kt | 416 +++++++++++ .../dimension/flare/ui/theme/FlareTheme.kt | 5 +- feature/agent/build.gradle.kts | 45 ++ .../dev/dimension/flare/di/AgentKoinModule.kt | 12 + .../status/StatusInsightPresenter.kt | 112 +++ .../agent/runtime/AgentAvailability.kt | 16 + .../agent/runtime/AiConfigKoogBridge.kt | 89 +++ .../agent/runtime/FlareAgentRuntime.kt | 11 + .../runtime/FlareAgentRuntimeProvider.kt | 47 ++ .../agent/status/StatusInsightAgentUseCase.kt | 707 ++++++++++++++++++ .../agent/status/StatusInsightTools.kt | 325 ++++++++ gradle/libs.versions.toml | 3 + ios-shared/build.gradle.kts | 2 + iosApp/flare/Localizable.xcstrings | 375 +++++++++- .../Component/Status/StatusUIKitLeaves.swift | 27 +- .../UI/Component/Status/StatusUIKitView.swift | 13 +- .../UI/Component/Status/StatusView.swift | 18 + .../flare/UI/Component/UIKitAppearance.swift | 5 +- iosApp/flare/UI/FlareTheme.swift | 37 +- iosApp/flare/UI/Route/Route.swift | 5 + iosApp/flare/UI/Route/Router.swift | 1 + iosApp/flare/UI/Screen/AiConfigScreen.swift | 14 + .../flare/UI/Screen/StatusInsightSheet.swift | 253 +++++++ settings.gradle.kts | 1 + .../microblog/handler/PostHandler.kt | 33 +- .../flare/data/datastore/model/AppSettings.kt | 1 + .../data/model/appearance/AppearanceModels.kt | 1 + .../flare/data/repository/AccountService.kt | 23 + .../presenter/settings/AiConfigPresenter.kt | 12 + .../dimension/flare/ui/route/DeeplinkRoute.kt | 6 + 44 files changed, 3156 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusInsightSheet.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusInsightDialog.kt create mode 100644 feature/agent/build.gradle.kts create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/di/AgentKoinModule.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/status/StatusInsightPresenter.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/AgentAvailability.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/AiConfigKoogBridge.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/FlareAgentRuntime.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/FlareAgentRuntimeProvider.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt create mode 100644 iosApp/flare/UI/Screen/StatusInsightSheet.swift diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f196e9d8b6..c4f11047a9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,6 +136,7 @@ dependencies { implementation(projects.feature.login) implementation(projects.feature.subscription) implementation(projects.feature.tab) + implementation(projects.feature.agent) implementation(projects.composeUi) implementation(libs.androidx.splash) implementation(libs.materialKolor) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aaef0f66d5..938742c855 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + diff --git a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt index fbd5c14809..5c9ee71995 100644 --- a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt +++ b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt @@ -58,17 +58,19 @@ fun FlareApp(content: @Composable () -> Unit) { } val appSettings = state.appSettings.takeSuccessOr(AppSettings("")) + val openAIConfig = appSettings.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI CompositionLocalProvider( LocalUriHandler provides uriHandler, LocalGlobalAppearance provides globalAppearance, LocalTimelineAppearance provides - remember(globalAppearance, timelineAppearance, appSettings.translateConfig, appSettings.aiConfig.tldr) { + remember(globalAppearance, timelineAppearance, appSettings.translateConfig, appSettings.aiConfig) { timelineAppearance.copy( aiConfig = - TimelineAppearance.AiConfig( - translation = true, - tldr = appSettings.aiConfig.tldr, - ), + TimelineAppearance.AiConfig( + translation = true, + tldr = appSettings.aiConfig.tldr, + agent = appSettings.aiConfig.agent && !openAIConfig?.model.isNullOrBlank(), + ), ) }, content = content, diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt index e636452f71..61cf3ed45e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt @@ -78,6 +78,12 @@ internal sealed interface Route : NavKey { val fxShareUrl: String? = null, val fixvxShareUrl: String? = null, ) : Status + + @Serializable + data class Insight( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : Status } @Serializable @@ -615,6 +621,13 @@ internal sealed interface Route : NavKey { ) } + is DeeplinkRoute.Status.Insight -> { + Status.Insight( + accountType = deeplinkRoute.accountType, + statusKey = deeplinkRoute.statusKey, + ) + } + is DeeplinkRoute.Status.MastodonReport -> { Status.MastodonReport( userKey = deeplinkRoute.userKey, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt index 3a2762914e..584be2a0dc 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AiConfigScreen.kt @@ -450,6 +450,31 @@ internal fun AiConfigScreen(onBack: () -> Unit) { ) } Spacer(modifier = Modifier.height(12.dp)) + SegmentedListItem( + onClick = { + state.setAIAgent(!state.aiAgent) + }, + shapes = ListItemDefaults.single(), + content = { + Text( + text = stringResource(id = R.string.settings_ai_config_enable_agent), + ) + }, + supportingContent = { + Text( + text = stringResource(id = R.string.settings_ai_config_agent_description), + ) + }, + trailingContent = { + Switch( + checked = state.aiAgent, + onCheckedChange = { + state.setAIAgent(it) + }, + ) + }, + ) + Spacer(modifier = Modifier.height(12.dp)) SegmentedListItem( onClick = { state.setAITldr(!state.aiTldr) 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 7580948b63..ba1ee52638 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 @@ -12,6 +12,7 @@ import dev.dimension.flare.ui.screen.status.action.BlueskyReportStatusDialog import dev.dimension.flare.ui.screen.status.action.DeleteStatusConfirmDialog import dev.dimension.flare.ui.screen.status.action.MastodonReportDialog import dev.dimension.flare.ui.screen.status.action.MisskeyReportDialog +import dev.dimension.flare.ui.screen.status.action.StatusInsightSheet import dev.dimension.flare.ui.screen.status.action.StatusShareSheet @OptIn(ExperimentalMaterial3Api::class) @@ -119,6 +120,15 @@ internal fun EntryProviderScope.statusEntryBuilder( ) } + entry( + metadata = BottomSheetSceneStrategy.bottomSheet() + ) { args -> + StatusInsightSheet( + accountType = args.accountType, + statusKey = args.statusKey, + ) + } + entry { args -> TwitterArticleScreen( accountType = args.accountType, 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 new file mode 100644 index 0000000000..fe6b4c5f5a --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/status/action/StatusInsightSheet.kt @@ -0,0 +1,344 @@ +package dev.dimension.flare.ui.screen.status.action + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Robot +import dev.dimension.flare.R +import dev.dimension.flare.data.model.PostActionStyle +import dev.dimension.flare.feature.agent.presenter.status.StatusInsightPresenter +import dev.dimension.flare.feature.agent.status.StatusInsightEvent +import dev.dimension.flare.feature.agent.status.StatusInsightPhase +import dev.dimension.flare.feature.agent.status.StatusInsightTraceKey +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.LocalTimelineAppearance +import dev.dimension.flare.ui.component.status.CommonStatusComponent +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.model.onLoading +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import moe.tlaster.precompose.molecule.producePresenter + +@Composable +internal fun StatusInsightSheet( + accountType: AccountType, + statusKey: MicroBlogKey, + modifier: Modifier = Modifier, +) { + val state by producePresenter("status_insight_${accountType}_$statusKey") { + remember(accountType, statusKey) { + StatusInsightPresenter( + accountType = accountType, + statusKey = statusKey, + ) + }.invoke() + } + + Column( + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = screenHorizontalPadding) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + ) + Text( + text = stringResource(id = R.string.status_insight_title), + style = MaterialTheme.typography.titleLarge, + ) + } + + state.post?.let { post -> + StatusInsightPostPreview(post = post) + } + + state.insight + .onLoading { + StatusInsightCurrentTrace( + trace = state.currentTrace?.label() ?: stringResource(id = R.string.status_insight_analyzing), + ) + }.onError { throwable -> + Text( + text = throwable.message ?: stringResource(id = R.string.status_insight_error), + color = MaterialTheme.colorScheme.error, + ) + }.onSuccess { text -> + Text( + text = text, + ) + } + } +} + +@Composable +private fun StatusInsightPostPreview(post: UiTimelineV2.Post) { + Card( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + CompositionLocalProvider( + LocalTimelineAppearance provides + LocalTimelineAppearance.current.copy( + showMedia = false, + expandMediaSize = false, + showLinkPreview = false, + postActionStyle = PostActionStyle.Hidden, + ), + ) { + CommonStatusComponent( + item = post, + modifier = + Modifier + .padding( + horizontal = screenHorizontalPadding, + vertical = 8.dp, + ).fillMaxWidth(), + isQuote = true, + maxLines = 3, + ) + } + } +} + +@Composable +private fun StatusInsightEvent.Trace.label(): String = + key?.label() + ?: when (phase) { + StatusInsightPhase.LoadingPostContext -> { + stringResource(id = R.string.status_insight_trace_loading_post_context) + } + + StatusInsightPhase.PostContextLoaded -> { + stringResource(id = R.string.status_insight_trace_post_context_loaded) + } + + StatusInsightPhase.PreparingImages -> { + stringResource(id = R.string.status_insight_trace_preparing_images) + } + + StatusInsightPhase.ImagesUnsupportedFallback -> { + stringResource(id = R.string.status_insight_trace_images_unsupported_fallback) + } + + StatusInsightPhase.AgentStarted -> { + stringResource(id = R.string.status_insight_trace_agent_started) + } + + StatusInsightPhase.StrategyStarted -> { + stringResource(id = R.string.status_insight_trace_strategy_started) + } + + StatusInsightPhase.StrategyCompleted -> { + stringResource(id = R.string.status_insight_trace_strategy_completed) + } + + StatusInsightPhase.SubgraphStarted -> { + stringResource(id = R.string.status_insight_trace_subgraph_started) + } + + StatusInsightPhase.SubgraphCompleted -> { + stringResource(id = R.string.status_insight_trace_subgraph_completed) + } + + StatusInsightPhase.SubgraphFailed -> { + stringResource(id = R.string.status_insight_trace_subgraph_failed) + } + + StatusInsightPhase.AskingModel -> { + stringResource( + id = R.string.status_insight_trace_asking_model, + detail.orEmpty(), + ) + } + + StatusInsightPhase.ModelResponseReceived -> { + stringResource(id = R.string.status_insight_trace_model_response_received) + } + + StatusInsightPhase.StreamingStarted -> { + stringResource( + id = R.string.status_insight_trace_streaming_started, + detail.orEmpty(), + ) + } + + StatusInsightPhase.StreamingResponse -> { + stringResource(id = R.string.status_insight_trace_streaming_response) + } + + StatusInsightPhase.StreamingCompleted -> { + stringResource(id = R.string.status_insight_trace_streaming_completed) + } + + StatusInsightPhase.StreamingFailed -> { + stringResource(id = R.string.status_insight_trace_streaming_failed) + } + + StatusInsightPhase.RunningStep -> { + stringResource(id = R.string.status_insight_trace_running_step) + } + + StatusInsightPhase.StepCompleted -> { + stringResource(id = R.string.status_insight_trace_step_completed) + } + + StatusInsightPhase.StepFailed -> { + stringResource(id = R.string.status_insight_trace_step_failed) + } + + StatusInsightPhase.ToolCallStarted -> { + stringResource( + id = R.string.status_insight_trace_tool_call_started, + detail.orEmpty(), + ) + } + + StatusInsightPhase.ToolCallCompleted -> { + stringResource( + id = R.string.status_insight_trace_tool_call_completed, + detail.orEmpty(), + ) + } + + StatusInsightPhase.ToolValidationFailed -> { + stringResource( + id = R.string.status_insight_trace_tool_validation_failed, + detail.orEmpty(), + ) + } + + StatusInsightPhase.ToolCallFailed -> { + stringResource( + id = R.string.status_insight_trace_tool_call_failed, + detail.orEmpty(), + ) + } + + StatusInsightPhase.AgentCompleted -> { + stringResource(id = R.string.status_insight_trace_agent_completed) + } + + StatusInsightPhase.AgentFailed -> { + stringResource(id = R.string.status_insight_trace_agent_failed) + } + + StatusInsightPhase.AgentClosing -> { + stringResource(id = R.string.status_insight_trace_agent_closing) + } + } + +@Composable +private fun StatusInsightTraceKey.label(): String = + when (this) { + StatusInsightTraceKey.LoadStatusContextStarted -> { + stringResource(id = R.string.status_insight_trace_tool_load_status_context_started) + } + + StatusInsightTraceKey.LoadStatusContextCompleted -> { + stringResource(id = R.string.status_insight_trace_tool_load_status_context_completed) + } + + StatusInsightTraceKey.LoadStatusContextValidationFailed -> { + stringResource(id = R.string.status_insight_trace_tool_load_status_context_validation_failed) + } + + StatusInsightTraceKey.LoadStatusContextFailed -> { + stringResource(id = R.string.status_insight_trace_tool_load_status_context_failed) + } + + StatusInsightTraceKey.SearchStatusStarted -> { + stringResource(id = R.string.status_insight_trace_tool_search_status_started) + } + + StatusInsightTraceKey.SearchStatusCompleted -> { + stringResource(id = R.string.status_insight_trace_tool_search_status_completed) + } + + StatusInsightTraceKey.SearchStatusValidationFailed -> { + stringResource(id = R.string.status_insight_trace_tool_search_status_validation_failed) + } + + StatusInsightTraceKey.SearchStatusFailed -> { + stringResource(id = R.string.status_insight_trace_tool_search_status_failed) + } + } + +@Composable +private fun StatusInsightCurrentTrace(trace: String) { + val transition = rememberInfiniteTransition() + val shimmerOffset by transition.animateFloat( + initialValue = -240f, + targetValue = 480f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + val color = MaterialTheme.colorScheme.onSurfaceVariant + val shimmerBrush = + Brush.linearGradient( + colors = + listOf( + color.copy(alpha = 0.35f), + color, + color.copy(alpha = 0.35f), + ), + start = Offset(shimmerOffset, 0f), + end = Offset(shimmerOffset + 220f, 0f), + ) + + 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), + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 948210e973..fb4b456028 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,6 +46,43 @@ You have unsaved changes. Save this as a draft before closing? Save Discard + Post insight + Analyzing this post… + Unable to analyze this post. + Loading post context… + Post context loaded + Preparing images… + Images are unsupported; retrying with text… + Starting analysis… + Planning analysis… + Analysis plan completed + Entering analysis stage… + Analysis stage completed + Analysis stage failed + Asking %1$s… + Reading model response… + Streaming from %1$s… + Receiving response… + Response stream completed + Response stream failed + Running analysis step… + Analysis step completed + Analysis step failed + %1$s + %1$s + %1$s + %1$s + Loading post context… + Post context loaded + Post context input was rejected + Failed to load post context + Searching… + Search completed + Search input was rejected + Search failed + Finishing analysis… + Analysis failed + Closing analysis… Drafts No drafts yet Edit @@ -127,6 +164,8 @@ Tap to edit Enable AI translation Use AI instead of Google Translate. Translations may take longer. + 启用 AI agent + 开启后,在已配置 OpenAI 兼容模型时,会在应用内相关位置显示 AI agent 功能入口。 Enable AI summaries Summarize long posts with AI. Available for posts longer than 500 characters. Enable auto-translate diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt index b7e5bf85a8..efb02c9793 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/CommonStatusComponent.kt @@ -55,6 +55,7 @@ import compose.icons.fontawesomeicons.solid.Language import compose.icons.fontawesomeicons.solid.Lock import compose.icons.fontawesomeicons.solid.LockOpen import compose.icons.fontawesomeicons.solid.Reply +import compose.icons.fontawesomeicons.solid.Robot import compose.icons.fontawesomeicons.solid.TriangleExclamation import compose.icons.fontawesomeicons.solid.Tv import dev.dimension.flare.compose.ui.Res @@ -121,6 +122,7 @@ import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuDivider import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuItem import dev.dimension.flare.ui.component.platform.PlatformDropdownMenuScope import dev.dimension.flare.ui.component.platform.PlatformFilledTonalButton +import dev.dimension.flare.ui.component.platform.PlatformIconButton import dev.dimension.flare.ui.component.platform.PlatformRadioButton import dev.dimension.flare.ui.component.platform.PlatformText import dev.dimension.flare.ui.component.platform.PlatformTextButton @@ -236,6 +238,32 @@ public fun CommonStatusComponent( color = PlatformTheme.colorScheme.caption, ) } + if (appearanceSettings.aiConfig.agent) { + PlatformIconButton( + onClick = { + uriHandler.openUri( + DeeplinkRoute + .Status + .Insight( + accountType = item.accountType, + statusKey = item.statusKey, + ).toUri(), + ) + }, + modifier = + Modifier + .size(24.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + modifier = + Modifier + .size(PlatformTheme.typography.caption.fontSize.value.dp), + tint = PlatformTheme.colorScheme.caption, + ) + } + } } } if (showAsFullWidth) { diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 5eaf2baea0..5418cf0fdb 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(projects.feature.login) implementation(projects.feature.subscription) implementation(projects.feature.tab) + implementation(projects.feature.agent) implementation(projects.composeUi) implementation(compose("org.jetbrains.compose.runtime:runtime")) diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 9b6a75609f..445ddbdb82 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -103,6 +103,43 @@ Share screenshot Share via FxEmbed Share via Fixvx + Post insight + Analyzing this post… + Unable to analyze this post. + Loading post context… + Post context loaded + Preparing images… + Images are unsupported; retrying with text… + Starting analysis… + Planning analysis… + Analysis plan completed + Entering analysis stage… + Analysis stage completed + Analysis stage failed + Asking %1$s… + Reading model response… + Streaming from %1$s… + Receiving response… + Response stream completed + Response stream failed + Running analysis step… + Analysis step completed + Analysis step failed + %1$s + %1$s + %1$s + %1$s + Loading post context… + Post context loaded + Post context input was rejected + Failed to load post context + Searching… + Search completed + Search input was rejected + Search failed + Finishing analysis… + Analysis failed + Closing analysis… Add list Edit list @@ -221,6 +258,8 @@ Not set Enable AI translation Use AI instead of Google Translate. Translations may take longer. + 启用 AI agent + 开启后,在已配置 OpenAI 兼容模型时,会在应用内相关位置显示 AI agent 功能入口。 Enable auto-translate Translate newly loaded timeline and profile content in the background and cache the results. This can use a large number of tokens. Enable AI summaries diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index d51b0b1c49..491038784f 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -130,6 +130,11 @@ internal sealed interface Route : NavKey { val fixvxShareUrl: String? = null, ) : FloatingRoute + data class StatusInsight( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : FloatingRoute + data class Search( val accountType: AccountType, val keyword: String, @@ -434,6 +439,13 @@ internal sealed interface Route : NavKey { ) } + is DeeplinkRoute.Status.Insight -> { + StatusInsight( + accountType = deeplinkRoute.accountType, + statusKey = deeplinkRoute.statusKey, + ) + } + is DeeplinkRoute.Status.MastodonReport -> { MastodonReport( accountType = deeplinkRoute.accountType, 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 8566f8f989..9b708cb4b7 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 @@ -96,6 +96,7 @@ import dev.dimension.flare.ui.screen.status.action.BlueskyReportStatusDialog import dev.dimension.flare.ui.screen.status.action.DeleteStatusConfirmDialog import dev.dimension.flare.ui.screen.status.action.MastodonReportDialog import dev.dimension.flare.ui.screen.status.action.MisskeyReportDialog +import dev.dimension.flare.ui.screen.status.action.StatusInsightDialog import dev.dimension.flare.ui.screen.status.action.StatusShareSheet import dev.dimension.flare.ui.screen.xqt.TwitterArticleScreen import io.github.composefluent.FluentTheme @@ -326,6 +327,16 @@ internal fun Router( ) } + entry( + metadata = dialog(), + ) { args -> + StatusInsightDialog( + accountType = args.accountType, + statusKey = args.statusKey, + onBack = onBack, + ) + } + entry( metadata = dialog(), ) { args -> diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 4e56aa4f7d..b3cf4c90b9 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -90,7 +90,9 @@ import dev.dimension.flare.settings_accounts_remove_confirm import dev.dimension.flare.settings_accounts_title import dev.dimension.flare.settings_ai_config_api_key import dev.dimension.flare.settings_ai_config_api_key_hint +import dev.dimension.flare.settings_ai_config_agent_description import dev.dimension.flare.settings_ai_config_description +import dev.dimension.flare.settings_ai_config_enable_agent import dev.dimension.flare.settings_ai_config_enable_pre_translation import dev.dimension.flare.settings_ai_config_enable_tldr import dev.dimension.flare.settings_ai_config_extra_body @@ -1688,6 +1690,24 @@ internal fun SettingsScreen( ExpanderItemSeparator() } } + ExpanderItem( + heading = { + Text(stringResource(Res.string.settings_ai_config_enable_agent)) + }, + caption = { + Text(stringResource(Res.string.settings_ai_config_agent_description)) + }, + trailing = { + Switcher( + checked = state.aiConfigState.aiAgent, + { + state.aiConfigState.setAIAgent(it) + }, + textBefore = true, + ) + }, + ) + ExpanderItemSeparator() ExpanderItem( heading = { Text(stringResource(Res.string.settings_ai_config_enable_tldr)) 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 new file mode 100644 index 0000000000..feb7ba869d --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/status/action/StatusInsightDialog.kt @@ -0,0 +1,416 @@ +package dev.dimension.flare.ui.screen.status.action + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Robot +import dev.dimension.flare.Res +import dev.dimension.flare.data.model.PostActionStyle +import dev.dimension.flare.feature.agent.presenter.status.StatusInsightPresenter +import dev.dimension.flare.feature.agent.status.StatusInsightEvent +import dev.dimension.flare.feature.agent.status.StatusInsightPhase +import dev.dimension.flare.feature.agent.status.StatusInsightTraceKey +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ok +import dev.dimension.flare.status_insight_analyzing +import dev.dimension.flare.status_insight_error +import dev.dimension.flare.status_insight_title +import dev.dimension.flare.status_insight_trace_agent_closing +import dev.dimension.flare.status_insight_trace_agent_completed +import dev.dimension.flare.status_insight_trace_agent_failed +import dev.dimension.flare.status_insight_trace_agent_started +import dev.dimension.flare.status_insight_trace_asking_model +import dev.dimension.flare.status_insight_trace_images_unsupported_fallback +import dev.dimension.flare.status_insight_trace_loading_post_context +import dev.dimension.flare.status_insight_trace_model_response_received +import dev.dimension.flare.status_insight_trace_post_context_loaded +import dev.dimension.flare.status_insight_trace_preparing_images +import dev.dimension.flare.status_insight_trace_running_step +import dev.dimension.flare.status_insight_trace_step_completed +import dev.dimension.flare.status_insight_trace_step_failed +import dev.dimension.flare.status_insight_trace_strategy_completed +import dev.dimension.flare.status_insight_trace_strategy_started +import dev.dimension.flare.status_insight_trace_streaming_completed +import dev.dimension.flare.status_insight_trace_streaming_failed +import dev.dimension.flare.status_insight_trace_streaming_response +import dev.dimension.flare.status_insight_trace_streaming_started +import dev.dimension.flare.status_insight_trace_subgraph_completed +import dev.dimension.flare.status_insight_trace_subgraph_failed +import dev.dimension.flare.status_insight_trace_subgraph_started +import dev.dimension.flare.status_insight_trace_tool_call_completed +import dev.dimension.flare.status_insight_trace_tool_call_failed +import dev.dimension.flare.status_insight_trace_tool_call_started +import dev.dimension.flare.status_insight_trace_tool_load_status_context_completed +import dev.dimension.flare.status_insight_trace_tool_load_status_context_failed +import dev.dimension.flare.status_insight_trace_tool_load_status_context_started +import dev.dimension.flare.status_insight_trace_tool_load_status_context_validation_failed +import dev.dimension.flare.status_insight_trace_tool_search_status_completed +import dev.dimension.flare.status_insight_trace_tool_search_status_failed +import dev.dimension.flare.status_insight_trace_tool_search_status_started +import dev.dimension.flare.status_insight_trace_tool_search_status_validation_failed +import dev.dimension.flare.status_insight_trace_tool_validation_failed +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.LocalTimelineAppearance +import dev.dimension.flare.ui.component.status.CommonStatusComponent +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.model.onLoading +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +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.FluentDialog +import io.github.composefluent.component.Text +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun StatusInsightDialog( + accountType: AccountType, + statusKey: MicroBlogKey, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val state by producePresenter("status_insight_${accountType}_$statusKey") { + remember(accountType, statusKey) { + StatusInsightPresenter( + accountType = accountType, + statusKey = statusKey, + ) + }.invoke() + } + + FluentDialog( + visible = true, + ) { + Column( + modifier = + modifier + .onKeyEvent { + if (it.key == Key.Escape) { + onBack() + true + } else { + false + } + }.width(560.dp) + .heightIn(max = 720.dp) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + ) + Text( + text = stringResource(Res.string.status_insight_title), + style = FluentTheme.typography.title, + ) + } + + Column( + modifier = + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + state.post?.let { post -> + StatusInsightPostPreview(post = post) + } + + state.insight + .onLoading { + StatusInsightCurrentTrace( + trace = state.currentTrace?.label() ?: stringResource(Res.string.status_insight_analyzing), + ) + }.onError { throwable -> + Text( + text = throwable.message ?: stringResource(Res.string.status_insight_error), + color = Color.Red, + ) + }.onSuccess { text -> + Text(text = text) + } + } + + AccentButton( + onClick = onBack, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(Res.string.ok)) + } + } + } +} + +@Composable +private fun StatusInsightPostPreview(post: UiTimelineV2.Post) { + Column( + modifier = + Modifier + .fillMaxWidth() + .border( + border = BorderStroke(1.dp, FluentTheme.colors.stroke.card.default), + shape = RoundedCornerShape(8.dp), + ), + ) { + CompositionLocalProvider( + LocalTimelineAppearance provides + LocalTimelineAppearance.current.copy( + showMedia = false, + expandMediaSize = false, + showLinkPreview = false, + postActionStyle = PostActionStyle.Hidden, + ), + ) { + CommonStatusComponent( + item = post, + modifier = + Modifier + .padding( + horizontal = screenHorizontalPadding, + vertical = 8.dp, + ).fillMaxWidth(), + isQuote = true, + maxLines = 3, + ) + } + } +} + +@Composable +private fun StatusInsightEvent.Trace.label(): String = + key?.label() + ?: when (phase) { + StatusInsightPhase.LoadingPostContext -> { + stringResource(Res.string.status_insight_trace_loading_post_context) + } + + StatusInsightPhase.PostContextLoaded -> { + stringResource(Res.string.status_insight_trace_post_context_loaded) + } + + StatusInsightPhase.PreparingImages -> { + stringResource(Res.string.status_insight_trace_preparing_images) + } + + StatusInsightPhase.ImagesUnsupportedFallback -> { + stringResource(Res.string.status_insight_trace_images_unsupported_fallback) + } + + StatusInsightPhase.AgentStarted -> { + stringResource(Res.string.status_insight_trace_agent_started) + } + + StatusInsightPhase.StrategyStarted -> { + stringResource(Res.string.status_insight_trace_strategy_started) + } + + StatusInsightPhase.StrategyCompleted -> { + stringResource(Res.string.status_insight_trace_strategy_completed) + } + + StatusInsightPhase.SubgraphStarted -> { + stringResource(Res.string.status_insight_trace_subgraph_started) + } + + StatusInsightPhase.SubgraphCompleted -> { + stringResource(Res.string.status_insight_trace_subgraph_completed) + } + + StatusInsightPhase.SubgraphFailed -> { + stringResource(Res.string.status_insight_trace_subgraph_failed) + } + + StatusInsightPhase.AskingModel -> { + stringResource( + Res.string.status_insight_trace_asking_model, + detail.orEmpty(), + ) + } + + StatusInsightPhase.ModelResponseReceived -> { + stringResource(Res.string.status_insight_trace_model_response_received) + } + + StatusInsightPhase.StreamingStarted -> { + stringResource( + Res.string.status_insight_trace_streaming_started, + detail.orEmpty(), + ) + } + + StatusInsightPhase.StreamingResponse -> { + stringResource(Res.string.status_insight_trace_streaming_response) + } + + StatusInsightPhase.StreamingCompleted -> { + stringResource(Res.string.status_insight_trace_streaming_completed) + } + + StatusInsightPhase.StreamingFailed -> { + stringResource(Res.string.status_insight_trace_streaming_failed) + } + + StatusInsightPhase.RunningStep -> { + stringResource(Res.string.status_insight_trace_running_step) + } + + StatusInsightPhase.StepCompleted -> { + stringResource(Res.string.status_insight_trace_step_completed) + } + + StatusInsightPhase.StepFailed -> { + stringResource(Res.string.status_insight_trace_step_failed) + } + + StatusInsightPhase.ToolCallStarted -> { + stringResource( + Res.string.status_insight_trace_tool_call_started, + detail.orEmpty(), + ) + } + + StatusInsightPhase.ToolCallCompleted -> { + stringResource( + Res.string.status_insight_trace_tool_call_completed, + detail.orEmpty(), + ) + } + + StatusInsightPhase.ToolValidationFailed -> { + stringResource( + Res.string.status_insight_trace_tool_validation_failed, + detail.orEmpty(), + ) + } + + StatusInsightPhase.ToolCallFailed -> { + stringResource( + Res.string.status_insight_trace_tool_call_failed, + detail.orEmpty(), + ) + } + + StatusInsightPhase.AgentCompleted -> { + stringResource(Res.string.status_insight_trace_agent_completed) + } + + StatusInsightPhase.AgentFailed -> { + stringResource(Res.string.status_insight_trace_agent_failed) + } + + StatusInsightPhase.AgentClosing -> { + stringResource(Res.string.status_insight_trace_agent_closing) + } + } + +@Composable +private fun StatusInsightTraceKey.label(): String = + when (this) { + StatusInsightTraceKey.LoadStatusContextStarted -> { + stringResource(Res.string.status_insight_trace_tool_load_status_context_started) + } + + StatusInsightTraceKey.LoadStatusContextCompleted -> { + stringResource(Res.string.status_insight_trace_tool_load_status_context_completed) + } + + StatusInsightTraceKey.LoadStatusContextValidationFailed -> { + stringResource(Res.string.status_insight_trace_tool_load_status_context_validation_failed) + } + + StatusInsightTraceKey.LoadStatusContextFailed -> { + stringResource(Res.string.status_insight_trace_tool_load_status_context_failed) + } + + StatusInsightTraceKey.SearchStatusStarted -> { + stringResource(Res.string.status_insight_trace_tool_search_status_started) + } + + StatusInsightTraceKey.SearchStatusCompleted -> { + stringResource(Res.string.status_insight_trace_tool_search_status_completed) + } + + StatusInsightTraceKey.SearchStatusValidationFailed -> { + stringResource(Res.string.status_insight_trace_tool_search_status_validation_failed) + } + + StatusInsightTraceKey.SearchStatusFailed -> { + stringResource(Res.string.status_insight_trace_tool_search_status_failed) + } + } + +@Composable +private fun StatusInsightCurrentTrace(trace: String) { + val transition = rememberInfiniteTransition() + val shimmerOffset by transition.animateFloat( + initialValue = -240f, + targetValue = 480f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + val color = LocalContentColor.current + val shimmerBrush = + Brush.linearGradient( + colors = + listOf( + color.copy(alpha = 0.35f), + color, + color.copy(alpha = 0.35f), + ), + start = Offset(shimmerOffset, 0f), + end = Offset(shimmerOffset + 180f, 0f), + ) + BasicText( + text = trace, + style = + LocalTextStyle.current.merge( + TextStyle(brush = shimmerBrush), + ), + ) +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt index 5ae6fe0911..2f3a6de3aa 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/theme/FlareTheme.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.toSize import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.WindowScope import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.data.datastore.model.AppSettings import dev.dimension.flare.data.model.Theme import dev.dimension.flare.data.model.VideoAutoplay import dev.dimension.flare.data.model.appearance.GlobalAppearance @@ -383,6 +384,7 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { } } state.appSettings.onSuccess { appSettings -> + val openAIConfig = appSettings.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI LaunchedEffect(appSettings.language) { if (appSettings.language.isNotEmpty()) { val locale = Locale.forLanguageTag(appSettings.language) @@ -393,12 +395,13 @@ internal fun ProvideThemeSettings(content: @Composable () -> Unit) { CompositionLocalProvider( LocalGlobalAppearance provides globalAppearance, LocalTimelineAppearance provides - remember(globalAppearance, timelineAppearance, appSettings.translateConfig, appSettings.aiConfig.tldr) { + remember(globalAppearance, timelineAppearance, appSettings.translateConfig, appSettings.aiConfig) { timelineAppearance.copy( aiConfig = TimelineAppearance.AiConfig( translation = true, tldr = appSettings.aiConfig.tldr, + agent = appSettings.aiConfig.agent && !openAIConfig?.model.isNullOrBlank(), ), ) }, diff --git a/feature/agent/build.gradle.kts b/feature/agent/build.gradle.kts new file mode 100644 index 0000000000..3ca4bd61a9 --- /dev/null +++ b/feature/agent/build.gradle.kts @@ -0,0 +1,45 @@ +import dev.dimension.flare.buildlogic.FlarePlatform +import dev.dimension.flare.buildlogic.flare + +plugins { + id("dev.dimension.flare.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.koin.compiler) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flare { + namespace = "dev.dimension.flare.feature.agent" + platforms( + FlarePlatform.ANDROID, + FlarePlatform.JVM, + FlarePlatform.IOS, + FlarePlatform.WEB, + ) + } + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.shared) + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.compose.runtime) + implementation(libs.koog.agents) + implementation(libs.koog.http.client.ktor) + implementation(libs.bundles.kotlinx) + implementation(dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.koin.annotations) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/di/AgentKoinModule.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/di/AgentKoinModule.kt new file mode 100644 index 0000000000..61e4c7b935 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/di/AgentKoinModule.kt @@ -0,0 +1,12 @@ +package dev.dimension.flare.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import kotlin.native.HiddenFromObjC + +@HiddenFromObjC +@Module +@Configuration +@ComponentScan("dev.dimension.flare.feature.agent") +internal class AgentKoinModule diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/status/StatusInsightPresenter.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/status/StatusInsightPresenter.kt new file mode 100644 index 0000000000..49da425248 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/status/StatusInsightPresenter.kt @@ -0,0 +1,112 @@ +package dev.dimension.flare.feature.agent.presenter.status + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.produceState +import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource +import dev.dimension.flare.data.repository.AccountService +import dev.dimension.flare.feature.agent.status.StatusInsightAgentUseCase +import dev.dimension.flare.feature.agent.status.StatusInsightEvent +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +public class StatusInsightPresenter( + private val accountType: AccountType, + private val statusKey: MicroBlogKey, +) : PresenterBase(), + KoinComponent { + private val accountService: AccountService by inject() + private val statusInsightAgentUseCase: StatusInsightAgentUseCase by inject() + + @Immutable + public interface State { + public val insight: UiState + public val post: UiTimelineV2.Post? + public val currentTrace: StatusInsightEvent.Trace? + } + + @Composable + override fun body(): State { + val state = + produceState( + initialValue = StateImpl(), + accountType, + statusKey, + ) { + accountService + .accountServiceFlow(accountType) + .combine(accountService.allAccountServicesFlow()) { service, searchDataSources -> + service to searchDataSources + }.collectLatest { (service, searchDataSources) -> + var post: UiTimelineV2.Post? = null + var currentTrace: StatusInsightEvent.Trace? = null + + fun update( + insight: UiState, + postValue: UiTimelineV2.Post? = post, + currentTraceValue: StatusInsightEvent.Trace? = currentTrace, + ) { + value = + StateImpl( + insight = insight, + post = postValue, + currentTrace = currentTraceValue, + ) + } + + update(UiState.Loading()) + + val postDataSource = + service as? PostDataSource + ?: run { + update(UiState.Error(IllegalStateException("Current account does not support post data source"))) + return@collectLatest + } + + try { + statusInsightAgentUseCase( + postDataSource = postDataSource, + statusKey = statusKey, + searchDataSources = searchDataSources, + ).collect { event -> + when (event) { + is StatusInsightEvent.PostLoaded -> { + post = event.post + update(UiState.Loading()) + } + + is StatusInsightEvent.Trace -> { + currentTrace = event + update(UiState.Loading()) + } + + is StatusInsightEvent.Result -> { + currentTrace = null + update(UiState.Success(event.text)) + } + } + } + } catch (throwable: Throwable) { + currentTrace = null + update(UiState.Error(throwable)) + } + } + } + + return state.value + } + + @Immutable + private data class StateImpl( + override val insight: UiState = UiState.Loading(), + override val post: UiTimelineV2.Post? = null, + override val currentTrace: StatusInsightEvent.Trace? = null, + ) : State +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/AgentAvailability.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/AgentAvailability.kt new file mode 100644 index 0000000000..b373c046ed --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/AgentAvailability.kt @@ -0,0 +1,16 @@ +package dev.dimension.flare.feature.agent.runtime + +public sealed interface AgentAvailability { + public data object Available : AgentAvailability + + public data class Unavailable( + val reason: Reason, + ) : AgentAvailability + + public enum class Reason { + OnDeviceAiUnsupported, + MissingOpenAIEndpoint, + MissingOpenAIApiKey, + MissingOpenAIModel, + } +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/AiConfigKoogBridge.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/AiConfigKoogBridge.kt new file mode 100644 index 0000000000..e85efc55b2 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/AiConfigKoogBridge.kt @@ -0,0 +1,89 @@ +package dev.dimension.flare.feature.agent.runtime + +import ai.koog.http.client.KoogHttpClient +import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings +import ai.koog.prompt.executor.clients.openai.OpenAILLMClient +import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor +import ai.koog.prompt.llm.LLMCapability +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.llm.LLModel +import dev.dimension.flare.data.datastore.model.AppSettings + +internal class AiConfigKoogBridge { + fun availability(aiConfig: AppSettings.AiConfig): AgentAvailability = + when (val type = aiConfig.type) { + AppSettings.AiConfig.Type.OnDevice -> { + AgentAvailability.Unavailable(AgentAvailability.Reason.OnDeviceAiUnsupported) + } + + is AppSettings.AiConfig.Type.OpenAI -> { + when { + type.serverUrl.isBlank() -> { + AgentAvailability.Unavailable(AgentAvailability.Reason.MissingOpenAIEndpoint) + } + + type.apiKey.isBlank() -> { + AgentAvailability.Unavailable(AgentAvailability.Reason.MissingOpenAIApiKey) + } + + type.model.isBlank() -> { + AgentAvailability.Unavailable(AgentAvailability.Reason.MissingOpenAIModel) + } + + else -> { + AgentAvailability.Available + } + } + } + } + + fun createRuntime( + aiConfig: AppSettings.AiConfig, + httpClientFactory: KoogHttpClient.Factory, + ): FlareAgentRuntime? { + if (availability(aiConfig) != AgentAvailability.Available) { + return null + } + + val openAIConfig = aiConfig.type as? AppSettings.AiConfig.Type.OpenAI ?: return null + val client = + OpenAILLMClient( + apiKey = openAIConfig.apiKey, + settings = + OpenAIClientSettings( + baseUrl = openAIConfig.serverUrl.trimEnd('/'), + chatCompletionsPath = "chat/completions", + responsesAPIPath = "responses", + embeddingsPath = "embeddings", + moderationsPath = "moderations", + modelsPath = "models", + ), + httpClientFactory = httpClientFactory, + ) + val model = + LLModel( + provider = LLMProvider.OpenAI, + id = openAIConfig.model, + capabilities = + listOf( + LLMCapability.Completion, + LLMCapability.OpenAIEndpoint.Completions, + LLMCapability.Temperature, + LLMCapability.Tools, + LLMCapability.ToolChoice, + LLMCapability.Vision.Image, + ), + contextLength = DEFAULT_CONTEXT_LENGTH, + ) + + return FlareAgentRuntime( + promptExecutor = MultiLLMPromptExecutor(client), + model = model, + aiConfig = aiConfig, + ) + } + + private companion object { + const val DEFAULT_CONTEXT_LENGTH = 128_000L + } +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/FlareAgentRuntime.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/FlareAgentRuntime.kt new file mode 100644 index 0000000000..217aa970ac --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/FlareAgentRuntime.kt @@ -0,0 +1,11 @@ +package dev.dimension.flare.feature.agent.runtime + +import ai.koog.prompt.executor.model.PromptExecutor +import ai.koog.prompt.llm.LLModel +import dev.dimension.flare.data.datastore.model.AppSettings + +internal class FlareAgentRuntime( + val promptExecutor: PromptExecutor, + val model: LLModel, + val aiConfig: AppSettings.AiConfig, +) diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/FlareAgentRuntimeProvider.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/FlareAgentRuntimeProvider.kt new file mode 100644 index 0000000000..85e2c77030 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/runtime/FlareAgentRuntimeProvider.kt @@ -0,0 +1,47 @@ +package dev.dimension.flare.feature.agent.runtime + +import ai.koog.http.client.KoogHttpClient +import ai.koog.http.client.ktor.KtorKoogHttpClient +import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.data.repository.SettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class FlareAgentRuntimeProvider( + private val settingsRepository: SettingsRepository, +) { + private val bridge = AiConfigKoogBridge() + private val httpClientFactory: KoogHttpClient.Factory by lazy { + KtorKoogHttpClient.Factory( + baseClient = ktorClient(config = {}), + ) + } + + val availability: Flow = + settingsRepository + .appSettings + .map { settings -> bridge.availability(settings.aiConfig) } + .distinctUntilChanged() + + suspend fun availability(): AgentAvailability = + bridge.availability( + settingsRepository + .appSettings + .first() + .aiConfig, + ) + + suspend fun createRuntime(): FlareAgentRuntime? = + bridge.createRuntime( + aiConfig = + settingsRepository + .appSettings + .first() + .aiConfig, + httpClientFactory = httpClientFactory, + ) +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt new file mode 100644 index 0000000000..908726f2f3 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt @@ -0,0 +1,707 @@ +package dev.dimension.flare.feature.agent.status + +import ai.koog.agents.core.agent.AIAgent +import ai.koog.agents.core.dsl.builder.node +import ai.koog.agents.core.dsl.builder.strategy +import ai.koog.agents.core.dsl.extension.nodeExecuteTools +import ai.koog.agents.core.dsl.extension.nodeLLMSendToolResults +import ai.koog.agents.core.dsl.extension.onTextMessage +import ai.koog.agents.core.dsl.extension.onToolCalls +import ai.koog.agents.core.tools.ToolRegistry +import ai.koog.agents.core.utils.ConfigureAction +import ai.koog.agents.features.eventHandler.feature.EventHandler +import ai.koog.prompt.Prompt +import ai.koog.prompt.message.AttachmentContent +import ai.koog.prompt.message.AttachmentSource +import ai.koog.prompt.message.Message +import androidx.paging.LoadState +import dev.dimension.flare.common.CacheState +import dev.dimension.flare.common.Locale +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource +import dev.dimension.flare.data.datasource.microblog.handler.PostTranslationDisplay +import dev.dimension.flare.data.repository.AccountMicroblogDataSource +import dev.dimension.flare.feature.agent.runtime.AgentAvailability +import dev.dimension.flare.feature.agent.runtime.FlareAgentRuntime +import dev.dimension.flare.feature.agent.runtime.FlareAgentRuntimeProvider +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class StatusInsightAgentUseCase( + private val runtimeProvider: FlareAgentRuntimeProvider, +) { + operator fun invoke( + postDataSource: PostDataSource, + statusKey: MicroBlogKey, + searchDataSources: List, + ): Flow = + channelFlow { + run(postDataSource, statusKey, searchDataSources) + } + + private suspend fun SendChannel.run( + postDataSource: PostDataSource, + statusKey: MicroBlogKey, + searchDataSources: List, + ) { + val runtime = + runtimeProvider.createRuntime() + ?: throw StatusInsightAgentUnavailableException(runtimeProvider.availability()) + send(StatusInsightEvent.Trace(StatusInsightPhase.LoadingPostContext)) + val post = postDataSource.loadPost(statusKey) + send(StatusInsightEvent.PostLoaded(post)) + send(StatusInsightEvent.Trace(StatusInsightPhase.PostContextLoaded)) + val imageAttachments = post.aiImageAttachments() + if (imageAttachments.isNotEmpty()) { + send(StatusInsightEvent.Trace(StatusInsightPhase.PreparingImages)) + } + val searchTargets = postDataSource.statusInsightSearchTargets(searchDataSources, post.platformType) + val toolRegistry = postDataSource.statusInsightToolRegistry(statusKey, searchTargets) + val systemPrompt = STATUS_INSIGHT_SYSTEM_PROMPT.withSearchPlatformGuidance(searchTargets) + val result = + try { + runtime.runAgent( + prompt = post.toInsightPrompt(Locale.language, includeImages = true), + toolRegistry = toolRegistry, + systemPrompt = systemPrompt, + ) { event -> + send(event) + } + } catch (throwable: Throwable) { + if (throwable is CancellationException) { + throw throwable + } + if (imageAttachments.isEmpty() || !throwable.isImageContentUnsupported()) { + throw throwable + } + send(StatusInsightEvent.Trace(StatusInsightPhase.ImagesUnsupportedFallback)) + runtime.runAgent( + prompt = post.toInsightPrompt(Locale.language, includeImages = false), + toolRegistry = toolRegistry, + systemPrompt = systemPrompt, + ) { event -> + send(event) + } + } + + send(StatusInsightEvent.Result(result.cleanPlainText())) + } + + private suspend fun FlareAgentRuntime.runAgent( + prompt: Prompt, + toolRegistry: ToolRegistry, + systemPrompt: String, + onEvent: suspend (StatusInsightEvent.Trace) -> Unit, + ): String { + val agent = createAgent(toolRegistry, systemPrompt, onEvent) + return try { + agent.run(prompt) + } finally { + agent.close() + } + } + + private fun FlareAgentRuntime.createAgent( + toolRegistry: ToolRegistry, + systemPrompt: String, + onEvent: suspend (StatusInsightEvent.Trace) -> Unit, + ): AIAgent = + AIAgent + .builder() + .promptExecutor(promptExecutor) + .llmModel(model) + .toolRegistry(toolRegistry) + .id("flare-status-insight") + .systemPrompt(systemPrompt) + .temperature(0.2) + .maxIterations(MAX_AGENT_ITERATIONS) + .graphStrategy( + strategy("status_insight_multimodal") { + val nodeAnalyze by node("analyze_post") { prompt -> + llm.writeSession { + appendPrompt { + messages(prompt.messages) + } + requestLLM() + } + } + val nodeExecuteTools by nodeExecuteTools("execute_status_insight_tools") + val nodeSendToolResults by nodeLLMSendToolResults("send_status_insight_tool_results") + + edge(nodeStart forwardTo nodeAnalyze) + edge(nodeAnalyze forwardTo nodeExecuteTools onToolCalls { true }) + edge(nodeAnalyze forwardTo nodeFinish onTextMessage { true }) + edge(nodeExecuteTools forwardTo nodeSendToolResults) + edge(nodeSendToolResults forwardTo nodeExecuteTools onToolCalls { true }) + edge(nodeSendToolResults forwardTo nodeFinish onTextMessage { true }) + }, + ).install( + EventHandler, + ConfigureAction { config -> + config.onAgentStarting { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AgentStarted)) + } + config.onStrategyStarting { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StrategyStarted)) + } + config.onStrategyCompleted { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StrategyCompleted)) + } + config.onSubgraphExecutionStarting { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.SubgraphStarted)) + } + config.onSubgraphExecutionCompleted { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.SubgraphCompleted)) + } + config.onSubgraphExecutionFailed { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.SubgraphFailed)) + } + config.onLLMCallStarting { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AskingModel, it.model.id)) + } + config.onLLMCallCompleted { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.ModelResponseReceived)) + } + config.onLLMStreamingStarting { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StreamingStarted, it.model.id)) + } + config.onLLMStreamingFrameReceived { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StreamingResponse)) + } + config.onLLMStreamingCompleted { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StreamingCompleted)) + } + config.onLLMStreamingFailed { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StreamingFailed)) + } + config.onNodeExecutionStarting { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.RunningStep)) + } + config.onNodeExecutionCompleted { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StepCompleted)) + } + config.onNodeExecutionFailed { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StepFailed)) + } + config.onToolCallStarting { + onEvent( + StatusInsightEvent.Trace( + phase = StatusInsightPhase.ToolCallStarted, + detail = it.toolName, + key = it.toolName.toToolTraceKey(StatusInsightPhase.ToolCallStarted), + ), + ) + } + config.onToolCallCompleted { + onEvent( + StatusInsightEvent.Trace( + phase = StatusInsightPhase.ToolCallCompleted, + detail = it.toolName, + key = it.toolName.toToolTraceKey(StatusInsightPhase.ToolCallCompleted), + ), + ) + } + config.onToolValidationFailed { + onEvent( + StatusInsightEvent.Trace( + phase = StatusInsightPhase.ToolValidationFailed, + detail = it.toolName, + key = it.toolName.toToolTraceKey(StatusInsightPhase.ToolValidationFailed), + ), + ) + } + config.onToolCallFailed { + onEvent( + StatusInsightEvent.Trace( + phase = StatusInsightPhase.ToolCallFailed, + detail = it.toolName, + key = it.toolName.toToolTraceKey(StatusInsightPhase.ToolCallFailed), + ), + ) + } + config.onAgentCompleted { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AgentCompleted)) + } + config.onAgentExecutionFailed { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AgentFailed)) + } + config.onAgentClosing { + onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AgentClosing)) + } + }, + ).build() + + private suspend fun PostDataSource.loadPost(statusKey: MicroBlogKey): UiTimelineV2.Post = + coroutineScope { + val cacheable = + postHandler.post( + postKey = statusKey, + translationDisplay = PostTranslationDisplay.Original, + ) + val data = + async { + cacheable + .data + .filterIsInstance>() + .map { it.data } + .filterIsInstance() + .first() + } + + cacheable.refresh() + val refreshState = cacheable.refreshState.first { it !is LoadState.Loading } + if (refreshState is LoadState.Error && !data.isCompleted) { + throw refreshState.error + } + + data.await() + } + + private fun PostDataSource.statusInsightSearchTargets( + searchDataSources: List, + currentPlatformType: PlatformType, + ): List { + val microblogDataSource = this as? MicroblogDataSource + return buildList { + addAll(searchDataSources.toStatusSearchTargets()) + if (microblogDataSource != null && none { it.dataSource === microblogDataSource }) { + add(StatusSearchTarget(platformType = currentPlatformType, dataSource = microblogDataSource)) + } + } + } + + private fun PostDataSource.statusInsightToolRegistry( + statusKey: MicroBlogKey, + searchTargets: List, + ): ToolRegistry { + val microblogDataSource = this as? MicroblogDataSource ?: return ToolRegistry.EMPTY + return ToolRegistry { + tool(LoadStatusContextTool(microblogDataSource, statusKey)) + tool(SearchStatusTool(searchTargets)) + } + } + + private fun UiTimelineV2.Post.toInsightPrompt( + targetLanguage: String, + includeImages: Boolean, + ): Prompt = + Prompt.build("status-insight") { + user { + text(toInsightPromptInput(targetLanguage)) + if (includeImages) { + aiImageAttachments().forEachIndexed { index, image -> + text("Image ${index + 1}: ${image.description.orEmpty()}") + image(image.toAttachmentSource()) + } + } else if (aiImageAttachments().isNotEmpty()) { + text( + "Image uploads were not accepted by this AI endpoint. " + + "Use only the image URLs and descriptions from the text context.", + ) + } + } + } + + private fun UiTimelineV2.Post.toInsightPromptInput(targetLanguage: String): String = + buildString { + appendLine("Analyze this social post for the user.") + appendLine("Return plain text only.") + appendLine("Respond in this language: $targetLanguage.") + appendLine() + appendLine("Post:") + appendLine("platform: ${platformType.name}") + appendLine("statusKey: $statusKey") + appendLine("createdAt: ${createdAt.value}") + appendLine("visibility: ${visibility?.name.orEmpty()}") + appendLine("authorName: ${user?.name?.raw.orEmpty()}") + appendLine("authorHandle: ${user?.handle?.raw.orEmpty()}") + appendLine("contentWarning: ${contentWarning?.raw.orEmpty()}") + appendLine("content: ${content.raw}") + appendLine("replyToHandle: ${replyToHandle.orEmpty()}") + appendLine("sourceChannel: ${sourceChannel?.name.orEmpty()}") + appendLine("imagesCount: ${images.size}") + aiImageAttachments().forEachIndexed { index, image -> + appendLine("image${index + 1}Url: ${image.url}") + appendLine("image${index + 1}Description: ${image.description.orEmpty()}") + } + appendLine("hasPoll: ${poll != null}") + appendLine("cardTitle: ${card?.title.orEmpty()}") + appendLine("cardDescription: ${card?.description.orEmpty()}") + appendLine("cardUrl: ${card?.url.orEmpty()}") + appendLine("quotes:") + quote.take(MAX_RELATED_POSTS).forEachIndexed { index, quotedPost -> + appendLine("- #$index ${quotedPost.user?.handle?.raw.orEmpty()}: ${quotedPost.content.raw.take(MAX_RELATED_TEXT_LENGTH)}") + } + appendLine("parents:") + parents.take(MAX_RELATED_POSTS).forEachIndexed { index, parentPost -> + appendLine("- #$index ${parentPost.user?.handle?.raw.orEmpty()}: ${parentPost.content.raw.take(MAX_RELATED_TEXT_LENGTH)}") + } + appendLine("referencesCount: ${references.size}") + appendLine("actions:") + actions.flattenItems().forEach { item -> + appendLine("- ${item.promptLabel()}: ${item.count?.value ?: 0}") + } + appendLine("emojiReactions:") + emojiReactions.forEach { reaction -> + appendLine("- ${reaction.name}: ${reaction.count.value}") + } + } + + private fun UiTimelineV2.Post.aiImageAttachments(): List = + images + .filterIsInstance() + .filter { image -> image.url.isNotBlank() } + .take(MAX_IMAGE_ATTACHMENTS) + + private fun UiMedia.Image.toAttachmentSource(): AttachmentSource.Image { + val format = url.imageFormat() ?: "jpg" + return AttachmentSource.Image( + content = AttachmentContent.URL(url), + format = format, + mimeType = format.toImageMimeType(), + fileName = url.imageFileName(format), + ) + } + + private fun String.imageFormat(): String? = + substringBefore("?") + .substringBefore("#") + .substringAfterLast("/", "") + .substringAfterLast(".", "") + .lowercase() + .takeIf { it in SUPPORTED_IMAGE_FORMATS } + + private fun String.imageFileName(format: String): String = + substringBefore("?") + .substringBefore("#") + .substringAfterLast("/", "") + .takeIf { it.contains(".") } + ?: "post-image.$format" + + private fun String.toImageMimeType(): String = + when (this) { + "jpg", "jpeg" -> { + "image/jpeg" + } + + "png" -> { + "image/png" + } + + "webp" -> { + "image/webp" + } + + "gif" -> { + "image/gif" + } + + else -> { + "image/jpeg" + } + } + + private fun List.flattenItems(): List = + flatMap { action -> + when (action) { + ActionMenu.Divider -> { + emptyList() + } + + is ActionMenu.Group -> { + listOf(action.displayItem) + action.actions.flattenItems() + } + + is ActionMenu.Item -> { + listOf(action) + } + } + } + + private fun ActionMenu.Item.promptLabel(): String = + when (val text = text) { + is ActionMenu.Item.Text.Localized -> { + text.type.name + } + + is ActionMenu.Item.Text.Raw -> { + text.text + } + + null -> { + updateKey.ifBlank { icon?.name.orEmpty() } + } + } + + private fun String.cleanPlainText(): String = + trim() + .removePrefix("```markdown") + .removePrefix("```") + .removeSuffix("```") + .trim() + + private fun Throwable.isImageContentUnsupported(): Boolean { + val message = + generateSequence(this) { it.cause } + .mapNotNull { it.message } + .joinToString("\n") + .lowercase() + return "image_url" in message && + ( + "unknown variant" in message || + "expected `text`" in message || + "expected 'text'" in message || + "invalid_request_error" in message + ) + } + + private fun String.toToolTraceKey(phase: StatusInsightPhase): StatusInsightTraceKey? = + when (phase) { + StatusInsightPhase.ToolCallStarted -> { + toToolCallStartedKey() + } + + StatusInsightPhase.ToolCallCompleted -> { + toToolCallCompletedKey() + } + + StatusInsightPhase.ToolValidationFailed -> { + toToolValidationFailedKey() + } + + StatusInsightPhase.ToolCallFailed -> { + toToolCallFailedKey() + } + + else -> { + null + } + } + + private fun String.toToolCallStartedKey(): StatusInsightTraceKey? = + when (this) { + "load_status_context" -> { + StatusInsightTraceKey.LoadStatusContextStarted + } + + "search_status" -> { + StatusInsightTraceKey.SearchStatusStarted + } + + else -> { + null + } + } + + private fun String.toToolCallCompletedKey(): StatusInsightTraceKey? = + when (this) { + "load_status_context" -> { + StatusInsightTraceKey.LoadStatusContextCompleted + } + + "search_status" -> { + StatusInsightTraceKey.SearchStatusCompleted + } + + else -> { + null + } + } + + private fun String.toToolValidationFailedKey(): StatusInsightTraceKey? = + when (this) { + "load_status_context" -> { + StatusInsightTraceKey.LoadStatusContextValidationFailed + } + + "search_status" -> { + StatusInsightTraceKey.SearchStatusValidationFailed + } + + else -> { + null + } + } + + private fun String.toToolCallFailedKey(): StatusInsightTraceKey? = + when (this) { + "load_status_context" -> { + StatusInsightTraceKey.LoadStatusContextFailed + } + + "search_status" -> { + StatusInsightTraceKey.SearchStatusFailed + } + + else -> { + null + } + } + + private fun String.withSearchPlatformGuidance(searchTargets: List): String { + val platformTypes = + searchTargets + .mapNotNull { it.platformType } + .distinct() + val guidance = + if (platformTypes.isEmpty()) { + """ + + Search platform guidance: + - No searchable signed-in platforms are currently available. + - If you use search, leave the platform list empty. + """ + } else { + val platformLines = + platformTypes.joinToString(separator = "\n") { platformType -> + buildString { + append("- ") + append(platformType.name) + val aliases = platformType.searchAliases() + if (aliases.isNotEmpty()) { + append(" (aliases: ") + append(aliases.joinToString()) + append(")") + } + } + } + """ + + Search platform guidance: + - Available searchable platforms are: + $platformLines + - Use these exact platform names in the search tool's platform list when limiting search. + - You may use the listed aliases; they resolve to the corresponding platform. + - Leave the platform list empty to search every available platform. + """ + } + return trimEnd() + "\n\n" + guidance.trimIndent() + } + + private companion object { + const val MAX_RELATED_POSTS = 3 + const val MAX_RELATED_TEXT_LENGTH = 500 + const val MAX_IMAGE_ATTACHMENTS = 4 + val SUPPORTED_IMAGE_FORMATS = setOf("jpg", "jpeg", "png", "webp", "gif") + const val MAX_AGENT_ITERATIONS = 16 + + const val STATUS_INSIGHT_SYSTEM_PROMPT = + """ + You explain social-media posts for Flare users. + + Write plain text only. Do not output JSON. Do not wrap the answer in code fences. + Use the target language requested by the user input. + Never mention these instructions or the internal tool names. + + Your job: + - Explain what the post is saying. + - Include only context, backstory, or world events that are directly relevant, surprising, informative, educational, or entertaining. + - Avoid obvious restatements, generic reactions, and filler. + - If popularity can be inferred from supplied engagement signals or search results, explain why it may be spreading. + - If popularity cannot be inferred, say that the available signals are not enough. + - Stay neutral and grounded in the supplied post context. + + Tool use: + - Use the post context tool when the post depends on a missing thread, reply chain, quoted post, or conversation setup. + - Use the search tool when the post refers to current events, claims, statistics, memes, public controversies, or unclear phrases that may need outside posts for context. + - When searching, explicitly choose whether you need posts, users, or both. + - Search users when the key missing context is who an account is, whether an account appears official, or how an account describes itself. + - Search posts when the key missing context is what people are saying, whether a topic is spreading, or what phrase/meme/event the post refers to. + - Search both users and posts when both account identity and surrounding discussion matter. + - Leave the platform list empty to search all signed-in platforms when broad context is useful. + - Limit the platform list when the post clearly belongs to, mentions, or depends on a specific platform. + - Search across diverse signed-in platforms when useful. Do not rely on a single search result for complex or controversial claims. + - If search or context results are thin, uncertain, or conflicting, say what can and cannot be inferred from the available signals. + - Do not call tools just to restate a simple post. + + Analysis style: + - Be clear, direct, and economical. + - For subjective or political content, keep a neutral tone and distinguish observed context from inference. + - Do not moralize, preach, dunk on the author, or use snarky slogans. + - Do not correct spelling or grammar unless the correction is necessary to explain meaning. + - For images, do not assume identities of depicted people unless the supplied context makes it highly reliable. + + Formatting: + - Write 3 to 5 short bullet points. + - Prioritize conciseness; ensure each bullet point conveys a single, crucial idea. + - Use simple, information-rich sentences. Avoid purple prose. + - Do not use nested bullets. + - Each bullet should carry one useful idea. + - Do not include post IDs, thread IDs, or a concluding summary. + """ + } +} + +public sealed interface StatusInsightEvent { + public data class PostLoaded( + public val post: UiTimelineV2.Post, + ) : StatusInsightEvent + + public data class Trace( + public val phase: StatusInsightPhase, + public val detail: String? = null, + public val key: StatusInsightTraceKey? = null, + ) : StatusInsightEvent + + public data class Result( + public val text: String, + ) : StatusInsightEvent +} + +public enum class StatusInsightPhase { + LoadingPostContext, + PostContextLoaded, + PreparingImages, + ImagesUnsupportedFallback, + AgentStarted, + StrategyStarted, + StrategyCompleted, + SubgraphStarted, + SubgraphCompleted, + SubgraphFailed, + AskingModel, + ModelResponseReceived, + StreamingStarted, + StreamingResponse, + StreamingCompleted, + StreamingFailed, + RunningStep, + StepCompleted, + StepFailed, + ToolCallStarted, + ToolCallCompleted, + ToolValidationFailed, + ToolCallFailed, + AgentCompleted, + AgentFailed, + AgentClosing, +} + +public enum class StatusInsightTraceKey { + LoadStatusContextStarted, + LoadStatusContextCompleted, + LoadStatusContextValidationFailed, + LoadStatusContextFailed, + SearchStatusStarted, + SearchStatusCompleted, + SearchStatusValidationFailed, + SearchStatusFailed, +} + +public class StatusInsightAgentUnavailableException public constructor( + public val availability: AgentAvailability, +) : IllegalStateException("Status insight agent is unavailable: $availability") diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt new file mode 100644 index 0000000000..1dc2f8e81a --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt @@ -0,0 +1,325 @@ +package dev.dimension.flare.feature.agent.status + +import ai.koog.agents.core.tools.SimpleTool +import ai.koog.agents.core.tools.annotations.LLMDescription +import ai.koog.serialization.typeToken +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest +import dev.dimension.flare.data.repository.AccountMicroblogDataSource +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.Serializable + +internal class LoadStatusContextTool( + private val dataSource: MicroblogDataSource, + private val statusKey: MicroBlogKey, +) : SimpleTool( + argsType = typeToken(), + name = "load_status_context", + description = + "Load the current post's conversation context, including surrounding replies or thread posts. " + + "Use this only when the post depends on missing conversation context.", + ) { + @Serializable + internal data class Args( + @property:LLMDescription("Why the current post needs conversation context.") + val reason: String? = null, + ) + + override suspend fun execute(args: Args): String = + dataSource + .context(statusKey) + .load( + pageSize = STATUS_CONTEXT_PAGE_SIZE, + request = PagingRequest.Refresh, + ).data + .filterIsInstance() + .toInsightPostToolListText( + title = "Status context", + emptyMessage = "No additional context posts were returned.", + maxItems = STATUS_CONTEXT_PAGE_SIZE, + ) +} + +internal class SearchStatusTool( + private val searchTargets: List, +) : SimpleTool( + argsType = typeToken(), + name = "search_status", + description = + "Search public or account-visible posts or users across the user's signed-in social platforms. " + + "Use this only when external posts or user profiles may explain a phrase, meme, event, account, " + + "or why a post is spreading.", + ) { + @Serializable + internal data class Args( + @property:LLMDescription("Search query. Keep it concise and use terms from the post.") + val query: String, + @property:LLMDescription("What to search for: Post, User, or Both.") + val target: SearchStatusTarget = SearchStatusTarget.Post, + @property:LLMDescription( + "Platform names to search. Leave empty to search all signed-in platforms. " + + "Use the platform names or aliases supplied in the system instructions.", + ) + val platforms: List = emptyList(), + ) + + override suspend fun execute(args: Args): String { + val query = args.query.trim() + if (query.isBlank()) { + return "Search query is blank." + } + val platformFilter = args.platforms.toPlatformFilter() + val targets = + searchTargets + .distinctBy { it.platformType } + .filterByPlatform(platformFilter) + if (targets.isEmpty()) { + return if (platformFilter.isEmpty()) { + "No signed-in accounts are available for search." + } else { + "No signed-in accounts are available for the requested platforms: ${args.platforms.joinToString()}." + } + } + val postResults = + if (args.target.searchPosts) { + targets.searchPosts(query) + } else { + emptyList() + } + val userResults = + if (args.target.searchUsers) { + targets.searchUsers(query) + } else { + emptyList() + } + return buildString { + appendLine("Search query: \"$query\"") + appendLine("Search target: ${args.target.name}") + appendLine("Platforms searched: ${targets.joinToString { it.platformType?.name ?: "Unknown" }}") + if (args.target.searchPosts) { + appendLine() + append( + postResults.toInsightPostToolListText( + title = "Post search results", + emptyMessage = "No matching posts were returned.", + maxItems = STATUS_SEARCH_PAGE_SIZE, + ), + ) + } + if (args.target.searchUsers) { + appendLine() + append( + userResults.toInsightUserToolListText( + title = "User search results", + emptyMessage = "No matching users were returned.", + maxItems = USER_SEARCH_PAGE_SIZE, + ), + ) + } + }.take(MAX_TOOL_RESULT_LENGTH) + } + + private suspend fun List.searchPosts(query: String): List = + coroutineScope { + map { target -> + async { + runCatching { + target.dataSource + .searchStatus(query) + .load( + pageSize = STATUS_SEARCH_PAGE_SIZE, + request = PagingRequest.Refresh, + ).data + .filterIsInstance() + }.getOrElse { emptyList() } + } + }.awaitAll() + }.flatten() + .distinctBy { it.platformType to it.statusKey } + + private suspend fun List.searchUsers(query: String): List = + coroutineScope { + map { target -> + async { + runCatching { + target.dataSource + .searchUser(query) + .load( + pageSize = USER_SEARCH_PAGE_SIZE, + request = PagingRequest.Refresh, + ).data + }.getOrElse { emptyList() } + } + }.awaitAll() + }.flatten() + .distinctBy { it.platformType to it.key } + + private fun List.filterByPlatform(platformFilter: Set): List { + if (platformFilter.isEmpty()) { + return this + } + return filter { it.platformType in platformFilter } + } + + private fun List.toPlatformFilter(): Set = mapNotNull { it.toPlatformTypeOrNull() }.toSet() + + private fun String.toPlatformTypeOrNull(): PlatformType? { + val normalized = + trim() + .lowercase() + .replace("-", "") + .replace("_", "") + .replace(" ", "") + if (normalized == "all" || normalized == "*") { + return null + } + return PlatformType.entries.firstOrNull { platformType -> + normalized == platformType.searchNameKey() || + platformType.searchAliases().any { alias -> normalized == alias.searchPlatformKey() } + } + } + + @Serializable + internal enum class SearchStatusTarget { + Post, + User, + Both, + } + + private val SearchStatusTarget.searchPosts: Boolean + get() = this == SearchStatusTarget.Post || this == SearchStatusTarget.Both + + private val SearchStatusTarget.searchUsers: Boolean + get() = this == SearchStatusTarget.User || this == SearchStatusTarget.Both +} + +internal data class StatusSearchTarget( + val platformType: PlatformType?, + val dataSource: MicroblogDataSource, +) + +internal fun List.toStatusSearchTargets(): List = + map { item -> + StatusSearchTarget( + platformType = item.platformType, + dataSource = item.dataSource, + ) + } + +internal fun PlatformType.searchAliases(): List = + when (name) { + PlatformType.Bluesky.name -> listOf("bsky") + PlatformType.xQt.name -> listOf("x", "twitter") + PlatformType.VVo.name -> listOf("weibo") + else -> emptyList() + } + +internal fun PlatformType.searchNameKey(): String = name.searchPlatformKey() + +private fun String.searchPlatformKey(): String = + trim() + .lowercase() + .replace("-", "") + .replace("_", "") + .replace(" ", "") + +private fun List.toInsightPostToolListText( + title: String, + emptyMessage: String, + maxItems: Int, +): String { + val posts = this + return buildString { + appendLine(title) + if (posts.isEmpty()) { + appendLine(emptyMessage) + return@buildString + } + posts.take(maxItems).forEachIndexed { index, post -> + appendLine() + appendLine("Post #${index + 1}") + append(post.toInsightPostToolText()) + } + } +} + +private fun List.toInsightUserToolListText( + title: String, + emptyMessage: String, + maxItems: Int, +): String { + val users = this + return buildString { + appendLine(title) + if (users.isEmpty()) { + appendLine(emptyMessage) + return@buildString + } + users.take(maxItems).forEachIndexed { index, user -> + appendLine() + appendLine("User #${index + 1}") + append(user.toInsightUserToolText()) + } + } +} + +private fun UiProfile.toInsightUserToolText(): String = + buildString { + appendLine("platform: ${platformType.name}") + appendLine("userKey: $key") + appendLine("displayName: ${name.raw}") + appendLine("handle: ${handle.raw}") + appendLine("description: ${description?.raw.orEmpty().take(MAX_TOOL_USER_DESCRIPTION_LENGTH)}") + appendLine("followers: ${matrices.fansCount}") + appendLine("following: ${matrices.followsCount}") + appendLine("posts: ${matrices.statusesCount}") + appendLine("avatarUrl: ${avatar?.url.orEmpty()}") + appendLine("bannerUrl: ${banner?.url.orEmpty()}") + } + +private fun UiTimelineV2.Post.toInsightPostToolText(): String = + buildString { + appendLine("platform: ${platformType.name}") + appendLine("statusKey: $statusKey") + appendLine("createdAt: ${createdAt.value}") + appendLine("authorName: ${user?.name?.raw.orEmpty()}") + appendLine("authorHandle: ${user?.handle?.raw.orEmpty()}") + appendLine("contentWarning: ${contentWarning?.raw.orEmpty()}") + appendLine("content: ${content.raw.take(MAX_TOOL_POST_TEXT_LENGTH)}") + appendLine("replyToHandle: ${replyToHandle.orEmpty()}") + appendLine("sourceChannel: ${sourceChannel?.name.orEmpty()}") + appendLine("imagesCount: ${images.size}") + images.filterIsInstance().take(MAX_TOOL_IMAGES).forEachIndexed { index, image -> + appendLine("image${index + 1}Url: ${image.url}") + appendLine("image${index + 1}Description: ${image.description.orEmpty()}") + } + if (quote.isNotEmpty()) { + appendLine("quotes:") + quote.take(MAX_TOOL_RELATED_POSTS).forEachIndexed { index, post -> + appendLine("- #${index + 1} ${post.user?.handle?.raw.orEmpty()}: ${post.content.raw.take(MAX_TOOL_RELATED_TEXT_LENGTH)}") + } + } + if (parents.isNotEmpty()) { + appendLine("parents:") + parents.take(MAX_TOOL_RELATED_POSTS).forEachIndexed { index, post -> + appendLine("- #${index + 1} ${post.user?.handle?.raw.orEmpty()}: ${post.content.raw.take(MAX_TOOL_RELATED_TEXT_LENGTH)}") + } + } + } + +private const val STATUS_CONTEXT_PAGE_SIZE = 100 +private const val STATUS_SEARCH_PAGE_SIZE = 20 +private const val USER_SEARCH_PAGE_SIZE = 20 +private const val MAX_TOOL_RESULT_LENGTH = 24_000 +private const val MAX_TOOL_POST_TEXT_LENGTH = 800 +private const val MAX_TOOL_USER_DESCRIPTION_LENGTH = 800 +private const val MAX_TOOL_RELATED_POSTS = 3 +private const val MAX_TOOL_RELATED_TEXT_LENGTH = 300 +private const val MAX_TOOL_IMAGES = 4 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 511a24ef79..db3e5d2b68 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ lifecycle-runtime-ktx = "2.10.0" activity-compose = "1.13.0" compose-bom = "2026.05.01" ksp = "2.3.9" +koog = "1.0.0" nucleus = "1.15.7" openaiClient = "4.1.0" paging = "3.5.0" @@ -82,6 +83,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } ksoup = { group = "com.fleeksoft.ksoup", name = "ksoup", version.ref = "ksoup" } +koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" } +koog-http-client-ktor = { module = "ai.koog:http-client-ktor", version.ref = "koog" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-runtime-ktx" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } diff --git a/ios-shared/build.gradle.kts b/ios-shared/build.gradle.kts index 1ccc643b7a..c02e3a55a5 100644 --- a/ios-shared/build.gradle.kts +++ b/ios-shared/build.gradle.kts @@ -34,6 +34,7 @@ kotlin { export(projects.social.vvo) export(projects.social.xqt) export(projects.feature.loginApi) + export(projects.feature.agent) export(projects.feature.login) export(projects.feature.subscription) export(projects.feature.tab) @@ -51,6 +52,7 @@ kotlin { api(projects.social.pixiv) api(projects.social.vvo) api(projects.social.xqt) + api(projects.feature.agent) api(projects.feature.login) api(projects.feature.subscription) api(projects.feature.tab) diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index 22764cfb0a..78b606873a 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -1123,9 +1123,6 @@ } } } - }, - "+%lld" : { - }, "about_description" : { "localizations" : { @@ -119049,6 +119046,376 @@ } } }, + "ai_config_post_insight" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 AI agent" + } + } + } + }, + "ai_config_post_insight_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "开启后,在已配置 OpenAI 兼容模型时,会在应用内相关位置显示 AI agent 功能入口。" + } + } + } + }, + "status_insight_analyzing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analyzing this post..." + } + } + } + }, + "status_insight_error" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to analyze this post." + } + } + } + }, + "status_insight_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post insight" + } + } + } + }, + "status_insight_trace_agent_closing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closing analysis..." + } + } + } + }, + "status_insight_trace_agent_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finishing analysis..." + } + } + } + }, + "status_insight_trace_agent_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysis failed" + } + } + } + }, + "status_insight_trace_agent_started" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starting analysis..." + } + } + } + }, + "status_insight_trace_asking_model" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asking %@..." + } + } + } + }, + "status_insight_trace_images_unsupported_fallback" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Images are unsupported; retrying with text..." + } + } + } + }, + "status_insight_trace_loading_post_context" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading post context..." + } + } + } + }, + "status_insight_trace_model_response_received" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reading model response..." + } + } + } + }, + "status_insight_trace_post_context_loaded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post context loaded" + } + } + } + }, + "status_insight_trace_preparing_images" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing images..." + } + } + } + }, + "status_insight_trace_running_step" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running analysis step..." + } + } + } + }, + "status_insight_trace_step_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysis step completed" + } + } + } + }, + "status_insight_trace_step_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysis step failed" + } + } + } + }, + "status_insight_trace_strategy_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysis plan completed" + } + } + } + }, + "status_insight_trace_strategy_started" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planning analysis..." + } + } + } + }, + "status_insight_trace_streaming_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Response stream completed" + } + } + } + }, + "status_insight_trace_streaming_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Response stream failed" + } + } + } + }, + "status_insight_trace_streaming_response" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receiving response..." + } + } + } + }, + "status_insight_trace_streaming_started" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Streaming from %@..." + } + } + } + }, + "status_insight_trace_subgraph_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysis stage completed" + } + } + } + }, + "status_insight_trace_subgraph_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysis stage failed" + } + } + } + }, + "status_insight_trace_subgraph_started" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entering analysis stage..." + } + } + } + }, + "status_insight_trace_tool_call_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tool call failed" + } + } + } + }, + "status_insight_trace_tool_load_status_context_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post context loaded" + } + } + } + }, + "status_insight_trace_tool_load_status_context_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to load post context" + } + } + } + }, + "status_insight_trace_tool_load_status_context_started" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading post context..." + } + } + } + }, + "status_insight_trace_tool_load_status_context_validation_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post context input was rejected" + } + } + } + }, + "status_insight_trace_tool_search_status_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search completed" + } + } + } + }, + "status_insight_trace_tool_search_status_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search failed" + } + } + } + }, + "status_insight_trace_tool_search_status_started" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Searching..." + } + } + } + }, + "status_insight_trace_tool_search_status_validation_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search input was rejected" + } + } + } + }, + "status_insight_trace_tool_validation_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tool input was rejected" + } + } + } + }, "status_menu_share" : { "localizations" : { "af" : { @@ -134585,4 +134952,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/iosApp/flare/UI/Component/Status/StatusUIKitLeaves.swift b/iosApp/flare/UI/Component/Status/StatusUIKitLeaves.swift index 8544bc86b9..ea48192c71 100644 --- a/iosApp/flare/UI/Component/Status/StatusUIKitLeaves.swift +++ b/iosApp/flare/UI/Component/Status/StatusUIKitLeaves.swift @@ -466,6 +466,8 @@ final class StatusTopEndView: UIView, ManualLayoutMeasurable, TimelineHeightProv private let translation = TranslateStatusStateView() private let platformLogo = UIImageView() private let time = DateTimeUILabel() + private let insightButton = UIButton(type: .system) + private var onInsightTapped: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) @@ -477,10 +479,23 @@ final class StatusTopEndView: UIView, ManualLayoutMeasurable, TimelineHeightProv platformLogo.setContentCompressionResistancePriority(.required, for: .horizontal) translation.setContentHuggingPriority(.required, for: .horizontal) translation.setContentCompressionResistancePriority(.required, for: .horizontal) + insightButton.tintColor = .secondaryLabel + insightButton.setImage(UIImage(named: "fa-robot"), for: .normal) + insightButton.imageView?.contentMode = .scaleAspectFit + insightButton.accessibilityLabel = String(localized: "status_insight_title") + insightButton.addAction( + UIAction { [weak self] _ in + self?.onInsightTapped?() + }, + for: .touchUpInside + ) + insightButton.setContentHuggingPriority(.required, for: .horizontal) + insightButton.setContentCompressionResistancePriority(.required, for: .horizontal) addSubview(visibility) addSubview(translation) addSubview(platformLogo) addSubview(time) + addSubview(insightButton) } required init(coder: NSCoder) { fatalError("init(coder:) not supported") } @@ -518,6 +533,9 @@ final class StatusTopEndView: UIView, ManualLayoutMeasurable, TimelineHeightProv if !time.isHidden { height = max(height, time.font?.lineHeight ?? 0) } + if !insightButton.isHidden { + height = max(height, 16) + } return ceil(height) } @@ -537,8 +555,11 @@ final class StatusTopEndView: UIView, ManualLayoutMeasurable, TimelineHeightProv post: UiTimelineV2.Post, showPlatformLogo: Bool, absoluteTimestamp: Bool, - isDetail: Bool + isDetail: Bool, + showAgentInsight: Bool, + onInsightTapped: (() -> Void)? ) { + self.onInsightTapped = onInsightTapped if let v = post.visibility { visibility.isHidden = false visibility.set(visibility: v) @@ -578,6 +599,7 @@ final class StatusTopEndView: UIView, ManualLayoutMeasurable, TimelineHeightProv time.fullTime = false time.set(data: post.createdAt) } + insightButton.isHidden = !showAgentInsight invalidateIntrinsicContentSize() setNeedsLayout() } @@ -623,6 +645,9 @@ final class StatusTopEndView: UIView, ManualLayoutMeasurable, TimelineHeightProv let size = time.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) items.append((time, CGSize(width: ceil(size.width), height: ceil(size.height)))) } + if !insightButton.isHidden { + items.append((insightButton, CGSize(width: 16, height: 16))) + } return items } } diff --git a/iosApp/flare/UI/Component/Status/StatusUIKitView.swift b/iosApp/flare/UI/Component/Status/StatusUIKitView.swift index 71da0f79f9..1672bb9c1e 100644 --- a/iosApp/flare/UI/Component/Status/StatusUIKitView.swift +++ b/iosApp/flare/UI/Component/Status/StatusUIKitView.swift @@ -638,7 +638,18 @@ final class StatusUIKitView: UIView, UIGestureRecognizerDelegate, ManualLayoutMe post: data, showPlatformLogo: appearance.showPlatformLogo, absoluteTimestamp: appearance.absoluteTimestamp, - isDetail: isDetail + isDetail: isDetail, + showAgentInsight: appearance.aiAgentEnabled && !isQuote, + onInsightTapped: { [weak self] in + guard let self else { return } + let route = DeeplinkRoute.StatusInsight( + accountType: data.accountType, + statusKey: data.statusKey + ) + if let url = URL(string: route.toUri()) { + self.openURL?(url) + } + } ) let onClicked: () -> Void = { [weak self] in self?.onUserTapped() } diff --git a/iosApp/flare/UI/Component/Status/StatusView.swift b/iosApp/flare/UI/Component/Status/StatusView.swift index fc25c4107b..518d9e2cfe 100644 --- a/iosApp/flare/UI/Component/Status/StatusView.swift +++ b/iosApp/flare/UI/Component/Status/StatusView.swift @@ -9,6 +9,7 @@ struct StatusView: View { @Environment(\.timelineAppearance.postActionStyle) private var postActionStyle @Environment(\.timelineAppearance.showPlatformLogo) private var showPlatformLogo @Environment(\.timelineAppearance.expandContentWarning) private var expandContentWarning + @Environment(\.timelineAppearance.aiConfig.agent) private var agentEnabled @Environment(\.openURL) private var openURL let data: UiTimelineV2.Post var isDetail: Bool = false @@ -296,6 +297,23 @@ struct StatusView: View { .font(.caption) .foregroundStyle(.secondary) } + if agentEnabled, !isQuote { + Button { + let route = DeeplinkRoute.StatusInsight( + accountType: data.accountType, + statusKey: data.statusKey + ) + if let url = URL(string: route.toUri()) { + openURL(url) + } + } label: { + Image("fa-robot") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("status_insight_title")) + } } } } diff --git a/iosApp/flare/UI/Component/UIKitAppearance.swift b/iosApp/flare/UI/Component/UIKitAppearance.swift index 3f2bfe7b89..4569d17a74 100644 --- a/iosApp/flare/UI/Component/UIKitAppearance.swift +++ b/iosApp/flare/UI/Component/UIKitAppearance.swift @@ -37,6 +37,7 @@ struct StatusUIKitAppearance: Equatable { let showLinkPreview: Bool let compatLinkPreview: Bool let expandMediaSize: Bool + let aiAgentEnabled: Bool init(timeline: TimelineAppearance, fontSizeDiff: Float = 0) { preferredContentSizeCategory = contentSizeCategory(fontSizeDiff: fontSizeDiff) @@ -55,6 +56,7 @@ struct StatusUIKitAppearance: Equatable { showLinkPreview = timeline.showLinkPreview compatLinkPreview = timeline.compatLinkPreview expandMediaSize = timeline.expandMediaSize + aiAgentEnabled = timeline.aiConfig.agent } static func == (lhs: Self, rhs: Self) -> Bool { @@ -70,7 +72,8 @@ struct StatusUIKitAppearance: Equatable { lhs.expandContentWarning == rhs.expandContentWarning && lhs.showLinkPreview == rhs.showLinkPreview && lhs.compatLinkPreview == rhs.compatLinkPreview && - lhs.expandMediaSize == rhs.expandMediaSize + lhs.expandMediaSize == rhs.expandMediaSize && + lhs.aiAgentEnabled == rhs.aiAgentEnabled } } diff --git a/iosApp/flare/UI/FlareTheme.swift b/iosApp/flare/UI/FlareTheme.swift index b1ebf01c9c..514c8060ad 100644 --- a/iosApp/flare/UI/FlareTheme.swift +++ b/iosApp/flare/UI/FlareTheme.swift @@ -44,16 +44,51 @@ struct FlareTheme: View { } .onSuccessOf(of: presenter.state.appSettings) { newValue in appSettings = newValue + timelineAppearance = timelineAppearance.withAppSettings(newValue) } .onSuccessOf(of: presenter.state.globalAppearance) { newValue in globalAppearance = newValue } .onSuccessOf(of: presenter.state.timelineAppearance) { newValue in - timelineAppearance = newValue + timelineAppearance = newValue.withAppSettings(appSettings) } } } +private extension TimelineAppearance { + func withAppSettings(_ appSettings: AppSettings) -> TimelineAppearance { + doCopy( + avatarShape: avatarShape, + showMedia: showMedia, + showSensitiveContent: showSensitiveContent, + expandContentWarning: expandContentWarning, + expandMediaSize: expandMediaSize, + videoAutoplay: videoAutoplay, + showLinkPreview: showLinkPreview, + compatLinkPreview: compatLinkPreview, + showNumbers: showNumbers, + postActionStyle: postActionStyle, + fullWidthPost: fullWidthPost, + absoluteTimestamp: absoluteTimestamp, + showPlatformLogo: showPlatformLogo, + timelineDisplayMode: timelineDisplayMode, + aiConfig: TimelineAppearance.AiConfig( + translation: true, + tldr: appSettings.aiConfig.tldr, + agent: appSettings.aiConfig.agent && appSettings.aiConfig.type.openAIModel?.isEmpty == false + ), + lineLimit: lineLimit, + showTranslateButton: showTranslateButton + ) + } +} + +private extension AppSettingsAiConfigType { + var openAIModel: String? { + (self as? AppSettingsAiConfigTypeOpenAI)?.model + } +} + private extension URL { var isWebURL: Bool { guard let scheme = scheme?.lowercased() else { return false } diff --git a/iosApp/flare/UI/Route/Route.swift b/iosApp/flare/UI/Route/Route.swift index 9cab522d18..db707d5279 100644 --- a/iosApp/flare/UI/Route/Route.swift +++ b/iosApp/flare/UI/Route/Route.swift @@ -112,6 +112,8 @@ enum Route: Hashable, Identifiable { MisskeyReportSheet(accountType: accountType, userKey: userKey, statusKey: statusKey) case .statusAddReaction(let accountType, let statusKey): StatusAddReactionSheet(accountType: accountType, statusKey: statusKey) + case .statusInsight(let accountType, let statusKey): + StatusInsightSheet(accountType: accountType, statusKey: statusKey) case .userFans(let account, let userKey): UserListScreen(accountType: account, userKey: userKey, isFollowing: false) case .userFollowing(let account, let userKey): @@ -174,6 +176,7 @@ enum Route: Hashable, Identifiable { case statusBlueskyReport(AccountType, MicroBlogKey) case statusDeleteConfirm(AccountType, MicroBlogKey) case statusDetail(AccountType, MicroBlogKey) + case statusInsight(AccountType, MicroBlogKey) case statusMastodonReport(AccountType, MicroBlogKey, MicroBlogKey?) case statusMisskeyReport(AccountType, MicroBlogKey, MicroBlogKey?) case statusVVOComment(AccountType, MicroBlogKey) @@ -264,6 +267,8 @@ enum Route: Hashable, Identifiable { return Route.statusDeleteConfirm(data.accountType, data.statusKey) case .detail(let data): return Route.statusDetail(data.accountType, data.statusKey) + case .insight(let data): + return Route.statusInsight(data.accountType, data.statusKey) case .mastodonReport(let data): return Route.statusMastodonReport(data.accountType, data.userKey, data.statusKey) case .misskeyReport(let data): diff --git a/iosApp/flare/UI/Route/Router.swift b/iosApp/flare/UI/Route/Router.swift index dca8351d38..53c7224270 100644 --- a/iosApp/flare/UI/Route/Router.swift +++ b/iosApp/flare/UI/Route/Router.swift @@ -105,6 +105,7 @@ struct Router: View { .editUserList, .statusShareSheet, .secondaryMenu, + .statusInsight, .statusAddReaction: return true default: diff --git a/iosApp/flare/UI/Screen/AiConfigScreen.swift b/iosApp/flare/UI/Screen/AiConfigScreen.swift index 14898df780..866214b996 100644 --- a/iosApp/flare/UI/Screen/AiConfigScreen.swift +++ b/iosApp/flare/UI/Screen/AiConfigScreen.swift @@ -163,6 +163,20 @@ struct AiConfigScreen: View { } } + Section { + Toggle( + isOn: Binding( + get: { presenter.state.aiAgent }, + set: { newValue in + presenter.state.setAIAgent(value: newValue) + } + ) + ) { + Text("ai_config_post_insight") + Text("ai_config_post_insight_description") + } + } + Section { Toggle( isOn: Binding( diff --git a/iosApp/flare/UI/Screen/StatusInsightSheet.swift b/iosApp/flare/UI/Screen/StatusInsightSheet.swift new file mode 100644 index 0000000000..6c4c8de815 --- /dev/null +++ b/iosApp/flare/UI/Screen/StatusInsightSheet.swift @@ -0,0 +1,253 @@ +import SwiftUI +import KotlinSharedUI + +struct StatusInsightSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.timelineAppearance) private var timelineAppearance + @StateObject private var presenter: KotlinPresenter + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let post = presenter.state.post { + StatusInsightPostPreview(post: post) + } + + StateView(state: presenter.state.insight) { text in + Text(verbatim: String(text)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } errorContent: { throwable in + Text(verbatim: throwable.message ?? String(localized: "status_insight_error")) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } loadingContent: { + StatusInsightCurrentTrace( + trace: presenter.state.currentTrace?.localizedLabel ?? String(localized: "status_insight_analyzing") + ) + } + } + .padding(.horizontal) + .padding(.bottom, 24) + } + .navigationTitle(Text("status_insight_title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button( + role: .cancel + ) { + dismiss() + } label: { + Label { + Text("Cancel") + } icon: { + Image("fa-xmark") + } + } + } + } + } +} + +extension StatusInsightSheet { + init( + accountType: AccountType, + statusKey: MicroBlogKey + ) { + self._presenter = .init( + wrappedValue: .init( + presenter: StatusInsightPresenter( + accountType: accountType, + statusKey: statusKey + ) + ) + ) + } +} + +private struct StatusInsightPostPreview: View { + @Environment(\.timelineAppearance) private var timelineAppearance + let post: UiTimelineV2.Post + + var body: some View { + StatusView( + data: post, + isQuote: true, + showMedia: false, + maxLine: 3, + showExpandTextButton: false, + forceHideActions: true, + showTranslate: false, + showParents: false + ) + .padding(12) + .environment(\.timelineAppearance, timelineAppearance.withStatusInsightPreviewDefaults()) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(.separator), lineWidth: 1) + ) + } +} + +private struct StatusInsightCurrentTrace: View { + let trace: String + + var body: some View { + HStack(spacing: 8) { + Image("fa-robot") + Text(verbatim: trace) + .font(.body) + .shimmeringText() + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +private struct ShimmeringTextModifier: ViewModifier { + @State private var phase: CGFloat = -1 + + func body(content: Content) -> some View { + content + .foregroundStyle(.secondary) + .overlay { + GeometryReader { proxy in + LinearGradient( + colors: [ + .secondary.opacity(0.35), + .primary, + .secondary.opacity(0.35), + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: max(proxy.size.width * 0.65, 120)) + .offset(x: phase * proxy.size.width) + } + .mask(content) + } + .onAppear { + phase = -1 + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 1.4 + } + } + } +} + +private extension View { + func shimmeringText() -> some View { + modifier(ShimmeringTextModifier()) + } +} + +private extension StatusInsightEventTrace { + var localizedLabel: String { + if let key { + return key.localizedLabel + } + + switch phase { + case .loadingPostContext: + return String(localized: "status_insight_trace_loading_post_context") + case .postContextLoaded: + return String(localized: "status_insight_trace_post_context_loaded") + case .preparingImages: + return String(localized: "status_insight_trace_preparing_images") + case .imagesUnsupportedFallback: + return String(localized: "status_insight_trace_images_unsupported_fallback") + case .agentStarted: + return String(localized: "status_insight_trace_agent_started") + case .strategyStarted: + return String(localized: "status_insight_trace_strategy_started") + case .strategyCompleted: + return String(localized: "status_insight_trace_strategy_completed") + case .subgraphStarted: + return String(localized: "status_insight_trace_subgraph_started") + case .subgraphCompleted: + return String(localized: "status_insight_trace_subgraph_completed") + case .subgraphFailed: + return String(localized: "status_insight_trace_subgraph_failed") + case .askingModel: + return String(format: String(localized: "status_insight_trace_asking_model"), detail ?? "") + case .modelResponseReceived: + return String(localized: "status_insight_trace_model_response_received") + case .streamingStarted: + return String(format: String(localized: "status_insight_trace_streaming_started"), detail ?? "") + case .streamingResponse: + return String(localized: "status_insight_trace_streaming_response") + case .streamingCompleted: + return String(localized: "status_insight_trace_streaming_completed") + case .streamingFailed: + return String(localized: "status_insight_trace_streaming_failed") + case .runningStep: + return String(localized: "status_insight_trace_running_step") + case .stepCompleted: + return String(localized: "status_insight_trace_step_completed") + case .stepFailed: + return String(localized: "status_insight_trace_step_failed") + case .toolCallStarted: + return detail ?? String(localized: "status_insight_trace_running_step") + case .toolCallCompleted: + return detail ?? String(localized: "status_insight_trace_step_completed") + case .toolValidationFailed: + return detail ?? String(localized: "status_insight_trace_tool_validation_failed") + case .toolCallFailed: + return detail ?? String(localized: "status_insight_trace_tool_call_failed") + case .agentCompleted: + return String(localized: "status_insight_trace_agent_completed") + case .agentFailed: + return String(localized: "status_insight_trace_agent_failed") + case .agentClosing: + return String(localized: "status_insight_trace_agent_closing") + } + } +} + +private extension StatusInsightTraceKey { + var localizedLabel: String { + switch self { + case .loadStatusContextStarted: + return String(localized: "status_insight_trace_tool_load_status_context_started") + case .loadStatusContextCompleted: + return String(localized: "status_insight_trace_tool_load_status_context_completed") + case .loadStatusContextValidationFailed: + return String(localized: "status_insight_trace_tool_load_status_context_validation_failed") + case .loadStatusContextFailed: + return String(localized: "status_insight_trace_tool_load_status_context_failed") + case .searchStatusStarted: + return String(localized: "status_insight_trace_tool_search_status_started") + case .searchStatusCompleted: + return String(localized: "status_insight_trace_tool_search_status_completed") + case .searchStatusValidationFailed: + return String(localized: "status_insight_trace_tool_search_status_validation_failed") + case .searchStatusFailed: + return String(localized: "status_insight_trace_tool_search_status_failed") + } + } +} + +private extension TimelineAppearance { + func withStatusInsightPreviewDefaults() -> TimelineAppearance { + doCopy( + avatarShape: avatarShape, + showMedia: false, + showSensitiveContent: showSensitiveContent, + expandContentWarning: true, + expandMediaSize: false, + videoAutoplay: .never, + showLinkPreview: false, + compatLinkPreview: compatLinkPreview, + showNumbers: showNumbers, + postActionStyle: .hidden, + fullWidthPost: fullWidthPost, + absoluteTimestamp: absoluteTimestamp, + showPlatformLogo: showPlatformLogo, + timelineDisplayMode: timelineDisplayMode, + aiConfig: aiConfig, + lineLimit: lineLimit, + showTranslateButton: showTranslateButton + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 64e413ed0b..2b0e1fc9df 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ include(":social:pixiv") include(":social:vvo") include(":social:xqt") include(":feature:login-api") +include(":feature:agent") include(":feature:login") include(":feature:subscription") include(":feature:tab") diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt index 7607445968..3c3af95567 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/PostHandler.kt @@ -5,6 +5,7 @@ import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.database.cache.mapper.saveToDatabase import dev.dimension.flare.data.database.cache.model.DbStatus +import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.datasource.microblog.loader.PostLoader import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper import dev.dimension.flare.data.datastore.AppDataStore @@ -17,9 +18,11 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -40,7 +43,10 @@ public class PostHandler( TranslationSettingsSupport.displayOptionsFlow(appDataStore) } - public fun post(postKey: MicroBlogKey): Cacheable { + public fun post( + postKey: MicroBlogKey, + translationDisplay: PostTranslationDisplay = PostTranslationDisplay.UserSettings, + ): Cacheable { val pagingKey = "post_only_$postKey" return Cacheable( fetchSource = { @@ -71,7 +77,7 @@ public class PostHandler( .get(pagingKey, accountType = dbAccountType) .filterNotNull(), ), - translationDisplayFlow, + translationDisplay.optionsFlow(translationDisplayFlow), ) { status, translationDisplayOptions -> TimelinePagingMapper.toUi( item = status, @@ -103,3 +109,26 @@ public class PostHandler( } } } + +@HiddenFromObjC +public enum class PostTranslationDisplay { + UserSettings, + Original, +} + +private fun PostTranslationDisplay.optionsFlow(userSettingsFlow: Flow): Flow = + when (this) { + PostTranslationDisplay.UserSettings -> { + userSettingsFlow + } + + PostTranslationDisplay.Original -> { + flowOf( + TranslationDisplayOptions( + translationEnabled = false, + autoDisplayEnabled = false, + providerCacheKey = "", + ), + ) + } + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt index 252e5d6a5d..79eaab1e1c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt @@ -61,6 +61,7 @@ public data class AppSettings( level = DeprecationLevel.ERROR, ) val preTranslation: Boolean = false, + val agent: Boolean = true, ) { public companion object { // for iOS diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceModels.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceModels.kt index 98c2fb4d22..2f5affe6c4 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceModels.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/appearance/AppearanceModels.kt @@ -56,6 +56,7 @@ public data class TimelineAppearance( public data class AiConfig( val translation: Boolean = false, val tldr: Boolean = false, + val agent: Boolean = false, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountService.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountService.kt index 8ee6155ae1..92c8ab7fc7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountService.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountService.kt @@ -3,9 +3,12 @@ package dev.dimension.flare.data.repository import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.model.UiAccount +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest import kotlinx.serialization.KSerializer import kotlinx.serialization.serializer import org.koin.core.annotation.Single @@ -15,6 +18,8 @@ import kotlin.native.HiddenFromObjC public interface AccountService { public fun accountServiceFlow(accountType: AccountType): Flow + public fun allAccountServicesFlow(): Flow> + public fun addAccount( account: UiAccount, credential: T, @@ -33,6 +38,12 @@ public interface AccountService { ): Job } +public data class AccountMicroblogDataSource( + public val accountKey: MicroBlogKey, + public val platformType: PlatformType, + public val dataSource: MicroblogDataSource, +) + public inline fun AccountService.addAccount( account: UiAccount, credential: T, @@ -69,6 +80,18 @@ internal class RepositoryAccountService( repository = repository, ) + @OptIn(ExperimentalCoroutinesApi::class) + override fun allAccountServicesFlow(): Flow> = + repository.allAccounts.mapLatest { accounts -> + accounts.map { account -> + AccountMicroblogDataSource( + accountKey = account.accountKey, + platformType = account.platformType, + dataSource = repository.getOrCreateDataSource(account), + ) + } + } + override fun addAccount( account: UiAccount, credential: T, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt index 4f9b731735..ea5526db85 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiConfigPresenter.kt @@ -83,6 +83,7 @@ public class AiConfigPresenter : public val supportedTranslateProviders: ImmutableList public val serverSuggestions: ImmutableList public val aiTldr: Boolean + public val aiAgent: Boolean public val translatePrompt: String public val tldrPrompt: String public val preTranslate: Boolean @@ -118,6 +119,8 @@ public class AiConfigPresenter : public fun setAITldr(value: Boolean) + public fun setAIAgent(value: Boolean) + public fun setTranslatePrompt(value: String) public fun setTldrPrompt(value: String) @@ -278,6 +281,7 @@ public class AiConfigPresenter : (appSettings.aiConfig.type as? AppSettings.AiConfig.Type.OpenAI)?.extraBody ?: "" override val aiTldr: Boolean = appSettings.aiConfig.tldr + override val aiAgent: Boolean = appSettings.aiConfig.agent override val translatePrompt: String = appSettings.aiConfig.translatePrompt override val tldrPrompt: String = appSettings.aiConfig.tldrPrompt override val preTranslate: Boolean = appSettings.translateConfig.preTranslate @@ -292,6 +296,14 @@ public class AiConfigPresenter : } } + override fun setAIAgent(value: Boolean) { + update { + copy( + agent = value, + ) + } + } + override fun setTranslatePrompt(value: String) { update { copy( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt index 7e040ee4a6..b5e302113e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt @@ -88,6 +88,12 @@ public sealed class DeeplinkRoute { val fxShareUrl: String? = null, val fixvxShareUrl: String? = null, ) : Status() + + @Serializable + public data class Insight( + val accountType: AccountType, + val statusKey: MicroBlogKey, + ) : Status() } @Serializable From 72317378b1510910a8d19a1969ba3ded85030165 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 7 Jun 2026 11:41:23 +0900 Subject: [PATCH 2/6] fix build --- .../main/java/dev/dimension/flare/ui/AppContainer.kt | 10 +++++----- .../flare/ui/screen/settings/SettingsScreen.kt | 2 +- .../dev/dimension/flare/di/NostrTestKoinModule.kt | 5 +++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt index 5c9ee71995..34ed5035c8 100644 --- a/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt +++ b/app/src/main/java/dev/dimension/flare/ui/AppContainer.kt @@ -66,11 +66,11 @@ fun FlareApp(content: @Composable () -> Unit) { remember(globalAppearance, timelineAppearance, appSettings.translateConfig, appSettings.aiConfig) { timelineAppearance.copy( aiConfig = - TimelineAppearance.AiConfig( - translation = true, - tldr = appSettings.aiConfig.tldr, - agent = appSettings.aiConfig.agent && !openAIConfig?.model.isNullOrBlank(), - ), + TimelineAppearance.AiConfig( + translation = true, + tldr = appSettings.aiConfig.tldr, + agent = appSettings.aiConfig.agent && !openAIConfig?.model.isNullOrBlank(), + ), ) }, content = content, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index b3cf4c90b9..c7efb3e945 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -88,9 +88,9 @@ import dev.dimension.flare.settings_about_telegram_description import dev.dimension.flare.settings_about_title import dev.dimension.flare.settings_accounts_remove_confirm import dev.dimension.flare.settings_accounts_title +import dev.dimension.flare.settings_ai_config_agent_description import dev.dimension.flare.settings_ai_config_api_key import dev.dimension.flare.settings_ai_config_api_key_hint -import dev.dimension.flare.settings_ai_config_agent_description import dev.dimension.flare.settings_ai_config_description import dev.dimension.flare.settings_ai_config_enable_agent import dev.dimension.flare.settings_ai_config_enable_pre_translation diff --git a/social/nostr/src/commonTest/kotlin/dev/dimension/flare/di/NostrTestKoinModule.kt b/social/nostr/src/commonTest/kotlin/dev/dimension/flare/di/NostrTestKoinModule.kt index e5aae7ffd3..f89bae21f4 100644 --- a/social/nostr/src/commonTest/kotlin/dev/dimension/flare/di/NostrTestKoinModule.kt +++ b/social/nostr/src/commonTest/kotlin/dev/dimension/flare/di/NostrTestKoinModule.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.di import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.Message import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.repository.AccountMicroblogDataSource import dev.dimension.flare.data.repository.AccountService import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -48,6 +49,10 @@ private object TestInAppNotification : InAppNotification { private object TestAccountService : AccountService { override fun accountServiceFlow(accountType: AccountType): Flow = unsupported() + override fun allAccountServicesFlow(): Flow> { + unsupported() + } + override fun addAccount( account: UiAccount, credential: T, From 56bb403e1c5c1d23c960ccc1b1df977fa777ad57 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 7 Jun 2026 12:15:40 +0900 Subject: [PATCH 3/6] fix build --- .../flare/ui/component/BottomSheetSceneStrategy.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/component/BottomSheetSceneStrategy.kt b/app/src/main/java/dev/dimension/flare/ui/component/BottomSheetSceneStrategy.kt index 39b96b4559..0efa3f1d1a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/BottomSheetSceneStrategy.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/BottomSheetSceneStrategy.kt @@ -4,7 +4,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.SheetState -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavEntry import androidx.navigation3.scene.OverlayScene @@ -25,7 +26,8 @@ private class BottomSheetScene( lateinit var sheetState: SheetState override val content: @Composable (() -> Unit) = { - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = properties.expandFully) + sheetState = + rememberBottomSheetState(initialValue = if (properties.expandFully) SheetValue.Expanded else SheetValue.PartiallyExpanded) ModalBottomSheet( onDismissRequest = { onBack() }, properties = properties.properties, From fe60d3a2479f31f8bbdc6470fea06b4cf567f766 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 7 Jun 2026 12:46:43 +0900 Subject: [PATCH 4/6] fix build --- app/proguard-rules.pro | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index de8c90b339..c9419d3307 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -86,3 +86,5 @@ -keep interface rust.nostr.sdk.** { *; } -keepclassmembers class rust.nostr.sdk.** { *; } -keepclassmembers interface rust.nostr.sdk.** { *; } +-dontwarn java.lang.management.** +-dontwarn javax.management.** \ No newline at end of file From d1b83ef66879a5ef9883a49a8ee4742eb807933d Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 7 Jun 2026 14:18:07 +0900 Subject: [PATCH 5/6] update ux --- .../main/kotlin/dev/dimension/flare/App.kt | 60 ++++++++++++------- .../ui/screen/home/HomeTimelineScreen.kt | 11 +++- .../ui/screen/home/NotificationScreen.kt | 5 +- .../flare/ui/screen/home/TimelineScreen.kt | 12 +++- .../agent/status/StatusInsightAgentUseCase.kt | 12 +++- .../agent/status/StatusInsightTools.kt | 5 +- 6 files changed, 75 insertions(+), 30 deletions(-) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index beb699b75b..0c420e5317 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -37,6 +38,8 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler @@ -123,6 +126,7 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { .width(72.dp) .verticalScroll(rememberScrollState()) .padding(top = LocalWindowPadding.current.calculateTopPadding()), + horizontalAlignment = Alignment.CenterHorizontally, ) { state.isLoggedIn .onSuccess { loggedIn -> @@ -282,26 +286,6 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { } Spacer(modifier = Modifier.height(8.dp)) - if (state.canComposeState.takeSuccess() == true) { - Button( - onClick = { - state.navigate( - Route.Compose.New, - ) - }, - modifier = - Modifier - .padding(horizontal = 8.dp, vertical = 4.dp) - .fillMaxWidth(), - iconOnly = true, - ) { - Icon( - FontAwesomeIcons.Solid.Pen, - contentDescription = stringResource(Res.string.home_compose), - modifier = Modifier.size(16.dp), - ) - } - } } // SubtleButton( // onClick = { @@ -431,6 +415,33 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { FluentTheme.colors.system.neutral }, ) + if (state.canComposeState.takeSuccess() == true) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = + Modifier + .shadow(4.dp, CircleShape) + .background( + FluentTheme.colors.fillAccent.secondary, + CircleShape, + ).fillMaxWidth(0.66f) + .aspectRatio(1f) + .clip(CircleShape) + .clickable { + state.navigate( + Route.Compose.New, + ) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + FontAwesomeIcons.Solid.Pen, + contentDescription = stringResource(Res.string.home_compose), + modifier = Modifier.size(16.dp), + tint = FluentTheme.colors.text.onAccent.primary, + ) + } + } Spacer(modifier = Modifier.weight(1f)) NavigationItem( icon = { @@ -459,6 +470,7 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { }, ) } +// CommandBarSeparator() CompositionLocalProvider( LocalUriHandler provides remember { @@ -484,6 +496,14 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { navigate = { route -> state.navigate(route) }, onBack = { state.goBack() }, ) + Spacer( + modifier = + Modifier + .fillMaxHeight() + .width(1.dp) + .background(FluentTheme.colors.stroke.divider.default) + .align(Alignment.CenterStart), + ) InAppNotificationComponent( modifier = Modifier diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index d17357b41d..629557cb74 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.platform.LocalUriHandler @@ -280,14 +281,18 @@ internal fun HomeTimelineScreen( Box( modifier = Modifier + .alpha(0.66f) .matchParentSize() + .blur(16.dp) .background( - if (LocalTimelineAppearance.current.timelineDisplayMode == TimelineDisplayMode.Plain) { - FluentTheme.colors.background.card.secondary + if (LocalTimelineAppearance.current.timelineDisplayMode == TimelineDisplayMode.Plain && + !FluentTheme.colors.darkMode + ) { + FluentTheme.colors.background.layer.default } else { FluentTheme.colors.background.mica.base }, - ).blur(32.dp), + ), ) Row( modifier = diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt index 5536b0cff0..586d30437d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/NotificationScreen.kt @@ -1,6 +1,7 @@ package dev.dimension.flare.ui.screen.home import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -8,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -76,6 +78,7 @@ internal fun NotificationScreen() { SelectorBar( modifier = Modifier + .horizontalScroll(rememberScrollState()) .let { if (LocalTimelineAppearance.current.timelineDisplayMode == TimelineDisplayMode.Plain) { it.padding(horizontal = screenHorizontalPadding) @@ -146,7 +149,7 @@ internal fun NotificationScreen() { maxLines = 1, modifier = Modifier.padding(start = 8.dp), ) - AnimatedVisibility(badge > 0) { + if (badge > 0) { Badge( status = BadgeStatus.Informational, content = { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 5bc169bb64..efbc325a30 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -77,7 +77,8 @@ internal fun DeckTimelineScreen( } tabState.tabItem.onSuccess { tabItem -> val timelineState = rememberTimelineItemPresenterWithLazyListState(tabItem) - val isTopBarExpanded = remember(tabItem.id) { androidx.compose.runtime.mutableStateOf(true) } + val isTopBarExpanded = + remember(tabItem.id) { androidx.compose.runtime.mutableStateOf(true) } val timelineAppearance = LocalTimelineAppearance.current CompositionLocalProvider( LocalTimelineAppearance provides @@ -124,8 +125,13 @@ internal fun DeckTimelineScreen( modifier = Modifier .matchParentSize() - .background(FluentTheme.colors.background.mica.base) - .blur(32.dp), + .background( + if (!FluentTheme.colors.darkMode) { + FluentTheme.colors.background.layer.default + } else { + FluentTheme.colors.background.mica.base + }, + ).blur(32.dp), ) Row( modifier = diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt index 908726f2f3..0049a30c79 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt @@ -614,6 +614,16 @@ internal class StatusInsightAgentUseCase( - If popularity can be inferred from supplied engagement signals or search results, explain why it may be spreading. - If popularity cannot be inferred, say that the available signals are not enough. - Stay neutral and grounded in the supplied post context. + + Iteration budget: + - You must finish within a small agent step budget. + - Prefer answering from the supplied post, author, media, quoted/reposted content, and engagement signals. + - For simple posts, do not call any tools. + - If tools are needed, make at most one tool-calling turn total. + - In that single tool-calling turn, call only the minimum necessary tools. You may call the post context tool and one search query in the same turn if both are clearly needed. + - After any tool result is returned, write the final answer immediately. Do not call another tool. + - If context is still incomplete after the first tool result, state the uncertainty in the answer instead of searching again. + - Never perform exploratory searches. Use one concise query with the most distinctive phrase, account name, hashtag, event name, or claim from the post. Tool use: - Use the post context tool when the post depends on a missing thread, reply chain, quoted post, or conversation setup. @@ -624,7 +634,7 @@ internal class StatusInsightAgentUseCase( - Search both users and posts when both account identity and surrounding discussion matter. - Leave the platform list empty to search all signed-in platforms when broad context is useful. - Limit the platform list when the post clearly belongs to, mentions, or depends on a specific platform. - - Search across diverse signed-in platforms when useful. Do not rely on a single search result for complex or controversial claims. + - Search across diverse signed-in platforms only when the first query needs broad coverage. Do not run separate searches per platform. - If search or context results are thin, uncertain, or conflicting, say what can and cannot be inferred from the available signals. - Do not call tools just to restate a simple post. diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt index 1dc2f8e81a..b3938830de 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt @@ -24,7 +24,8 @@ internal class LoadStatusContextTool( name = "load_status_context", description = "Load the current post's conversation context, including surrounding replies or thread posts. " + - "Use this only when the post depends on missing conversation context.", + "Use this only when the post depends on missing conversation context. " + + "After this tool returns, answer from the available context instead of calling more tools.", ) { @Serializable internal data class Args( @@ -55,7 +56,7 @@ internal class SearchStatusTool( description = "Search public or account-visible posts or users across the user's signed-in social platforms. " + "Use this only when external posts or user profiles may explain a phrase, meme, event, account, " + - "or why a post is spreading.", + "or why a post is spreading. Use one concise query, then answer from the returned results.", ) { @Serializable internal data class Args( From 9e472e848ac602161bb2719d9d8544639bc0d889 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sun, 7 Jun 2026 21:43:26 +0900 Subject: [PATCH 6/6] add more agent --- .../ui/component/BottomSheetSceneStrategy.kt | 11 +- .../dimension/flare/ui/component/SearchBar.kt | 79 ++- .../ui/component/agent/AgentChatComponents.kt | 436 +++++++++++++++ .../dev/dimension/flare/ui/route/Route.kt | 9 + .../ui/screen/agent/AgentChatHistoryScreen.kt | 138 +++++ .../flare/ui/screen/agent/AgentChatScreen.kt | 78 +++ .../flare/ui/screen/home/DiscoverScreen.kt | 38 +- .../flare/ui/screen/home/HomeEntryBuilder.kt | 14 + .../flare/ui/screen/home/HomeScreen.kt | 62 ++- .../flare/ui/screen/home/SearchScreen.kt | 37 ++ .../settings/SettingsSelectEntryBuilder.kt | 22 + .../ui/screen/status/StatusEntryBuilder.kt | 4 +- .../status/action/StatusInsightSheet.kt | 230 +++----- app/src/main/res/values-zh-rCN/strings.xml | 5 + app/src/main/res/values-zh-rTW/strings.xml | 5 + app/src/main/res/values/strings.xml | 9 + .../main/composeResources/values/strings.xml | 8 + .../ui/component/agent/AgentChatComponents.kt | 323 +++++++++++ .../dev/dimension/flare/ui/route/Route.kt | 7 + .../dev/dimension/flare/ui/route/Router.kt | 38 ++ .../flare/ui/screen/home/DiscoverScreen.kt | 39 +- .../flare/ui/screen/home/SearchScreen.kt | 20 +- .../ui/screen/settings/AgentChatScreen.kt | 101 ++++ .../ui/screen/settings/AgentHistoryScreen.kt | 142 +++++ .../ui/screen/settings/SettingsScreen.kt | 31 ++ .../status/action/StatusInsightDialog.kt | 142 ++--- feature/agent/build.gradle.kts | 17 + .../agent/chat/GenericChatAgentUseCase.kt | 134 +++++ .../agent/common/AgentChatHistoryProvider.kt | 365 +++++++++++++ .../agent/common/AgentConversationEvent.kt | 15 + .../common/AgentConversationTitleGenerator.kt | 75 +++ .../flare/feature/agent/common/AgentTools.kt | 240 +++++++++ .../flare/feature/agent/common/AgentTrace.kt | 51 ++ .../feature/agent/common/FlareAgentRunner.kt | 200 +++++++ .../feature/agent/database/AgentDatabase.kt | 38 ++ .../agent/database/ProvideAgentDatabase.kt | 17 + .../database/dao/AgentConversationDao.kt | 81 +++ .../database/model/DbAgentConversation.kt | 14 + .../model/DbAgentConversationAttachment.kt | 17 + .../agent/database/model/DbAgentMessage.kt | 16 + .../presenter/AgentChatHistoryPresenter.kt | 53 ++ .../presenter/AgentChatPresenterController.kt | 198 +++++++ .../presenter/chat/GenericChatPresenter.kt | 192 +++++++ .../status/StatusInsightPresenter.kt | 168 +++--- .../agent/status/StatusInsightAgentUseCase.kt | 510 +++++------------- .../agent/status/StatusInsightTools.kt | 267 +++++---- gradle/libs.versions.toml | 1 + iosApp/flare/Localizable.xcstrings | 240 ++++++++- iosApp/flare/UI/Component/AgentChatView.swift | 509 +++++++++++++++++ iosApp/flare/UI/Route/Route.swift | 20 +- .../UI/Screen/AgentChatHistoryScreen.swift | 47 ++ iosApp/flare/UI/Screen/AgentChatScreen.swift | 47 ++ iosApp/flare/UI/Screen/DiscoverScreen.swift | 18 +- iosApp/flare/UI/Screen/SearchScreen.swift | 44 +- .../flare/UI/Screen/SecondaryTabsScreen.swift | 7 + .../flare/UI/Screen/StatusInsightSheet.swift | 67 +-- .../data/database/DriverFactory.android.kt | 8 +- .../data/database/DriverFactory.apple.kt | 9 +- .../data/database/DatabaseDriverProvider.kt | 4 +- .../flare/data/database/DriverFactory.kt | 8 +- .../settings/AiAgentEnabledPresenter.kt | 32 ++ .../flare/data/database/DriverFactory.jvm.kt | 6 +- .../database/DatabaseDriverProvider.nonWeb.kt | 4 +- .../database/DatabaseDriverProvider.wasmJs.kt | 2 +- .../data/database/DriverFactory.wasmJs.kt | 6 +- 65 files changed, 4844 insertions(+), 931 deletions(-) create mode 100644 app/src/main/java/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatHistoryScreen.kt create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatScreen.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentChatScreen.kt create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentHistoryScreen.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/chat/GenericChatAgentUseCase.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentChatHistoryProvider.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationEvent.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationTitleGenerator.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentTools.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentTrace.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/FlareAgentRunner.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/AgentDatabase.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/ProvideAgentDatabase.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/dao/AgentConversationDao.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentConversation.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentConversationAttachment.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentMessage.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/AgentChatHistoryPresenter.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/AgentChatPresenterController.kt create mode 100644 feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/chat/GenericChatPresenter.kt create mode 100644 iosApp/flare/UI/Component/AgentChatView.swift create mode 100644 iosApp/flare/UI/Screen/AgentChatHistoryScreen.swift create mode 100644 iosApp/flare/UI/Screen/AgentChatScreen.swift create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenter.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/component/BottomSheetSceneStrategy.kt b/app/src/main/java/dev/dimension/flare/ui/component/BottomSheetSceneStrategy.kt index 0efa3f1d1a..ba4600df57 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/BottomSheetSceneStrategy.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/BottomSheetSceneStrategy.kt @@ -27,7 +27,16 @@ private class BottomSheetScene( override val content: @Composable (() -> Unit) = { sheetState = - rememberBottomSheetState(initialValue = if (properties.expandFully) SheetValue.Expanded else SheetValue.PartiallyExpanded) + rememberBottomSheetState( + initialValue = + if (properties.expandFully) SheetValue.Expanded else SheetValue.PartiallyExpanded, + enabledValues = + if (properties.expandFully) { + setOf(SheetValue.Expanded, SheetValue.Hidden) + } else { + setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded, SheetValue.Hidden) + }, + ) ModalBottomSheet( onDismissRequest = { onBack() }, properties = properties.properties, diff --git a/app/src/main/java/dev/dimension/flare/ui/component/SearchBar.kt b/app/src/main/java/dev/dimension/flare/ui/component/SearchBar.kt index 95b8b59dbe..5aea225567 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/SearchBar.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/SearchBar.kt @@ -6,6 +6,9 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -64,6 +67,7 @@ internal fun SearchBar( onSearch: (String) -> Unit, modifier: Modifier = Modifier, trailingIcon: @Composable (() -> Unit)? = null, + historyFloatingActionButton: @Composable (() -> Unit)? = null, ) { val keyboardController = LocalSoftwareKeyboardController.current SearchContent( @@ -85,6 +89,7 @@ internal fun SearchBar( }, queryTextState = state.queryTextState, trailingIcon = trailingIcon, + historyFloatingActionButton = historyFloatingActionButton, ) } @@ -102,6 +107,7 @@ private fun SearchContent( onBack: () -> Unit, modifier: Modifier = Modifier, trailingIcon: @Composable (() -> Unit)? = null, + historyFloatingActionButton: @Composable (() -> Unit)? = null, ) { androidx.compose.material3.SearchBar( inputField = { @@ -137,33 +143,54 @@ private fun SearchContent( expanded = expanded, onExpandedChange = onExpandedChange, ) { - LazyColumn( - modifier = Modifier.imePadding(), + Box( + modifier = + Modifier + .fillMaxSize() + .imePadding(), ) { - historyState.onSuccess { history -> - items(history.size) { index -> - val item = history[index] - ListItem( - headlineContent = { - Text(text = item.keyword) - }, - modifier = - Modifier - .clickable { - onSearch(item.keyword) - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - trailingContent = { - IconButton(onClick = { - onDelete.invoke(item) - }) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Xmark, - contentDescription = stringResource(R.string.delete), - ) - } - }, - ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + bottom = if (historyFloatingActionButton != null) 88.dp else 0.dp, + ), + ) { + historyState.onSuccess { history -> + items(history.size) { index -> + val item = history[index] + ListItem( + headlineContent = { + Text(text = item.keyword) + }, + modifier = + Modifier + .clickable { + onSearch(item.keyword) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + trailingContent = { + IconButton(onClick = { + onDelete.invoke(item) + }) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Xmark, + contentDescription = stringResource(R.string.delete), + ) + } + }, + ) + } + } + } + historyFloatingActionButton?.let { fab -> + Box( + modifier = + Modifier + .align(androidx.compose.ui.Alignment.BottomCenter) + .padding(bottom = 16.dp), + ) { + fab() } } } 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 new file mode 100644 index 0000000000..d275e66c25 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt @@ -0,0 +1,436 @@ +package dev.dimension.flare.ui.component.agent + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imeNestedScroll +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.LaunchedEffect +import androidx.compose.runtime.getValue +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.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +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.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.theme.screenHorizontalPadding + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun AgentChatContent( + title: String, + messages: List, + input: String, + isRunning: Boolean, + canSend: Boolean, + error: Throwable?, + runningTrace: String, + inputPlaceholder: String, + sendContentDescription: String, + messageText: (Message) -> String, + isUserMessage: (Message) -> Boolean, + onInputChange: (String) -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier, + showHeader: Boolean = true, + leadingContentItemCount: Int = 0, + leadingContent: LazyListScope.() -> Unit = {}, +) { + AgentChatScaffold( + messages = messages, + input = input, + isRunning = isRunning, + canSend = canSend, + error = error, + runningTrace = runningTrace, + inputPlaceholder = inputPlaceholder, + sendContentDescription = sendContentDescription, + messageText = messageText, + isUserMessage = isUserMessage, + onInputChange = onInputChange, + onSend = onSend, + modifier = modifier, + topBar = + if (showHeader) { + { + AgentChatHeader( + title = title, + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding) + .padding(vertical = 12.dp), + ) + } + } else { + {} + }, + leadingContentItemCount = leadingContentItemCount, + leadingContent = leadingContent, + ) +} + +@Composable +internal fun AgentChatScaffold( + messages: List, + input: String, + isRunning: Boolean, + canSend: Boolean, + error: Throwable?, + runningTrace: String, + inputPlaceholder: String, + sendContentDescription: String, + messageText: (Message) -> String, + isUserMessage: (Message) -> Boolean, + onInputChange: (String) -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + reserveBottomBarHeight: Boolean = true, + leadingContentItemCount: Int = 0, + leadingContent: LazyListScope.() -> Unit = {}, +) { + val bottomBarHeight = + if (reserveBottomBarHeight) { + LocalBottomBarHeight.current + } else { + 0.dp + } + + FlareScaffold( + modifier = modifier.fillMaxSize(), + topBar = topBar, + bottomBar = { + Surface { + Box { + HorizontalDivider( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + color = FlareDividerDefaults.color, + thickness = FlareDividerDefaults.thickness, + ) + AgentChatInput( + value = input, + enabled = !isRunning, + canSend = canSend, + placeholder = inputPlaceholder, + sendContentDescription = sendContentDescription, + onValueChange = onInputChange, + onSend = onSend, + modifier = + Modifier + .padding(bottom = bottomBarHeight) + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal, + ), + ).consumeWindowInsets( + PaddingValues( + bottom = bottomBarHeight, + ), + ).imePadding() + .fillMaxWidth() + .padding( + horizontal = screenHorizontalPadding, + vertical = 8.dp, + ), + ) + } + } + }, + contentWindowInsets = + ScaffoldDefaults.contentWindowInsets.add( + WindowInsets(bottom = bottomBarHeight), + ), + ) { contentPadding -> + AgentChatMessageList( + messages = messages, + isRunning = isRunning, + error = error, + runningTrace = runningTrace, + messageText = messageText, + isUserMessage = isUserMessage, + modifier = + Modifier + .consumeWindowInsets(contentPadding) + .fillMaxSize() + .padding(horizontal = screenHorizontalPadding), + contentPadding = contentPadding, + leadingContentItemCount = leadingContentItemCount, + leadingContent = leadingContent, + ) + } +} + +@Composable +internal fun AgentChatHeader( + title: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + ) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun AgentChatMessageList( + messages: List, + isRunning: Boolean, + error: Throwable?, + runningTrace: String, + messageText: (Message) -> String, + isUserMessage: (Message) -> Boolean, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + leadingContentItemCount: Int = 0, + leadingContent: LazyListScope.() -> Unit = {}, +) { + val listState = rememberLazyListState() + val itemCount = + messages.size + + leadingContentItemCount + + (if (isRunning) 1 else 0) + + (if (error != null) 1 else 0) + + if (listState.firstVisibleItemIndex == 0) { + LaunchedEffect(itemCount) { + if (itemCount > 0) { + listState.scrollToItem(0) + } + } + } + + LazyColumn( + modifier = + modifier + .fillMaxSize() + .imePadding() + .imeNestedScroll(), + state = listState, + reverseLayout = true, + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Bottom), + ) { + error?.let { throwable -> + item { + AgentChatError( + text = throwable.message ?: stringResource(id = R.string.status_insight_error), + ) + } + } + + if (isRunning) { + item { + AgentChatCurrentTrace(trace = runningTrace) + } + } + + items(messages.asReversed()) { message -> + AgentChatMessageBubble( + text = messageText(message), + isUser = isUserMessage(message), + ) + } + + leadingContent() + } +} + +@Composable +internal fun AgentChatMessageBubble( + text: String, + isUser: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = + if (isUser) { + Arrangement.End + } else { + Arrangement.Start + }, + ) { + Card( + modifier = Modifier.fillMaxWidth(0.88f), + colors = + CardDefaults.cardColors( + containerColor = + if (isUser) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + ), + ) { + Text( + text = text, + modifier = Modifier.padding(12.dp), + color = + if (isUser) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } +} + +@Composable +internal fun AgentChatInput( + value: String, + enabled: Boolean, + canSend: Boolean, + placeholder: String, + sendContentDescription: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.fillMaxWidth(), + enabled = enabled, + minLines = 1, + maxLines = 4, + placeholder = { + Text(text = placeholder) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = + KeyboardActions( + onSend = { + if (canSend) { + onSend() + } + }, + ), + trailingIcon = { + IconButton( + onClick = onSend, + enabled = canSend, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.PaperPlane, + contentDescription = sendContentDescription, + ) + } + }, + ) +} + +@Composable +internal fun AgentChatError( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier, + color = MaterialTheme.colorScheme.error, + ) +} + +@Composable +internal fun AgentChatCurrentTrace( + trace: String, + modifier: Modifier = Modifier, +) { + val transition = rememberInfiniteTransition() + val shimmerOffset by transition.animateFloat( + initialValue = -240f, + targetValue = 480f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + val color = MaterialTheme.colorScheme.onSurfaceVariant + val shimmerBrush = + Brush.linearGradient( + colors = + listOf( + color.copy(alpha = 0.35f), + color, + color.copy(alpha = 0.35f), + ), + start = Offset(shimmerOffset, 0f), + end = Offset(shimmerOffset + 220f, 0f), + ) + + 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), + ) + } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt index 61cf3ed45e..80bfe54ff7 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt @@ -118,6 +118,9 @@ internal sealed interface Route : NavKey { @Serializable data object LocalHistory : Settings + @Serializable + data object AgentHistory : Settings + @Serializable data object AiConfig : Settings @@ -213,6 +216,12 @@ internal sealed interface Route : NavKey { @Serializable data object DraftBox : Route + @Serializable + data class AgentChat( + val conversationId: String = "generic-chat", + val initialMessage: String? = null, + ) : Route + @Serializable sealed interface Profile : Route { @Serializable diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatHistoryScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatHistoryScreen.kt new file mode 100644 index 0000000000..83ff7c7472 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatHistoryScreen.kt @@ -0,0 +1,138 @@ +package dev.dimension.flare.ui.screen.agent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Plus +import dev.dimension.flare.R +import dev.dimension.flare.feature.agent.presenter.AgentChatHistoryPresenter +import dev.dimension.flare.ui.component.BackButton +import dev.dimension.flare.ui.component.DateTimeText +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar +import dev.dimension.flare.ui.component.FlareScaffold +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import dev.dimension.flare.ui.theme.segmentedShapes2 +import moe.tlaster.precompose.molecule.producePresenter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AgentChatHistoryScreen( + onBack: () -> Unit, + onConversationClick: (String) -> Unit, + onNewConversationClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val state by producePresenter { + AgentChatHistoryPresenter().invoke() + } + val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + FlareScaffold( + topBar = { + FlareLargeFlexibleTopAppBar( + title = { + Text(text = stringResource(id = R.string.agent_history_title)) + }, + navigationIcon = { + BackButton(onBack = onBack) + }, + scrollBehavior = topAppBarScrollBehavior, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onNewConversationClick) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(id = R.string.agent_chat_title), + ) + } + }, + modifier = modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + ) { contentPadding -> + if (state.conversations.isEmpty()) { + Text( + text = stringResource(id = R.string.agent_history_empty), + modifier = + Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(24.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = screenHorizontalPadding), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + itemsIndexed(state.conversations, key = { _, item -> item.id }) { index, conversation -> + AgentHistoryConversationItem( + conversation = conversation, + index = index, + totalCount = state.conversations.size, + onClick = { + onConversationClick(conversation.id) + }, + ) + } + } + } + } +} + +@Composable +private fun AgentHistoryConversationItem( + conversation: AgentChatHistoryPresenter.Conversation, + index: Int, + totalCount: Int, + onClick: () -> Unit, +) { + SegmentedListItem( + onClick = onClick, + shapes = ListItemDefaults.segmentedShapes2(index, totalCount), + modifier = Modifier.fillMaxWidth(), + content = { + Row { + Text( + text = conversation.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + DateTimeText( + data = conversation.updatedAt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) +} 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 new file mode 100644 index 0000000000..a82b31155c --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/agent/AgentChatScreen.kt @@ -0,0 +1,78 @@ +package dev.dimension.flare.ui.screen.agent + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import dev.dimension.flare.R +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.presenter.invoke +import dev.dimension.flare.ui.screen.status.action.StatusInsightPostPreview +import moe.tlaster.precompose.molecule.producePresenter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AgentChatScreen( + conversationId: String, + initialMessage: String?, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val normalizedInitialMessage = initialMessage?.trim()?.takeIf { it.isNotEmpty() } + val state by producePresenter("agent_chat_${conversationId}_${normalizedInitialMessage.orEmpty()}") { + GenericChatPresenter( + conversationId = conversationId, + initialMessage = normalizedInitialMessage, + ).invoke() + } + val fallbackTitle = stringResource(id = R.string.agent_chat_title) + val title = state.title?.takeIf { it.isNotBlank() } ?: fallbackTitle + val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + + AgentChatScaffold( + messages = state.messages, + input = state.input, + isRunning = state.isRunning, + canSend = state.canSend, + error = state.error, + runningTrace = stringResource(id = R.string.agent_chat_thinking), + inputPlaceholder = stringResource(id = R.string.agent_chat_input_placeholder), + sendContentDescription = stringResource(id = R.string.agent_chat_send), + messageText = GenericChatPresenter.Message::text, + isUserMessage = { it is GenericChatPresenter.Message.User }, + onInputChange = state::setInput, + onSend = state::sendMessage, + modifier = modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + leadingContentItemCount = state.statusInsightPosts.size, + leadingContent = { + state.statusInsightPosts.forEach { post -> + item { + StatusInsightPostPreview(post = post) + } + } + }, + topBar = { + FlareTopAppBar( + title = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + BackButton(onBack = onBack) + }, + scrollBehavior = topAppBarScrollBehavior, + ) + }, + ) +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index f70a125529..1fac490153 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -34,6 +35,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Robot import dev.dimension.flare.R import dev.dimension.flare.common.isLoading import dev.dimension.flare.common.isRefreshing @@ -45,6 +49,7 @@ import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.common.items import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareDropdownMenu import dev.dimension.flare.ui.component.FlareScaffold import dev.dimension.flare.ui.component.LocalTimelineAppearance @@ -71,10 +76,14 @@ import moe.tlaster.precompose.molecule.producePresenter @OptIn(ExperimentalLayoutApi::class) @Composable -internal fun DiscoverScreen(onUserClick: (AccountType, MicroBlogKey) -> Unit) { +internal fun DiscoverScreen( + onUserClick: (AccountType, MicroBlogKey) -> Unit, + onAskAiClick: (String?) -> Unit, +) { val state by producePresenter("discover") { discoverPresenter() } val lazyListState = rememberLazyStaggeredGridState() val isBigScreen = isBigScreen() + val showAskAi = LocalTimelineAppearance.current.aiConfig.agent RegisterTabCallback( lazyListState = lazyListState, onRefresh = { @@ -143,6 +152,33 @@ internal fun DiscoverScreen(onUserClick: (AccountType, MicroBlogKey) -> Unit) { null } }, + historyFloatingActionButton = + if (showAskAi) { + { + ExtendedFloatingActionButton( + onClick = { + onAskAiClick( + state.queryTextState + .text + .toString() + .trim() + .takeIf { it.isNotEmpty() }, + ) + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + ) + }, + text = { + Text(text = stringResource(id = R.string.ask_ai)) + }, + ) + } + } else { + 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 fab1a6f0fe..57d300fcfd 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 @@ -12,6 +12,7 @@ import dev.dimension.flare.ui.component.BottomSheetSceneStrategy 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 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) internal fun EntryProviderScope.homeEntryBuilder( @@ -63,11 +64,21 @@ internal fun EntryProviderScope.homeEntryBuilder( onUserClick = { accountType, userKey -> navigate(Route.Profile.User(accountType, userKey)) }, + onAskAiClick = { initialMessage -> + navigate(Route.AgentChat(initialMessage = initialMessage)) + }, ) } entry { NotificationScreen() } + entry { + AgentChatScreen( + conversationId = it.conversationId, + initialMessage = it.initialMessage, + onBack = onBack, + ) + } entry { args -> SearchScreen( initialQuery = args.query, @@ -75,6 +86,9 @@ internal fun EntryProviderScope.homeEntryBuilder( onUserClick = { accountType, userKey -> navigate(Route.Profile.User(accountType, userKey)) }, + onAskAiClick = { initialMessage -> + navigate(Route.AgentChat(initialMessage = initialMessage)) + }, ) } entry( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index a2cb2cff94..a4fff3b933 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -53,6 +53,7 @@ import compose.icons.fontawesomeicons.solid.House import compose.icons.fontawesomeicons.solid.MagnifyingGlass import compose.icons.fontawesomeicons.solid.Pen import compose.icons.fontawesomeicons.solid.PenToSquare +import compose.icons.fontawesomeicons.solid.Robot import compose.icons.fontawesomeicons.solid.SquareRss import dev.dimension.flare.R import dev.dimension.flare.model.AccountType @@ -78,6 +79,7 @@ import dev.dimension.flare.ui.presenter.home.LoggedInPresenter import dev.dimension.flare.ui.presenter.home.SecondaryTabsPresenter import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.settings.AiAgentEnabledPresenter import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.route.Router import dev.dimension.flare.ui.screen.splash.SplashScreen @@ -312,6 +314,23 @@ internal fun HomeScreen(afterInit: () -> Unit) { Text(text = stringResource(id = R.string.settings_local_history_title)) }, ) + if (state.aiAgentEnabled) { + item( + selected = currentRoute is Route.Settings.AgentHistory, + onClick = { + state.navigate(Route.Settings.AgentHistory) + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = stringResource(id = R.string.agent_history_title), + ) + }, + label = { + Text(text = stringResource(id = R.string.agent_history_title)) + }, + ) + } } state.secondaryTabsState.onSuccess { secondaryTabs -> secondaryTabs.forEach { item -> @@ -424,6 +443,23 @@ internal fun HomeScreen(afterInit: () -> Unit) { Text(text = stringResource(id = R.string.settings_local_history_title)) }, ) + if (state.aiAgentEnabled) { + item( + selected = currentRoute is Route.Settings.AgentHistory, + onClick = { + state.navigate(Route.Settings.AgentHistory) + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = stringResource(id = R.string.agent_history_title), + ) + }, + label = { + Text(text = stringResource(id = R.string.agent_history_title)) + }, + ) + } } item( selected = currentRoute is Route.Settings.Main, @@ -531,6 +567,10 @@ private fun presenter(uriHandler: UriHandler) = remember { AllNotificationBadgePresenter() }.invoke() + val aiAgentEnabledState = + remember { + AiAgentEnabledPresenter() + }.invoke() val firstDirection = remember(tabs.tabs) { tabs.tabs.map { @@ -544,15 +584,22 @@ private fun presenter(uriHandler: UriHandler) = } } val topLevelRoutes = - remember(tabs.tabs) { + remember(tabs.tabs, aiAgentEnabledState.enabled) { tabs.tabs.map { state -> state.map { it.route }.toSet() + - setOf( - Route.Settings.Main, - Route.DraftBox, - Route.Rss.Sources, - Route.Settings.LocalHistory, - ) + buildSet { + addAll( + setOf( + Route.Settings.Main, + Route.DraftBox, + Route.Rss.Sources, + Route.Settings.LocalHistory, + ), + ) + if (aiAgentEnabledState.enabled) { + add(Route.Settings.AgentHistory) + } + } } } val scope = rememberCoroutineScope() @@ -586,6 +633,7 @@ private fun presenter(uriHandler: UriHandler) = val wideNavigationRailState = wideNavigationRailState val loggedInState = loggedInState.isLoggedIn val canComposeState = canComposeState.canCompose + val aiAgentEnabled = aiAgentEnabledState.enabled fun navigate(route: Route) { navigate( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/SearchScreen.kt index c9a397a12d..f127e4250f 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/SearchScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -19,13 +20,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Robot +import dev.dimension.flare.R import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareDropdownMenu import dev.dimension.flare.ui.component.FlareScaffold +import dev.dimension.flare.ui.component.LocalTimelineAppearance import dev.dimension.flare.ui.component.RefreshContainer import dev.dimension.flare.ui.component.SearchBar import dev.dimension.flare.ui.component.SearchBarState @@ -43,6 +51,7 @@ internal fun SearchScreen( initialQuery: String, accountType: AccountType, onUserClick: (AccountType, MicroBlogKey) -> Unit, + onAskAiClick: (String?) -> Unit, ) { val state by producePresenter("search_${accountType}_$initialQuery") { presenter( @@ -52,6 +61,7 @@ internal fun SearchScreen( } val lazyListState = rememberLazyStaggeredGridState() val isBigScreen = isBigScreen() + val showAskAi = LocalTimelineAppearance.current.aiConfig.agent RegisterTabCallback( lazyListState = lazyListState, onRefresh = { @@ -116,6 +126,33 @@ internal fun SearchScreen( null } }, + historyFloatingActionButton = + if (showAskAi) { + { + ExtendedFloatingActionButton( + onClick = { + onAskAiClick( + state.queryTextState + .text + .toString() + .trim() + .takeIf { it.isNotEmpty() }, + ) + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + ) + }, + text = { + Text(text = stringResource(id = R.string.ask_ai)) + }, + ) + } + } else { + null + }, ) } }, diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt index c10eb073f4..b8332f80ee 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt @@ -6,6 +6,8 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.scene.DialogSceneStrategy import dev.dimension.flare.ui.route.Route +import dev.dimension.flare.ui.screen.agent.AgentChatHistoryScreen +import kotlin.time.Clock @OptIn(ExperimentalMaterial3AdaptiveApi::class) internal fun EntryProviderScope.settingsSelectEntryBuilder( @@ -166,6 +168,26 @@ internal fun EntryProviderScope.settingsSelectEntryBuilder( ) } + entry( + metadata = ListDetailSceneStrategy.detailPane( + sceneKey = "Settings" + ) + ) { + AgentChatHistoryScreen( + onBack = onBack, + onConversationClick = { conversationId -> + navigate(Route.AgentChat(conversationId = conversationId)) + }, + onNewConversationClick = { + navigate( + Route.AgentChat( + conversationId = "generic-chat:${Clock.System.now().toEpochMilliseconds()}", + ), + ) + }, + ) + } + entry( metadata = ListDetailSceneStrategy.detailPane( sceneKey = "Settings" 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 ba1ee52638..3a1bfcbc07 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,7 +121,9 @@ internal fun EntryProviderScope.statusEntryBuilder( } entry( - metadata = BottomSheetSceneStrategy.bottomSheet() + metadata = BottomSheetSceneStrategy.bottomSheet( + expandFully = true, + ) ) { args -> StatusInsightSheet( accountType = args.accountType, 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 fe6b4c5f5a..be47bc2815 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,54 +1,38 @@ package dev.dimension.flare.ui.screen.status.action -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll 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.getValue import androidx.compose.runtime.remember -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.res.stringResource import androidx.compose.ui.unit.dp -import compose.icons.FontAwesomeIcons -import compose.icons.fontawesomeicons.Solid -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.AgentPhase +import dev.dimension.flare.feature.agent.common.AgentToolKey +import dev.dimension.flare.feature.agent.common.AgentTrace import dev.dimension.flare.feature.agent.presenter.status.StatusInsightPresenter -import dev.dimension.flare.feature.agent.status.StatusInsightEvent -import dev.dimension.flare.feature.agent.status.StatusInsightPhase -import dev.dimension.flare.feature.agent.status.StatusInsightTraceKey import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.ui.component.FAIcon +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.status.CommonStatusComponent import dev.dimension.flare.ui.model.UiTimelineV2 -import dev.dimension.flare.ui.model.onError -import dev.dimension.flare.ui.model.onLoading -import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding import moe.tlaster.precompose.molecule.producePresenter +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun StatusInsightSheet( accountType: AccountType, @@ -64,53 +48,44 @@ internal fun StatusInsightSheet( }.invoke() } - Column( - modifier = - modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = screenHorizontalPadding) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - FAIcon( - imageVector = FontAwesomeIcons.Solid.Robot, - contentDescription = null, - ) - Text( - text = stringResource(id = R.string.status_insight_title), - style = MaterialTheme.typography.titleLarge, + 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), ) - } - - state.post?.let { post -> - StatusInsightPostPreview(post = post) - } - - state.insight - .onLoading { - StatusInsightCurrentTrace( - trace = state.currentTrace?.label() ?: stringResource(id = R.string.status_insight_analyzing), - ) - }.onError { throwable -> - Text( - text = throwable.message ?: stringResource(id = R.string.status_insight_error), - color = MaterialTheme.colorScheme.error, - ) - }.onSuccess { text -> - Text( - text = text, - ) + }, + reserveBottomBarHeight = false, + leadingContentItemCount = if (state.post != null) 1 else 0, + leadingContent = { + state.post?.let { post -> + item { + StatusInsightPostPreview(post = post) + } } - } + }, + ) } @Composable -private fun StatusInsightPostPreview(post: UiTimelineV2.Post) { +internal fun StatusInsightPostPreview(post: UiTimelineV2.Post) { Card( modifier = Modifier.fillMaxWidth(), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), @@ -140,205 +115,172 @@ private fun StatusInsightPostPreview(post: UiTimelineV2.Post) { } @Composable -private fun StatusInsightEvent.Trace.label(): String = - key?.label() +private fun AgentTrace.label(): String = + toolKey?.label() ?: when (phase) { - StatusInsightPhase.LoadingPostContext -> { + AgentPhase.LoadingPostContext -> { stringResource(id = R.string.status_insight_trace_loading_post_context) } - StatusInsightPhase.PostContextLoaded -> { + AgentPhase.PostContextLoaded -> { stringResource(id = R.string.status_insight_trace_post_context_loaded) } - StatusInsightPhase.PreparingImages -> { + AgentPhase.PreparingImages -> { stringResource(id = R.string.status_insight_trace_preparing_images) } - StatusInsightPhase.ImagesUnsupportedFallback -> { + AgentPhase.ImagesUnsupportedFallback -> { stringResource(id = R.string.status_insight_trace_images_unsupported_fallback) } - StatusInsightPhase.AgentStarted -> { + AgentPhase.AgentStarted -> { stringResource(id = R.string.status_insight_trace_agent_started) } - StatusInsightPhase.StrategyStarted -> { + AgentPhase.StrategyStarted -> { stringResource(id = R.string.status_insight_trace_strategy_started) } - StatusInsightPhase.StrategyCompleted -> { + AgentPhase.StrategyCompleted -> { stringResource(id = R.string.status_insight_trace_strategy_completed) } - StatusInsightPhase.SubgraphStarted -> { + AgentPhase.SubgraphStarted -> { stringResource(id = R.string.status_insight_trace_subgraph_started) } - StatusInsightPhase.SubgraphCompleted -> { + AgentPhase.SubgraphCompleted -> { stringResource(id = R.string.status_insight_trace_subgraph_completed) } - StatusInsightPhase.SubgraphFailed -> { + AgentPhase.SubgraphFailed -> { stringResource(id = R.string.status_insight_trace_subgraph_failed) } - StatusInsightPhase.AskingModel -> { + AgentPhase.AskingModel -> { stringResource( id = R.string.status_insight_trace_asking_model, detail.orEmpty(), ) } - StatusInsightPhase.ModelResponseReceived -> { + AgentPhase.ModelResponseReceived -> { stringResource(id = R.string.status_insight_trace_model_response_received) } - StatusInsightPhase.StreamingStarted -> { + AgentPhase.StreamingStarted -> { stringResource( id = R.string.status_insight_trace_streaming_started, detail.orEmpty(), ) } - StatusInsightPhase.StreamingResponse -> { + AgentPhase.StreamingResponse -> { stringResource(id = R.string.status_insight_trace_streaming_response) } - StatusInsightPhase.StreamingCompleted -> { + AgentPhase.StreamingCompleted -> { stringResource(id = R.string.status_insight_trace_streaming_completed) } - StatusInsightPhase.StreamingFailed -> { + AgentPhase.StreamingFailed -> { stringResource(id = R.string.status_insight_trace_streaming_failed) } - StatusInsightPhase.RunningStep -> { + AgentPhase.RunningStep -> { stringResource(id = R.string.status_insight_trace_running_step) } - StatusInsightPhase.StepCompleted -> { + AgentPhase.StepCompleted -> { stringResource(id = R.string.status_insight_trace_step_completed) } - StatusInsightPhase.StepFailed -> { + AgentPhase.StepFailed -> { stringResource(id = R.string.status_insight_trace_step_failed) } - StatusInsightPhase.ToolCallStarted -> { + AgentPhase.ToolCallStarted -> { stringResource( id = R.string.status_insight_trace_tool_call_started, detail.orEmpty(), ) } - StatusInsightPhase.ToolCallCompleted -> { + AgentPhase.ToolCallCompleted -> { stringResource( id = R.string.status_insight_trace_tool_call_completed, detail.orEmpty(), ) } - StatusInsightPhase.ToolValidationFailed -> { + AgentPhase.ToolValidationFailed -> { stringResource( id = R.string.status_insight_trace_tool_validation_failed, detail.orEmpty(), ) } - StatusInsightPhase.ToolCallFailed -> { + AgentPhase.ToolCallFailed -> { stringResource( id = R.string.status_insight_trace_tool_call_failed, detail.orEmpty(), ) } - StatusInsightPhase.AgentCompleted -> { + AgentPhase.AgentCompleted -> { stringResource(id = R.string.status_insight_trace_agent_completed) } - StatusInsightPhase.AgentFailed -> { + AgentPhase.AgentFailed -> { stringResource(id = R.string.status_insight_trace_agent_failed) } - StatusInsightPhase.AgentClosing -> { + AgentPhase.AgentClosing -> { stringResource(id = R.string.status_insight_trace_agent_closing) } } @Composable -private fun StatusInsightTraceKey.label(): String = +private fun AgentToolKey.label(): String = when (this) { - StatusInsightTraceKey.LoadStatusContextStarted -> { + AgentToolKey.LoadStatusContextStarted -> { stringResource(id = R.string.status_insight_trace_tool_load_status_context_started) } - StatusInsightTraceKey.LoadStatusContextCompleted -> { + AgentToolKey.LoadStatusContextCompleted -> { stringResource(id = R.string.status_insight_trace_tool_load_status_context_completed) } - StatusInsightTraceKey.LoadStatusContextValidationFailed -> { + AgentToolKey.LoadStatusContextValidationFailed -> { stringResource(id = R.string.status_insight_trace_tool_load_status_context_validation_failed) } - StatusInsightTraceKey.LoadStatusContextFailed -> { + AgentToolKey.LoadStatusContextFailed -> { stringResource(id = R.string.status_insight_trace_tool_load_status_context_failed) } - StatusInsightTraceKey.SearchStatusStarted -> { + AgentToolKey.SearchPostsStarted, + AgentToolKey.SearchUsersStarted, + -> { stringResource(id = R.string.status_insight_trace_tool_search_status_started) } - StatusInsightTraceKey.SearchStatusCompleted -> { + AgentToolKey.SearchPostsCompleted, + AgentToolKey.SearchUsersCompleted, + -> { stringResource(id = R.string.status_insight_trace_tool_search_status_completed) } - StatusInsightTraceKey.SearchStatusValidationFailed -> { + AgentToolKey.SearchPostsValidationFailed, + AgentToolKey.SearchUsersValidationFailed, + -> { stringResource(id = R.string.status_insight_trace_tool_search_status_validation_failed) } - StatusInsightTraceKey.SearchStatusFailed -> { + AgentToolKey.SearchPostsFailed, + AgentToolKey.SearchUsersFailed, + -> { stringResource(id = R.string.status_insight_trace_tool_search_status_failed) } } - -@Composable -private fun StatusInsightCurrentTrace(trace: String) { - val transition = rememberInfiniteTransition() - val shimmerOffset by transition.animateFloat( - initialValue = -240f, - targetValue = 480f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1200, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - ) - val color = MaterialTheme.colorScheme.onSurfaceVariant - val shimmerBrush = - Brush.linearGradient( - colors = - listOf( - color.copy(alpha = 0.35f), - color, - color.copy(alpha = 0.35f), - ), - start = Offset(shimmerOffset, 0f), - end = Offset(shimmerOffset + 220f, 0f), - ) - - 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), - ) - } -} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1bc9260ea4..033a42f3d4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -46,6 +46,11 @@ 您有未保存的更改。在关闭前将其保存为草稿吗? 保存 舍弃 + 问问AI + AI 聊天 + 想问什么都可以… + 发送 + 正在思考… 草稿箱 暂无草稿 编辑 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 289b451328..f084fadd13 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -45,6 +45,11 @@ 您有未儲存的變更。是否在關閉前將其儲存為草稿? 儲存 捨棄 + 問問AI + AI 聊天 + 想問什麼都可以… + 發送 + 正在思考… 草稿 目前沒有草稿 編輯 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb4b456028..802e34d640 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,8 @@ Save Discard Post insight + Ask a follow-up… + Send Analyzing this post… Unable to analyze this post. Loading post context… @@ -83,6 +85,13 @@ Finishing analysis… Analysis failed Closing analysis… + Ask AI + AI Chat + Flare Agent + No agent chat history yet. + Ask anything… + Send + Thinking… Drafts No drafts yet Edit diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 445ddbdb82..1c8f24ce74 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -104,6 +104,10 @@ Share via FxEmbed Share via Fixvx Post insight + AI Chat + Ask anything… + Send + Thinking… Analyzing this post… Unable to analyze this post. Loading post context… @@ -140,6 +144,7 @@ Finishing analysis… Analysis failed Closing analysis… + Ask AI Add list Edit list @@ -222,6 +227,9 @@ Choose which server guest mode connects to Local History Search posts and profiles cached on this device + Flare Agent + View local Flare Agent chat history + No agent chat history yet. App logs Save logs Clear logs 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 new file mode 100644 index 0000000000..41e6d09e94 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/agent/AgentChatComponents.kt @@ -0,0 +1,323 @@ +package dev.dimension.flare.ui.component.agent + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.shape.RoundedCornerShape +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.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +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 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.status_insight_error +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FlareScrollBar +import io.github.composefluent.FluentTheme +import io.github.composefluent.LocalContentColor +import io.github.composefluent.LocalTextStyle +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import io.github.composefluent.component.TextField +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun AgentChatScaffold( + messages: List, + input: String, + isRunning: Boolean, + canSend: Boolean, + error: Throwable?, + runningTrace: String, + inputPlaceholder: String, + sendContentDescription: String, + messageText: (Message) -> String, + isUserMessage: (Message) -> Boolean, + onInputChange: (String) -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier, + leadingContentItemCount: Int = 0, + leadingContent: LazyListScope.() -> Unit = {}, +) { + val textState = rememberTextFieldState(input) + LaunchedEffect(input) { + if (textState.text.toString() != input) { + textState.setTextAndPlaceCursorAtEnd(input) + } + } + LaunchedEffect(textState) { + snapshotFlow { textState.text.toString() } + .distinctUntilChanged() + .collect(onInputChange) + } + + Column( + modifier = + modifier + .fillMaxSize() + .background(FluentTheme.colors.background.solid.base), + ) { + AgentChatMessageList( + messages = messages, + isRunning = isRunning, + error = error, + runningTrace = runningTrace, + messageText = messageText, + isUserMessage = isUserMessage, + leadingContentItemCount = leadingContentItemCount, + leadingContent = leadingContent, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) + AgentChatInput( + state = textState, + enabled = !isRunning, + canSend = canSend, + placeholder = inputPlaceholder, + sendContentDescription = sendContentDescription, + onSend = { + onSend() + textState.clearText() + }, + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + ) + } +} + +@Composable +private fun AgentChatMessageList( + messages: List, + isRunning: Boolean, + error: Throwable?, + runningTrace: String, + messageText: (Message) -> String, + isUserMessage: (Message) -> Boolean, + 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) + + if (listState.firstVisibleItemIndex == 0) { + LaunchedEffect(itemCount) { + if (itemCount > 0) { + listState.scrollToItem(0) + } + } + } + + FlareScrollBar( + state = listState, + reverseLayout = true, + modifier = modifier, + ) { + LazyColumn( + state = listState, + reverseLayout = true, + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.Bottom), + modifier = Modifier.fillMaxSize(), + ) { + error?.let { throwable -> + item { + Text( + text = throwable.message ?: stringResource(Res.string.status_insight_error), + color = FluentTheme.colors.system.critical, + ) + } + } + + if (isRunning) { + item { + AgentChatCurrentTrace(trace = runningTrace.ifBlank { stringResource(Res.string.agent_chat_thinking) }) + } + } + + items(messages.asReversed()) { message -> + AgentChatMessageBubble( + text = messageText(message), + isUser = isUserMessage(message), + ) + } + + leadingContent() + } + } +} + +@Composable +private fun AgentChatMessageBubble( + text: String, + isUser: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Box( + modifier = + Modifier + .fillMaxWidth(0.82f) + .background( + color = + if (isUser) { + FluentTheme.colors.fillAccent.default + } else { + FluentTheme.colors.background.layer.default + }, + shape = RoundedCornerShape(8.dp), + ).padding(12.dp), + ) { + Text( + text = text, + color = + if (isUser) { + FluentTheme.colors.text.onAccent.primary + } else { + FluentTheme.colors.text.text.primary + }, + ) + } + } +} + +@Composable +private fun AgentChatInput( + state: TextFieldState, + enabled: Boolean, + canSend: Boolean, + placeholder: String, + sendContentDescription: String, + onSend: () -> Unit, + modifier: Modifier = Modifier, +) { + TextField( + state = state, + enabled = enabled, + modifier = + modifier.onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isShiftPressed) { + if (canSend) { + onSend() + } + true + } else { + false + } + }, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), + trailing = { + SubtleButton( + onClick = onSend, + disabled = !canSend, + iconOnly = true, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.PaperPlane, + contentDescription = sendContentDescription, + ) + } + }, + placeholder = { + Text(text = placeholder) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + onKeyboardAction = { + if (canSend) { + onSend() + } + }, + ) +} + +@Composable +private fun AgentChatCurrentTrace(trace: String) { + val transition = rememberInfiniteTransition() + val shimmerOffset by transition.animateFloat( + initialValue = -240f, + targetValue = 480f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + val color = LocalContentColor.current + val shimmerBrush = + Brush.linearGradient( + colors = + listOf( + color.copy(alpha = 0.35f), + color, + color.copy(alpha = 0.35f), + ), + start = Offset(shimmerOffset, 0f), + end = Offset(shimmerOffset + 180f, 0f), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + ) + androidx.compose.foundation.text.BasicText( + text = trace, + style = + LocalTextStyle.current.merge( + TextStyle(brush = shimmerBrush), + ), + ) + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index 491038784f..929978ffa6 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -240,6 +240,13 @@ internal sealed interface Route : NavKey { data object LocalCache : ScreenRoute + data object AgentHistory : ScreenRoute + + data class AgentChat( + val conversationId: String = "generic-chat", + val initialMessage: String? = null, + ) : ScreenRoute + data class NostrRelays( val accountKey: MicroBlogKey, ) : ScreenRoute 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 9b708cb4b7..2b44502889 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 @@ -84,6 +84,8 @@ import dev.dimension.flare.ui.screen.rss.ImportOPMLScreen import dev.dimension.flare.ui.screen.rss.RssListScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen import dev.dimension.flare.ui.screen.serviceselect.WebViewLoginScreen +import dev.dimension.flare.ui.screen.settings.AgentChatScreen +import dev.dimension.flare.ui.screen.settings.AgentHistoryScreen import dev.dimension.flare.ui.screen.settings.AppLoggingScreen import dev.dimension.flare.ui.screen.settings.LocalCacheScreen import dev.dimension.flare.ui.screen.settings.NostrRelaysScreen @@ -117,6 +119,14 @@ internal fun Router( val listDetailStrategy = rememberListDetailSceneStrategy() val isBigScreen = isBigScreen() + val navigateToAgentChat: (String?) -> Unit = { initialMessage -> + navigate( + Route.AgentChat( + conversationId = "generic-chat:${kotlin.time.Clock.System.now().toEpochMilliseconds()}", + initialMessage = initialMessage, + ), + ) + } if (enableDeepLinkHandler) { OnDeepLink { val route = Route.parse(it) @@ -520,6 +530,7 @@ internal fun Router( ), ) }, + toAskAi = navigateToAgentChat, ) } @@ -535,6 +546,7 @@ internal fun Router( ), ) }, + toAskAi = navigateToAgentChat, ) } @@ -633,6 +645,9 @@ internal fun Router( toLocalCache = { navigate(Route.LocalCache) }, + toAgentHistory = { + navigate(Route.AgentHistory) + }, toAppLog = { navigate(Route.AppLogging) }, @@ -931,6 +946,29 @@ internal fun Router( LocalCacheScreen() } + entry { + AgentHistoryScreen( + onConversationClick = { conversationId -> + navigate(Route.AgentChat(conversationId = conversationId)) + }, + onNewConversationClick = { + navigate( + Route.AgentChat( + conversationId = "generic-chat:${kotlin.time.Clock.System.now().toEpochMilliseconds()}", + ), + ) + }, + ) + } + + entry { args -> + AgentChatScreen( + conversationId = args.conversationId, + initialMessage = args.initialMessage, + onBack = onBack, + ) + } + entry { args -> NostrRelaysScreen( accountKey = args.accountKey, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt index 88cbc3e500..08f4b2afa1 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DiscoverScreen.kt @@ -36,10 +36,12 @@ import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.MagnifyingGlass +import compose.icons.fontawesomeicons.solid.Robot import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.RegisterTabCallback import dev.dimension.flare.Res +import dev.dimension.flare.ask_ai import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess import dev.dimension.flare.common.refreshSuspend @@ -70,6 +72,7 @@ import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding import dev.dimension.flare.users import io.github.composefluent.ExperimentalFluentApi +import io.github.composefluent.component.AccentButton import io.github.composefluent.component.AutoSuggestBoxDefaults import io.github.composefluent.component.AutoSuggestionBox import io.github.composefluent.component.ListItem @@ -89,6 +92,7 @@ import org.jetbrains.compose.resources.stringResource internal fun DiscoverScreen( toUser: (AccountType, MicroBlogKey) -> Unit, toSearch: (AccountType, String) -> Unit, + toAskAi: (String?) -> Unit, ) { val state by producePresenter( key = "discover", @@ -107,8 +111,10 @@ internal fun DiscoverScreen( item( span = StaggeredGridItemSpan.FullLine, ) { - Box( - contentAlignment = Alignment.Center, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, ) { AutoSuggestionBox( expanded = state.isHistoryExpanded, @@ -189,6 +195,18 @@ internal fun DiscoverScreen( ) } } + if (LocalTimelineAppearance.current.aiConfig.agent) { + AskAiButton( + onClick = { + toAskAi( + state.textState.text + .toString() + .trim() + .takeIf { it.isNotEmpty() }, + ) + }, + ) + } } } state.accounts.onSuccess { accounts -> @@ -460,6 +478,23 @@ internal fun DiscoverScreen( } } +@Composable +internal fun AskAiButton(onClick: () -> Unit) { + AccentButton(onClick = onClick) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text(text = stringResource(Res.string.ask_ai)) + } + } +} + @Composable private fun presenter() = run { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt index a566a3f43b..73a5def572 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/SearchScreen.kt @@ -51,6 +51,7 @@ import dev.dimension.flare.ui.component.AvatarComponentDefaults import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareScrollBar import dev.dimension.flare.ui.component.Header +import dev.dimension.flare.ui.component.LocalTimelineAppearance import dev.dimension.flare.ui.component.status.CommonStatusHeaderComponent import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid import dev.dimension.flare.ui.component.status.UserPlaceholder @@ -83,6 +84,7 @@ fun SearchScreen( initialQuery: String?, accountType: AccountType, toUser: (AccountType, MicroBlogKey) -> Unit, + toAskAi: (String?) -> Unit, ) { val state by producePresenter("search_${accountType}_$initialQuery") { presenter(initialQuery, accountType) @@ -102,8 +104,10 @@ fun SearchScreen( item( span = StaggeredGridItemSpan.FullLine, ) { - Box( - contentAlignment = Alignment.Center, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, ) { AutoSuggestionBox( expanded = state.isHistoryExpanded, @@ -176,6 +180,18 @@ fun SearchScreen( ) } } + if (LocalTimelineAppearance.current.aiConfig.agent) { + AskAiButton( + onClick = { + toAskAi( + state.textState.text + .toString() + .trim() + .takeIf { it.isNotEmpty() }, + ) + }, + ) + } } } state.accounts.onSuccess { accounts -> 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 new file mode 100644 index 0000000000..0b3b3e4e02 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentChatScreen.kt @@ -0,0 +1,101 @@ +package dev.dimension.flare.ui.screen.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.ArrowLeft +import compose.icons.fontawesomeicons.solid.Robot +import dev.dimension.flare.Res +import dev.dimension.flare.agent_chat_input_placeholder +import dev.dimension.flare.agent_chat_send +import dev.dimension.flare.agent_chat_thinking +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.presenter.invoke +import dev.dimension.flare.ui.screen.status.action.StatusInsightPostPreview +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.SubtleButton +import io.github.composefluent.component.Text +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun AgentChatScreen( + conversationId: String, + initialMessage: String?, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val normalizedInitialMessage = initialMessage?.trim()?.takeIf { it.isNotEmpty() } + val state by producePresenter("agent_chat_${conversationId}_${normalizedInitialMessage.orEmpty()}") { + GenericChatPresenter( + conversationId = conversationId, + initialMessage = normalizedInitialMessage, + ).invoke() + } + val fallbackTitle = stringResource(Res.string.agent_chat_title) + val title = state.title?.takeIf { it.isNotBlank() } ?: fallbackTitle + + Column(modifier = modifier.fillMaxSize()) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SubtleButton( + onClick = onBack, + iconOnly = true, + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ArrowLeft, + contentDescription = null, + ) + } + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + ) + Text( + text = title, + style = FluentTheme.typography.subtitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + AgentChatScaffold( + messages = state.messages, + input = state.input, + isRunning = state.isRunning, + canSend = state.canSend, + error = state.error, + runningTrace = stringResource(Res.string.agent_chat_thinking), + inputPlaceholder = stringResource(Res.string.agent_chat_input_placeholder), + sendContentDescription = stringResource(Res.string.agent_chat_send), + messageText = GenericChatPresenter.Message::text, + isUserMessage = { it is GenericChatPresenter.Message.User }, + onInputChange = state::setInput, + onSend = state::sendMessage, + leadingContentItemCount = state.statusInsightPosts.size, + leadingContent = { + state.statusInsightPosts.forEach { post -> + item { + StatusInsightPostPreview(post = post) + } + } + }, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentHistoryScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentHistoryScreen.kt new file mode 100644 index 0000000000..4860c97c94 --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/AgentHistoryScreen.kt @@ -0,0 +1,142 @@ +package dev.dimension.flare.ui.screen.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.Plus +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.Res +import dev.dimension.flare.agent_chat_title +import dev.dimension.flare.agent_history_empty +import dev.dimension.flare.feature.agent.presenter.AgentChatHistoryPresenter +import dev.dimension.flare.ui.component.DateTimeText +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FlareScrollBar +import dev.dimension.flare.ui.component.listCard +import dev.dimension.flare.ui.component.status.ListComponent +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.Text +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun AgentHistoryScreen( + onConversationClick: (String) -> Unit, + onNewConversationClick: () -> Unit, +) { + val state by producePresenter { + AgentChatHistoryPresenter().invoke() + } + val listState = rememberLazyListState() + Column( + modifier = + Modifier + .fillMaxSize() + .padding(LocalWindowPadding.current), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.End, + ) { + AccentButton(onClick = onNewConversationClick) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Plus, + contentDescription = stringResource(Res.string.agent_chat_title), + ) + } + } + if (state.conversations.isEmpty()) { + Text( + text = stringResource(Res.string.agent_history_empty), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + color = FluentTheme.colors.text.text.secondary, + ) + } else { + FlareScrollBar(listState) { + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = screenHorizontalPadding), + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + state = listState, + ) { + itemsIndexed(state.conversations, key = { _, item -> item.id }) { index, conversation -> + AgentHistoryConversationItem( + conversation = conversation, + index = index, + totalCount = state.conversations.size, + onClick = { + onConversationClick(conversation.id) + }, + ) + } + } + } + } + } +} + +@Composable +private fun AgentHistoryConversationItem( + conversation: AgentChatHistoryPresenter.Conversation, + index: Int, + totalCount: Int, + onClick: () -> Unit, +) { + ListComponent( + modifier = + Modifier + .fillMaxWidth() + .listCard( + index = index, + totalCount = totalCount, + ).background(FluentTheme.colors.control.default) + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable(onClick = onClick), + headlineContent = { + Row { + Text( + text = conversation.title, + style = FluentTheme.typography.body, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + DateTimeText( + data = conversation.updatedAt, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index c7efb3e945..faae6dd4a4 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -47,6 +47,7 @@ import compose.icons.fontawesomeicons.solid.Language import compose.icons.fontawesomeicons.solid.List import compose.icons.fontawesomeicons.solid.Lock import compose.icons.fontawesomeicons.solid.Plus +import compose.icons.fontawesomeicons.solid.Robot import compose.icons.fontawesomeicons.solid.Trash import dev.dimension.flare.BuildConfig import dev.dimension.flare.LocalWindowPadding @@ -88,6 +89,8 @@ import dev.dimension.flare.settings_about_telegram_description import dev.dimension.flare.settings_about_title import dev.dimension.flare.settings_accounts_remove_confirm import dev.dimension.flare.settings_accounts_title +import dev.dimension.flare.settings_agent_history_description +import dev.dimension.flare.settings_agent_history_title import dev.dimension.flare.settings_ai_config_agent_description import dev.dimension.flare.settings_ai_config_api_key import dev.dimension.flare.settings_ai_config_api_key_hint @@ -296,6 +299,7 @@ internal fun SettingsScreen( toLogin: () -> Unit, toDraftBox: () -> Unit, toLocalCache: () -> Unit, + toAgentHistory: () -> Unit, toAppLog: () -> Unit, toRSSManagement: () -> Unit, toNostrRelays: (MicroBlogKey) -> Unit, @@ -1145,6 +1149,33 @@ internal fun SettingsScreen( icon = null, ) } + AnimatedVisibility( + state.aiConfigState.aiAgent, + ) { + CardExpanderItem( + onClick = toAgentHistory, + heading = { + Text(stringResource(Res.string.settings_agent_history_title)) + }, + trailing = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.AngleRight, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + }, + caption = { + Text(stringResource(Res.string.settings_agent_history_description)) + }, + icon = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Robot, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + } CardExpanderItem( onClick = toDraftBox, heading = { 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 feb7ba869d..5525d65e9f 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 @@ -37,11 +37,13 @@ import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Robot import dev.dimension.flare.Res +import dev.dimension.flare.agent_chat_input_placeholder +import dev.dimension.flare.agent_chat_send import dev.dimension.flare.data.model.PostActionStyle +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.status.StatusInsightPresenter -import dev.dimension.flare.feature.agent.status.StatusInsightEvent -import dev.dimension.flare.feature.agent.status.StatusInsightPhase -import dev.dimension.flare.feature.agent.status.StatusInsightTraceKey import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ok @@ -84,11 +86,9 @@ import dev.dimension.flare.status_insight_trace_tool_search_status_validation_fa import dev.dimension.flare.status_insight_trace_tool_validation_failed 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.UiTimelineV2 -import dev.dimension.flare.ui.model.onError -import dev.dimension.flare.ui.model.onLoading -import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme @@ -148,31 +148,31 @@ internal fun StatusInsightDialog( ) } - Column( + AgentChatScaffold( + messages = state.messages, + input = state.input, + isRunning = state.isRunning, + canSend = state.canSend, + error = state.error, + runningTrace = state.currentTrace?.label() ?: stringResource(Res.string.status_insight_analyzing), + inputPlaceholder = stringResource(Res.string.agent_chat_input_placeholder), + sendContentDescription = stringResource(Res.string.agent_chat_send), + messageText = StatusInsightPresenter.Message::text, + isUserMessage = { it is StatusInsightPresenter.Message.User }, + onInputChange = state::setInput, + onSend = state::sendMessage, + leadingContentItemCount = if (state.post != null) 1 else 0, + leadingContent = { + state.post?.let { post -> + item { + StatusInsightPostPreview(post = post) + } + } + }, modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - state.post?.let { post -> - StatusInsightPostPreview(post = post) - } - - state.insight - .onLoading { - StatusInsightCurrentTrace( - trace = state.currentTrace?.label() ?: stringResource(Res.string.status_insight_analyzing), - ) - }.onError { throwable -> - Text( - text = throwable.message ?: stringResource(Res.string.status_insight_error), - color = Color.Red, - ) - }.onSuccess { text -> - Text(text = text) - } - } + .weight(1f, fill = true), + ) AccentButton( onClick = onBack, @@ -185,7 +185,7 @@ internal fun StatusInsightDialog( } @Composable -private fun StatusInsightPostPreview(post: UiTimelineV2.Post) { +internal fun StatusInsightPostPreview(post: UiTimelineV2.Post) { Column( modifier = Modifier @@ -220,164 +220,172 @@ private fun StatusInsightPostPreview(post: UiTimelineV2.Post) { } @Composable -private fun StatusInsightEvent.Trace.label(): String = - key?.label() +private fun AgentTrace.label(): String = + toolKey?.label() ?: when (phase) { - StatusInsightPhase.LoadingPostContext -> { + AgentPhase.LoadingPostContext -> { stringResource(Res.string.status_insight_trace_loading_post_context) } - StatusInsightPhase.PostContextLoaded -> { + AgentPhase.PostContextLoaded -> { stringResource(Res.string.status_insight_trace_post_context_loaded) } - StatusInsightPhase.PreparingImages -> { + AgentPhase.PreparingImages -> { stringResource(Res.string.status_insight_trace_preparing_images) } - StatusInsightPhase.ImagesUnsupportedFallback -> { + AgentPhase.ImagesUnsupportedFallback -> { stringResource(Res.string.status_insight_trace_images_unsupported_fallback) } - StatusInsightPhase.AgentStarted -> { + AgentPhase.AgentStarted -> { stringResource(Res.string.status_insight_trace_agent_started) } - StatusInsightPhase.StrategyStarted -> { + AgentPhase.StrategyStarted -> { stringResource(Res.string.status_insight_trace_strategy_started) } - StatusInsightPhase.StrategyCompleted -> { + AgentPhase.StrategyCompleted -> { stringResource(Res.string.status_insight_trace_strategy_completed) } - StatusInsightPhase.SubgraphStarted -> { + AgentPhase.SubgraphStarted -> { stringResource(Res.string.status_insight_trace_subgraph_started) } - StatusInsightPhase.SubgraphCompleted -> { + AgentPhase.SubgraphCompleted -> { stringResource(Res.string.status_insight_trace_subgraph_completed) } - StatusInsightPhase.SubgraphFailed -> { + AgentPhase.SubgraphFailed -> { stringResource(Res.string.status_insight_trace_subgraph_failed) } - StatusInsightPhase.AskingModel -> { + AgentPhase.AskingModel -> { stringResource( Res.string.status_insight_trace_asking_model, detail.orEmpty(), ) } - StatusInsightPhase.ModelResponseReceived -> { + AgentPhase.ModelResponseReceived -> { stringResource(Res.string.status_insight_trace_model_response_received) } - StatusInsightPhase.StreamingStarted -> { + AgentPhase.StreamingStarted -> { stringResource( Res.string.status_insight_trace_streaming_started, detail.orEmpty(), ) } - StatusInsightPhase.StreamingResponse -> { + AgentPhase.StreamingResponse -> { stringResource(Res.string.status_insight_trace_streaming_response) } - StatusInsightPhase.StreamingCompleted -> { + AgentPhase.StreamingCompleted -> { stringResource(Res.string.status_insight_trace_streaming_completed) } - StatusInsightPhase.StreamingFailed -> { + AgentPhase.StreamingFailed -> { stringResource(Res.string.status_insight_trace_streaming_failed) } - StatusInsightPhase.RunningStep -> { + AgentPhase.RunningStep -> { stringResource(Res.string.status_insight_trace_running_step) } - StatusInsightPhase.StepCompleted -> { + AgentPhase.StepCompleted -> { stringResource(Res.string.status_insight_trace_step_completed) } - StatusInsightPhase.StepFailed -> { + AgentPhase.StepFailed -> { stringResource(Res.string.status_insight_trace_step_failed) } - StatusInsightPhase.ToolCallStarted -> { + AgentPhase.ToolCallStarted -> { stringResource( Res.string.status_insight_trace_tool_call_started, detail.orEmpty(), ) } - StatusInsightPhase.ToolCallCompleted -> { + AgentPhase.ToolCallCompleted -> { stringResource( Res.string.status_insight_trace_tool_call_completed, detail.orEmpty(), ) } - StatusInsightPhase.ToolValidationFailed -> { + AgentPhase.ToolValidationFailed -> { stringResource( Res.string.status_insight_trace_tool_validation_failed, detail.orEmpty(), ) } - StatusInsightPhase.ToolCallFailed -> { + AgentPhase.ToolCallFailed -> { stringResource( Res.string.status_insight_trace_tool_call_failed, detail.orEmpty(), ) } - StatusInsightPhase.AgentCompleted -> { + AgentPhase.AgentCompleted -> { stringResource(Res.string.status_insight_trace_agent_completed) } - StatusInsightPhase.AgentFailed -> { + AgentPhase.AgentFailed -> { stringResource(Res.string.status_insight_trace_agent_failed) } - StatusInsightPhase.AgentClosing -> { + AgentPhase.AgentClosing -> { stringResource(Res.string.status_insight_trace_agent_closing) } } @Composable -private fun StatusInsightTraceKey.label(): String = +private fun AgentToolKey.label(): String = when (this) { - StatusInsightTraceKey.LoadStatusContextStarted -> { + AgentToolKey.LoadStatusContextStarted -> { stringResource(Res.string.status_insight_trace_tool_load_status_context_started) } - StatusInsightTraceKey.LoadStatusContextCompleted -> { + AgentToolKey.LoadStatusContextCompleted -> { stringResource(Res.string.status_insight_trace_tool_load_status_context_completed) } - StatusInsightTraceKey.LoadStatusContextValidationFailed -> { + AgentToolKey.LoadStatusContextValidationFailed -> { stringResource(Res.string.status_insight_trace_tool_load_status_context_validation_failed) } - StatusInsightTraceKey.LoadStatusContextFailed -> { + AgentToolKey.LoadStatusContextFailed -> { stringResource(Res.string.status_insight_trace_tool_load_status_context_failed) } - StatusInsightTraceKey.SearchStatusStarted -> { + AgentToolKey.SearchPostsStarted, + AgentToolKey.SearchUsersStarted, + -> { stringResource(Res.string.status_insight_trace_tool_search_status_started) } - StatusInsightTraceKey.SearchStatusCompleted -> { + AgentToolKey.SearchPostsCompleted, + AgentToolKey.SearchUsersCompleted, + -> { stringResource(Res.string.status_insight_trace_tool_search_status_completed) } - StatusInsightTraceKey.SearchStatusValidationFailed -> { + AgentToolKey.SearchPostsValidationFailed, + AgentToolKey.SearchUsersValidationFailed, + -> { stringResource(Res.string.status_insight_trace_tool_search_status_validation_failed) } - StatusInsightTraceKey.SearchStatusFailed -> { + AgentToolKey.SearchPostsFailed, + AgentToolKey.SearchUsersFailed, + -> { stringResource(Res.string.status_insight_trace_tool_search_status_failed) } } diff --git a/feature/agent/build.gradle.kts b/feature/agent/build.gradle.kts index 3ca4bd61a9..9c07e23738 100644 --- a/feature/agent/build.gradle.kts +++ b/feature/agent/build.gradle.kts @@ -6,8 +6,10 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) alias(libs.plugins.koin.compiler) alias(libs.plugins.compose.compiler) + alias(libs.plugins.room) } kotlin { @@ -19,6 +21,9 @@ kotlin { FlarePlatform.IOS, FlarePlatform.WEB, ) + ksp( + libs.room.compiler, + ) } sourceSets { @@ -28,13 +33,21 @@ kotlin { implementation(dependencies.platform(libs.compose.bom)) implementation(libs.compose.runtime) implementation(libs.koog.agents) + implementation(libs.koog.agents.features.memory) implementation(libs.koog.http.client.ktor) implementation(libs.bundles.kotlinx) + implementation(libs.room.runtime) + implementation(libs.sqlite) implementation(dependencies.platform(libs.koin.bom)) implementation(libs.koin.core) implementation(libs.koin.annotations) } } + val nonWebMain by getting { + dependencies { + implementation(libs.sqlite.bundled) + } + } val commonTest by getting { dependencies { implementation(kotlin("test")) @@ -43,3 +56,7 @@ kotlin { } } } + +room3 { + schemaDirectory("$projectDir/schemas") +} 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 new file mode 100644 index 0000000000..9e5aeb4b77 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/chat/GenericChatAgentUseCase.kt @@ -0,0 +1,134 @@ +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.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.runtime.AgentAvailability +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import org.koin.core.annotation.Single + +@Single +internal class GenericChatAgentUseCase( + private val agentRunner: FlareAgentRunner, +) { + operator fun invoke( + userInput: String, + searchDataSources: List, + conversationId: String, + ): Flow> = + channelFlow { + run( + userInput = userInput, + searchDataSources = searchDataSources, + conversationId = conversationId, + ) + } + + suspend fun clearConversation(conversationId: String) { + agentRunner.clearConversation(conversationId) + } + + private suspend fun SendChannel>.run( + userInput: String, + searchDataSources: List, + conversationId: String, + ) { + val userInputValue = userInput.trim() + if (userInputValue.isBlank()) { + return + } + val result = + try { + agentRunner.run( + request = + FlareAgentRequest( + prompt = userInputValue.toGenericChatPrompt(), + systemPrompt = GENERIC_CHAT_SYSTEM_PROMPT, + agentId = "flare-generic-chat", + strategyName = "generic_chat", + analyzeNodeName = "answer_user", + executeToolsNodeName = "execute_generic_chat_tools", + sendToolResultsNodeName = "send_generic_chat_tool_results", + toolContext = + AgentToolContext( + searchDataSources = searchDataSources, + ), + temperature = 0.5, + maxIterations = MAX_AGENT_ITERATIONS, + chatMemoryWindowSize = CHAT_MEMORY_WINDOW_SIZE, + ), + conversationId = conversationId, + ) { trace -> + send(AgentConversationEvent.Trace(trace)) + } + } catch (throwable: Throwable) { + if (throwable is CancellationException) { + throw throwable + } + if (throwable is FlareAgentUnavailableException) { + throw GenericChatAgentUnavailableException(throwable.availability) + } + throw throwable + } + + send(AgentConversationEvent.Result(result.cleanPlainText())) + } + + private fun String.toGenericChatPrompt(): Prompt = + Prompt.build("generic-chat") { + user { + text( + buildString { + appendLine("Answer the user's message.") + appendLine("Respond in this language when appropriate: ${Locale.language}.") + appendLine() + appendLine("User message:") + append(this@toGenericChatPrompt) + }, + ) + } + } + + private fun String.cleanPlainText(): String = + trim() + .removePrefix("```markdown") + .removePrefix("```") + .removeSuffix("```") + .trim() + + private companion object { + const val MAX_AGENT_ITERATIONS = 16 + const val CHAT_MEMORY_WINDOW_SIZE = 30 + + const val GENERIC_CHAT_SYSTEM_PROMPT = + """ + You are Flare's general-purpose chat assistant. + + Core behavior: + - Be helpful, truthful, direct, and conversational. + - 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 answer depends on current social context, public discussion, account identity, or recent posts available through signed-in services, use the available search tools. + - 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. + - 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. + """ + } +} + +public class GenericChatAgentUnavailableException public constructor( + public val availability: AgentAvailability, +) : IllegalStateException("Generic chat agent is unavailable: $availability") 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 new file mode 100644 index 0000000000..794733d193 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentChatHistoryProvider.kt @@ -0,0 +1,365 @@ +package dev.dimension.flare.feature.agent.common + +import ai.koog.agents.chatMemory.feature.ChatHistoryProvider +import ai.koog.prompt.message.Message +import dev.dimension.flare.common.PlatformDispatchers +import dev.dimension.flare.feature.agent.database.AgentDatabase +import dev.dimension.flare.feature.agent.database.connect +import dev.dimension.flare.feature.agent.database.model.DbAgentConversation +import dev.dimension.flare.feature.agent.database.model.DbAgentConversationAttachment +import dev.dimension.flare.feature.agent.database.model.DbAgentMessage +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Single +import kotlin.time.Clock + +@Single +internal class AgentChatHistoryProvider( + private val database: AgentDatabase, + private val titleGenerator: AgentConversationTitleGenerator, +) : ChatHistoryProvider { + private val titleScope = CoroutineScope(SupervisorJob() + PlatformDispatchers.IO) + + fun observeRecords(): Flow> = + database + .conversationDao() + .observeConversations() + .map { conversations -> + conversations.map { conversation -> + AgentChatHistoryRecord( + conversationId = conversation.conversationId, + title = conversation.title.orEmpty().ifBlank { conversation.conversationId }, + updatedAt = conversation.updatedAt, + ) + } + } + + fun observeRecord(conversationId: String): Flow = + database + .conversationDao() + .observeConversation(conversationId) + .map { conversation -> + conversation?.toHistoryRecord() + } + + fun observeMessages(conversationId: String): Flow> = + database + .conversationDao() + .observeMessages(conversationId) + .map { messages -> + messages.mapNotNull { it.toHistoryMessage() } + } + + fun observeAttachments( + conversationId: String, + owner: AgentConversationAttachmentOwner, + groupKey: String, + ): Flow> = + database + .conversationDao() + .observeAttachments( + conversationId = conversationId, + owner = owner.name, + groupKey = groupKey, + ).map { attachments -> + attachments.mapNotNull { it.toAttachment() } + } + + fun observeStatusInsightPosts(conversationId: String): Flow> = + observeAttachments( + conversationId = conversationId, + owner = AgentConversationAttachmentOwner.Context, + groupKey = STATUS_INSIGHT_SOURCE_GROUP_KEY, + ).map { attachments -> + attachments.mapNotNull { (it as? AgentConversationAttachment.Post)?.post } + } + + suspend fun storeAttachments( + conversationId: String, + owner: AgentConversationAttachmentOwner, + groupKey: String, + attachments: List, + ) { + val now = Clock.System.now().toEpochMilliseconds() + database.connect { + database.conversationDao().deleteAttachmentGroup( + conversationId = conversationId, + owner = owner.name, + groupKey = groupKey, + ) + database.conversationDao().insertAttachments( + attachments.mapIndexed { index, attachment -> + attachment.toDbAttachment( + conversationId = conversationId, + owner = owner, + groupKey = groupKey, + position = index, + createdAt = now, + ) + }, + ) + } + } + + suspend fun storeStatusInsightSourcePosts( + conversationId: String, + posts: List, + ) = storeAttachments( + conversationId = conversationId, + owner = AgentConversationAttachmentOwner.Context, + groupKey = STATUS_INSIGHT_SOURCE_GROUP_KEY, + attachments = posts.map { AgentConversationAttachment.Post(it) }, + ) + + suspend fun clear(conversationId: String) { + database.connect { + database.conversationDao().deleteMessages(conversationId) + database.conversationDao().deleteAttachments(conversationId) + database.conversationDao().deleteConversation(conversationId) + } + } + + override suspend fun store( + conversationId: String, + messages: List, + ) { + val now = Clock.System.now().toEpochMilliseconds() + val existing = database.conversationDao().getConversation(conversationId) + val historyMessages = messages.mapNotNull { it.toHistoryMessage() } + val fallbackTitle = + existing?.title + ?: historyMessages.firstOrNull { it.role == AgentChatHistoryMessage.Role.User }?.text?.fallbackTitle() + ?: conversationId + database.connect { + database.conversationDao().upsertConversation( + DbAgentConversation( + conversationId = conversationId, + title = fallbackTitle, + titleGenerated = existing?.titleGenerated ?: false, + createdAt = existing?.createdAt ?: now, + updatedAt = now, + ), + ) + database.conversationDao().deleteMessages(conversationId) + database.conversationDao().insertMessages( + messages.mapIndexed { index, message -> + message.toDbMessage( + conversationId = conversationId, + position = index, + ) + }, + ) + } + maybeGenerateTitle( + conversationId = conversationId, + messages = historyMessages, + existing = existing, + ) + } + + override suspend fun load(conversationId: String): List = + database + .conversationDao() + .getMessages(conversationId) + .mapNotNull { it.toMessage() } + + private fun maybeGenerateTitle( + conversationId: String, + messages: List, + existing: DbAgentConversation?, + ) { + if (existing?.titleGenerated == true || messages.none { it.role == AgentChatHistoryMessage.Role.Assistant }) { + return + } + titleScope.launch { + val generatedTitle = titleGenerator.generate(messages) ?: return@launch + database.conversationDao().updateGeneratedTitle( + conversationId = conversationId, + title = generatedTitle, + ) + } + } + + private fun Message.toDbMessage( + conversationId: String, + position: Int, + ): DbAgentMessage = + DbAgentMessage( + conversationId = conversationId, + position = position, + role = role.name, + text = displayText(), + messageJson = json.encodeToString(this), + createdAt = metaInfo.timestamp.toEpochMilliseconds(), + ) + + private fun DbAgentMessage.toMessage(): Message? = + runCatching { + json.decodeFromString(messageJson) + }.getOrNull() + + private fun DbAgentConversation.toHistoryRecord(): AgentChatHistoryRecord = + AgentChatHistoryRecord( + conversationId = conversationId, + title = title.orEmpty().ifBlank { conversationId }, + updatedAt = updatedAt, + ) + + private fun Message.toHistoryMessage(): AgentChatHistoryMessage? { + val text = displayText() + if (text.isBlank()) { + return null + } + return AgentChatHistoryMessage( + role = + when (this) { + is Message.System -> AgentChatHistoryMessage.Role.System + is Message.User -> AgentChatHistoryMessage.Role.User + is Message.Assistant -> AgentChatHistoryMessage.Role.Assistant + }, + text = text, + createdAt = metaInfo.timestamp.toEpochMilliseconds(), + ) + } + + private fun DbAgentMessage.toHistoryMessage(): AgentChatHistoryMessage? { + if (text.isBlank()) { + return null + } + return AgentChatHistoryMessage( + role = + when (role) { + Message.Role.System.name -> AgentChatHistoryMessage.Role.System + Message.Role.User.name -> AgentChatHistoryMessage.Role.User + Message.Role.Assistant.name -> AgentChatHistoryMessage.Role.Assistant + else -> return null + }, + text = text, + createdAt = createdAt, + ) + } + + private fun AgentConversationAttachment.toDbAttachment( + conversationId: String, + owner: AgentConversationAttachmentOwner, + groupKey: String, + position: Int, + createdAt: Long, + ): DbAgentConversationAttachment = + when (this) { + is AgentConversationAttachment.Post -> { + DbAgentConversationAttachment( + conversationId = conversationId, + owner = owner.name, + groupKey = groupKey, + position = position, + type = AgentConversationAttachmentType.Post.name, + contentJson = json.encodeToString(post), + createdAt = createdAt, + ) + } + + is AgentConversationAttachment.User -> { + DbAgentConversationAttachment( + conversationId = conversationId, + owner = owner.name, + groupKey = groupKey, + position = position, + type = AgentConversationAttachmentType.User.name, + contentJson = json.encodeToString(user), + createdAt = createdAt, + ) + } + } + + private fun DbAgentConversationAttachment.toAttachment(): AgentConversationAttachment? = + runCatching { + when (type) { + AgentConversationAttachmentType.Post.name -> { + AgentConversationAttachment.Post( + post = json.decodeFromString(contentJson), + ) + } + + AgentConversationAttachmentType.User.name -> { + AgentConversationAttachment.User( + user = json.decodeFromString(contentJson), + ) + } + + else -> { + null + } + } + }.getOrNull() + + private fun Message.displayText(): String = + textContent() + .trim() + .substringAfter("User message:\n") + .trim() + + private fun String.fallbackTitle(): String = + lineSequence() + .firstOrNull { it.isNotBlank() } + ?.trim() + ?.take(MAX_FALLBACK_TITLE_CHARS) + .orEmpty() + .ifBlank { "Flare Agent" } + + private companion object { + const val MAX_FALLBACK_TITLE_CHARS = 80 + const val STATUS_INSIGHT_SOURCE_GROUP_KEY = "status-insight-source" + val json = + Json { + ignoreUnknownKeys = true + } + } +} + +internal enum class AgentConversationAttachmentOwner { + Context, + User, + Assistant, +} + +private enum class AgentConversationAttachmentType { + Post, + User, +} + +internal sealed interface AgentConversationAttachment { + data class Post( + val post: UiTimelineV2.Post, + ) : AgentConversationAttachment + + data class User( + val user: UiProfile, + ) : AgentConversationAttachment +} + +internal data class AgentChatHistoryRecord( + val conversationId: String, + val title: String, + val updatedAt: Long, +) + +internal data class AgentChatHistoryMessage( + val role: Role, + val text: String, + val createdAt: Long, +) { + enum class Role { + System, + User, + Assistant, + } +} 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 new file mode 100644 index 0000000000..9f249da10f --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationEvent.kt @@ -0,0 +1,15 @@ +package dev.dimension.flare.feature.agent.common + +internal sealed interface AgentConversationEvent { + data class ContentLoaded( + val content: Content, + ) : AgentConversationEvent + + data class Trace( + val trace: Trace, + ) : AgentConversationEvent + + data class Result( + val text: String, + ) : AgentConversationEvent +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationTitleGenerator.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationTitleGenerator.kt new file mode 100644 index 0000000000..29d8437d6a --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentConversationTitleGenerator.kt @@ -0,0 +1,75 @@ +package dev.dimension.flare.feature.agent.common + +import ai.koog.prompt.Prompt +import dev.dimension.flare.feature.agent.runtime.FlareAgentRuntimeProvider +import kotlinx.coroutines.CancellationException +import org.koin.core.annotation.Single + +@Single +internal class AgentConversationTitleGenerator( + private val runtimeProvider: FlareAgentRuntimeProvider, +) { + suspend fun generate(messages: List): String? { + val content = + messages + .filter { it.role != AgentChatHistoryMessage.Role.System } + .takeLast(MAX_MESSAGES) + .joinToString("\n") { message -> + "${message.role.name}: ${message.text.take(MAX_MESSAGE_CHARS)}" + }.takeIf { it.isNotBlank() } + ?: return null + val runtime = runtimeProvider.createRuntime() ?: return null + return try { + runtime + .promptExecutor + .execute( + prompt = + Prompt.build("agent-conversation-title") { + system { + text( + """ + Generate a short title for a chat conversation. + Rules: + - Use the same language as the conversation when possible. + - For Chinese, Japanese, or Korean, keep it around 10 characters. + - For other languages, keep it under 4 words. + - Return only the title. + - Do not use quotation marks. + """.trimIndent(), + ) + } + user { + text(content) + } + }, + model = runtime.model, + ).textContent() + .cleanTitle() + } catch (throwable: Throwable) { + if (throwable is CancellationException) { + throw throwable + } + null + } finally { + runtime.promptExecutor.close() + } + } + + private fun String.cleanTitle(): String? = + trim() + .removePrefix("\"") + .removeSuffix("\"") + .removePrefix("'") + .removeSuffix("'") + .lineSequence() + .firstOrNull() + ?.trim() + ?.take(MAX_TITLE_CHARS) + ?.takeIf { it.isNotBlank() } + + private companion object { + const val MAX_MESSAGES = 8 + const val MAX_MESSAGE_CHARS = 500 + const val MAX_TITLE_CHARS = 24 + } +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentTools.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentTools.kt new file mode 100644 index 0000000000..3ef645b574 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentTools.kt @@ -0,0 +1,240 @@ +package dev.dimension.flare.feature.agent.common + +import ai.koog.agents.core.tools.ToolRegistry +import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource +import dev.dimension.flare.data.repository.AccountMicroblogDataSource +import dev.dimension.flare.feature.agent.status.LoadStatusContextTool +import dev.dimension.flare.feature.agent.status.SearchPostsTool +import dev.dimension.flare.feature.agent.status.SearchUsersTool +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import org.koin.core.annotation.Single + +@Single +internal class AgentToolProvider { + fun resolve(context: AgentToolContext): AgentToolSet { + val statusContext = context.status + val microblogDataSource = statusContext?.postDataSource as? MicroblogDataSource + val searchTargets = + buildList { + addAll(context.searchDataSources.toAgentSearchTargets()) + if (statusContext != null && microblogDataSource != null && none { it.dataSource === microblogDataSource }) { + add(AgentSearchTarget(platformType = statusContext.currentPlatformType, dataSource = microblogDataSource)) + } + } + + val session = + AgentToolSession( + status = statusContext, + searchTargets = searchTargets, + ) + return AgentToolSet( + toolRegistry = + ToolRegistry { + tool(LoadStatusContextTool(session)) + tool(SearchPostsTool(session)) + tool(SearchUsersTool(session)) + }, + systemPromptGuidance = searchTargets.searchPlatformGuidance(), + traceRegistry = AGENT_TOOL_TRACE_REGISTRY, + ) + } + + private companion object { + val AGENT_TOOL_TRACE_REGISTRY = + agentToolTraceRegistry( + LoadStatusContextTool.NAME to + AgentToolTraceKeys( + started = AgentToolKey.LoadStatusContextStarted, + completed = AgentToolKey.LoadStatusContextCompleted, + validationFailed = AgentToolKey.LoadStatusContextValidationFailed, + failed = AgentToolKey.LoadStatusContextFailed, + ), + SearchPostsTool.NAME to + AgentToolTraceKeys( + started = AgentToolKey.SearchPostsStarted, + completed = AgentToolKey.SearchPostsCompleted, + validationFailed = AgentToolKey.SearchPostsValidationFailed, + failed = AgentToolKey.SearchPostsFailed, + ), + SearchUsersTool.NAME to + AgentToolTraceKeys( + started = AgentToolKey.SearchUsersStarted, + completed = AgentToolKey.SearchUsersCompleted, + validationFailed = AgentToolKey.SearchUsersValidationFailed, + failed = AgentToolKey.SearchUsersFailed, + ), + ) + } +} + +internal data class AgentToolContext( + val status: StatusContext? = null, + val searchDataSources: List = emptyList(), +) { + data class StatusContext( + val postDataSource: PostDataSource, + val statusKey: MicroBlogKey, + val currentPlatformType: PlatformType, + ) + + companion object { + val Empty = AgentToolContext() + } +} + +internal data class AgentToolSession( + val status: AgentToolContext.StatusContext?, + val searchTargets: List, +) { + fun statusMicroblogDataSource(): MicroblogDataSource? = status?.postDataSource as? MicroblogDataSource + + fun statusKey(): MicroBlogKey? = status?.statusKey + + fun statusContextUnavailableMessage(): String = + when { + status == null -> { + "The current agent session does not include a post context." + } + + statusMicroblogDataSource() == null -> { + "The current post context does not support microblog tools." + } + + else -> { + "The current post context is unavailable." + } + } +} + +internal data class AgentToolSet( + val toolRegistry: ToolRegistry, + val systemPromptGuidance: String, + val traceRegistry: AgentToolTraceRegistry, +) { + companion object { + val Empty = + AgentToolSet( + toolRegistry = ToolRegistry.EMPTY, + systemPromptGuidance = "", + traceRegistry = AgentToolTraceRegistry(emptyMap()), + ) + } +} + +internal data class AgentSearchTarget( + val platformType: PlatformType?, + val dataSource: MicroblogDataSource, +) + +internal fun List.toAgentSearchTargets(): List = + map { item -> + AgentSearchTarget( + platformType = item.platformType, + dataSource = item.dataSource, + ) + } + +internal fun List.filterByPlatformNames(platforms: List): List { + val platformFilter = platforms.toPlatformFilter() + if (platformFilter.isEmpty()) { + return this + } + return filter { it.platformType in platformFilter } +} + +internal fun List.searchPlatformGuidance(): String { + val platformTypes = + mapNotNull { it.platformType } + .distinct() + return if (platformTypes.isEmpty()) { + """ + + Search platform guidance: + - No searchable signed-in platforms are currently available. + - If you use search, leave the platform list empty. + """ + } else { + val platformLines = + platformTypes.joinToString(separator = "\n") { platformType -> + buildString { + append("- ") + append(platformType.name) + val aliases = platformType.searchAliases() + if (aliases.isNotEmpty()) { + append(" (aliases: ") + append(aliases.joinToString()) + append(")") + } + } + } + """ + + Search platform guidance: + - Available searchable platforms are: + $platformLines + - Use these exact platform names in the search tool's platform list when limiting search. + - You may use the listed aliases; they resolve to the corresponding platform. + - Leave the platform list empty to search every available platform. + """ + }.trimIndent() +} + +internal data class AgentToolTraceKeys( + val started: AgentToolKey, + val completed: AgentToolKey, + val validationFailed: AgentToolKey, + val failed: AgentToolKey, +) + +internal class AgentToolTraceRegistry( + private val keysByToolName: Map, +) { + fun keyFor( + toolName: String?, + phase: AgentPhase, + ): AgentToolKey? { + val keys = keysByToolName[toolName] ?: return null + return when (phase) { + AgentPhase.ToolCallStarted -> keys.started + AgentPhase.ToolCallCompleted -> keys.completed + AgentPhase.ToolValidationFailed -> keys.validationFailed + AgentPhase.ToolCallFailed -> keys.failed + else -> null + } + } +} + +internal fun agentToolTraceRegistry(vararg entries: Pair): AgentToolTraceRegistry = + AgentToolTraceRegistry(entries.toMap()) + +private fun List.toPlatformFilter(): Set = mapNotNull { it.toPlatformTypeOrNull() }.toSet() + +private fun String.toPlatformTypeOrNull(): PlatformType? { + val normalized = searchPlatformKey() + if (normalized == "all" || normalized == "*") { + return null + } + return PlatformType.entries.firstOrNull { platformType -> + normalized == platformType.searchNameKey() || + platformType.searchAliases().any { alias -> normalized == alias.searchPlatformKey() } + } +} + +private fun PlatformType.searchAliases(): List = + when (name) { + PlatformType.Bluesky.name -> listOf("bsky") + PlatformType.xQt.name -> listOf("x", "twitter") + PlatformType.VVo.name -> listOf("weibo") + else -> emptyList() + } + +private fun PlatformType.searchNameKey(): String = name.searchPlatformKey() + +private fun String.searchPlatformKey(): String = + trim() + .lowercase() + .replace("-", "") + .replace("_", "") + .replace(" ", "") diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentTrace.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentTrace.kt new file mode 100644 index 0000000000..654b1f6aa9 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/AgentTrace.kt @@ -0,0 +1,51 @@ +package dev.dimension.flare.feature.agent.common + +public data class AgentTrace( + public val phase: AgentPhase, + public val detail: String? = null, + public val toolKey: AgentToolKey? = null, +) + +public enum class AgentPhase { + LoadingPostContext, + PostContextLoaded, + PreparingImages, + ImagesUnsupportedFallback, + AgentStarted, + StrategyStarted, + StrategyCompleted, + SubgraphStarted, + SubgraphCompleted, + SubgraphFailed, + AskingModel, + ModelResponseReceived, + StreamingStarted, + StreamingResponse, + StreamingCompleted, + StreamingFailed, + RunningStep, + StepCompleted, + StepFailed, + ToolCallStarted, + ToolCallCompleted, + ToolValidationFailed, + ToolCallFailed, + AgentCompleted, + AgentFailed, + AgentClosing, +} + +public enum class AgentToolKey { + LoadStatusContextStarted, + LoadStatusContextCompleted, + LoadStatusContextValidationFailed, + LoadStatusContextFailed, + SearchPostsStarted, + SearchPostsCompleted, + SearchPostsValidationFailed, + SearchPostsFailed, + SearchUsersStarted, + SearchUsersCompleted, + SearchUsersValidationFailed, + SearchUsersFailed, +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/FlareAgentRunner.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/FlareAgentRunner.kt new file mode 100644 index 0000000000..75a4e8f3f6 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/common/FlareAgentRunner.kt @@ -0,0 +1,200 @@ +package dev.dimension.flare.feature.agent.common + +import ai.koog.agents.chatMemory.feature.ChatMemory +import ai.koog.agents.core.agent.AIAgent +import ai.koog.agents.core.dsl.builder.node +import ai.koog.agents.core.dsl.builder.strategy +import ai.koog.agents.core.dsl.extension.nodeExecuteTools +import ai.koog.agents.core.dsl.extension.nodeLLMSendToolResults +import ai.koog.agents.core.dsl.extension.onTextMessage +import ai.koog.agents.core.dsl.extension.onToolCalls +import ai.koog.agents.core.tools.ToolRegistry +import ai.koog.agents.core.utils.ConfigureAction +import ai.koog.agents.features.eventHandler.feature.EventHandler +import ai.koog.prompt.Prompt +import ai.koog.prompt.message.Message +import dev.dimension.flare.feature.agent.runtime.AgentAvailability +import dev.dimension.flare.feature.agent.runtime.FlareAgentRuntime +import dev.dimension.flare.feature.agent.runtime.FlareAgentRuntimeProvider +import org.koin.core.annotation.Single + +@Single +internal class FlareAgentRunner( + private val runtimeProvider: FlareAgentRuntimeProvider, + private val toolProvider: AgentToolProvider, + private val chatHistoryProvider: AgentChatHistoryProvider, +) { + suspend fun clearConversation(conversationId: String) { + chatHistoryProvider.clear(conversationId) + } + + suspend fun run( + request: FlareAgentRequest, + conversationId: String, + onTrace: suspend (AgentTrace) -> Unit, + ): String { + val runtime = + runtimeProvider.createRuntime() + ?: throw FlareAgentUnavailableException(runtimeProvider.availability()) + val toolSet = toolProvider.resolve(request.toolContext) + val resolvedRequest = request.withToolSet(toolSet) + val agent = runtime.createAgent(resolvedRequest, onTrace) + return try { + agent.run(request.prompt, conversationId) + } finally { + agent.close() + } + } + + private fun FlareAgentRuntime.createAgent( + request: FlareAgentRequest, + onTrace: suspend (AgentTrace) -> Unit, + ): AIAgent = + AIAgent + .builder() + .promptExecutor(promptExecutor) + .llmModel(model) + .toolRegistry(request.toolRegistry) + .id(request.agentId) + .systemPrompt(request.systemPromptWithToolGuidance) + .temperature(request.temperature) + .maxIterations(request.maxIterations) + .graphStrategy( + strategy(request.strategyName) { + val nodeAnalyze by node(request.analyzeNodeName) { prompt -> + llm.writeSession { + appendPrompt { + messages(prompt.messages) + } + requestLLM() + } + } + val nodeExecuteTools by nodeExecuteTools(request.executeToolsNodeName) + val nodeSendToolResults by nodeLLMSendToolResults(request.sendToolResultsNodeName) + + edge(nodeStart forwardTo nodeAnalyze) + edge(nodeAnalyze forwardTo nodeExecuteTools onToolCalls { true }) + edge(nodeAnalyze forwardTo nodeFinish onTextMessage { true }) + edge(nodeExecuteTools forwardTo nodeSendToolResults) + edge(nodeSendToolResults forwardTo nodeExecuteTools onToolCalls { true }) + edge(nodeSendToolResults forwardTo nodeFinish onTextMessage { true }) + }, + ).install( + ChatMemory, + ConfigureAction { config -> + config.chatHistoryProvider = chatHistoryProvider + config.windowSize(request.chatMemoryWindowSize) + }, + ).install( + EventHandler, + ConfigureAction { config -> + config.onAgentStarting { + onTrace(AgentTrace(AgentPhase.AgentStarted)) + } + config.onStrategyStarting { + onTrace(AgentTrace(AgentPhase.StrategyStarted)) + } + config.onStrategyCompleted { + onTrace(AgentTrace(AgentPhase.StrategyCompleted)) + } + config.onSubgraphExecutionStarting { + onTrace(AgentTrace(AgentPhase.SubgraphStarted)) + } + config.onSubgraphExecutionCompleted { + onTrace(AgentTrace(AgentPhase.SubgraphCompleted)) + } + config.onSubgraphExecutionFailed { + onTrace(AgentTrace(AgentPhase.SubgraphFailed)) + } + config.onLLMCallStarting { + onTrace(AgentTrace(AgentPhase.AskingModel, detail = it.model.id)) + } + config.onLLMCallCompleted { + onTrace(AgentTrace(AgentPhase.ModelResponseReceived)) + } + config.onLLMStreamingStarting { + onTrace(AgentTrace(AgentPhase.StreamingStarted, detail = it.model.id)) + } + config.onLLMStreamingFrameReceived { + onTrace(AgentTrace(AgentPhase.StreamingResponse)) + } + config.onLLMStreamingCompleted { + onTrace(AgentTrace(AgentPhase.StreamingCompleted)) + } + config.onLLMStreamingFailed { + onTrace(AgentTrace(AgentPhase.StreamingFailed)) + } + config.onNodeExecutionStarting { + onTrace(AgentTrace(AgentPhase.RunningStep)) + } + config.onNodeExecutionCompleted { + onTrace(AgentTrace(AgentPhase.StepCompleted)) + } + config.onNodeExecutionFailed { + onTrace(AgentTrace(AgentPhase.StepFailed)) + } + config.onToolCallStarting { + onTrace(request.toToolTrace(it.toolName, AgentPhase.ToolCallStarted)) + } + config.onToolCallCompleted { + onTrace(request.toToolTrace(it.toolName, AgentPhase.ToolCallCompleted)) + } + config.onToolValidationFailed { + onTrace(request.toToolTrace(it.toolName, AgentPhase.ToolValidationFailed)) + } + config.onToolCallFailed { + onTrace(request.toToolTrace(it.toolName, AgentPhase.ToolCallFailed)) + } + config.onAgentCompleted { + onTrace(AgentTrace(AgentPhase.AgentCompleted)) + } + config.onAgentExecutionFailed { + onTrace(AgentTrace(AgentPhase.AgentFailed)) + } + config.onAgentClosing { + onTrace(AgentTrace(AgentPhase.AgentClosing)) + } + }, + ).build() +} + +internal data class FlareAgentRequest( + val prompt: Prompt, + val systemPrompt: String, + val agentId: String, + val strategyName: String, + val analyzeNodeName: String, + val executeToolsNodeName: String, + val sendToolResultsNodeName: String, + val toolContext: AgentToolContext = AgentToolContext.Empty, + val temperature: Double = 0.2, + val maxIterations: Int = 16, + val chatMemoryWindowSize: Int = 20, + private val toolSet: AgentToolSet = AgentToolSet.Empty, +) { + val toolRegistry: ToolRegistry = toolSet.toolRegistry + val systemPromptWithToolGuidance: String = systemPrompt.withToolGuidance(toolSet.systemPromptGuidance) + + fun withToolSet(toolSet: AgentToolSet): FlareAgentRequest = copy(toolSet = toolSet) + + fun toToolTrace( + toolName: String, + phase: AgentPhase, + ): AgentTrace = + AgentTrace( + phase = phase, + detail = toolName, + toolKey = toolSet.traceRegistry.keyFor(toolName, phase), + ) +} + +internal class FlareAgentUnavailableException( + val availability: AgentAvailability, +) : IllegalStateException("Flare agent is unavailable: $availability") + +private fun String.withToolGuidance(guidance: String): String = + if (guidance.isBlank()) { + this + } else { + trimEnd() + "\n\n" + guidance + } diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/AgentDatabase.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/AgentDatabase.kt new file mode 100644 index 0000000000..74d08e8fb0 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/AgentDatabase.kt @@ -0,0 +1,38 @@ +package dev.dimension.flare.feature.agent.database + +import androidx.room3.ConstructedBy +import androidx.room3.Database +import androidx.room3.RoomDatabase +import androidx.room3.RoomDatabaseConstructor +import androidx.room3.immediateTransaction +import androidx.room3.useWriterConnection +import dev.dimension.flare.feature.agent.database.dao.AgentConversationDao +import dev.dimension.flare.feature.agent.database.model.DbAgentConversation +import dev.dimension.flare.feature.agent.database.model.DbAgentConversationAttachment +import dev.dimension.flare.feature.agent.database.model.DbAgentMessage + +@Database( + entities = [ + DbAgentConversation::class, + DbAgentConversationAttachment::class, + DbAgentMessage::class, + ], + version = 3, + exportSchema = false, +) +@ConstructedBy(AgentDatabaseConstructor::class) +internal abstract class AgentDatabase : RoomDatabase() { + abstract fun conversationDao(): AgentConversationDao +} + +@Suppress("NO_ACTUAL_FOR_EXPECT") +internal expect object AgentDatabaseConstructor : RoomDatabaseConstructor { + override fun initialize(): AgentDatabase +} + +internal suspend fun RoomDatabase.connect(block: suspend () -> R): R = + useWriterConnection { + it.immediateTransaction { + block.invoke() + } + } diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/ProvideAgentDatabase.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/ProvideAgentDatabase.kt new file mode 100644 index 0000000000..0c7305216d --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/ProvideAgentDatabase.kt @@ -0,0 +1,17 @@ +package dev.dimension.flare.feature.agent.database + +import dev.dimension.flare.common.PlatformDispatchers +import dev.dimension.flare.data.database.DriverFactory +import dev.dimension.flare.data.database.createDatabaseDriver +import org.koin.core.annotation.Single + +private const val AGENT_DATABASE_NAME = "agent.db" + +@Single +internal fun provideAgentDatabase(driverFactory: DriverFactory): AgentDatabase = + driverFactory + .createBuilder(AGENT_DATABASE_NAME) + .fallbackToDestructiveMigration(dropAllTables = true) + .setDriver(createDatabaseDriver()) + .setQueryCoroutineContext(PlatformDispatchers.IO) + .build() diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/dao/AgentConversationDao.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/dao/AgentConversationDao.kt new file mode 100644 index 0000000000..5322d88aba --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/dao/AgentConversationDao.kt @@ -0,0 +1,81 @@ +package dev.dimension.flare.feature.agent.database.dao + +import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy +import androidx.room3.Query +import dev.dimension.flare.feature.agent.database.model.DbAgentConversation +import dev.dimension.flare.feature.agent.database.model.DbAgentConversationAttachment +import dev.dimension.flare.feature.agent.database.model.DbAgentMessage +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface AgentConversationDao { + @Query("SELECT * FROM agent_conversations ORDER BY updatedAt DESC") + fun observeConversations(): Flow> + + @Query("SELECT * FROM agent_conversations WHERE conversationId = :conversationId") + fun observeConversation(conversationId: String): Flow + + @Query("SELECT * FROM agent_messages WHERE conversationId = :conversationId ORDER BY position ASC") + fun observeMessages(conversationId: String): Flow> + + @Query( + """ + SELECT * FROM agent_conversation_attachments + WHERE conversationId = :conversationId + AND owner = :owner + AND groupKey = :groupKey + ORDER BY position ASC + """, + ) + fun observeAttachments( + conversationId: String, + owner: String, + groupKey: String, + ): Flow> + + @Query("SELECT * FROM agent_messages WHERE conversationId = :conversationId ORDER BY position ASC") + suspend fun getMessages(conversationId: String): List + + @Query("SELECT * FROM agent_conversations WHERE conversationId = :conversationId") + suspend fun getConversation(conversationId: String): DbAgentConversation? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertConversation(conversation: DbAgentConversation) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessages(messages: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAttachments(attachments: List) + + @Query("DELETE FROM agent_messages WHERE conversationId = :conversationId") + suspend fun deleteMessages(conversationId: String) + + @Query( + """ + DELETE FROM agent_conversation_attachments + WHERE conversationId = :conversationId + AND owner = :owner + AND groupKey = :groupKey + """, + ) + suspend fun deleteAttachmentGroup( + conversationId: String, + owner: String, + groupKey: String, + ) + + @Query("DELETE FROM agent_conversation_attachments WHERE conversationId = :conversationId") + suspend fun deleteAttachments(conversationId: String) + + @Query("DELETE FROM agent_conversations WHERE conversationId = :conversationId") + suspend fun deleteConversation(conversationId: String) + + @Query("UPDATE agent_conversations SET title = :title, titleGenerated = 1 WHERE conversationId = :conversationId") + suspend fun updateGeneratedTitle( + conversationId: String, + title: String, + ) +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentConversation.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentConversation.kt new file mode 100644 index 0000000000..76bac272dc --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentConversation.kt @@ -0,0 +1,14 @@ +package dev.dimension.flare.feature.agent.database.model + +import androidx.room3.Entity +import androidx.room3.PrimaryKey + +@Entity(tableName = "agent_conversations") +internal data class DbAgentConversation( + @PrimaryKey + val conversationId: String, + val title: String?, + val titleGenerated: Boolean, + val createdAt: Long, + val updatedAt: Long, +) diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentConversationAttachment.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentConversationAttachment.kt new file mode 100644 index 0000000000..41c63c6e98 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentConversationAttachment.kt @@ -0,0 +1,17 @@ +package dev.dimension.flare.feature.agent.database.model + +import androidx.room3.Entity + +@Entity( + tableName = "agent_conversation_attachments", + primaryKeys = ["conversationId", "owner", "groupKey", "position"], +) +internal data class DbAgentConversationAttachment( + val conversationId: String, + val owner: String, + val groupKey: String, + val position: Int, + val type: String, + val contentJson: String, + val createdAt: Long, +) diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentMessage.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentMessage.kt new file mode 100644 index 0000000000..17de76ce83 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/database/model/DbAgentMessage.kt @@ -0,0 +1,16 @@ +package dev.dimension.flare.feature.agent.database.model + +import androidx.room3.Entity + +@Entity( + tableName = "agent_messages", + primaryKeys = ["conversationId", "position"], +) +internal data class DbAgentMessage( + val conversationId: String, + val position: Int, + val role: String, + val text: String, + val messageJson: String, + val createdAt: Long, +) diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/AgentChatHistoryPresenter.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/AgentChatHistoryPresenter.kt new file mode 100644 index 0000000000..a4fe2d242d --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/AgentChatHistoryPresenter.kt @@ -0,0 +1,53 @@ +package dev.dimension.flare.feature.agent.presenter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import dev.dimension.flare.feature.agent.common.AgentChatHistoryProvider +import dev.dimension.flare.ui.presenter.PresenterBase +import dev.dimension.flare.ui.render.UiDateTime +import dev.dimension.flare.ui.render.toUi +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.time.Instant + +public class AgentChatHistoryPresenter : + PresenterBase(), + KoinComponent { + private val historyProvider: AgentChatHistoryProvider by inject() + + @Immutable + public interface State { + public val conversations: ImmutableList + } + + @Immutable + public data class Conversation( + val id: String, + val title: String, + val updatedAt: UiDateTime, + ) + + @Composable + override fun body(): State { + val records by historyProvider.observeRecords().collectAsState(emptyList()) + return StateImpl( + conversations = + records + .map { record -> + Conversation( + id = record.conversationId, + title = record.title, + updatedAt = Instant.fromEpochMilliseconds(record.updatedAt).toUi(), + ) + }.toImmutableList(), + ) + } + + private data class StateImpl( + override val conversations: ImmutableList, + ) : State +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/AgentChatPresenterController.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/AgentChatPresenterController.kt new file mode 100644 index 0000000000..850dec8ddc --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/AgentChatPresenterController.kt @@ -0,0 +1,198 @@ +package dev.dimension.flare.feature.agent.presenter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.dimension.flare.feature.agent.common.AgentConversationEvent +import dev.dimension.flare.ui.model.UiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@Immutable +internal data class AgentChatPresenterController( + val messages: ImmutableList, + val input: String, + val isRunning: Boolean, + val content: Content?, + val currentTrace: Trace?, + val error: Throwable?, + private val setInput: (String) -> Unit, + private val sendMessage: () -> Unit, + private val isAssistantMessage: (Message) -> Boolean, + private val messageText: (Message) -> String, +) { + val insight: UiState = + when { + error != null -> { + UiState.Error(error) + } + + isRunning && messages.none(isAssistantMessage) -> { + UiState.Loading() + } + + else -> { + messages + .lastOrNull(isAssistantMessage) + ?.let { UiState.Success(messageText(it)) } + ?: UiState.Loading() + } + } + + val canSend: Boolean = input.isNotBlank() && !isRunning + + fun setInput(value: String) { + setInput.invoke(value) + } + + fun sendMessage() { + sendMessage.invoke() + } +} + +@Composable +internal fun rememberAgentChatPresenterController( + key: String, + conversationId: String, + contextFlow: Flow, + runAgent: (Context, String?, String) -> Flow>, + userMessage: (String) -> Message, + assistantMessage: (String) -> Message, + isAssistantMessage: (Message) -> Boolean, + messageText: (Message) -> String, + missingContextError: () -> Throwable, + autoRunOnContext: Boolean = true, + initialUserInput: String? = null, + initialMessages: List = emptyList(), +): AgentChatPresenterController { + val scope = rememberCoroutineScope() + var messages: ImmutableList by remember(key) { + mutableStateOf(persistentListOf()) + } + var input by remember(key) { + mutableStateOf("") + } + var isRunning by remember(key) { + mutableStateOf(false) + } + var content by remember(key) { + mutableStateOf(null) + } + var currentTrace by remember(key) { + mutableStateOf(null) + } + var error by remember(key) { + mutableStateOf(null) + } + var context by remember(key) { + mutableStateOf(null) + } + var runJob by remember(key) { + mutableStateOf(null) + } + var initialUserInputConsumed by remember(key) { + mutableStateOf(false) + } + + fun updateMessages(transform: (List) -> List) { + messages = transform(messages).toImmutableList() + } + + fun runCurrentAgent(userInput: String?) { + val contextValue = + context + ?: run { + error = missingContextError() + return + } + runJob?.cancel() + runJob = + scope.launch { + isRunning = true + currentTrace = null + error = null + try { + runAgent(contextValue, userInput, conversationId).collect { event -> + when (event) { + is AgentConversationEvent.ContentLoaded -> { + content = event.content + } + + is AgentConversationEvent.Trace -> { + currentTrace = event.trace + } + + is AgentConversationEvent.Result -> { + currentTrace = null + updateMessages { + it + assistantMessage(event.text) + } + } + } + } + } catch (throwable: Throwable) { + currentTrace = null + error = throwable + } finally { + isRunning = false + } + } + } + + LaunchedEffect(key, conversationId, initialMessages) { + contextFlow.collectLatest { contextValue -> + runJob?.cancel() + messages = initialMessages.toImmutableList() + input = "" + isRunning = false + content = null + currentTrace = null + error = null + context = contextValue + val initialText = initialUserInput?.trim()?.takeIf { it.isNotEmpty() } + if (!initialUserInputConsumed && initialText != null) { + initialUserInputConsumed = true + updateMessages { + it + userMessage(initialText) + } + runCurrentAgent(userInput = initialText) + } else if (autoRunOnContext) { + runCurrentAgent(userInput = null) + } + } + } + + return AgentChatPresenterController( + messages = messages, + input = input, + isRunning = isRunning, + content = content, + currentTrace = currentTrace, + error = error, + setInput = { + input = it + }, + sendMessage = { + val text = input.trim() + if (text.isNotEmpty() && !isRunning) { + input = "" + updateMessages { + it + userMessage(text) + } + runCurrentAgent(userInput = text) + } + }, + isAssistantMessage = isAssistantMessage, + messageText = messageText, + ) +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/chat/GenericChatPresenter.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/chat/GenericChatPresenter.kt new file mode 100644 index 0000000000..24312e0611 --- /dev/null +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/chat/GenericChatPresenter.kt @@ -0,0 +1,192 @@ +package dev.dimension.flare.feature.agent.presenter.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.dimension.flare.data.repository.AccountMicroblogDataSource +import dev.dimension.flare.data.repository.AccountService +import dev.dimension.flare.feature.agent.chat.GenericChatAgentUseCase +import dev.dimension.flare.feature.agent.common.AgentChatHistoryMessage +import dev.dimension.flare.feature.agent.common.AgentChatHistoryProvider +import dev.dimension.flare.feature.agent.common.AgentTrace +import dev.dimension.flare.feature.agent.presenter.rememberAgentChatPresenterController +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.map +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +public class GenericChatPresenter( + private val conversationId: String = "generic-chat", + private val initialMessage: String? = null, +) : PresenterBase(), + KoinComponent { + private val accountService: AccountService by inject() + private val genericChatAgentUseCase: GenericChatAgentUseCase by inject() + private val historyProvider: AgentChatHistoryProvider by inject() + + @Immutable + public interface State { + public val response: UiState + public val title: String? + public val messages: ImmutableList + public val input: String + public val isRunning: Boolean + public val statusInsightPosts: ImmutableList + public val currentTrace: AgentTrace? + public val error: Throwable? + public val canSend: Boolean + + public fun setInput(value: String) + + public fun sendMessage() + } + + @Composable + override fun body(): State { + val restoredMessages by remember(conversationId) { + historyProvider.observeMessages(conversationId) + }.collectAsState(emptyList()) + val statusInsightPosts by remember(conversationId) { + historyProvider.observeStatusInsightPosts(conversationId) + }.collectAsState(emptyList()) + val conversationRecord by remember(conversationId) { + historyProvider.observeRecord(conversationId) + }.collectAsState(null) + val contextFlow = + remember { + accountService + .allAccountServicesFlow() + .map { searchDataSources -> + GenericChatContext(searchDataSources) + } + } + val controller = + rememberAgentChatPresenterController( + key = conversationId, + conversationId = conversationId, + contextFlow = contextFlow, + runAgent = { context, userInput, currentConversationId -> + genericChatAgentUseCase( + userInput = userInput.orEmpty(), + searchDataSources = context.searchDataSources, + conversationId = currentConversationId, + ) + }, + userMessage = Message::User, + assistantMessage = Message::Assistant, + isAssistantMessage = { it is Message.Assistant }, + messageText = Message::text, + missingContextError = { + IllegalStateException("Generic chat context is unavailable") + }, + autoRunOnContext = false, + initialUserInput = initialMessage, + initialMessages = restoredMessages.mapNotNull { it.toPresenterMessage(statusInsightPosts) }, + ) + + return StateImpl( + response = controller.insight, + title = conversationRecord?.title, + messages = controller.messages, + input = controller.input, + isRunning = controller.isRunning, + statusInsightPosts = statusInsightPosts.toImmutableList(), + currentTrace = controller.currentTrace, + error = controller.error, + canSend = controller.canSend, + onSetInput = controller::setInput, + onSendMessage = controller::sendMessage, + ) + } + + @Immutable + public sealed interface Message { + public val text: String + + @Immutable + public data class User( + override val text: String, + ) : Message + + @Immutable + public data class Assistant( + override val text: String, + ) : Message + } + + @Immutable + private data class StateImpl( + override val response: UiState, + override val title: String?, + override val messages: ImmutableList, + override val input: String, + override val isRunning: Boolean, + override val statusInsightPosts: ImmutableList, + override val currentTrace: AgentTrace?, + override val error: Throwable?, + override val canSend: Boolean, + private val onSetInput: (String) -> Unit, + private val onSendMessage: () -> Unit, + ) : State { + override fun setInput(value: String) { + onSetInput.invoke(value) + } + + override fun sendMessage() { + onSendMessage.invoke() + } + } + + private data class GenericChatContext( + val searchDataSources: List, + ) + + private fun AgentChatHistoryMessage.toPresenterMessage(statusInsightPosts: List): Message? = + when (role) { + AgentChatHistoryMessage.Role.User -> { + val displayText = text.statusInsightDisplayText(statusInsightPosts) ?: return null + Message.User(displayText) + } + + AgentChatHistoryMessage.Role.Assistant -> { + Message.Assistant(text) + } + + AgentChatHistoryMessage.Role.System -> { + null + } + } + + private fun String.statusInsightDisplayText(statusInsightPosts: List): String? { + if (statusInsightPosts.isEmpty() || !conversationId.startsWith(STATUS_INSIGHT_CONVERSATION_PREFIX)) { + return this + } + val latestQuestion = + substringAfter("Latest user question:\n", missingDelimiterValue = "") + .substringBefore("\n\nCurrent post snapshot:") + .trim() + .takeIf { it.isNotBlank() } + if (latestQuestion != null) { + return latestQuestion + } + if (isStatusInsightSourcePrompt()) { + return null + } + return this + } + + private fun String.isStatusInsightSourcePrompt(): Boolean = + contains("Analyze this social post for the user.") || + contains("Current post snapshot:") || + contains("\nPost:\nplatform:") + + private companion object { + const val STATUS_INSIGHT_CONVERSATION_PREFIX = "status-insight:" + } +} diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/status/StatusInsightPresenter.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/status/StatusInsightPresenter.kt index 49da425248..0866b9eaa5 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/status/StatusInsightPresenter.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/presenter/status/StatusInsightPresenter.kt @@ -2,17 +2,19 @@ package dev.dimension.flare.feature.agent.presenter.status import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource +import dev.dimension.flare.data.repository.AccountMicroblogDataSource import dev.dimension.flare.data.repository.AccountService +import dev.dimension.flare.feature.agent.common.AgentTrace +import dev.dimension.flare.feature.agent.presenter.rememberAgentChatPresenterController import dev.dimension.flare.feature.agent.status.StatusInsightAgentUseCase -import dev.dimension.flare.feature.agent.status.StatusInsightEvent import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.PresenterBase -import kotlinx.coroutines.flow.collectLatest +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.combine import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -28,85 +30,115 @@ public class StatusInsightPresenter( @Immutable public interface State { public val insight: UiState + public val messages: ImmutableList + public val input: String + public val isRunning: Boolean public val post: UiTimelineV2.Post? - public val currentTrace: StatusInsightEvent.Trace? + public val currentTrace: AgentTrace? + public val error: Throwable? + public val canSend: Boolean + + public fun setInput(value: String) + + public fun sendMessage() } @Composable override fun body(): State { - val state = - produceState( - initialValue = StateImpl(), - accountType, - statusKey, - ) { + val key = "$accountType:$statusKey" + val conversationId = + remember(accountType, statusKey) { + "status-insight:$accountType:$statusKey" + } + val contextFlow = + remember(accountType) { accountService .accountServiceFlow(accountType) - .combine(accountService.allAccountServicesFlow()) { service, searchDataSources -> - service to searchDataSources - }.collectLatest { (service, searchDataSources) -> - var post: UiTimelineV2.Post? = null - var currentTrace: StatusInsightEvent.Trace? = null - - fun update( - insight: UiState, - postValue: UiTimelineV2.Post? = post, - currentTraceValue: StatusInsightEvent.Trace? = currentTrace, - ) { - value = - StateImpl( - insight = insight, - post = postValue, - currentTrace = currentTraceValue, - ) - } - - update(UiState.Loading()) - - val postDataSource = - service as? PostDataSource - ?: run { - update(UiState.Error(IllegalStateException("Current account does not support post data source"))) - return@collectLatest - } - - try { - statusInsightAgentUseCase( + .combine(accountService.allAccountServicesFlow()) { service, availableSearchDataSources -> + (service as? PostDataSource)?.let { postDataSource -> + StatusInsightContext( postDataSource = postDataSource, - statusKey = statusKey, - searchDataSources = searchDataSources, - ).collect { event -> - when (event) { - is StatusInsightEvent.PostLoaded -> { - post = event.post - update(UiState.Loading()) - } - - is StatusInsightEvent.Trace -> { - currentTrace = event - update(UiState.Loading()) - } - - is StatusInsightEvent.Result -> { - currentTrace = null - update(UiState.Success(event.text)) - } - } - } - } catch (throwable: Throwable) { - currentTrace = null - update(UiState.Error(throwable)) + searchDataSources = availableSearchDataSources, + ) } } } + val controller = + rememberAgentChatPresenterController( + key = key, + conversationId = conversationId, + contextFlow = contextFlow, + runAgent = { context, userInput, currentConversationId -> + statusInsightAgentUseCase( + postDataSource = context.postDataSource, + statusKey = statusKey, + searchDataSources = context.searchDataSources, + userInput = userInput, + conversationId = currentConversationId, + ) + }, + userMessage = Message::User, + assistantMessage = Message::Assistant, + isAssistantMessage = { it is Message.Assistant }, + messageText = Message::text, + missingContextError = { + IllegalStateException("Current account does not support post data source") + }, + ) - return state.value + return StateImpl( + messages = controller.messages, + input = controller.input, + isRunning = controller.isRunning, + post = controller.content, + currentTrace = controller.currentTrace, + error = controller.error, + insight = controller.insight, + canSend = controller.canSend, + onSetInput = controller::setInput, + onSendMessage = controller::sendMessage, + ) + } + + @Immutable + public sealed interface Message { + public val text: String + + @Immutable + public data class User( + override val text: String, + ) : Message + + @Immutable + public data class Assistant( + override val text: String, + ) : Message } @Immutable private data class StateImpl( - override val insight: UiState = UiState.Loading(), - override val post: UiTimelineV2.Post? = null, - override val currentTrace: StatusInsightEvent.Trace? = null, - ) : State + override val messages: ImmutableList, + override val input: String, + override val isRunning: Boolean, + override val post: UiTimelineV2.Post?, + override val currentTrace: AgentTrace?, + override val error: Throwable?, + override val insight: UiState, + override val canSend: Boolean, + private val onSetInput: (String) -> Unit, + private val onSendMessage: () -> Unit, + ) : State { + override fun setInput(value: String) { + onSetInput.invoke(value) + } + + override fun sendMessage() { + onSendMessage.invoke() + } + } + + private data class StatusInsightContext( + val postDataSource: PostDataSource, + val searchDataSources: List, + ) } diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt index 0049a30c79..332e3679a2 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightAgentUseCase.kt @@ -1,32 +1,25 @@ package dev.dimension.flare.feature.agent.status -import ai.koog.agents.core.agent.AIAgent -import ai.koog.agents.core.dsl.builder.node -import ai.koog.agents.core.dsl.builder.strategy -import ai.koog.agents.core.dsl.extension.nodeExecuteTools -import ai.koog.agents.core.dsl.extension.nodeLLMSendToolResults -import ai.koog.agents.core.dsl.extension.onTextMessage -import ai.koog.agents.core.dsl.extension.onToolCalls -import ai.koog.agents.core.tools.ToolRegistry -import ai.koog.agents.core.utils.ConfigureAction -import ai.koog.agents.features.eventHandler.feature.EventHandler import ai.koog.prompt.Prompt import ai.koog.prompt.message.AttachmentContent import ai.koog.prompt.message.AttachmentSource -import ai.koog.prompt.message.Message import androidx.paging.LoadState import dev.dimension.flare.common.CacheState import dev.dimension.flare.common.Locale import dev.dimension.flare.data.datasource.microblog.ActionMenu -import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource import dev.dimension.flare.data.datasource.microblog.handler.PostTranslationDisplay 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.AgentPhase +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.runtime.AgentAvailability -import dev.dimension.flare.feature.agent.runtime.FlareAgentRuntime -import dev.dimension.flare.feature.agent.runtime.FlareAgentRuntimeProvider import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.coroutines.CancellationException @@ -42,44 +35,67 @@ import org.koin.core.annotation.Single @Single internal class StatusInsightAgentUseCase( - private val runtimeProvider: FlareAgentRuntimeProvider, + private val agentRunner: FlareAgentRunner, + private val chatHistoryProvider: AgentChatHistoryProvider, ) { operator fun invoke( postDataSource: PostDataSource, statusKey: MicroBlogKey, searchDataSources: List, - ): Flow = + userInput: String? = null, + conversationId: String = statusKey.statusInsightConversationId(), + ): Flow> = channelFlow { - run(postDataSource, statusKey, searchDataSources) + run(postDataSource, statusKey, searchDataSources, userInput, conversationId) } - private suspend fun SendChannel.run( + private suspend fun SendChannel>.run( postDataSource: PostDataSource, statusKey: MicroBlogKey, searchDataSources: List, + userInput: String?, + conversationId: String, ) { - val runtime = - runtimeProvider.createRuntime() - ?: throw StatusInsightAgentUnavailableException(runtimeProvider.availability()) - send(StatusInsightEvent.Trace(StatusInsightPhase.LoadingPostContext)) + val userInputValue = userInput?.trim().orEmpty() + if (userInputValue.isBlank()) { + agentRunner.clearConversation(conversationId) + } + send(AgentTrace(AgentPhase.LoadingPostContext).toConversationEvent()) val post = postDataSource.loadPost(statusKey) - send(StatusInsightEvent.PostLoaded(post)) - send(StatusInsightEvent.Trace(StatusInsightPhase.PostContextLoaded)) + chatHistoryProvider.storeStatusInsightSourcePosts( + conversationId = conversationId, + posts = listOf(post), + ) + send(AgentConversationEvent.ContentLoaded(post)) + send(AgentTrace(AgentPhase.PostContextLoaded).toConversationEvent()) val imageAttachments = post.aiImageAttachments() if (imageAttachments.isNotEmpty()) { - send(StatusInsightEvent.Trace(StatusInsightPhase.PreparingImages)) + send(AgentTrace(AgentPhase.PreparingImages).toConversationEvent()) } - val searchTargets = postDataSource.statusInsightSearchTargets(searchDataSources, post.platformType) - val toolRegistry = postDataSource.statusInsightToolRegistry(statusKey, searchTargets) - val systemPrompt = STATUS_INSIGHT_SYSTEM_PROMPT.withSearchPlatformGuidance(searchTargets) + val toolContext = + AgentToolContext( + status = + AgentToolContext.StatusContext( + postDataSource = postDataSource, + statusKey = statusKey, + currentPlatformType = post.platformType, + ), + searchDataSources = searchDataSources, + ) val result = try { - runtime.runAgent( - prompt = post.toInsightPrompt(Locale.language, includeImages = true), - toolRegistry = toolRegistry, - systemPrompt = systemPrompt, + val prompt = + post.toInsightPrompt( + targetLanguage = Locale.language, + userInput = userInputValue, + includeImages = true, + ) + agentRunner.runStatusInsightAgent( + prompt = prompt, + toolContext = toolContext, + conversationId = conversationId, ) { event -> - send(event) + send(event.toConversationEvent()) } } catch (throwable: Throwable) { if (throwable is CancellationException) { @@ -88,162 +104,53 @@ internal class StatusInsightAgentUseCase( if (imageAttachments.isEmpty() || !throwable.isImageContentUnsupported()) { throw throwable } - send(StatusInsightEvent.Trace(StatusInsightPhase.ImagesUnsupportedFallback)) - runtime.runAgent( - prompt = post.toInsightPrompt(Locale.language, includeImages = false), - toolRegistry = toolRegistry, - systemPrompt = systemPrompt, + send(AgentTrace(AgentPhase.ImagesUnsupportedFallback).toConversationEvent()) + val prompt = + post.toInsightPrompt( + targetLanguage = Locale.language, + userInput = userInputValue, + includeImages = false, + ) + agentRunner.runStatusInsightAgent( + prompt = prompt, + toolContext = toolContext, + conversationId = conversationId, ) { event -> - send(event) + send(event.toConversationEvent()) } } - send(StatusInsightEvent.Result(result.cleanPlainText())) + send(AgentConversationEvent.Result(result.cleanPlainText())) } - private suspend fun FlareAgentRuntime.runAgent( + private suspend fun FlareAgentRunner.runStatusInsightAgent( prompt: Prompt, - toolRegistry: ToolRegistry, - systemPrompt: String, - onEvent: suspend (StatusInsightEvent.Trace) -> Unit, - ): String { - val agent = createAgent(toolRegistry, systemPrompt, onEvent) - return try { - agent.run(prompt) - } finally { - agent.close() + toolContext: AgentToolContext, + conversationId: String, + onEvent: suspend (AgentTrace) -> Unit, + ): String = + try { + run( + request = + FlareAgentRequest( + prompt = prompt, + systemPrompt = STATUS_INSIGHT_SYSTEM_PROMPT, + agentId = "flare-status-insight", + strategyName = "status_insight_multimodal", + analyzeNodeName = "analyze_post", + executeToolsNodeName = "execute_status_insight_tools", + sendToolResultsNodeName = "send_status_insight_tool_results", + toolContext = toolContext, + temperature = 0.2, + maxIterations = MAX_AGENT_ITERATIONS, + chatMemoryWindowSize = CHAT_MEMORY_WINDOW_SIZE, + ), + conversationId = conversationId, + onTrace = onEvent, + ) + } catch (throwable: FlareAgentUnavailableException) { + throw StatusInsightAgentUnavailableException(throwable.availability) } - } - - private fun FlareAgentRuntime.createAgent( - toolRegistry: ToolRegistry, - systemPrompt: String, - onEvent: suspend (StatusInsightEvent.Trace) -> Unit, - ): AIAgent = - AIAgent - .builder() - .promptExecutor(promptExecutor) - .llmModel(model) - .toolRegistry(toolRegistry) - .id("flare-status-insight") - .systemPrompt(systemPrompt) - .temperature(0.2) - .maxIterations(MAX_AGENT_ITERATIONS) - .graphStrategy( - strategy("status_insight_multimodal") { - val nodeAnalyze by node("analyze_post") { prompt -> - llm.writeSession { - appendPrompt { - messages(prompt.messages) - } - requestLLM() - } - } - val nodeExecuteTools by nodeExecuteTools("execute_status_insight_tools") - val nodeSendToolResults by nodeLLMSendToolResults("send_status_insight_tool_results") - - edge(nodeStart forwardTo nodeAnalyze) - edge(nodeAnalyze forwardTo nodeExecuteTools onToolCalls { true }) - edge(nodeAnalyze forwardTo nodeFinish onTextMessage { true }) - edge(nodeExecuteTools forwardTo nodeSendToolResults) - edge(nodeSendToolResults forwardTo nodeExecuteTools onToolCalls { true }) - edge(nodeSendToolResults forwardTo nodeFinish onTextMessage { true }) - }, - ).install( - EventHandler, - ConfigureAction { config -> - config.onAgentStarting { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AgentStarted)) - } - config.onStrategyStarting { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StrategyStarted)) - } - config.onStrategyCompleted { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StrategyCompleted)) - } - config.onSubgraphExecutionStarting { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.SubgraphStarted)) - } - config.onSubgraphExecutionCompleted { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.SubgraphCompleted)) - } - config.onSubgraphExecutionFailed { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.SubgraphFailed)) - } - config.onLLMCallStarting { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AskingModel, it.model.id)) - } - config.onLLMCallCompleted { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.ModelResponseReceived)) - } - config.onLLMStreamingStarting { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StreamingStarted, it.model.id)) - } - config.onLLMStreamingFrameReceived { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StreamingResponse)) - } - config.onLLMStreamingCompleted { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StreamingCompleted)) - } - config.onLLMStreamingFailed { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StreamingFailed)) - } - config.onNodeExecutionStarting { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.RunningStep)) - } - config.onNodeExecutionCompleted { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StepCompleted)) - } - config.onNodeExecutionFailed { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.StepFailed)) - } - config.onToolCallStarting { - onEvent( - StatusInsightEvent.Trace( - phase = StatusInsightPhase.ToolCallStarted, - detail = it.toolName, - key = it.toolName.toToolTraceKey(StatusInsightPhase.ToolCallStarted), - ), - ) - } - config.onToolCallCompleted { - onEvent( - StatusInsightEvent.Trace( - phase = StatusInsightPhase.ToolCallCompleted, - detail = it.toolName, - key = it.toolName.toToolTraceKey(StatusInsightPhase.ToolCallCompleted), - ), - ) - } - config.onToolValidationFailed { - onEvent( - StatusInsightEvent.Trace( - phase = StatusInsightPhase.ToolValidationFailed, - detail = it.toolName, - key = it.toolName.toToolTraceKey(StatusInsightPhase.ToolValidationFailed), - ), - ) - } - config.onToolCallFailed { - onEvent( - StatusInsightEvent.Trace( - phase = StatusInsightPhase.ToolCallFailed, - detail = it.toolName, - key = it.toolName.toToolTraceKey(StatusInsightPhase.ToolCallFailed), - ), - ) - } - config.onAgentCompleted { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AgentCompleted)) - } - config.onAgentExecutionFailed { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AgentFailed)) - } - config.onAgentClosing { - onEvent(StatusInsightEvent.Trace(StatusInsightPhase.AgentClosing)) - } - }, - ).build() private suspend fun PostDataSource.loadPost(statusKey: MicroBlogKey): UiTimelineV2.Post = coroutineScope { @@ -271,37 +178,20 @@ internal class StatusInsightAgentUseCase( data.await() } - private fun PostDataSource.statusInsightSearchTargets( - searchDataSources: List, - currentPlatformType: PlatformType, - ): List { - val microblogDataSource = this as? MicroblogDataSource - return buildList { - addAll(searchDataSources.toStatusSearchTargets()) - if (microblogDataSource != null && none { it.dataSource === microblogDataSource }) { - add(StatusSearchTarget(platformType = currentPlatformType, dataSource = microblogDataSource)) - } - } - } - - private fun PostDataSource.statusInsightToolRegistry( - statusKey: MicroBlogKey, - searchTargets: List, - ): ToolRegistry { - val microblogDataSource = this as? MicroblogDataSource ?: return ToolRegistry.EMPTY - return ToolRegistry { - tool(LoadStatusContextTool(microblogDataSource, statusKey)) - tool(SearchStatusTool(searchTargets)) - } - } - private fun UiTimelineV2.Post.toInsightPrompt( targetLanguage: String, + userInput: String, includeImages: Boolean, ): Prompt = Prompt.build("status-insight") { user { - text(toInsightPromptInput(targetLanguage)) + text( + if (userInput.isBlank()) { + toInsightPromptInput(targetLanguage) + } else { + toInsightChatPromptInput(targetLanguage, userInput) + }, + ) if (includeImages) { aiImageAttachments().forEachIndexed { index, image -> text("Image ${index + 1}: ${image.description.orEmpty()}") @@ -316,6 +206,24 @@ internal class StatusInsightAgentUseCase( } } + private fun UiTimelineV2.Post.toInsightChatPromptInput( + targetLanguage: String, + userInput: String, + ): String = + buildString { + appendLine("Continue the conversation about this social post.") + appendLine("Answer the user's latest question directly.") + appendLine("Use previous messages from chat memory when available, but do not repeat the full earlier explanation.") + appendLine("Return plain text only.") + appendLine("Respond in this language: $targetLanguage.") + appendLine() + appendLine("Latest user question:") + appendLine(userInput) + appendLine() + appendLine("Current post snapshot:") + append(toInsightPromptInput(targetLanguage)) + } + private fun UiTimelineV2.Post.toInsightPromptInput(targetLanguage: String): String = buildString { appendLine("Analyze this social post for the user.") @@ -469,128 +377,7 @@ internal class StatusInsightAgentUseCase( ) } - private fun String.toToolTraceKey(phase: StatusInsightPhase): StatusInsightTraceKey? = - when (phase) { - StatusInsightPhase.ToolCallStarted -> { - toToolCallStartedKey() - } - - StatusInsightPhase.ToolCallCompleted -> { - toToolCallCompletedKey() - } - - StatusInsightPhase.ToolValidationFailed -> { - toToolValidationFailedKey() - } - - StatusInsightPhase.ToolCallFailed -> { - toToolCallFailedKey() - } - - else -> { - null - } - } - - private fun String.toToolCallStartedKey(): StatusInsightTraceKey? = - when (this) { - "load_status_context" -> { - StatusInsightTraceKey.LoadStatusContextStarted - } - - "search_status" -> { - StatusInsightTraceKey.SearchStatusStarted - } - - else -> { - null - } - } - - private fun String.toToolCallCompletedKey(): StatusInsightTraceKey? = - when (this) { - "load_status_context" -> { - StatusInsightTraceKey.LoadStatusContextCompleted - } - - "search_status" -> { - StatusInsightTraceKey.SearchStatusCompleted - } - - else -> { - null - } - } - - private fun String.toToolValidationFailedKey(): StatusInsightTraceKey? = - when (this) { - "load_status_context" -> { - StatusInsightTraceKey.LoadStatusContextValidationFailed - } - - "search_status" -> { - StatusInsightTraceKey.SearchStatusValidationFailed - } - - else -> { - null - } - } - - private fun String.toToolCallFailedKey(): StatusInsightTraceKey? = - when (this) { - "load_status_context" -> { - StatusInsightTraceKey.LoadStatusContextFailed - } - - "search_status" -> { - StatusInsightTraceKey.SearchStatusFailed - } - - else -> { - null - } - } - - private fun String.withSearchPlatformGuidance(searchTargets: List): String { - val platformTypes = - searchTargets - .mapNotNull { it.platformType } - .distinct() - val guidance = - if (platformTypes.isEmpty()) { - """ - - Search platform guidance: - - No searchable signed-in platforms are currently available. - - If you use search, leave the platform list empty. - """ - } else { - val platformLines = - platformTypes.joinToString(separator = "\n") { platformType -> - buildString { - append("- ") - append(platformType.name) - val aliases = platformType.searchAliases() - if (aliases.isNotEmpty()) { - append(" (aliases: ") - append(aliases.joinToString()) - append(")") - } - } - } - """ - - Search platform guidance: - - Available searchable platforms are: - $platformLines - - Use these exact platform names in the search tool's platform list when limiting search. - - You may use the listed aliases; they resolve to the corresponding platform. - - Leave the platform list empty to search every available platform. - """ - } - return trimEnd() + "\n\n" + guidance.trimIndent() - } + private fun AgentTrace.toConversationEvent(): AgentConversationEvent.Trace = AgentConversationEvent.Trace(this) private companion object { const val MAX_RELATED_POSTS = 3 @@ -598,6 +385,7 @@ internal class StatusInsightAgentUseCase( const val MAX_IMAGE_ATTACHMENTS = 4 val SUPPORTED_IMAGE_FORMATS = setOf("jpg", "jpeg", "png", "webp", "gif") const val MAX_AGENT_ITERATIONS = 16 + const val CHAT_MEMORY_WINDOW_SIZE = 20 const val STATUS_INSIGHT_SYSTEM_PROMPT = """ @@ -627,11 +415,9 @@ internal class StatusInsightAgentUseCase( Tool use: - Use the post context tool when the post depends on a missing thread, reply chain, quoted post, or conversation setup. - - Use the search tool when the post refers to current events, claims, statistics, memes, public controversies, or unclear phrases that may need outside posts for context. - - When searching, explicitly choose whether you need posts, users, or both. - - Search users when the key missing context is who an account is, whether an account appears official, or how an account describes itself. - - Search posts when the key missing context is what people are saying, whether a topic is spreading, or what phrase/meme/event the post refers to. - - Search both users and posts when both account identity and surrounding discussion matter. + - Use search_posts when the post refers to current events, claims, statistics, memes, public controversies, unclear phrases, or surrounding discussion. + - Use search_users when the key missing context is who an account is, whether an account appears official, or how an account describes itself. + - Use both search_posts and search_users only when both account identity and surrounding discussion matter. - Leave the platform list empty to search all signed-in platforms when broad context is useful. - Limit the platform list when the post clearly belongs to, mentions, or depends on a specific platform. - Search across diverse signed-in platforms only when the first query needs broad coverage. Do not run separate searches per platform. @@ -656,61 +442,7 @@ internal class StatusInsightAgentUseCase( } } -public sealed interface StatusInsightEvent { - public data class PostLoaded( - public val post: UiTimelineV2.Post, - ) : StatusInsightEvent - - public data class Trace( - public val phase: StatusInsightPhase, - public val detail: String? = null, - public val key: StatusInsightTraceKey? = null, - ) : StatusInsightEvent - - public data class Result( - public val text: String, - ) : StatusInsightEvent -} - -public enum class StatusInsightPhase { - LoadingPostContext, - PostContextLoaded, - PreparingImages, - ImagesUnsupportedFallback, - AgentStarted, - StrategyStarted, - StrategyCompleted, - SubgraphStarted, - SubgraphCompleted, - SubgraphFailed, - AskingModel, - ModelResponseReceived, - StreamingStarted, - StreamingResponse, - StreamingCompleted, - StreamingFailed, - RunningStep, - StepCompleted, - StepFailed, - ToolCallStarted, - ToolCallCompleted, - ToolValidationFailed, - ToolCallFailed, - AgentCompleted, - AgentFailed, - AgentClosing, -} - -public enum class StatusInsightTraceKey { - LoadStatusContextStarted, - LoadStatusContextCompleted, - LoadStatusContextValidationFailed, - LoadStatusContextFailed, - SearchStatusStarted, - SearchStatusCompleted, - SearchStatusValidationFailed, - SearchStatusFailed, -} +private fun MicroBlogKey.statusInsightConversationId(): String = "status-insight:$host:$id" public class StatusInsightAgentUnavailableException public constructor( public val availability: AgentAvailability, diff --git a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt index b3938830de..6e9d5e365d 100644 --- a/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt +++ b/feature/agent/src/commonMain/kotlin/dev/dimension/flare/feature/agent/status/StatusInsightTools.kt @@ -3,11 +3,10 @@ package dev.dimension.flare.feature.agent.status import ai.koog.agents.core.tools.SimpleTool import ai.koog.agents.core.tools.annotations.LLMDescription import ai.koog.serialization.typeToken -import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest -import dev.dimension.flare.data.repository.AccountMicroblogDataSource -import dev.dimension.flare.model.MicroBlogKey -import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.feature.agent.common.AgentSearchTarget +import dev.dimension.flare.feature.agent.common.AgentToolSession +import dev.dimension.flare.feature.agent.common.filterByPlatformNames import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 @@ -17,11 +16,10 @@ import kotlinx.coroutines.coroutineScope import kotlinx.serialization.Serializable internal class LoadStatusContextTool( - private val dataSource: MicroblogDataSource, - private val statusKey: MicroBlogKey, + private val session: AgentToolSession, ) : SimpleTool( argsType = typeToken(), - name = "load_status_context", + name = NAME, description = "Load the current post's conversation context, including surrounding replies or thread posts. " + "Use this only when the post depends on missing conversation context. " + @@ -33,8 +31,10 @@ internal class LoadStatusContextTool( val reason: String? = null, ) - override suspend fun execute(args: Args): String = - dataSource + override suspend fun execute(args: Args): String { + val dataSource = session.statusMicroblogDataSource() ?: return session.statusContextUnavailableMessage() + val statusKey = session.statusKey() ?: return session.statusContextUnavailableMessage() + return dataSource .context(statusKey) .load( pageSize = STATUS_CONTEXT_PAGE_SIZE, @@ -46,24 +46,27 @@ internal class LoadStatusContextTool( emptyMessage = "No additional context posts were returned.", maxItems = STATUS_CONTEXT_PAGE_SIZE, ) + } + + companion object { + const val NAME = "load_status_context" + } } -internal class SearchStatusTool( - private val searchTargets: List, -) : SimpleTool( +internal class SearchPostsTool( + private val session: AgentToolSession, +) : SimpleTool( argsType = typeToken(), - name = "search_status", + name = NAME, description = - "Search public or account-visible posts or users across the user's signed-in social platforms. " + - "Use this only when external posts or user profiles may explain a phrase, meme, event, account, " + - "or why a post is spreading. Use one concise query, then answer from the returned results.", + "Search public or account-visible posts across the user's signed-in social platforms. " + + "Use this when posts may explain a phrase, meme, event, claim, or why something is spreading. " + + "Use one concise query, then answer from the returned results.", ) { @Serializable internal data class Args( @property:LLMDescription("Search query. Keep it concise and use terms from the post.") val query: String, - @property:LLMDescription("What to search for: Post, User, or Both.") - val target: SearchStatusTarget = SearchStatusTarget.Post, @property:LLMDescription( "Platform names to search. Leave empty to search all signed-in platforms. " + "Use the platform names or aliases supplied in the system instructions.", @@ -76,160 +79,130 @@ internal class SearchStatusTool( if (query.isBlank()) { return "Search query is blank." } - val platformFilter = args.platforms.toPlatformFilter() val targets = - searchTargets + session.searchTargets .distinctBy { it.platformType } - .filterByPlatform(platformFilter) + .filterByPlatformNames(args.platforms) if (targets.isEmpty()) { - return if (platformFilter.isEmpty()) { + return if (args.platforms.isEmpty()) { "No signed-in accounts are available for search." } else { "No signed-in accounts are available for the requested platforms: ${args.platforms.joinToString()}." } } - val postResults = - if (args.target.searchPosts) { - targets.searchPosts(query) - } else { - emptyList() - } - val userResults = - if (args.target.searchUsers) { - targets.searchUsers(query) - } else { - emptyList() - } + val postResults = targets.searchPosts(query) return buildString { appendLine("Search query: \"$query\"") - appendLine("Search target: ${args.target.name}") + appendLine("Search target: Posts") appendLine("Platforms searched: ${targets.joinToString { it.platformType?.name ?: "Unknown" }}") - if (args.target.searchPosts) { - appendLine() - append( - postResults.toInsightPostToolListText( - title = "Post search results", - emptyMessage = "No matching posts were returned.", - maxItems = STATUS_SEARCH_PAGE_SIZE, - ), - ) - } - if (args.target.searchUsers) { - appendLine() - append( - userResults.toInsightUserToolListText( - title = "User search results", - emptyMessage = "No matching users were returned.", - maxItems = USER_SEARCH_PAGE_SIZE, - ), - ) - } + appendLine() + append( + postResults.toInsightPostToolListText( + title = "Post search results", + emptyMessage = "No matching posts were returned.", + maxItems = STATUS_SEARCH_PAGE_SIZE, + ), + ) }.take(MAX_TOOL_RESULT_LENGTH) } - private suspend fun List.searchPosts(query: String): List = - coroutineScope { - map { target -> - async { - runCatching { - target.dataSource - .searchStatus(query) - .load( - pageSize = STATUS_SEARCH_PAGE_SIZE, - request = PagingRequest.Refresh, - ).data - .filterIsInstance() - }.getOrElse { emptyList() } - } - }.awaitAll() - }.flatten() - .distinctBy { it.platformType to it.statusKey } - - private suspend fun List.searchUsers(query: String): List = - coroutineScope { - map { target -> - async { - runCatching { - target.dataSource - .searchUser(query) - .load( - pageSize = USER_SEARCH_PAGE_SIZE, - request = PagingRequest.Refresh, - ).data - }.getOrElse { emptyList() } - } - }.awaitAll() - }.flatten() - .distinctBy { it.platformType to it.key } - - private fun List.filterByPlatform(platformFilter: Set): List { - if (platformFilter.isEmpty()) { - return this - } - return filter { it.platformType in platformFilter } + companion object { + const val NAME = "search_posts" } +} - private fun List.toPlatformFilter(): Set = mapNotNull { it.toPlatformTypeOrNull() }.toSet() +internal class SearchUsersTool( + private val session: AgentToolSession, +) : SimpleTool( + argsType = typeToken(), + name = NAME, + description = + "Search public or account-visible user profiles across the user's signed-in social platforms. " + + "Use this when account identity, official status, bio, handle, or profile context may explain a post. " + + "Use one concise query, then answer from the returned results.", + ) { + @Serializable + internal data class Args( + @property:LLMDescription("Search query. Keep it concise and use terms from the post or account.") + val query: String, + @property:LLMDescription( + "Platform names to search. Leave empty to search all signed-in platforms. " + + "Use the platform names or aliases supplied in the system instructions.", + ) + val platforms: List = emptyList(), + ) - private fun String.toPlatformTypeOrNull(): PlatformType? { - val normalized = - trim() - .lowercase() - .replace("-", "") - .replace("_", "") - .replace(" ", "") - if (normalized == "all" || normalized == "*") { - return null + override suspend fun execute(args: Args): String { + val query = args.query.trim() + if (query.isBlank()) { + return "Search query is blank." } - return PlatformType.entries.firstOrNull { platformType -> - normalized == platformType.searchNameKey() || - platformType.searchAliases().any { alias -> normalized == alias.searchPlatformKey() } + val targets = + session.searchTargets + .distinctBy { it.platformType } + .filterByPlatformNames(args.platforms) + if (targets.isEmpty()) { + return if (args.platforms.isEmpty()) { + "No signed-in accounts are available for search." + } else { + "No signed-in accounts are available for the requested platforms: ${args.platforms.joinToString()}." + } } + val userResults = targets.searchUsers(query) + return buildString { + appendLine("Search query: \"$query\"") + appendLine("Search target: Users") + appendLine("Platforms searched: ${targets.joinToString { it.platformType?.name ?: "Unknown" }}") + appendLine() + append( + userResults.toInsightUserToolListText( + title = "User search results", + emptyMessage = "No matching users were returned.", + maxItems = USER_SEARCH_PAGE_SIZE, + ), + ) + }.take(MAX_TOOL_RESULT_LENGTH) } - @Serializable - internal enum class SearchStatusTarget { - Post, - User, - Both, + companion object { + const val NAME = "search_users" } - - private val SearchStatusTarget.searchPosts: Boolean - get() = this == SearchStatusTarget.Post || this == SearchStatusTarget.Both - - private val SearchStatusTarget.searchUsers: Boolean - get() = this == SearchStatusTarget.User || this == SearchStatusTarget.Both } -internal data class StatusSearchTarget( - val platformType: PlatformType?, - val dataSource: MicroblogDataSource, -) - -internal fun List.toStatusSearchTargets(): List = - map { item -> - StatusSearchTarget( - platformType = item.platformType, - dataSource = item.dataSource, - ) - } - -internal fun PlatformType.searchAliases(): List = - when (name) { - PlatformType.Bluesky.name -> listOf("bsky") - PlatformType.xQt.name -> listOf("x", "twitter") - PlatformType.VVo.name -> listOf("weibo") - else -> emptyList() - } - -internal fun PlatformType.searchNameKey(): String = name.searchPlatformKey() +private suspend fun List.searchPosts(query: String): List = + coroutineScope { + map { target -> + async { + runCatching { + target.dataSource + .searchStatus(query) + .load( + pageSize = STATUS_SEARCH_PAGE_SIZE, + request = PagingRequest.Refresh, + ).data + .filterIsInstance() + }.getOrElse { emptyList() } + } + }.awaitAll() + }.flatten() + .distinctBy { it.platformType to it.statusKey } -private fun String.searchPlatformKey(): String = - trim() - .lowercase() - .replace("-", "") - .replace("_", "") - .replace(" ", "") +private suspend fun List.searchUsers(query: String): List = + coroutineScope { + map { target -> + async { + runCatching { + target.dataSource + .searchUser(query) + .load( + pageSize = USER_SEARCH_PAGE_SIZE, + request = PagingRequest.Refresh, + ).data + }.getOrElse { emptyList() } + } + }.awaitAll() + }.flatten() + .distinctBy { it.platformType to it.key } private fun List.toInsightPostToolListText( title: String, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db3e5d2b68..526c1e1f5a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } ksoup = { group = "com.fleeksoft.ksoup", name = "ksoup", version.ref = "ksoup" } koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" } +koog-agents-features-memory = { module = "ai.koog:agents-features-memory", version.ref = "koog" } koog-http-client-ktor = { module = "ai.koog:http-client-ktor", version.ref = "koog" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-runtime-ktx" } diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index 78b606873a..1bb3eb90c2 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -3012,6 +3012,167 @@ } } }, + "ask_ai" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask AI" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIに聞く" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "问问 AI" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "問問 AI" + } + } + } + }, + "agent_chat_input_placeholder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask anything..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIに聞く..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "问问 AI..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "問問 AI..." + } + } + } + }, + "agent_chat_send" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "送信" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "發送" + } + } + } + }, + "agent_chat_thinking" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thinking..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "考え中..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "思考中..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "思考中..." + } + } + } + }, + "agent_chat_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI Chat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIチャット" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 聊天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 對話" + } + } + } + }, + "agent_history_empty" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No agent chat history yet." + } + } + } + }, + "agent_history_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flare Agent" + } + } + } + }, "AI" : { "comment" : "The name of the AI translation service.", "localizations" : { @@ -4142,6 +4303,26 @@ } } }, + "ai_config_post_insight" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 AI agent" + } + } + } + }, + "ai_config_post_insight_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "开启后,在已配置 OpenAI 兼容模型时,会在应用内相关位置显示 AI agent 功能入口。" + } + } + } + }, "ai_config_pre_translate" : { "localizations" : { "af" : { @@ -115238,6 +115419,27 @@ } } }, + "settings_agent_history_description" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View local Flare Agent chat history" + } + } + } + }, + "settings_agent_history_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flare Agent" + } + } + } + }, "settings_privacy_policy" : { "localizations" : { "en" : { @@ -119046,26 +119248,6 @@ } } }, - "ai_config_post_insight" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "启用 AI agent" - } - } - } - }, - "ai_config_post_insight_description" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "开启后,在已配置 OpenAI 兼容模型时,会在应用内相关位置显示 AI agent 功能入口。" - } - } - } - }, "status_insight_analyzing" : { "localizations" : { "en" : { @@ -119093,6 +119275,24 @@ "state" : "translated", "value" : "Post insight" } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "投稿インサイト" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "帖子洞察" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "貼文洞察" + } } } }, diff --git a/iosApp/flare/UI/Component/AgentChatView.swift b/iosApp/flare/UI/Component/AgentChatView.swift new file mode 100644 index 0000000000..5ac85a40b0 --- /dev/null +++ b/iosApp/flare/UI/Component/AgentChatView.swift @@ -0,0 +1,509 @@ +import ChatLayout +import SwiftUI +import SwiftUIBackports +import KotlinSharedUI + +struct AgentChatView: View { + let messages: [Message] + let input: String + let isRunning: Bool + let canSend: Bool + let error: KotlinThrowable? + let runningTrace: String + let inputPlaceholder: String + let messageText: (Message) -> String + let isUserMessage: (Message) -> Bool + let onInputChange: (String) -> Void + let onSend: () -> Void + private let leadingContent: () -> AnyView + + @State private var draft: String = "" + + init( + messages: [Message], + input: String, + isRunning: Bool, + canSend: Bool, + error: KotlinThrowable?, + runningTrace: String, + inputPlaceholder: String, + messageText: @escaping (Message) -> String, + isUserMessage: @escaping (Message) -> Bool, + onInputChange: @escaping (String) -> Void, + onSend: @escaping () -> Void, + leadingContent: @escaping () -> AnyView = { AnyView(EmptyView()) } + ) { + self.messages = messages + self.input = input + self.isRunning = isRunning + self.canSend = canSend + self.error = error + self.runningTrace = runningTrace + self.inputPlaceholder = inputPlaceholder + self.messageText = messageText + self.isUserMessage = isUserMessage + self.onInputChange = onInputChange + self.onSend = onSend + self.leadingContent = leadingContent + } + + var body: some View { + AgentChatMessagesView(rows: rows) + .background(Color(.systemGroupedBackground)) + .ignoresSafeArea() + .safeAreaInset(edge: .bottom) { + HStack(alignment: .center, spacing: 10) { + TextField(inputPlaceholder, text: $draft, axis: .vertical) + .lineLimit(1...4) + .disabled(isRunning) + .submitLabel(.send) + .onSubmit { + if canSend { + submit() + } + } + .padding() + .backport + .glassEffect(.regularInteractive, in: .capsule, fallbackBackground: .regularMaterial) + + Button { + submit() + } label: { + Image(systemName: "paperplane.fill") + .font(.title2) + .frame(width: 48, height: 48) + } + .backport + .glassProminentButtonStyle() + .disabled(!canSend) + } + .padding([.horizontal, .bottom]) + } + .onAppear { + draft = input + } + .onChange(of: input) { _, value in + if draft != value { + draft = value + } + } + .onChange(of: draft) { _, value in + onInputChange(value) + } + } + + private var rows: [AgentChatRow] { + var result: [AgentChatRow] = [ + .leading(content: leadingContent()) + ] + + result.append( + contentsOf: messages.enumerated().map { index, message in + .message( + id: "message-\(index)", + text: messageText(message), + isUser: isUserMessage(message) + ) + } + ) + + if isRunning { + result.append(.running(text: runningTrace)) + } + + if let error { + result.append(.error(text: error.message ?? String(localized: "status_insight_error"))) + } + + return result + } + + private func submit() { + onSend() + draft = "" + } +} + +private struct AgentChatMessagesView: UIViewControllerRepresentable { + let rows: [AgentChatRow] + + func makeUIViewController(context: Context) -> AgentChatMessagesController { + let controller = AgentChatMessagesController() + controller.update(rows: rows) + return controller + } + + func updateUIViewController(_ controller: AgentChatMessagesController, context: Context) { + controller.update(rows: rows) + } +} + +private struct AgentChatRow { + let id: String + let renderHash: Int + let content: AgentChatRowContent + + static func leading(content: AnyView) -> AgentChatRow { + AgentChatRow( + id: "leading-content", + renderHash: 0, + content: .leading(content) + ) + } + + static func message(id: String, text: String, isUser: Bool) -> AgentChatRow { + AgentChatRow( + id: id, + renderHash: text.hashValue ^ isUser.hashValue, + content: .message(text: text, isUser: isUser) + ) + } + + static func running(text: String) -> AgentChatRow { + AgentChatRow( + id: "running", + renderHash: text.hashValue, + content: .running(text) + ) + } + + static func error(text: String) -> AgentChatRow { + AgentChatRow( + id: "error", + renderHash: text.hashValue, + content: .error(text) + ) + } +} + +private enum AgentChatRowContent { + case leading(AnyView) + case message(text: String, isUser: Bool) + case running(String) + case error(String) +} + +@MainActor +private final class AgentChatMessagesController: UIViewController, UICollectionViewDelegate, ChatLayoutDelegate { + private enum Section { + static let main = 0 + } + private enum Metrics { + static let bottomInputInset: CGFloat = 112 + } + + private let chatLayout = CollectionViewChatLayout() + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private var rowsByID: [String: AgentChatRow] = [:] + private var rowIDs: [String] = [] + private var renderHashes: [String: Int] = [:] + private var didApplyInitialRows = false + private var keyboardBottomInset: CGFloat = 0 + + override func viewDidLoad() { + super.viewDidLoad() + setupCollectionView() + setupDataSource() + setupKeyboardObservers() + applySnapshot(animated: false) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if !didApplyInitialRows, collectionView.numberOfItems(inSection: Section.main) > 0 { + didApplyInitialRows = true + scrollToBottom(animated: false) + } + } + + func update(rows: [AgentChatRow]) { + rowsByID = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) + rowIDs = rows.map(\.id) + let nextRenderHashes = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0.renderHash) }) + let needsReconfigure = renderHashes != nextRenderHashes + renderHashes = nextRenderHashes + + guard isViewLoaded else { return } + applySnapshot(animated: didApplyInitialRows) + if needsReconfigure { + reconfigureVisibleCells() + collectionView.collectionViewLayout.invalidateLayout() + } + } + + private func setupCollectionView() { + chatLayout.settings.interItemSpacing = 10 + chatLayout.settings.additionalInsets = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) + chatLayout.keepContentAtBottomOfVisibleArea = true + chatLayout.keepContentOffsetAtBottomOnBatchUpdates = true + chatLayout.processOnlyVisibleItemsOnAnimatedBatchUpdates = false + chatLayout.delegate = self + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: chatLayout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.alwaysBounceVertical = true + collectionView.backgroundColor = .systemGroupedBackground + collectionView.keyboardDismissMode = .interactive + collectionView.contentInsetAdjustmentBehavior = .always + collectionView.automaticallyAdjustsScrollIndicatorInsets = true + collectionView.delegate = self + collectionView.register(AgentChatCollectionViewCell.self, forCellWithReuseIdentifier: AgentChatCollectionViewCell.reuseIdentifier) + updateCollectionInsets() + + view.backgroundColor = .systemGroupedBackground + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func setupDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, id in + guard let self, + let row = self.rowsByID[id] else { + return nil + } + + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: AgentChatCollectionViewCell.reuseIdentifier, + for: indexPath + ) as! AgentChatCollectionViewCell + cell.configure(row: row) + return cell + } + } + + private func setupKeyboardObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return + } + + let convertedEndFrame = view.convert(endFrame, from: nil) + keyboardBottomInset = max(0, view.bounds.maxY - convertedEndFrame.minY) + updateCollectionInsets() + if isNearBottom { + scrollToBottom(animated: false) + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + keyboardBottomInset = 0 + updateCollectionInsets() + if isNearBottom { + scrollToBottom(animated: false) + } + } + + private func updateCollectionInsets() { + guard collectionView != nil else { return } + let bottomInset = Metrics.bottomInputInset + keyboardBottomInset + var contentInset = collectionView.contentInset + contentInset.bottom = bottomInset + collectionView.contentInset = contentInset + + var scrollIndicatorInsets = collectionView.verticalScrollIndicatorInsets + scrollIndicatorInsets.bottom = bottomInset + collectionView.verticalScrollIndicatorInsets = scrollIndicatorInsets + } + + private func applySnapshot(animated: Bool) { + guard dataSource != nil else { return } + + let wasNearBottom = isNearBottom + let positionSnapshot = wasNearBottom ? nil : chatLayout.getContentOffsetSnapshot(from: .top) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([Section.main]) + snapshot.appendItems(rowIDs, toSection: Section.main) + dataSource.apply(snapshot, animatingDifferences: animated) { [weak self] in + guard let self else { return } + if wasNearBottom { + self.scrollToBottom(animated: false) + } else if let positionSnapshot { + self.chatLayout.restoreContentOffset(with: positionSnapshot) + } + } + } + + private var isNearBottom: Bool { + guard collectionView.bounds.height > 0 else { return true } + let maxOffsetY = max( + -collectionView.adjustedContentInset.top, + collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + ) + return collectionView.contentOffset.y >= maxOffsetY - 80 + } + + private func scrollToBottom(animated: Bool) { + let itemCount = collectionView.numberOfItems(inSection: Section.main) + guard itemCount > 0 else { return } + collectionView.scrollToItem( + at: IndexPath(item: itemCount - 1, section: Section.main), + at: .bottom, + animated: animated + ) + } + + private func reconfigureVisibleCells() { + for cell in collectionView.visibleCells { + guard let indexPath = collectionView.indexPath(for: cell), + let id = dataSource.itemIdentifier(for: indexPath), + let row = rowsByID[id], + let agentCell = cell as? AgentChatCollectionViewCell else { + continue + } + agentCell.configure(row: row) + } + } + + func sizeForItem(_ chatLayout: CollectionViewChatLayout, at indexPath: IndexPath) -> ItemSize { + .estimated(CGSize(width: chatLayout.layoutFrame.width, height: 72)) + } + + func alignmentForItem(_ chatLayout: CollectionViewChatLayout, at indexPath: IndexPath) -> ChatItemAlignment { + .fullWidth + } +} + +private final class AgentChatCollectionViewCell: UICollectionViewCell { + static let reuseIdentifier = "AgentChatCollectionViewCell" + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + contentView.backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not supported") + } + + override func prepareForReuse() { + super.prepareForReuse() + contentConfiguration = nil + } + + func configure(row: AgentChatRow) { + contentConfiguration = UIHostingConfiguration { + AgentChatRowView(row: row) + } + .margins(.all, 0) + .background(.clear) + setNeedsLayout() + } + + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + guard let attributes = layoutAttributes.copy() as? ChatLayoutAttributes else { + return super.preferredLayoutAttributesFitting(layoutAttributes) + } + + let width = attributes.layoutFrame.width > 0 ? attributes.layoutFrame.width : layoutAttributes.size.width + contentView.bounds = CGRect(x: 0, y: 0, width: width, height: contentView.bounds.height) + contentView.setNeedsLayout() + contentView.layoutIfNeeded() + + let size = contentView.systemLayoutSizeFitting( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + attributes.size = CGSize(width: width, height: max(ceil(size.height), 1)) + attributes.alignment = .fullWidth + return attributes + } +} + +private struct AgentChatRowView: View { + let row: AgentChatRow + + var body: some View { + Group { + switch row.content { + case .leading(let content): + content + .padding(.horizontal) + case .message(let text, let isUser): + AgentChatMessageBubble(text: text, isUser: isUser) + case .running(let text): + StatusInsightCurrentTrace(trace: text) + .padding(.horizontal) + case .error(let text): + Text(verbatim: text) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct AgentChatMessageBubble: View { + let text: String + let isUser: Bool + + var body: some View { + HStack { + if isUser { + Spacer(minLength: 44) + } + + messageTextView + .textSelection(.enabled) + .padding(12) + .foregroundStyle(isUser ? .white : .primary) + .fixedSize(horizontal: false, vertical: true) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(isUser ? Color.accentColor : Color(.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(isUser ? Color.clear : Color(.separator).opacity(0.35), lineWidth: 1) + ) + + if !isUser { + Spacer(minLength: 44) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal) + } + + private var messageTextView: Text { + if isUser { + Text(verbatim: text) + } else if let attributedText = try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + Text(attributedText) + } else { + Text(verbatim: text) + } + } +} diff --git a/iosApp/flare/UI/Route/Route.swift b/iosApp/flare/UI/Route/Route.swift index db707d5279..6169d4ad01 100644 --- a/iosApp/flare/UI/Route/Route.swift +++ b/iosApp/flare/UI/Route/Route.swift @@ -53,13 +53,17 @@ enum Route: Hashable, Identifiable { case .notification: NotificationScreen() case .discover: - DiscoverScreen() + DiscoverScreen { query in + onNavigate(.agentChat(Self.newGenericChatConversationId(), query)) + } case .accountManagement: AccountManagementScreen() case .nostrRelays(let accountKey): NostrRelaysScreen(accountKey: accountKey) case .search(let accountType, let query): - SearchScreen(accountType: accountType, initialQuery: query) + SearchScreen(accountType: accountType, initialQuery: query) { query in + onNavigate(.agentChat(Self.newGenericChatConversationId(), query)) + } case .composeNew: ComposeScreen(accountType: nil) case .composeDraft(let groupId): @@ -92,6 +96,12 @@ enum Route: Hashable, Identifiable { LocalFilterScreen() case .aiConfig: AiConfigScreen() + case .agentHistory: + AgentChatHistoryScreen { + onNavigate(.agentChat(Self.newGenericChatConversationId(), nil)) + } + case .agentChat(let conversationId, let initialMessage): + AgentChatScreen(conversationId: conversationId, initialMessage: initialMessage) case .translationConfig: TranslationConfigScreen() case .tabSettings: @@ -189,6 +199,8 @@ enum Route: Hashable, Identifiable { case localHostory case moreMenuCustomize case aiConfig + case agentHistory + case agentChat(String, String?) case translationConfig case storage case appearanceTheme @@ -222,6 +234,10 @@ enum Route: Hashable, Identifiable { case draftBox case secondaryMenu + private static func newGenericChatConversationId() -> String { + "generic-chat:\(Int64(Date().timeIntervalSince1970 * 1000))" + } + fileprivate static func fromCompose(_ compose: DeeplinkRoute.Compose) -> Route? { switch onEnum(of: compose) { case .new(let data): diff --git a/iosApp/flare/UI/Screen/AgentChatHistoryScreen.swift b/iosApp/flare/UI/Screen/AgentChatHistoryScreen.swift new file mode 100644 index 0000000000..1bac4c2c72 --- /dev/null +++ b/iosApp/flare/UI/Screen/AgentChatHistoryScreen.swift @@ -0,0 +1,47 @@ +import SwiftUI +import KotlinSharedUI + +struct AgentChatHistoryScreen: View { + let onNewConversation: () -> Void + @StateObject private var presenter = KotlinPresenter(presenter: AgentChatHistoryPresenter()) + + var body: some View { + Group { + if presenter.state.conversations.isEmpty { + ContentUnavailableView { + Label { + Text("agent_history_empty") + } icon: { + Image("fa-robot") + } + } + } else { + List { + ForEach(presenter.state.conversations, id: \.id) { conversation in + NavigationLink(value: Route.agentChat(conversation.id, nil)) { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: conversation.title) + .font(.headline) + .lineLimit(2) + + DateTimeText(data: conversation.updatedAt) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + } + } + } + .navigationTitle("agent_history_title") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: onNewConversation) { + Image("fa-plus") + } + .accessibilityLabel(Text("agent_chat_title")) + } + } + } +} diff --git a/iosApp/flare/UI/Screen/AgentChatScreen.swift b/iosApp/flare/UI/Screen/AgentChatScreen.swift new file mode 100644 index 0000000000..7324753de8 --- /dev/null +++ b/iosApp/flare/UI/Screen/AgentChatScreen.swift @@ -0,0 +1,47 @@ +import SwiftUI +import KotlinSharedUI + +struct AgentChatScreen: View { + @StateObject private var presenter: KotlinPresenter + + var body: some View { + AgentChatView( + messages: Array(presenter.state.messages), + input: presenter.state.input, + isRunning: presenter.state.isRunning, + canSend: presenter.state.canSend, + error: presenter.state.error, + runningTrace: String(localized: "agent_chat_thinking"), + inputPlaceholder: String(localized: "agent_chat_input_placeholder"), + messageText: { $0.text }, + isUserMessage: { message in + String(describing: type(of: message)).contains("User") + }, + onInputChange: presenter.state.setInput, + onSend: presenter.state.sendMessage, + leadingContent: { + AnyView( + ForEach(Array(presenter.state.statusInsightPosts.enumerated()), id: \.offset) { _, post in + StatusInsightPostPreview(post: post) + } + ) + } + ) + .navigationTitle(presenter.state.title?.isEmpty == false ? presenter.state.title! : String(localized: "agent_chat_title")) + .navigationBarTitleDisplayMode(.inline) + } +} + +extension AgentChatScreen { + init(conversationId: String, initialMessage: String?) { + let normalizedInitialMessage = initialMessage?.trimmingCharacters(in: .whitespacesAndNewlines) + self._presenter = .init( + wrappedValue: .init( + presenter: GenericChatPresenter( + conversationId: conversationId, + initialMessage: normalizedInitialMessage?.isEmpty == false ? normalizedInitialMessage : nil + ) + ) + ) + } +} diff --git a/iosApp/flare/UI/Screen/DiscoverScreen.swift b/iosApp/flare/UI/Screen/DiscoverScreen.swift index 4ebdfc48eb..a8350c23bf 100644 --- a/iosApp/flare/UI/Screen/DiscoverScreen.swift +++ b/iosApp/flare/UI/Screen/DiscoverScreen.swift @@ -4,6 +4,8 @@ import Flow struct DiscoverScreen: View { @Environment(\.openURL) private var openURL + @Environment(\.timelineAppearance.aiConfig.agent) private var agentEnabled + let onAskAi: (String?) -> Void @StateObject private var presenter: KotlinPresenter @StateObject private var searchPresenter: KotlinPresenter @StateObject private var searchHistoryPresenter: KotlinPresenter @@ -11,7 +13,8 @@ struct DiscoverScreen: View { @State private var committedSearchText = "" @State private var isSearchPresented = false - init() { + init(onAskAi: @escaping (String?) -> Void = { _ in }) { + self.onAskAi = onAskAi self._presenter = .init(wrappedValue: .init(presenter: DiscoverPresenter())) self._searchPresenter = .init(wrappedValue: .init(presenter: SearchPresenter(accountType: AccountType.Guest.shared, initialQuery: ""))) self._searchHistoryPresenter = .init(wrappedValue: .init(presenter: SearchHistoryPresenter())) @@ -81,6 +84,13 @@ struct DiscoverScreen: View { } } .searchable(text: $searchText, isPresented: $isSearchPresented) + .safeAreaInset(edge: .bottom) { + if agentEnabled && isSearchPresented { + AskAiSearchAccessory { + askAi() + } + } + } .searchSuggestions { SearchHistorySuggestions( state: searchHistoryPresenter.state, @@ -250,4 +260,10 @@ struct DiscoverScreen: View { searchPresenter.state.search(query: query) isSearchPresented = false } + + private func askAi() { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + isSearchPresented = false + onAskAi(query.isEmpty ? nil : query) + } } diff --git a/iosApp/flare/UI/Screen/SearchScreen.swift b/iosApp/flare/UI/Screen/SearchScreen.swift index 6f809f9db7..1c020f646c 100644 --- a/iosApp/flare/UI/Screen/SearchScreen.swift +++ b/iosApp/flare/UI/Screen/SearchScreen.swift @@ -3,6 +3,8 @@ import SwiftUI struct SearchScreen: View { @Environment(\.openURL) private var openURL + @Environment(\.timelineAppearance.aiConfig.agent) private var agentEnabled + let onAskAi: (String?) -> Void @StateObject private var searchPresenter: KotlinPresenter @StateObject private var searchHistoryPresenter: KotlinPresenter @State var searchText = "" @@ -10,7 +12,12 @@ struct SearchScreen: View { @State private var isSearchPresented = false @State private var didRecordInitialQuery = false - init(accountType: AccountType, initialQuery: String) { + init( + accountType: AccountType, + initialQuery: String, + onAskAi: @escaping (String?) -> Void = { _ in } + ) { + self.onAskAi = onAskAi self._searchPresenter = .init(wrappedValue: .init(presenter: SearchPresenter(accountType: accountType, initialQuery: initialQuery))) self._searchHistoryPresenter = .init(wrappedValue: .init(presenter: SearchHistoryPresenter())) self._searchText = .init(initialValue: initialQuery) @@ -116,6 +123,13 @@ struct SearchScreen: View { } } .searchable(text: $searchText, isPresented: $isSearchPresented) + .safeAreaInset(edge: .bottom) { + if agentEnabled && isSearchPresented { + AskAiSearchAccessory { + askAi() + } + } + } .searchSuggestions { SearchHistorySuggestions( state: searchHistoryPresenter.state, @@ -158,4 +172,32 @@ struct SearchScreen: View { searchPresenter.state.search(query: query) isSearchPresented = false } + + private func askAi() { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + isSearchPresented = false + onAskAi(query.isEmpty ? nil : query) + } +} + +struct AskAiSearchAccessory: View { + let action: () -> Void + + var body: some View { + HStack { + Button(action: action) { + Label { + Text("ask_ai") + } icon: { + Image("fa-robot") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(.regularMaterial) + } } diff --git a/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift b/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift index 623248115b..e3eecbd413 100644 --- a/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift +++ b/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift @@ -62,6 +62,13 @@ struct SecondaryTabsScreen: View { Image("fa-clock-rotate-left") } } + NavigationLink(value: Route.agentHistory) { + Label { + Text("settings_agent_history_title") + } icon: { + Image("fa-robot") + } + } NavigationLink(value: Route.settings) { Label { Text("settings_title") diff --git a/iosApp/flare/UI/Screen/StatusInsightSheet.swift b/iosApp/flare/UI/Screen/StatusInsightSheet.swift index 6c4c8de815..d1cde503cb 100644 --- a/iosApp/flare/UI/Screen/StatusInsightSheet.swift +++ b/iosApp/flare/UI/Screen/StatusInsightSheet.swift @@ -7,30 +7,31 @@ struct StatusInsightSheet: View { @StateObject private var presenter: KotlinPresenter var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - if let post = presenter.state.post { - StatusInsightPostPreview(post: post) - } - - StateView(state: presenter.state.insight) { text in - Text(verbatim: String(text)) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } errorContent: { throwable in - Text(verbatim: throwable.message ?? String(localized: "status_insight_error")) - .foregroundStyle(.red) - .frame(maxWidth: .infinity, alignment: .leading) - } loadingContent: { - StatusInsightCurrentTrace( - trace: presenter.state.currentTrace?.localizedLabel ?? String(localized: "status_insight_analyzing") - ) - } + AgentChatView( + messages: Array(presenter.state.messages), + input: presenter.state.input, + isRunning: presenter.state.isRunning, + canSend: presenter.state.canSend, + error: presenter.state.error, + runningTrace: presenter.state.currentTrace?.localizedLabel ?? String(localized: "status_insight_analyzing"), + inputPlaceholder: String(localized: "agent_chat_input_placeholder"), + messageText: { $0.text }, + isUserMessage: { message in + String(describing: type(of: message)).contains("User") + }, + onInputChange: presenter.state.setInput, + onSend: presenter.state.sendMessage, + leadingContent: { + AnyView( + Group { + if let post = presenter.state.post { + StatusInsightPostPreview(post: post) + } + } + ) } - .padding(.horizontal) - .padding(.bottom, 24) - } - .navigationTitle(Text("status_insight_title")) + ) + .navigationTitle(String(localized: "status_insight_title")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -66,7 +67,7 @@ extension StatusInsightSheet { } } -private struct StatusInsightPostPreview: View { +struct StatusInsightPostPreview: View { @Environment(\.timelineAppearance) private var timelineAppearance let post: UiTimelineV2.Post @@ -90,7 +91,7 @@ private struct StatusInsightPostPreview: View { } } -private struct StatusInsightCurrentTrace: View { +struct StatusInsightCurrentTrace: View { let trace: String var body: some View { @@ -142,10 +143,10 @@ private extension View { } } -private extension StatusInsightEventTrace { +private extension AgentTrace { var localizedLabel: String { - if let key { - return key.localizedLabel + if let toolKey { + return toolKey.localizedLabel } switch phase { @@ -205,7 +206,7 @@ private extension StatusInsightEventTrace { } } -private extension StatusInsightTraceKey { +private extension AgentToolKey { var localizedLabel: String { switch self { case .loadStatusContextStarted: @@ -216,13 +217,13 @@ private extension StatusInsightTraceKey { return String(localized: "status_insight_trace_tool_load_status_context_validation_failed") case .loadStatusContextFailed: return String(localized: "status_insight_trace_tool_load_status_context_failed") - case .searchStatusStarted: + case .searchPostsStarted, .searchUsersStarted: return String(localized: "status_insight_trace_tool_search_status_started") - case .searchStatusCompleted: + case .searchPostsCompleted, .searchUsersCompleted: return String(localized: "status_insight_trace_tool_search_status_completed") - case .searchStatusValidationFailed: + case .searchPostsValidationFailed, .searchUsersValidationFailed: return String(localized: "status_insight_trace_tool_search_status_validation_failed") - case .searchStatusFailed: + case .searchPostsFailed, .searchUsersFailed: return String(localized: "status_insight_trace_tool_search_status_failed") } } diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/data/database/DriverFactory.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/data/database/DriverFactory.android.kt index fa408feebf..8955020abe 100644 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/data/database/DriverFactory.android.kt +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/data/database/DriverFactory.android.kt @@ -8,10 +8,10 @@ import org.koin.core.annotation.Single import java.io.File @Single -internal actual class DriverFactory( - @Provided private val context: Context, +public actual class DriverFactory( + @Provided @PublishedApi internal val context: Context, ) { - actual inline fun createBuilder( + public actual inline fun createBuilder( name: String, isCache: Boolean, ): RoomDatabase.Builder { @@ -28,7 +28,7 @@ internal actual class DriverFactory( ) } - actual fun deleteDatabase( + public actual fun deleteDatabase( name: String, isCache: Boolean, ) { diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/data/database/DriverFactory.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/data/database/DriverFactory.apple.kt index 64232c9de3..4c089b9c7d 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/data/database/DriverFactory.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/data/database/DriverFactory.apple.kt @@ -11,10 +11,12 @@ import platform.Foundation.NSFileManager import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSUserDomainMask import platform.Foundation.stringWithString +import kotlin.native.HiddenFromObjC @Single -internal actual class DriverFactory { - actual inline fun createBuilder( +@HiddenFromObjC +public actual class DriverFactory { + public actual inline fun createBuilder( name: String, isCache: Boolean, ): RoomDatabase.Builder { @@ -32,7 +34,7 @@ internal actual class DriverFactory { } @OptIn(ExperimentalForeignApi::class) - actual fun deleteDatabase( + public actual fun deleteDatabase( name: String, isCache: Boolean, ) { @@ -44,6 +46,7 @@ internal actual class DriverFactory { } } + @PublishedApi internal fun databaseDirPath(): String = iosDirPath("databases") @OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.UnsafeNumber::class) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.kt index 8019a8bb7f..2cb5c9352f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.kt @@ -1,5 +1,7 @@ package dev.dimension.flare.data.database import androidx.sqlite.SQLiteDriver +import kotlin.native.HiddenFromObjC -internal expect fun createDatabaseDriver(): SQLiteDriver +@HiddenFromObjC +public expect fun createDatabaseDriver(): SQLiteDriver diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DriverFactory.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DriverFactory.kt index 311a3ccd41..77f6a438ac 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DriverFactory.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DriverFactory.kt @@ -1,14 +1,16 @@ package dev.dimension.flare.data.database import androidx.room3.RoomDatabase +import kotlin.native.HiddenFromObjC -internal expect class DriverFactory { - inline fun createBuilder( +@HiddenFromObjC +public expect class DriverFactory { + public inline fun createBuilder( name: String, isCache: Boolean = false, ): RoomDatabase.Builder - fun deleteDatabase( + public fun deleteDatabase( name: String, isCache: Boolean, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenter.kt new file mode 100644 index 0000000000..8f37ad586c --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenter.kt @@ -0,0 +1,32 @@ +package dev.dimension.flare.ui.presenter.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.ui.presenter.PresenterBase +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +public class AiAgentEnabledPresenter : + PresenterBase(), + KoinComponent { + private val appDataStore: AppDataStore by inject() + + public interface State { + public val enabled: Boolean + } + + @Composable + override fun body(): State { + val appSettings by appDataStore.appSettingsStore.data.collectAsState(AppSettings(version = "")) + return StateImpl( + enabled = appSettings.aiConfig.agent, + ) + } + + private data class StateImpl( + override val enabled: Boolean, + ) : State +} diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/data/database/DriverFactory.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/database/DriverFactory.jvm.kt index 2f6dce204e..8731583337 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/data/database/DriverFactory.jvm.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/database/DriverFactory.jvm.kt @@ -7,8 +7,8 @@ import org.koin.core.annotation.Single import java.io.File @Single -internal actual class DriverFactory { - actual inline fun createBuilder( +public actual class DriverFactory { + public actual inline fun createBuilder( name: String, isCache: Boolean, ): RoomDatabase.Builder { @@ -24,7 +24,7 @@ internal actual class DriverFactory { ) } - actual fun deleteDatabase( + public actual fun deleteDatabase( name: String, isCache: Boolean, ) { diff --git a/shared/src/nonWebMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.nonWeb.kt b/shared/src/nonWebMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.nonWeb.kt index 401af34863..8cd87f9b1a 100644 --- a/shared/src/nonWebMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.nonWeb.kt +++ b/shared/src/nonWebMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.nonWeb.kt @@ -2,5 +2,7 @@ package dev.dimension.flare.data.database import androidx.sqlite.SQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlin.native.HiddenFromObjC -internal actual fun createDatabaseDriver(): SQLiteDriver = BundledSQLiteDriver() +@HiddenFromObjC +public actual fun createDatabaseDriver(): SQLiteDriver = BundledSQLiteDriver() diff --git a/shared/src/wasmJsMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.wasmJs.kt b/shared/src/wasmJsMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.wasmJs.kt index 779337c8e1..851cad85e5 100644 --- a/shared/src/wasmJsMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.wasmJs.kt +++ b/shared/src/wasmJsMain/kotlin/dev/dimension/flare/data/database/DatabaseDriverProvider.wasmJs.kt @@ -6,7 +6,7 @@ import org.w3c.dom.Worker import kotlin.js.ExperimentalWasmJsInterop import kotlin.js.js -internal actual fun createDatabaseDriver(): SQLiteDriver = +public actual fun createDatabaseDriver(): SQLiteDriver = SerialWebSQLiteDriver( delegate = WebWorkerSQLiteDriver(createSQLiteWorker()), ) diff --git a/shared/src/wasmJsMain/kotlin/dev/dimension/flare/data/database/DriverFactory.wasmJs.kt b/shared/src/wasmJsMain/kotlin/dev/dimension/flare/data/database/DriverFactory.wasmJs.kt index 0e4b3bee77..cceb363014 100644 --- a/shared/src/wasmJsMain/kotlin/dev/dimension/flare/data/database/DriverFactory.wasmJs.kt +++ b/shared/src/wasmJsMain/kotlin/dev/dimension/flare/data/database/DriverFactory.wasmJs.kt @@ -5,8 +5,8 @@ import androidx.room3.RoomDatabase import org.koin.core.annotation.Single @Single -internal actual class DriverFactory { - actual inline fun createBuilder( +public actual class DriverFactory { + public actual inline fun createBuilder( name: String, isCache: Boolean, ): RoomDatabase.Builder = @@ -15,7 +15,7 @@ internal actual class DriverFactory { name = name, ).setSingleConnectionPool() - actual fun deleteDatabase( + public actual fun deleteDatabase( name: String, isCache: Boolean, ) {