diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/JourneyAssistAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/JourneyAssistAPI.kt index 94f6076ab9..17b9f3031d 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/JourneyAssistAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/JourneyAssistAPI.kt @@ -4,10 +4,12 @@ import com.instructure.canvasapi2.models.journey.assist.JourneyAssistRequestBody import com.instructure.canvasapi2.models.journey.assist.JourneyAssistResponse import retrofit2.http.Body import retrofit2.http.POST +import retrofit2.http.Query interface JourneyAssistAPI { @POST("assist") suspend fun answerPrompt( @Body body: JourneyAssistRequestBody, + @Query("studyToolsOnly") studyToolsOnly: Boolean ): JourneyAssistResponse } \ No newline at end of file diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonHomePage.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonHomePage.kt index 07977dd90c..912d7330e6 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonHomePage.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonHomePage.kt @@ -18,7 +18,6 @@ package com.instructure.horizon.pages import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -28,8 +27,6 @@ class HorizonHomePage(private val composeTestRule: ComposeTestRule) { .assertIsDisplayed() composeTestRule.onNodeWithText("Learn") .assertIsDisplayed() - composeTestRule.onNodeWithContentDescription("IgniteAI") - .assertIsDisplayed() composeTestRule.onNodeWithText("Skillspace") .assertIsDisplayed() composeTestRule.onNodeWithText("Account") @@ -46,11 +43,6 @@ class HorizonHomePage(private val composeTestRule: ComposeTestRule) { .performClick() } - fun clickAiAssistantTab() { - composeTestRule.onNodeWithContentDescription("IgniteAI") - .performClick() - } - fun clickSkillspaceTab() { composeTestRule.onNodeWithText("Skillspace") .performClick() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/AiInformationScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/AiInformationScreen.kt new file mode 100644 index 0000000000..eecff5ac89 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/AiInformationScreen.kt @@ -0,0 +1,412 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.features.aiassistant.aiinformation + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.horizon.R +import com.instructure.horizon.features.aiassistant.aiinformation.model.AiInformationData +import com.instructure.horizon.features.aiassistant.aiinformation.model.AiInformationNutritionFactsData +import com.instructure.horizon.features.aiassistant.aiinformation.model.AiInformationPermissionLevelsData +import com.instructure.horizon.features.aiassistant.aiinformation.model.DataPermissionLevel +import com.instructure.horizon.features.aiassistant.aiinformation.model.NutritionFactBlock +import com.instructure.horizon.features.aiassistant.aiinformation.model.NutritionFactSegment +import com.instructure.horizon.features.aiassistant.aiinformation.nutritionfacts.AiInformationNutritionFactsScreen +import com.instructure.horizon.features.aiassistant.aiinformation.permissionlevels.AiInformationPermissionLevelsScreen +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonSpace +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.molecules.IconButton +import com.instructure.horizon.horizonui.molecules.IconButtonColor +import com.instructure.horizon.horizonui.molecules.IconButtonSize +import com.instructure.horizon.horizonui.molecules.TextLink +import com.instructure.horizon.horizonui.molecules.TextLinkColor +import com.instructure.horizon.horizonui.molecules.TextLinkSize + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AiInformationScreen( + onDismiss: () -> Unit, + data: AiInformationData = defaultAiInformationData(), +) { + var showPermissionLevels by rememberSaveable { mutableStateOf(false) } + var showNutritionFacts by rememberSaveable { mutableStateOf(false) } + + val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + containerColor = HorizonColors.Surface.pageSecondary(), + onDismissRequest = onDismiss, + sheetState = bottomSheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .navigationBarsPadding() + ) { + AiInformationHeader(onDismiss = onDismiss) + HorizonSpace(SpaceSize.SPACE_16) + Text( + text = data.title, + style = HorizonTypography.h1, + color = HorizonColors.Text.title(), + ) + HorizonSpace(SpaceSize.SPACE_24) + Text( + text = data.permissionLevelText, + style = HorizonTypography.labelLargeBold, + color = HorizonColors.Text.title(), + ) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = data.permissionLevel, + style = HorizonTypography.tag, + color = HorizonColors.PrimitivesViolet.violet40(), + ) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = data.description, + style = HorizonTypography.p1, + color = HorizonColors.Text.body(), + ) + HorizonSpace(SpaceSize.SPACE_16) + TextLink( + text = data.permissionLevelsModalTriggerText, + textLinkColor = TextLinkColor.Black, + textLinkSize = TextLinkSize.NORMAL, + onClick = { showPermissionLevels = true }, + ) + HorizonSpace(SpaceSize.SPACE_24) + Text( + text = data.modelNameText, + style = HorizonTypography.labelLargeBold, + color = HorizonColors.Text.title(), + ) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = data.modelName, + style = HorizonTypography.p1, + color = HorizonColors.Text.body(), + ) + HorizonSpace(SpaceSize.SPACE_16) + TextLink( + text = data.nutritionFactsModalTriggerText, + textLinkColor = TextLinkColor.Black, + textLinkSize = TextLinkSize.NORMAL, + onClick = { showNutritionFacts = true }, + ) + HorizonSpace(SpaceSize.SPACE_32) + } + } + + if (showPermissionLevels) { + AiInformationPermissionLevelsScreen( + data = data.permissionLevelsData, + onDismiss = { showPermissionLevels = false }, + ) + } + + if (showNutritionFacts) { + AiInformationNutritionFactsScreen( + data = data.nutritionFactsData, + onDismiss = { showNutritionFacts = false }, + ) + } +} + +@Composable +private fun defaultAiInformationData() = AiInformationData( + title = stringResource(R.string.aiInformation_title), + permissionLevelText = stringResource(R.string.aiInformation_permissionLevelText), + permissionLevel = stringResource(R.string.aiInformation_permissionLevel), + description = stringResource(R.string.aiInformation_description), + permissionLevelsModalTriggerText = stringResource(R.string.aiInformation_permissionLevelsModalTriggerText), + modelNameText = stringResource(R.string.aiInformation_modelNameText), + modelName = stringResource(R.string.aiInformation_modelName), + nutritionFactsModalTriggerText = stringResource(R.string.aiInformation_nutritionFactsModalTriggerText), + permissionLevelsData = AiInformationPermissionLevelsData( + title = stringResource(R.string.aiInformation_permissionLevels_title), + currentFeatureText = stringResource(R.string.aiInformation_permissionLevels_currentFeatureText), + currentFeature = stringResource(R.string.aiInformation_permissionLevels_currentFeature), + closeButtonText = stringResource(R.string.aiInformation_close), + levels = listOf( + DataPermissionLevel( + level = stringResource(R.string.aiInformation_level1), + title = stringResource(R.string.aiInformation_level1_title), + description = stringResource(R.string.aiInformation_level1_description), + ), + DataPermissionLevel( + level = stringResource(R.string.aiInformation_level2), + title = stringResource(R.string.aiInformation_level2_title), + description = stringResource(R.string.aiInformation_level2_description), + isHighlighted = true, + ), + DataPermissionLevel( + level = stringResource(R.string.aiInformation_level3), + title = stringResource(R.string.aiInformation_level3_title), + description = stringResource(R.string.aiInformation_level3_description), + ), + DataPermissionLevel( + level = stringResource(R.string.aiInformation_level4), + title = stringResource(R.string.aiInformation_level4_title), + description = stringResource(R.string.aiInformation_level4_description), + ), + ), + ), + nutritionFactsData = AiInformationNutritionFactsData( + title = stringResource(R.string.aiInformation_nutritionFacts_title), + featureName = stringResource(R.string.aiInformation_nutritionFacts_featureName), + closeButtonText = stringResource(R.string.aiInformation_close), + blocks = listOf( + NutritionFactBlock( + blockTitle = stringResource(R.string.aiInformation_block_modelData), + segments = listOf( + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_baseModel_title), + description = stringResource(R.string.aiInformation_segment_baseModel_description), + value = stringResource(R.string.aiInformation_modelName), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_trainedWithUserData_title), + description = stringResource(R.string.aiInformation_segment_trainedWithUserData_description), + value = stringResource(R.string.aiInformation_segment_trainedWithUserData_value), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_dataSharedWithModel_title), + description = stringResource(R.string.aiInformation_segment_dataSharedWithModel_description), + value = stringResource(R.string.aiInformation_segment_dataSharedWithModel_value), + ), + ), + ), + NutritionFactBlock( + blockTitle = stringResource(R.string.aiInformation_block_privacyCompliance), + segments = listOf( + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_dataRetention_title), + description = stringResource(R.string.aiInformation_segment_dataRetention_description), + value = stringResource(R.string.aiInformation_segment_dataRetention_value), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_dataLogging_title), + description = stringResource(R.string.aiInformation_segment_dataLogging_description), + value = stringResource(R.string.aiInformation_segment_dataLogging_value), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_regionsSupported_title), + description = stringResource(R.string.aiInformation_segment_regionsSupported_description), + value = stringResource(R.string.aiInformation_segment_regionsSupported_value), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_pii_title), + description = stringResource(R.string.aiInformation_segment_pii_description), + value = stringResource(R.string.aiInformation_segment_pii_value), + ), + ), + ), + NutritionFactBlock( + blockTitle = stringResource(R.string.aiInformation_block_outputs), + segments = listOf( + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_aiSettingsControl_title), + description = stringResource(R.string.aiInformation_segment_aiSettingsControl_description), + value = stringResource(R.string.aiInformation_segment_value), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_humanInTheLoop_title), + description = stringResource(R.string.aiInformation_segment_humanInTheLoop_description), + value = stringResource(R.string.aiInformation_segment_humanInTheLoop_value), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_guardrails_title), + description = stringResource(R.string.aiInformation_segment_guardrails_description), + value = stringResource(R.string.aiInformation_segment_guardrails_value), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_expectedRisks_title), + description = stringResource(R.string.aiInformation_segment_expectedRisks_description), + value = stringResource(R.string.aiInformation_segment_expectedRisks_value), + ), + NutritionFactSegment( + segmentTitle = stringResource(R.string.aiInformation_segment_intendedOutcomes_title), + description = stringResource(R.string.aiInformation_segment_intendedOutcomes_description), + value = stringResource(R.string.aiInformation_segment_intendedOutcomes_value), + ), + ), + ), + ), + ), +) + +@Composable +internal fun AiInformationHeader( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ai_filled), + contentDescription = null, + tint = HorizonColors.Surface.aiGradientStart(), + modifier = Modifier.size(20.dp), + ) + HorizonSpace(SpaceSize.SPACE_4) + Text( + text = stringResource(R.string.igniteAIToolbarTitle), + style = HorizonTypography.labelLargeBold, + color = HorizonColors.Surface.aiGradientStart(), + modifier = Modifier.weight(1f), + ) + IconButton( + iconRes = R.drawable.close, + contentDescription = stringResource(R.string.igniteAIDismissContentDescription), + size = IconButtonSize.SMALL, + color = IconButtonColor.Ghost, + onClick = onDismiss, + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun AiInformationScreenPreview() { + ContextKeeper.appContext = LocalContext.current + AiInformationScreen( + data = previewAiInformationData(), + onDismiss = {}, + ) +} + +internal fun previewAiInformationData() = AiInformationData( + title = "Study Tools", + permissionLevelText = "Permission Level", + permissionLevel = "LEVEL 2", + description = "We utilise off-the-shelf AI models and customer data as input to provide AI-powered features. No data is used for training this model.", + permissionLevelsModalTriggerText = "View Permission Levels", + modelNameText = "Base Model", + modelName = "Claude 3.5 Haiku by Anthropic and Cohere multi-language v3", + nutritionFactsModalTriggerText = "View AI Nutrition Facts", + permissionLevelsData = AiInformationPermissionLevelsData( + title = "Data Permission Levels", + currentFeatureText = "Current Feature:", + currentFeature = "Study Tools", + closeButtonText = "Close", + levels = listOf( + DataPermissionLevel( + level = "LEVEL 1", + title = "Descriptive Analytics and Research", + description = "We leverage anonymised aggregate data for detailed analytics to inform model development and product improvements. No AI models are used at this level.", + ), + DataPermissionLevel( + level = "LEVEL 2", + title = "AI-Powered Features Without Data Training", + description = "We utilise off-the-shelf AI models and customer data as input to provide AI-powered features. No data is used for training this model.", + isHighlighted = true, + ), + DataPermissionLevel( + level = "LEVEL 3", + title = "AI Customization for Individual Institutions", + description = "We customise AI solutions tailored to the unique needs and resources of educational institutions.", + ), + DataPermissionLevel( + level = "LEVEL 4", + title = "Collaborative AI Consortium", + description = "We established a consortium with educational institutions that shares anonymised data, best practices, and research findings.", + ), + ), + ), + nutritionFactsData = AiInformationNutritionFactsData( + title = "Nutrition Facts", + featureName = "Study Tools", + closeButtonText = "Close", + blocks = listOf( + NutritionFactBlock( + blockTitle = "Model & Data", + segments = listOf( + NutritionFactSegment( + segmentTitle = "Base Model", + description = "The foundational AI on which further training and customizations are built.", + value = "Claude 3.5 Haiku by Anthropic and Cohere multi-language v3", + ), + NutritionFactSegment( + segmentTitle = "Trained with User Data", + description = "Indicates the AI model has been given customer data in order to improve its results.", + value = "No", + ), + NutritionFactSegment( + segmentTitle = "Data Shared with Model", + description = "Indicates which training or operational content was given to the model.", + value = "Course content", + ), + ), + ), + NutritionFactBlock( + blockTitle = "Privacy & Compliance", + segments = listOf( + NutritionFactSegment( + segmentTitle = "Data Retention", + description = "How long the model stores customer data.", + value = "No", + ), + NutritionFactSegment( + segmentTitle = "Data Logging", + description = "Recording the AI's performance for auditing, analysis, and improvement.", + value = "No", + ), + NutritionFactSegment( + segmentTitle = "Regions Supported", + description = "The locations where the AI model is officially available and supported.", + value = "Global", + ), + NutritionFactSegment( + segmentTitle = "PII", + description = "Sensitive data that can be used to identify an individual.", + value = "No", + ), + ), + ), + ), + ), +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/model/AiInformationData.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/model/AiInformationData.kt new file mode 100644 index 0000000000..fc16696233 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/model/AiInformationData.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.features.aiassistant.aiinformation.model + +data class AiInformationData( + val title: String, + val permissionLevelText: String, + val permissionLevel: String, + val description: String, + val permissionLevelsModalTriggerText: String, + val modelNameText: String, + val modelName: String, + val nutritionFactsModalTriggerText: String, + val permissionLevelsData: AiInformationPermissionLevelsData, + val nutritionFactsData: AiInformationNutritionFactsData, +) + +data class AiInformationPermissionLevelsData( + val title: String, + val currentFeatureText: String, + val currentFeature: String, + val closeButtonText: String, + val levels: List, +) + +data class DataPermissionLevel( + val level: String, + val title: String, + val description: String, + val isHighlighted: Boolean = false, +) + +data class AiInformationNutritionFactsData( + val title: String, + val featureName: String, + val closeButtonText: String, + val blocks: List, +) + +data class NutritionFactBlock( + val blockTitle: String, + val segments: List, +) + +data class NutritionFactSegment( + val segmentTitle: String, + val description: String, + val value: String, + val valueDescription: String? = null, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/nutritionfacts/AiInformationNutritionFactsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/nutritionfacts/AiInformationNutritionFactsScreen.kt new file mode 100644 index 0000000000..42dcfa5509 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/nutritionfacts/AiInformationNutritionFactsScreen.kt @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.features.aiassistant.aiinformation.nutritionfacts + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.horizon.features.aiassistant.aiinformation.AiInformationHeader +import com.instructure.horizon.features.aiassistant.aiinformation.model.AiInformationNutritionFactsData +import com.instructure.horizon.features.aiassistant.aiinformation.model.NutritionFactBlock +import com.instructure.horizon.features.aiassistant.aiinformation.model.NutritionFactSegment +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius +import com.instructure.horizon.horizonui.foundation.HorizonSpace +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.molecules.Button +import com.instructure.horizon.horizonui.molecules.ButtonColor + +@Composable +fun AiInformationNutritionFactsScreen( + data: AiInformationNutritionFactsData, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(HorizonColors.Surface.pageSecondary()) + .statusBarsPadding(), + ) { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + AiInformationHeader(onDismiss = onDismiss) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = data.title, + style = HorizonTypography.h2, + color = HorizonColors.Text.title(), + ) + } + HorizontalDivider(thickness = 1.dp, color = HorizonColors.LineAndBorder.lineStroke()) + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp), + contentPadding = PaddingValues(vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { + Text( + text = data.featureName, + style = HorizonTypography.h2, + color = HorizonColors.Text.title(), + ) + } + items(data.blocks) { block -> + NutritionFactBlockSection(block = block) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + .navigationBarsPadding(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + label = data.closeButtonText, + color = ButtonColor.Black, + onClick = onDismiss, + ) + } + } + } +} + +@Composable +private fun NutritionFactBlockSection( + block: NutritionFactBlock, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = block.blockTitle, + style = HorizonTypography.h3, + color = HorizonColors.Text.title(), + ) + HorizonSpace(SpaceSize.SPACE_16) + block.segments.forEach { segment -> + NutritionFactSegmentCard(segment = segment) + HorizonSpace(SpaceSize.SPACE_16) + } + } +} + +@Composable +private fun NutritionFactSegmentCard( + segment: NutritionFactSegment, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(HorizonCornerRadius.level2) + .border( + width = 1.dp, + color = HorizonColors.LineAndBorder.lineStroke(), + shape = HorizonCornerRadius.level2, + ) + .padding(16.dp), + ) { + Text( + text = segment.segmentTitle, + style = HorizonTypography.labelLargeBold, + color = HorizonColors.Text.title(), + ) + HorizonSpace(SpaceSize.SPACE_4) + Text( + text = segment.description, + style = HorizonTypography.p2, + color = HorizonColors.Surface.attention(), + ) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = segment.value, + style = HorizonTypography.p1, + color = HorizonColors.Text.body(), + ) + if (segment.valueDescription != null) { + HorizonSpace(SpaceSize.SPACE_4) + Text( + text = segment.valueDescription, + style = HorizonTypography.p2, + color = HorizonColors.Text.body(), + ) + } + } +} + +@Composable +@Preview +private fun AiInformationNutritionFactsScreenPreview() { + ContextKeeper.appContext = LocalContext.current + AiInformationNutritionFactsScreen( + data = AiInformationNutritionFactsData( + title = "Nutrition Facts", + featureName = "Study Tools", + closeButtonText = "Close", + blocks = listOf( + NutritionFactBlock( + blockTitle = "Model & Data", + segments = listOf( + NutritionFactSegment( + segmentTitle = "Base Model", + description = "The foundational AI on which further training and customizations are built.", + value = "Claude 3.5 Haiku by Anthropic and Cohere multi-language v3", + ), + NutritionFactSegment( + segmentTitle = "Trained with User Data", + description = "Indicates the AI model has been given customer data in order to improve its results.", + value = "No", + ), + NutritionFactSegment( + segmentTitle = "Data Shared with Model", + description = "Indicates which training or operational content was given to the model.", + value = "Course content", + ), + ), + ), + NutritionFactBlock( + blockTitle = "Privacy & Compliance", + segments = listOf( + NutritionFactSegment( + segmentTitle = "Data Retention", + description = "How long the model stores customer data.", + value = "No", + ), + NutritionFactSegment( + segmentTitle = "Data Logging", + description = "Recording the AI's performance for auditing, analysis, and improvement.", + value = "No", + ), + ), + ), + ), + ), + onDismiss = {}, + ) +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/permissionlevels/AiInformationPermissionLevelsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/permissionlevels/AiInformationPermissionLevelsScreen.kt new file mode 100644 index 0000000000..21010b2c50 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/aiinformation/permissionlevels/AiInformationPermissionLevelsScreen.kt @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.features.aiassistant.aiinformation.permissionlevels + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.horizon.features.aiassistant.aiinformation.AiInformationHeader +import com.instructure.horizon.features.aiassistant.aiinformation.model.AiInformationPermissionLevelsData +import com.instructure.horizon.features.aiassistant.aiinformation.model.DataPermissionLevel +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius +import com.instructure.horizon.horizonui.foundation.HorizonSpace +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.molecules.Button +import com.instructure.horizon.horizonui.molecules.ButtonColor + +@Composable +fun AiInformationPermissionLevelsScreen( + data: AiInformationPermissionLevelsData, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(HorizonColors.Surface.pageSecondary()) + .statusBarsPadding(), + ) { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + AiInformationHeader(onDismiss = onDismiss) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = data.title, + style = HorizonTypography.h2, + color = HorizonColors.Text.title(), + ) + } + HorizontalDivider(thickness = 1.dp, color = HorizonColors.LineAndBorder.lineStroke()) + LazyColumn( + modifier = Modifier + .weight(1f), + contentPadding = PaddingValues(vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + items(data.levels) { level -> + if (level.isHighlighted) { + HighlightedPermissionLevelItem( + level = level, + currentFeatureText = data.currentFeatureText, + currentFeature = data.currentFeature, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } else { + PermissionLevelItem( + level = level, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + .navigationBarsPadding(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + label = data.closeButtonText, + color = ButtonColor.Black, + onClick = onDismiss, + ) + } + } + } +} + +@Composable +private fun PermissionLevelItem( + level: DataPermissionLevel, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = level.level, + style = HorizonTypography.tag, + color = HorizonColors.PrimitivesViolet.violet40(), + ) + HorizonSpace(SpaceSize.SPACE_4) + Text( + text = level.title, + style = HorizonTypography.h4, + color = HorizonColors.Text.title(), + ) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = level.description, + style = HorizonTypography.p1, + color = HorizonColors.Surface.attention(), + ) + } +} + +@Composable +private fun HighlightedPermissionLevelItem( + level: DataPermissionLevel, + currentFeatureText: String, + currentFeature: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(HorizonCornerRadius.level2) + .border( + width = 1.dp, + color = HorizonColors.LineAndBorder.lineStroke(), + shape = HorizonCornerRadius.level2, + ), + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .background(brush = HorizonColors.Surface.aiGradient()) + .padding(16.dp), + ) { + Text( + text = "$currentFeatureText $currentFeature", + style = HorizonTypography.labelLargeBold, + color = HorizonColors.Text.surfaceColored(), + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(HorizonColors.Surface.cardPrimary()) + .padding(16.dp), + ) { + Text( + text = level.level, + style = HorizonTypography.tag, + color = HorizonColors.PrimitivesViolet.violet40(), + ) + HorizonSpace(SpaceSize.SPACE_4) + Text( + text = level.title, + style = HorizonTypography.h4, + color = HorizonColors.Text.title(), + ) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = level.description, + style = HorizonTypography.p1, + color = HorizonColors.Surface.attention(), + ) + } + } + } +} + +@Composable +@Preview +private fun AiInformationPermissionLevelsScreenPreview() { + ContextKeeper.appContext = LocalContext.current + AiInformationPermissionLevelsScreen( + data = AiInformationPermissionLevelsData( + title = "Data Permission Levels", + currentFeatureText = "Current Feature:", + currentFeature = "Study Tools", + closeButtonText = "Close", + levels = listOf( + DataPermissionLevel( + level = "LEVEL 1", + title = "Descriptive Analytics and Research", + description = "We leverage anonymised aggregate data for detailed analytics to inform model development and product improvements. No AI models are used at this level.", + ), + DataPermissionLevel( + level = "LEVEL 2", + title = "AI-Powered Features Without Data Training", + description = "We utilise off-the-shelf AI models and customer data as input to provide AI-powered features. No data is used for training this model.", + isHighlighted = true, + ), + DataPermissionLevel( + level = "LEVEL 3", + title = "AI Customization for Individual Institutions", + description = "We customise AI solutions tailored to the unique needs and resources of educational institutions.", + ), + DataPermissionLevel( + level = "LEVEL 4", + title = "Collaborative AI Consortium", + description = "We established a consortium with educational institutions that shares anonymised data, best practices, and research findings.", + ), + ), + ), + onDismiss = {}, + ) +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatScreen.kt index 989e44d257..d194636aac 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -86,9 +85,6 @@ fun AiAssistChatScreen( navController = navController, onClearChatHistory = state.onClearChatHistory, onDismiss = { onDismiss() }, - inputTextValue = state.inputTextValue, - onInputTextChanged = { state.onInputTextChanged(it) }, - onInputTextSubmitted = { state.onInputTextSubmitted() }, ) { modifier -> val scrollState = rememberLazyListState() LaunchedEffect(state.messages.size) { @@ -128,7 +124,7 @@ fun AiAssistChatScreen( modifier = Modifier .fillMaxWidth() .semantics { - contentDescription = context.getString(R.string.a11y_igniteAiLoadingContentDescription) + contentDescription = context.getString(R.string.a11y_studyToolsLoadingContentDescription) } .focusRequester(loadingFocusRequester) .focusable() @@ -158,7 +154,6 @@ private fun AssistChatScreenPreview() { role = JourneyAssistRole.Assistant ) ), - inputTextValue = TextFieldValue("Hi,"), isLoading = true ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatUiState.kt index 0d36a7d23c..aa3f7a5328 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatUiState.kt @@ -1,6 +1,5 @@ package com.instructure.horizon.features.aiassistant.chat -import androidx.compose.ui.text.input.TextFieldValue import com.instructure.horizon.features.aiassistant.common.model.AiAssistMessage data class AiAssistChatUiState( @@ -8,9 +7,6 @@ data class AiAssistChatUiState( val isLoading: Boolean = false, val error: String? = null, val isFeedbackEnabled: Boolean = false, - val inputTextValue: TextFieldValue = TextFieldValue(""), - val onInputTextChanged: (TextFieldValue) -> Unit = {}, - val onInputTextSubmitted: () -> Unit = {}, val onClearChatHistory: () -> Unit = {}, val onChipClicked: (String) -> Unit = {}, val onNavigateToCards: () -> Unit = {}, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModel.kt index 52c2b224e6..fbce67c81b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModel.kt @@ -16,7 +16,6 @@ */ package com.instructure.horizon.features.aiassistant.chat -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.models.journey.assist.JourneyAssistRole @@ -39,8 +38,6 @@ class AiAssistChatViewModel @Inject constructor( private val aiAssistContextProvider: AiAssistContextProvider, ): ViewModel() { private val _uiState = MutableStateFlow(AiAssistChatUiState( - onInputTextChanged = ::onTextInputChanged, - onInputTextSubmitted = ::onTextInputSubmitted, onClearChatHistory = ::onClearChatHistory, onChipClicked = ::onChipClicked, onNavigateToCards = ::onNavigateToCards, @@ -51,27 +48,6 @@ class AiAssistChatViewModel @Inject constructor( private var aiAssistContextState = aiAssistContextProvider.aiAssistContext.state private var aiAssistMessages = aiAssistContextProvider.aiAssistContext.chatHistory.toMutableList() - private fun onTextInputChanged(newValue: TextFieldValue) { - _uiState.update { - it.copy( - inputTextValue = newValue, - ) - } - } - - private fun onTextInputSubmitted() { - val prompt = _uiState.value.inputTextValue.text - val message = addMessage(prompt) - _uiState.update { - it.copy( - inputTextValue = TextFieldValue(""), - messages = it.messages + message, - ) - } - - evaluatePrompt(message) - } - private fun evaluatePrompt(message: AiAssistMessage) { viewModelScope.tryLaunch { _uiState.update { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepository.kt index 2f802b4ac3..ffc2f8b9d7 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepository.kt @@ -22,7 +22,7 @@ class AiAssistRepository @Inject constructor( state: JourneyAssistState ): AiAssistResponse { val requestBody = JourneyAssistRequestBody(prompt, history, state) - val response = journeyAssistAPI.answerPrompt(requestBody) + val response = journeyAssistAPI.answerPrompt(requestBody, true) val message = AiAssistMessage( text = response.response.orEmpty(), role = JourneyAssistRole.Assistant, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistDetailedFeedback.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistDetailedFeedback.kt index 4d822859d3..6db1295d3f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistDetailedFeedback.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistDetailedFeedback.kt @@ -74,7 +74,7 @@ fun AiAssistDetailedFeedback( ) { Column( modifier = modifier - .clip(HorizonCornerRadius.level2) + .clip(HorizonCornerRadius.level1_5) .background(Color.White.copy(alpha = 0.1f)) .padding(16.dp) ) { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistFeedback.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistFeedback.kt index f510b336f5..f145e39d96 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistFeedback.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistFeedback.kt @@ -71,7 +71,7 @@ private fun AiAssistPositiveFeedbackIcon( } else { painterResource(R.drawable.thumb_up) }, - contentDescription = stringResource(R.string.a11y_igniteAIPositiveFeedback), + contentDescription = stringResource(R.string.a11y_studyToolsPositiveFeedback), tint = HorizonColors.Text.surfaceColored(), modifier = Modifier.clickable { onClick() }, ) @@ -88,7 +88,7 @@ private fun AiAssistNegativeFeedbackIcon( } else { painterResource(R.drawable.thumb_down) }, - contentDescription = stringResource(R.string.a11y_igniteAINegativeFeedback), + contentDescription = stringResource(R.string.a11y_studyToolsNegativeFeedback), tint = HorizonColors.Text.surfaceColored(), modifier = Modifier.clickable { onClick() }, ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistInput.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistInput.kt index 6784067c21..e5f84ed0b3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistInput.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistInput.kt @@ -51,7 +51,7 @@ fun AiAssistInput( .fillMaxWidth() .background( HorizonColors.Surface.cardPrimary(), - HorizonCornerRadius.level2 + HorizonCornerRadius.level1_5 ) ) { AiAssistTextArea( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistResponseTextBlock.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistResponseTextBlock.kt index d3e714cc14..5905477668 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistResponseTextBlock.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistResponseTextBlock.kt @@ -103,29 +103,13 @@ fun AiAssistResponseTextBlock( HorizonSpace(SpaceSize.SPACE_8) FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { chips.forEach { chip -> AiAssistSuggestionTextBlock( text = chip.label, - onClick = chip.onClick - ) - } - } - } - - if (chips.isNotEmpty()) { - HorizonSpace(SpaceSize.SPACE_8) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - chips.forEach { chip -> - AiAssistSuggestionTextBlock( - text = chip.label, - onClick = chip.onClick + onClick = chip.onClick, ) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistScaffold.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistScaffold.kt index 9104fa1819..65fe13394c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistScaffold.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistScaffold.kt @@ -16,73 +16,75 @@ */ package com.instructure.horizon.features.aiassistant.common.composable +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import com.instructure.horizon.features.aiassistant.aiinformation.AiInformationScreen import com.instructure.horizon.horizonui.foundation.HorizonColors -import com.instructure.horizon.horizonui.foundation.HorizonSpace -import com.instructure.horizon.horizonui.foundation.SpaceSize -import com.instructure.horizon.horizonui.molecules.HorizonDivider +import com.instructure.horizon.horizonui.organisms.scaffolds.EdgeToEdgeScaffold @Composable fun AiAssistScaffold( navController: NavHostController, onClearChatHistory: () -> Unit, onDismiss: () -> Unit, - inputTextValue: TextFieldValue? = null, - onInputTextChanged: ((TextFieldValue) -> Unit)? = null, - onInputTextSubmitted: (() -> Unit)? = null, modifier: Modifier = Modifier, content: @Composable (Modifier) -> Unit, ) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - AiAssistToolbar( - onDismissPressed = { - onClearChatHistory() - onDismiss() - }, - onBackPressed = if (navController.previousBackStackEntry != null) { - { - onClearChatHistory() - navController.popBackStack() - } - } else { - null - }, - modifier = Modifier - .padding(horizontal = 16.dp) - ) - - HorizonDivider(color = HorizonColors.Surface.pagePrimary()) - HorizonSpace(SpaceSize.SPACE_16) + var showAiInformation by rememberSaveable { mutableStateOf(false) } - content(Modifier.weight(1f).padding(horizontal = 24.dp)) - - if (inputTextValue != null && onInputTextChanged != null && onInputTextSubmitted != null) { - val focusManager = LocalFocusManager.current - AiAssistInput( - value = inputTextValue, - onValueChange = { onInputTextChanged(it) }, - onSubmitPressed = { - focusManager.clearFocus() - onInputTextSubmitted() + EdgeToEdgeScaffold( + statusBarColor = HorizonColors.Surface.aiGradientStart(), + statusBarAlpha = 0f, + navigationBarColor = HorizonColors.Surface.aiGradientEnd(), + navigationBarAlpha = 0f, + containerColor = Color.Transparent, + modifier = modifier.background(HorizonColors.Surface.aiGradient()) + ) { contentPadding -> + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(contentPadding) + ) { + AiAssistToolbar( + onDismissPressed = { + onClearChatHistory() + onDismiss() }, + onBackPressed = if (navController.previousBackStackEntry != null) { + { + onClearChatHistory() + navController.popBackStack() + } + } else { + null + }, + onInfoPressed = { showAiInformation = true }, modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 8.dp, bottom = 24.dp) ) + + content(Modifier + .weight(1f) + .padding(horizontal = 24.dp)) } } -} \ No newline at end of file + + if (showAiInformation) { + AiInformationScreen( + onDismiss = { showAiInformation = false }, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistSuggestionTextBlock.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistSuggestionTextBlock.kt index c1f7f18ce7..7fd907ca03 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistSuggestionTextBlock.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistSuggestionTextBlock.kt @@ -19,6 +19,7 @@ package com.instructure.horizon.features.aiassistant.common.composable import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,10 +38,11 @@ fun AiAssistSuggestionTextBlock( ) { Box( modifier = modifier + .fillMaxWidth() .border( 1.dp, HorizonColors.LineAndBorder.lineStroke(), - HorizonCornerRadius.level4 + HorizonCornerRadius.level1_5 ) .padding(16.dp) .clickable { onClick() } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistTextArea.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistTextArea.kt index 1cbe110747..a3f57d23f8 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistTextArea.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistTextArea.kt @@ -69,7 +69,7 @@ private fun TextAreaBox( innerTextField() if (value.text.isEmpty()) { Text( - text = stringResource(R.string.igniteAIEnterAPromptLabel), + text = stringResource(R.string.studyToolsEnterAPromptLabel), style = HorizonTypography.p1, color = HorizonColors.Text.timestamp(), ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistToolbar.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistToolbar.kt index dc45c50ad6..208d9d3503 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistToolbar.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistToolbar.kt @@ -17,16 +17,14 @@ package com.instructure.horizon.features.aiassistant.common.composable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -48,54 +46,61 @@ fun AiAssistToolbar( onDismissPressed: () -> Unit, modifier: Modifier = Modifier, onBackPressed: (() -> Unit)? = null, + onInfoPressed: (() -> Unit)? = null, ) { - CenterAlignedTopAppBar( - colors = TopAppBarDefaults.centerAlignedTopAppBarColors().copy( - containerColor = Color.Transparent - ), - modifier = modifier, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterResource(R.drawable.ai_filled), - contentDescription = null, - tint = HorizonColors.Icon.surfaceColored(), - modifier = Modifier - .size(24.dp) - ) + Row( + modifier = modifier.padding(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onBackPressed != null) { + IconButton( + iconRes = R.drawable.arrow_back, + contentDescription = stringResource(R.string.a11yNavigateBack), + size = IconButtonSize.SMALL, + color = IconButtonColor.WhiteOutline, + onClick = onBackPressed, + ) + HorizonSpace(SpaceSize.SPACE_4) + } + + Icon( + painter = painterResource(R.drawable.ai_filled), + contentDescription = null, + tint = HorizonColors.Icon.surfaceColored(), + modifier = Modifier + .size(24.dp) + ) - HorizonSpace(SpaceSize.SPACE_4) + HorizonSpace(SpaceSize.SPACE_4) - Text( - text = stringResource(R.string.igniteAIToolbarTitle), - style = HorizonTypography.h3, - color = HorizonColors.Text.surfaceColored() - ) - } - }, - navigationIcon = { - if (onBackPressed != null) { - IconButton( - iconRes = R.drawable.arrow_back, - contentDescription = stringResource(R.string.a11yNavigateBack), - size = IconButtonSize.SMALL, - color = IconButtonColor.WhiteOutline, - onClick = onBackPressed, - ) - } - }, - actions = { + Text( + text = stringResource(R.string.studyToolsToolbarTitle), + style = HorizonTypography.h3, + color = HorizonColors.Text.surfaceColored(), + modifier = modifier.weight(1f) + ) + + HorizonSpace(SpaceSize.SPACE_4) + + if (onInfoPressed != null) { IconButton( - iconRes = R.drawable.close, - contentDescription = stringResource(R.string.igniteAIDismissContentDescription), + iconRes = R.drawable.info, + contentDescription = stringResource(R.string.a11y_aiInformation), size = IconButtonSize.SMALL, - color = IconButtonColor.WhiteOutline, - onClick = onDismissPressed, + color = IconButtonColor.BlackGhost, + onClick = onInfoPressed, ) + HorizonSpace(SpaceSize.SPACE_4) } - ) + + IconButton( + iconRes = R.drawable.close, + contentDescription = stringResource(R.string.studyToolsDismissContentDescription), + size = IconButtonSize.SMALL, + color = IconButtonColor.BlackGhost, + onClick = onDismissPressed, + ) + } } @Composable diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistUserTextBlock.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistUserTextBlock.kt index 855c4668d6..1737c6219a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistUserTextBlock.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/common/composable/AiAssistUserTextBlock.kt @@ -36,7 +36,7 @@ fun AiAssistUserTextBlock( ) { Box( modifier = modifier - .clip(HorizonCornerRadius.level2) + .clip(HorizonCornerRadius.level1_5) .background(HorizonColors.Surface.cardPrimary()) .padding(16.dp) ) { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/flashcard/composable/AiAssistFlashcard.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/flashcard/composable/AiAssistFlashcard.kt index a33fb4f288..668cb24e05 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/flashcard/composable/AiAssistFlashcard.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/flashcard/composable/AiAssistFlashcard.kt @@ -89,7 +89,7 @@ fun AiAssistFlashcard( } .clearAndSetSemantics { onClick( - label = context.getString(R.string.a11y_aiAssistFlashcardClickActionLabel), + label = context.getString(R.string.a11y_studyToolsFlashcardClickActionLabel), action = { onClick(); true } ) contentDescription = cardContentDescription diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/main/AiAssistMainScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/main/AiAssistMainScreen.kt index ba5386346c..edb60a8abf 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/main/AiAssistMainScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/main/AiAssistMainScreen.kt @@ -97,12 +97,6 @@ fun AiAssistMainScreen( navController = navController, onClearChatHistory = { }, onDismiss = { onDismiss() }, - inputTextValue = promptInput, - onInputTextChanged = { promptInput = it }, - onInputTextSubmitted = { - state.sendMessage(promptInput.text) - promptInput = TextFieldValue("") - } ) { modifier -> LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -130,7 +124,7 @@ fun AiAssistMainScreen( modifier = Modifier .fillMaxWidth() .semantics { - contentDescription = context.getString(R.string.a11y_igniteAiLoadingContentDescription) + contentDescription = context.getString(R.string.a11y_studyToolsLoadingContentDescription) } .focusRequester(loadingFocusRequester) .focusable() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/quiz/composable/AiAssistQuizAnswer.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/quiz/composable/AiAssistQuizAnswer.kt index 83443f06a8..0619064632 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/quiz/composable/AiAssistQuizAnswer.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/quiz/composable/AiAssistQuizAnswer.kt @@ -65,7 +65,7 @@ fun AiAssistQuizAnswer( Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier - .clip(HorizonCornerRadius.level2) + .clip(HorizonCornerRadius.level1_5) .clickable { onClick() } .conditional(status != AiAssistQuizAnswerStatus.UNSELECTED) { this @@ -75,7 +75,7 @@ fun AiAssistQuizAnswer( this .border( HorizonBorder.level1(HorizonColors.Surface.pageSecondary()), - shape = HorizonCornerRadius.level2, + shape = HorizonCornerRadius.level1_5, ) } .padding(16.dp) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeBottomNavigationBar.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeBottomNavigationBar.kt index 4302663d17..c130777c53 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeBottomNavigationBar.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeBottomNavigationBar.kt @@ -22,33 +22,23 @@ import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.requiredSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.NavigationBar import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import com.instructure.horizon.R -import com.instructure.horizon.features.aiassistant.AiAssistantScreen import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonElevation -import com.instructure.horizon.horizonui.molecules.IconButton -import com.instructure.horizon.horizonui.molecules.IconButtonColor import com.instructure.horizon.horizonui.organisms.navelements.SelectableNavigationItem data class BottomNavItem( - val route: String?, + val route: String, @StringRes val label: Int, @DrawableRes val icon: Int, @DrawableRes val selectedIcon: Int, @@ -58,7 +48,6 @@ data class BottomNavItem( private val bottomNavItems = listOf( BottomNavItem(HomeNavigationRoute.Dashboard.route, R.string.bottomNav_home, R.drawable.home, R.drawable.home_filled), BottomNavItem(HomeNavigationRoute.Learn.route, R.string.bottomNav_learn, R.drawable.book_2, R.drawable.book_2_filled, true), - BottomNavItem(null, R.string.bottomNav_igniteAI, R.drawable.ai, R.drawable.ai_filled), BottomNavItem(HomeNavigationRoute.Skillspace.route, R.string.bottomNav_skillspace, R.drawable.hub, R.drawable.hub_filled), BottomNavItem( HomeNavigationRoute.Account.route, @@ -85,10 +74,6 @@ fun HomeBottomNavigationBar( buttonsEnabled: Boolean = true, modifier: Modifier = Modifier ) { - var isAiAssistVisible by rememberSaveable { mutableStateOf(false) } - if (isAiAssistVisible) { - AiAssistantScreen(navController) { isAiAssistVisible = false } - } val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination val visible = isBottomBarVisible(navController) @@ -104,43 +89,22 @@ fun HomeBottomNavigationBar( modifier = modifier ) { bottomNavItems.forEach { item -> - val selected = - currentDestination?.hierarchy?.any { it.route == item.route } == true - if (item.route == null) { - AiAssistantItem(item, buttonsEnabled, onClick = { - isAiAssistVisible = true - }) - } else { - SelectableNavigationItem(item, selected, buttonsEnabled, onClick = { - navController.navigate(item.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - - // Do not restore screen state when navigating to Dashboard screen - // Restore when navigating between other screens - restoreState = item.route != HomeNavigationRoute.Dashboard.route || - (item.route == HomeNavigationRoute.Dashboard.route && currentDestination?.route == HomeNavigationRoute.Dashboard.route) + val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true + SelectableNavigationItem(item, selected, buttonsEnabled, onClick = { + navController.navigate(item.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true } - }) - } + launchSingleTop = true + + // Do not restore screen state when navigating to Dashboard screen + // Restore when navigating between other screens + restoreState = item.route != HomeNavigationRoute.Dashboard.route || + (item.route == HomeNavigationRoute.Dashboard.route && currentDestination?.route == HomeNavigationRoute.Dashboard.route) + } + }) } } } } -} - -@Composable -fun RowScope.AiAssistantItem(item: BottomNavItem, enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - IconButton( - modifier = modifier - .requiredSize(44.dp) - .weight(1f), - onClick = onClick, - contentDescription = stringResource(item.label), - iconRes = R.drawable.ai, - color = IconButtonColor.Ai, - enabled = enabled - ) } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt index d9d039b1bf..d9215a9e41 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt @@ -144,6 +144,8 @@ fun ModuleItemSequenceScreen(navController: NavHostController, uiState: ModuleIt showNextButton = uiState.currentPosition < uiState.items.size - 1, showPreviousButton = uiState.currentPosition > 0, showNotebookButton = uiState.currentItem?.moduleItemContent is ModuleItemContent.Page, + showAiAssistButton = (uiState.currentItem?.moduleItemContent is ModuleItemContent.File) + || (uiState.currentItem?.moduleItemContent is ModuleItemContent.Page), showAssignmentToolsButton = uiState.currentItem?.moduleItemContent is ModuleItemContent.Assignment, onNextClick = uiState.onNextClick, onPreviousClick = uiState.onPreviousClick, @@ -559,6 +561,7 @@ private fun ModuleItemSequenceBottomBar( showNextButton: Boolean, showPreviousButton: Boolean, showNotebookButton: Boolean, + showAiAssistButton: Boolean, showAssignmentToolsButton: Boolean, onNextClick: () -> Unit, onPreviousClick: () -> Unit, @@ -594,9 +597,9 @@ private fun ModuleItemSequenceBottomBar( .align(Alignment.Center), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally) ) { - IconButton( + if (showAiAssistButton) IconButton( iconRes = R.drawable.ai, - contentDescription = stringResource(R.string.a11y_openIgniteAI), + contentDescription = stringResource(R.string.a11y_openStudyTools), enabled = aiAssistEnabled, color = IconButtonColor.Ai, elevation = HorizonElevation.level4, diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index 870f4f606d..e832668f68 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -20,7 +20,7 @@ Learn Skillspace Account - IgniteAI + Study Tools %1$s\%% Resume learning Assignment @@ -199,10 +199,13 @@ Download file Open with File couldn’t be downloaded. + Study tools + Dismiss Study tools IgniteAI Dismiss IgniteAI - Rate IgniteAI with positive feedback - Rate IgniteAI with negative feedback + AI Information + Rate Study tools with positive feedback + Rate Study tools with negative feedback Enter a prompt Submit prompt Check answer @@ -506,7 +509,7 @@ Unread Previous module item Previous module - Open IgniteAI + Open Study tools Open notebook Open more options Next module item @@ -516,9 +519,9 @@ Programs Browse File a ticket for a personal response from our support team. - Enter a prompt - Loading response - Flip the card + Enter a prompt + Loading response + Flip the card Failed to load courses All courses Not started @@ -658,4 +661,70 @@ %1$d min No results found. Try adjusting your search terms. Failed to update + Study tools + Permission Level + LEVEL 1 + We utilize off-the-shelf AI models and customer data as input to provide AI-powered features. No data is used for training this model. + Permission Levels + Base Model + Claude 3.5 Haiku by Anthropic and Cohere multi-language v3 + View AI Nutrition Facts + Data Permission Levels + Current Feature: + Study tools + Close + LEVEL 1 + Descriptive Analytics and Research + We leverage anonymized aggregate data for detailed analytics to inform model development and product improvements. No AI models are used at this level. + LEVEL 2 + AI-Powered Features Without Data Training + We utilize off-the-shelf AI models and customer data as input to provide AI-powered features. No data is used for training this model. + LEVEL 3 + AI Customization for Individual Institutions + We customize AI solutions tailored to the unique needs and resources of educational institutions. We use customer data to fine-tune data and train AI models that only serve your institution. Your institution\'s data only serves them through trained models. + LEVEL 4 + Collaborative AI Consortium + We established a consortium with educational institutions that shares anonymized data, best practices, and research findings. This fosters collaboration and accelerates the responsible development of AI in education. Specialized AI models are created for better outcomes in education, cost savings, and more. + Nutrition Facts + Study tools + Model & Data + Privacy & Compliance + Outputs + Value + Description + Base Model + The foundational AI on which further training and customizations are built. + Trained with User Data + Indicates the AI model has been given customer data in order to improve its results. + No + Data Shared with Model + Indicates which training or operational content was given to the model. + Course content + Data Retention + How long the model stores customer data. + No + Data Logging + Recording the AI\'s performance for auditing, analysis, and improvement. + Chat logs are retained for 30 days for troubleshooting and debugging + Regions Supported + The locations where the AI model is officially available and supported. + Global + PII + Sensitive data that can be used to identify an individual. + Not Exposed + AI Settings Control + The ability to turn the AI on or off within the product. + No + Human in the Loop + Indicates if a human is involved in the AI\'s process or output. + Yes + Guardrails + Preventative safety mechanisms or limitations built into the AI model. + Yes + Expected Risks + Any risks the model may pose to the user. + Low risk + Intended Outcomes + The specific results the AI model is meant to achieve. + AI Generated content may contain mistakes or inaccurate information and should always be verified \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModelTest.kt index 3a4a6de482..591e12b71b 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModelTest.kt @@ -16,8 +16,6 @@ */ package com.instructure.horizon.features.aiassistant.chat -import androidx.compose.ui.text.input.TextFieldValue -import com.instructure.canvasapi2.models.journey.assist.JourneyAssistChipOption import com.instructure.canvasapi2.models.journey.assist.JourneyAssistRole import com.instructure.canvasapi2.models.journey.assist.JourneyAssistState import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider @@ -26,11 +24,9 @@ import com.instructure.horizon.features.aiassistant.common.AiAssistResponse import com.instructure.horizon.features.aiassistant.common.model.AiAssistContext import com.instructure.horizon.features.aiassistant.common.model.AiAssistMessage import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll -import io.mockk.verify import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue @@ -106,233 +102,6 @@ class AiAssistChatViewModelTest { assertFalse(viewModel.uiState.value.isLoading) } - @Test - fun `Text input change updates state`() = runTest { - val viewModel = getViewModel() - - val newText = TextFieldValue("Test input") - viewModel.uiState.value.onInputTextChanged(newText) - - assertEquals("Test input", viewModel.uiState.value.inputTextValue.text) - } - - @Test - fun `Text submission sends message and clears input`() = runTest { - val viewModel = getViewModel() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Test message")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - assertTrue(viewModel.uiState.value.messages.any { - it.role == JourneyAssistRole.User && it.text == "Test message" - }) - assertEquals("", viewModel.uiState.value.inputTextValue.text) - } - - @Test - fun `Text submission receives response from repository`() = runTest { - val viewModel = getViewModel() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Test message")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { repository.answerPrompt("Test message", any(), any()) } - assertTrue(viewModel.uiState.value.messages.any { - it.role == JourneyAssistRole.Assistant && it.text == "Test response" - }) - assertFalse(viewModel.uiState.value.isLoading) - } - - @Test - fun `Loading state is set during message submission`() = runTest { - coEvery { - repository.answerPrompt(any(), any(), any()) - } coAnswers { - kotlinx.coroutines.delay(100) - val message = AiAssistMessage( - text = "Response", - role = JourneyAssistRole.Assistant - ) - AiAssistResponse(message, testState) - } - - val viewModel = getViewModel() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Test message")) - viewModel.uiState.value.onInputTextSubmitted() - - assertTrue(viewModel.uiState.value.isLoading) - - testDispatcher.scheduler.advanceUntilIdle() - - assertFalse(viewModel.uiState.value.isLoading) - } - - @Test - fun `Messages are appended in correct order`() = runTest { - val viewModel = getViewModel() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("First message")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Second message")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - val messages = viewModel.uiState.value.messages - assertEquals(4, messages.size) - assertEquals(JourneyAssistRole.User, messages[0].role) - assertEquals("First message", messages[0].text) - assertEquals(JourneyAssistRole.Assistant, messages[1].role) - assertEquals(JourneyAssistRole.User, messages[2].role) - assertEquals("Second message", messages[2].text) - assertEquals(JourneyAssistRole.Assistant, messages[3].role) - } - - @Test - fun `Chip click sends prompt and receives response`() = runTest { - val viewModel = getViewModel() - - viewModel.uiState.value.onChipClicked("Suggested prompt") - testDispatcher.scheduler.advanceUntilIdle() - - assertTrue(viewModel.uiState.value.messages.any { - it.role == JourneyAssistRole.User && it.text == "Suggested prompt" - }) - coVerify { repository.answerPrompt("Suggested prompt", any(), any()) } - assertTrue(viewModel.uiState.value.messages.any { - it.role == JourneyAssistRole.Assistant - }) - } - - @Test - fun `Clear chat history updates context provider`() = runTest { - val existingMessage = AiAssistMessage( - text = "Existing message", - role = JourneyAssistRole.User - ) - val contextWithHistory = testContext.copy(chatHistory = listOf(existingMessage)) - every { aiAssistContextProvider.aiAssistContext } returns contextWithHistory - - val viewModel = getViewModel() - viewModel.uiState.value.onClearChatHistory() - - verify { - aiAssistContextProvider.aiAssistContext = match { - it.chatHistory.isEmpty() - } - } - } - - @Test - fun `Navigate to cards updates context and removes last message`() = runTest { - val responseMessage = AiAssistMessage( - text = "Response with cards", - role = JourneyAssistRole.Assistant, - chipOptions = listOf(JourneyAssistChipOption("Option 1", "prompt1")) - ) - coEvery { - repository.answerPrompt(any(), any(), any()) - } returns AiAssistResponse(responseMessage, testState) - - val viewModel = getViewModel() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Generate cards")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - val messageCountBefore = viewModel.uiState.value.messages.size - viewModel.uiState.value.onNavigateToCards() - - assertEquals(messageCountBefore - 1, viewModel.uiState.value.messages.size) - verify { aiAssistContextProvider.aiAssistContext = any() } - } - - @Test - fun `Repository is called with conversation history`() = runTest { - val viewModel = getViewModel() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("First")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Second")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 2) { - repository.answerPrompt(any(), any(), any()) - } - - coVerify { - repository.answerPrompt( - "Second", - match { history -> - history.any { it.role == JourneyAssistRole.User && it.text == "First" } && - history.any { it.role == JourneyAssistRole.Assistant } && - history.any { it.role == JourneyAssistRole.User && it.text == "Second" } - }, - any() - ) - } - } - - @Test - fun `State is updated from repository response`() = runTest { - val updatedState = JourneyAssistState( - courseID = "456", - fileID = "789", - pageID = "101" - ) - val message = AiAssistMessage( - text = "Response", - role = JourneyAssistRole.Assistant - ) - coEvery { - repository.answerPrompt(any(), any(), any()) - } returns AiAssistResponse(message, updatedState) - - val viewModel = getViewModel() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Test")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Second")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { - repository.answerPrompt( - "Second", - any(), - match { state -> - state.courseID == "456" && - state.fileID == "789" && - state.pageID == "101" - } - ) - } - } - - @Test - fun `Error handling sets loading to false`() = runTest { - coEvery { - repository.answerPrompt(any(), any(), any()) - } throws Exception("Network error") - - val viewModel = getViewModel() - - viewModel.uiState.value.onInputTextChanged(TextFieldValue("Test")) - viewModel.uiState.value.onInputTextSubmitted() - testDispatcher.scheduler.advanceUntilIdle() - - assertFalse(viewModel.uiState.value.isLoading) - } - private fun getViewModel(): AiAssistChatViewModel { return AiAssistChatViewModel(repository, aiAssistContextProvider) } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepositoryTest.kt index f63d58bcf7..618396771c 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepositoryTest.kt @@ -91,7 +91,7 @@ class AiAssistRepositoryTest { ) coEvery { - journeyAssistAPI.answerPrompt(any()) + journeyAssistAPI.answerPrompt(any(), true) } returns apiResponse val result = repository.answerPrompt( @@ -117,7 +117,7 @@ class AiAssistRepositoryTest { ) coEvery { - journeyAssistAPI.answerPrompt(any()) + journeyAssistAPI.answerPrompt(any(), true) } returns apiResponse val result = repository.answerPrompt( @@ -144,7 +144,7 @@ class AiAssistRepositoryTest { ) coEvery { - journeyAssistAPI.answerPrompt(any()) + journeyAssistAPI.answerPrompt(any(), true) } returns JourneyAssistResponse(response = "Response") repository.answerPrompt(prompt, history, testState) @@ -155,7 +155,8 @@ class AiAssistRepositoryTest { requestBody.prompt == prompt && requestBody.history == history && requestBody.state == testState - } + }, + true ) } } @@ -169,7 +170,7 @@ class AiAssistRepositoryTest { ) coEvery { - journeyAssistAPI.answerPrompt(any()) + journeyAssistAPI.answerPrompt(any(), true) } returns JourneyAssistResponse( response = "Response", state = updatedState @@ -187,7 +188,7 @@ class AiAssistRepositoryTest { @Test fun `answerPrompt handles null state in response`() = runTest { coEvery { - journeyAssistAPI.answerPrompt(any()) + journeyAssistAPI.answerPrompt(any(), true) } returns JourneyAssistResponse( response = "Response", state = null @@ -205,7 +206,7 @@ class AiAssistRepositoryTest { @Test fun `answerPrompt with empty history`() = runTest { coEvery { - journeyAssistAPI.answerPrompt(any()) + journeyAssistAPI.answerPrompt(any(), true) } returns JourneyAssistResponse(response = "Response") repository.answerPrompt( @@ -216,7 +217,8 @@ class AiAssistRepositoryTest { coVerify { journeyAssistAPI.answerPrompt( - match { it.history.isEmpty() } + match { it.history.isEmpty() }, + true ) } }