From b77551c317600dae27e5c0d3185102ec7185a405 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:58:11 -0700 Subject: [PATCH] feat(documents): AI Powered Document in four modes, previewed before saving The Documents surface had no AI entry point at all. `powered_document` now drafts a full markdown document in each of the four modes the web app offers, set as `context.mode` on POST /api/ai/suggest: - article - a standalone piece from a topic the user describes. - from_list - `context.listId`, a write-up of one of the user's lists. - from_article - `context.documentId`, a new document from an existing one. - research_url - `context.url`, a document researched from a web page. Only a reference is ever sent. The server resolves the source itself, under the owning user (IDOR-guarded) and through its SSRF-guarded fetcher, so the client never ships list rows or page text. Nothing is written until the user says so. The flow reuses the preview -> confirm contract #4 landed rather than re-implementing it: AiPreviewSession runs /suggest, and /generate is reachable only through a ConfirmedPreview, which only an on-screen preview can mint. Backing out is therefore incapable of writing - it is a property of the types, not of this screen remembering to check. The preview lets the title be edited; the rest of the artifact envelope (including keys this client does not model) round-trips to /generate untouched. Source pickers, and why they look the way they do. No feature module in this repo depends on another, and that stays true here: - The document picker is DocumentSearchOverlay, the browser's own search picker, reused as-is from ui.browser rather than reimplemented. - The lists browser lives in :feature:lists, which this module must not import, so the list source is read through a one-endpoint, read-only /api/lists client owned by this module (ListSourcesApi/ListSourcesRepository) and shown in a small in-module dialog. Each feature owning its own Retrofit interface over the shared authed Retrofit is the established convention; this is one GET, no cache, no schema, no rows. :feature:documents does now depend on :feature:ai. That module is a leaf - it depends only on :core:*, owns no navigation, and was built by #4 to be consumed ("the AI surfaces themselves live in the feature modules that use them"). It is a capability module, not a peer surface, and it is the only feature dependency added. Gating and quota. AiGate is the single check: the control is drawn only when GET /api/ai/status resolves to Available, so a free account, an account whose status could not be read, and a deployment with no ANTHROPIC_API_KEY all see no control at all rather than one that 403s. Remaining daily actions are shown from the same gate, refreshed from the quota /suggest and /generate echo back. Spending a generation is avoided where it can be. A rejected call still burns one of the 50 per day, so the Research URL is checked locally first (http/https only, a real host - matching what the server's fetcher would accept), a derived mode with no source picked issues nothing, and an instruction over the 500-word cap for this feature is refused before the request. Errors keep their own wording per `code`: quota_exceeded reads "Daily AI limit reached. Try again tomorrow." and rate_limited carries its Retry-After seconds, so the two 429s never blur into one generic failure. A saved draft pulls the tree so the new document is in the cache, then replaces this screen with the editor. Tests: one ViewModel test per mode, each asserting source selection -> suggest (with the exact context sent) -> confirm, and that the suggestion alone wrote nothing; backing out of a preview issues zero generate calls, as does confirming afterwards; a free account and an unconfigured deployment both see no control and can issue nothing, on this surface and on the browser; an invalid and a non-http Research URL are both refused before any request; quota_exceeded surfaces as the daily-limit message on both legs; the edited title reaches the artifact while the markdown survives; plus the URL validator and Compose coverage of the gated control (instrumented, not executed here - no emulator available). Closes #9 Co-Authored-By: Claude Opus 5 --- .../navigation/InterlinedListNavHost.kt | 14 + feature/documents/build.gradle.kts | 6 + .../ui/DocumentsBrowserScreenTest.kt | 27 + .../documents/data/ListSourcesRepository.kt | 48 ++ .../documents/data/remote/ListSourcesApi.kt | 21 + .../data/remote/dto/ListSourceDtos.kt | 23 + .../feature/documents/di/DocumentsModule.kt | 13 + .../feature/documents/domain/ListSource.kt | 15 + .../ui/browser/DocumentsBrowserScreen.kt | 18 + .../ui/browser/DocumentsBrowserViewModel.kt | 23 + .../documents/ui/powered/DocumentArtifact.kt | 38 ++ .../ui/powered/PoweredDocumentMode.kt | 55 ++ .../ui/powered/PoweredDocumentScreen.kt | 517 ++++++++++++++++++ .../ui/powered/PoweredDocumentViewModel.kt | 345 ++++++++++++ .../documents/ui/powered/ResearchUrl.kt | 32 ++ .../ui/DocumentsBrowserViewModelTest.kt | 41 +- .../ui/powered/PoweredDocumentFakes.kt | 64 +++ .../powered/PoweredDocumentViewModelTest.kt | 473 ++++++++++++++++ .../documents/ui/powered/ResearchUrlTest.kt | 40 ++ 19 files changed, 1811 insertions(+), 2 deletions(-) create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/ListSourcesRepository.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/ListSourcesApi.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/ListSourceDtos.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/ListSource.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/DocumentArtifact.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentMode.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentScreen.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentViewModel.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/ResearchUrl.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentFakes.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentViewModelTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/ResearchUrlTest.kt diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 8ae99df..6099be4 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -43,6 +43,7 @@ import com.interlinedlist.android.feature.documents.sync.DocumentsSyncScheduler import com.interlinedlist.android.feature.documents.ui.share.DocumentShareRoute import com.interlinedlist.android.feature.documents.ui.share.SharedDocumentRoute import com.interlinedlist.android.feature.documents.ui.collaborators.DocumentCollaboratorsRoute +import com.interlinedlist.android.feature.documents.ui.powered.PoweredDocumentRoute import com.interlinedlist.android.feature.documents.ui.templates.DocumentTemplatesRoute import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsRoute import com.interlinedlist.android.feature.integrations.ui.export.ExportRoute @@ -112,6 +113,7 @@ object Routes { const val DOCUMENT_FOLDER = "documents/folder/{folderId}" const val DOCUMENT_EDITOR = "documents/editor/{documentId}" const val DOCUMENT_TEMPLATES = "documents/templates" + const val DOCUMENT_POWERED = "documents/powered" // Documents sharing (Milestone F). const val DOCUMENT_SHARE = "documents/{documentId}/share" @@ -429,6 +431,18 @@ private fun MainShell( onOpenFolder = { id -> tabNav.navigate(Routes.documentFolder(id)) }, onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) }, onOpenTemplates = { tabNav.navigate(Routes.DOCUMENT_TEMPLATES) }, + onOpenPoweredDocument = { tabNav.navigate(Routes.DOCUMENT_POWERED) }, + ) + } + composable(Routes.DOCUMENT_POWERED) { + // A saved draft replaces this screen with the new document, so the + // Powered Document form is not left on the back stack behind it. + PoweredDocumentRoute( + onOpenDocument = { id -> + tabNav.popBackStack() + tabNav.navigate(Routes.documentEditor(id)) + }, + onBack = { tabNav.popBackStack() }, ) } composable(Routes.DOCUMENT_TEMPLATES) { diff --git a/feature/documents/build.gradle.kts b/feature/documents/build.gradle.kts index 9b9bd1f..0c1432e 100644 --- a/feature/documents/build.gradle.kts +++ b/feature/documents/build.gradle.kts @@ -31,6 +31,12 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:network")) implementation(project(":core:datastore")) + // The AI capability module: the `/api/ai/*` client, the availability gate and + // the preview -> confirm contract. `:feature:ai` is a leaf that depends only on + // `:core:*` and owns no navigation of its own -- its surfaces live in the + // feature modules that use them, which is what the Powered Document entry + // point on this surface is. No other feature module is depended on. + implementation(project(":feature:ai")) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserScreenTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserScreenTest.kt index 485e81a..c088e71 100644 --- a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserScreenTest.kt +++ b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserScreenTest.kt @@ -1,7 +1,9 @@ package com.interlinedlist.android.feature.documents.ui +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -44,6 +46,7 @@ class DocumentsBrowserScreenTest { onCreateFolder: (String) -> Unit = {}, onSearchQueryChange: (String) -> Unit = {}, onBack: (() -> Unit)? = null, + onOpenPoweredDocument: () -> Unit = {}, ) { composeRule.setContent { InterlinedListTheme { @@ -61,11 +64,35 @@ class DocumentsBrowserScreenTest { onCloseSearch = {}, onSearchQueryChange = onSearchQueryChange, onBack = onBack, + onOpenPoweredDocument = onOpenPoweredDocument, ) } } } + @Test + fun poweredDocumentAction_isHidden_whenAiIsNotAvailable() { + setContent( + DocumentsBrowserUiState(isLoading = false, contents = rootContents(), isAiEnabled = false), + ) + composeRule.onAllNodesWithTag(DocumentsBrowserTestTags.POWERED_DOCUMENT_ACTION) + .assertCountEquals(0) + } + + @Test + fun poweredDocumentAction_opensTheSurface_whenAiIsAvailable() { + var opened = false + setContent( + state = DocumentsBrowserUiState(isLoading = false, contents = rootContents(), isAiEnabled = true), + onOpenPoweredDocument = { opened = true }, + ) + + composeRule.onNodeWithTag(DocumentsBrowserTestTags.POWERED_DOCUMENT_ACTION) + .assertIsDisplayed() + .performClick() + assert(opened) + } + @Test fun emptyState_isShown_whenFolderIsEmpty() { setContent(DocumentsBrowserUiState(isLoading = false, contents = rootContents())) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/ListSourcesRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/ListSourcesRepository.kt new file mode 100644 index 0000000..49be790 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/ListSourcesRepository.kt @@ -0,0 +1,48 @@ +package com.interlinedlist.android.feature.documents.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.documents.data.remote.ListSourcesApi +import com.interlinedlist.android.feature.documents.domain.ListSource +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +/** + * Read-only access to the user's lists, used solely to populate the "Derived + * From List" source picker. Nothing is cached: the picker is opened rarely and a + * list created moments ago must show up. + */ +interface ListSourcesRepository { + + /** The user's lists as pickable sources. */ + suspend fun getListSources(): ApiResult> +} + +class DefaultListSourcesRepository @Inject constructor( + private val api: ListSourcesApi, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : ListSourcesRepository { + + override suspend fun getListSources(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getLists(PICKER_LIMIT) }.map { response -> + response.listsOrEmpty.mapNotNull { dto -> + val id = dto.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + ListSource( + id = id, + title = dto.title?.takeIf { it.isNotBlank() } ?: "Untitled list", + description = dto.description?.takeIf { it.isNotBlank() }, + ) + } + } + } + + private companion object { + /** A picker, not a browser — one page of lists is plenty. */ + const val PICKER_LIMIT = 100 + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/ListSourcesApi.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/ListSourcesApi.kt new file mode 100644 index 0000000..e1db91c --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/ListSourcesApi.kt @@ -0,0 +1,21 @@ +package com.interlinedlist.android.feature.documents.data.remote + +import com.interlinedlist.android.feature.documents.data.remote.dto.ListSourcesResponse +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * The one lists endpoint this module needs: the picker behind the "Derived From + * List" Powered Document mode. + * + * It is declared here, and not imported from `:feature:lists`, because no feature + * module in this repo depends on another. Each feature owns its own Retrofit + * interface over the shared authed Retrofit, so reading `/api/lists` from the + * documents module costs one read-only call and keeps the module graph flat. + */ +interface ListSourcesApi { + + /** The user's lists, newest first, capped at [limit]. */ + @GET("api/lists") + suspend fun getLists(@Query("limit") limit: Int): ListSourcesResponse +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/ListSourceDtos.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/ListSourceDtos.kt new file mode 100644 index 0000000..ca4edb1 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/ListSourceDtos.kt @@ -0,0 +1,23 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * A row of `GET /api/lists`, narrowed to the fields the Powered Document list + * picker shows. The endpoint returns a lot more (folder, github, parent chain); + * everything unmodelled is ignored. + */ +@Serializable +data class ListSourceDto( + val id: String? = null, + val title: String? = null, + val description: String? = null, +) + +/** `GET /api/lists` → `{ lists: [...], pagination: { … } }`. */ +@Serializable +data class ListSourcesResponse( + val lists: List? = null, +) { + val listsOrEmpty: List get() = lists.orEmpty() +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt index ef614ec..869d808 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt @@ -3,13 +3,16 @@ package com.interlinedlist.android.feature.documents.di import android.content.Context import androidx.room.Room import com.interlinedlist.android.feature.documents.data.DefaultDocumentsRepository +import com.interlinedlist.android.feature.documents.data.DefaultListSourcesRepository import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.data.ListSourcesRepository import com.interlinedlist.android.feature.documents.data.local.DocumentDao import com.interlinedlist.android.feature.documents.data.local.DocumentsDatabase import com.interlinedlist.android.feature.documents.data.local.FolderDao import com.interlinedlist.android.feature.documents.data.local.PendingOpDao import com.interlinedlist.android.feature.documents.data.local.SyncMetaDao import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.interlinedlist.android.feature.documents.data.remote.ListSourcesApi import dagger.Binds import dagger.Module import dagger.Provides @@ -27,6 +30,10 @@ abstract class DocumentsRepositoryModule { @Binds @Singleton abstract fun bindDocumentsRepository(impl: DefaultDocumentsRepository): DocumentsRepository + + @Binds + @Singleton + abstract fun bindListSourcesRepository(impl: DefaultListSourcesRepository): ListSourcesRepository } /** Provides this feature's API, its own Room database, and DAOs. */ @@ -39,6 +46,12 @@ object DocumentsDataModule { fun provideDocumentsApi(retrofit: Retrofit): DocumentsApi = retrofit.create(DocumentsApi::class.java) + /** Read-only `/api/lists` access for the Powered Document list-source picker. */ + @Provides + @Singleton + fun provideListSourcesApi(retrofit: Retrofit): ListSourcesApi = + retrofit.create(ListSourcesApi::class.java) + @Provides @Singleton fun provideDocumentsDatabase(@ApplicationContext context: Context): DocumentsDatabase = diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/ListSource.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/ListSource.kt new file mode 100644 index 0000000..7241ee9 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/ListSource.kt @@ -0,0 +1,15 @@ +package com.interlinedlist.android.feature.documents.domain + +/** + * One of the signed-in user's lists, reduced to what the "Derived From List" + * Powered Document mode needs: something to show in the picker and the `listId` + * the AI endpoint resolves server-side. + * + * Only the id is sent — `/api/ai/suggest` loads the list's schema and rows itself + * under the owning user, so the client never ships list content. + */ +data class ListSource( + val id: String, + val title: String, + val description: String? = null, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt index 19505ff..405bf85 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.DriveFileMove import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Dashboard @@ -66,6 +67,7 @@ object DocumentsBrowserTestTags { const val CREATE_FOLDER = "browserCreateFolder" const val SEARCH_ACTION = "browserSearchAction" const val TEMPLATES_ACTION = "browserTemplatesAction" + const val POWERED_DOCUMENT_ACTION = "browserPoweredDocumentAction" const val SEARCH_FIELD = "browserSearchField" const val SEARCH_RESULTS = "browserSearchResults" const val BREADCRUMB = "browserBreadcrumb" @@ -91,6 +93,7 @@ fun DocumentsRoute( onOpenDocument: (String) -> Unit, modifier: Modifier = Modifier, onOpenTemplates: () -> Unit = {}, + onOpenPoweredDocument: () -> Unit = {}, viewModel: DocumentsBrowserViewModel = hiltViewModel(), ) { DocumentsFolderRoute( @@ -99,6 +102,7 @@ fun DocumentsRoute( onBack = null, modifier = modifier, onOpenTemplates = onOpenTemplates, + onOpenPoweredDocument = onOpenPoweredDocument, viewModel = viewModel, ) } @@ -114,6 +118,7 @@ fun DocumentsFolderRoute( onBack: (() -> Unit)?, modifier: Modifier = Modifier, onOpenTemplates: () -> Unit = {}, + onOpenPoweredDocument: () -> Unit = {}, viewModel: DocumentsBrowserViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -132,6 +137,7 @@ fun DocumentsFolderRoute( onSearchQueryChange = viewModel::onSearchQueryChange, onBack = onBack, onOpenTemplates = onOpenTemplates, + onOpenPoweredDocument = onOpenPoweredDocument, modifier = modifier, ) } @@ -154,6 +160,7 @@ fun DocumentsBrowserScreen( onBack: (() -> Unit)?, modifier: Modifier = Modifier, onOpenTemplates: () -> Unit = {}, + onOpenPoweredDocument: () -> Unit = {}, ) { var dialog by remember { mutableStateOf(BrowserDialog.None) } @@ -188,6 +195,17 @@ fun DocumentsBrowserScreen( ) { Icon(Icons.Default.Dashboard, contentDescription = "Templates") } + // Drawn only when the AI gate says this account may use AI, so a + // free account is never offered a control that would fail. + if (state.isAiEnabled) { + IconButton( + onClick = onOpenPoweredDocument, + modifier = Modifier + .testTag(DocumentsBrowserTestTags.POWERED_DOCUMENT_ACTION), + ) { + Icon(Icons.Default.AutoAwesome, contentDescription = "Powered Document") + } + } IconButton( onClick = { dialog = BrowserDialog.CreateFolder }, modifier = Modifier.testTag(DocumentsBrowserTestTags.CREATE_FOLDER), diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt index 9056cc0..0c04e9f 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.ai.domain.AiGate import com.interlinedlist.android.feature.documents.data.DocumentsRepository import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.FolderContents @@ -39,6 +40,12 @@ data class DocumentsBrowserUiState( val isSearchActive: Boolean = false, val isSearching: Boolean = false, val searchResults: List = emptyList(), + /** + * Whether the Powered Document control may be drawn. False unless + * `GET /api/ai/status` says this account may use AI, so a free account — or a + * deployment with no provider configured — never sees the entry point at all. + */ + val isAiEnabled: Boolean = false, ) { val isEmpty: Boolean get() = contents.isEmpty && !isLoading @@ -62,6 +69,7 @@ data class DocumentsBrowserUiState( @HiltViewModel class DocumentsBrowserViewModel @Inject constructor( private val repository: DocumentsRepository, + private val aiGate: AiGate, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -77,6 +85,7 @@ class DocumentsBrowserViewModel @Inject constructor( init { observeContents() observeFolders() + observeAiAvailability() refresh() } @@ -96,6 +105,20 @@ class DocumentsBrowserViewModel @Inject constructor( } } + /** + * The AI gate decides whether the Powered Document control exists. It is + * resolved once per app session and shared by every AI surface, so opening the + * Documents tab costs at most one `/api/ai/status` read. + */ + private fun observeAiAvailability() { + viewModelScope.launch { aiGate.ensureResolved() } + viewModelScope.launch { + aiGate.availability.collect { availability -> + _uiState.update { it.copy(isAiEnabled = availability.isEnabled) } + } + } + } + /** Pulls the whole tree from the API into Room; the observers render the result. */ fun refresh() { _uiState.update { diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/DocumentArtifact.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/DocumentArtifact.kt new file mode 100644 index 0000000..2f14017 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/DocumentArtifact.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.documents.ui.powered + +import com.interlinedlist.android.feature.ai.domain.AiArtifact +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** + * Reads the `document` artifact `powered_document` produces: + * `{ kind, title, markdown, outline[], isPublic }`. + * + * `:feature:ai` deliberately keeps artifacts as raw JSON so it never has to know + * about every feature's shape — each surface decodes the one it asked for, and + * unknown keys survive the round trip back to `/generate` untouched. + */ +val AiArtifact.documentTitle: String? + get() = payload.string("title") + +val AiArtifact.documentMarkdown: String + get() = payload.string("markdown").orEmpty() + +val AiArtifact.documentOutline: List + get() = (payload["outline"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + ?.filter { it.isNotBlank() } + .orEmpty() + +/** + * The same artifact with the user's title. Every other key — including any this + * client does not model — is carried over verbatim, because `/generate` + * re-validates the whole envelope. + */ +fun AiArtifact.withTitle(title: String): AiArtifact = + AiArtifact(JsonObject(payload + ("title" to JsonPrimitive(title)))) + +private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentMode.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentMode.kt new file mode 100644 index 0000000..02f8fb9 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentMode.kt @@ -0,0 +1,55 @@ +package com.interlinedlist.android.feature.documents.ui.powered + +/** + * The four ways a Powered Document can be drafted. The value goes out as + * `context.mode` on `POST /api/ai/suggest`; the derived modes additionally need + * the id/URL of their source, which the server resolves under the owning user. + */ +enum class PoweredDocumentMode( + val apiValue: String, + val label: String, + /** Placeholder for the instruction field, worded for this mode. */ + val instructionHint: String, +) { + /** No source — drafts from the described topic alone. */ + ARTICLE( + apiValue = "article", + label = "Standalone article", + instructionHint = "What should the article cover?", + ), + + /** Writes up one of the user's lists (`context.listId`). */ + FROM_LIST( + apiValue = "from_list", + label = "From a list", + instructionHint = "How should this list be written up? (optional)", + ), + + /** Writes a new document from an existing one (`context.documentId`). */ + FROM_ARTICLE( + apiValue = "from_article", + label = "From a document", + instructionHint = "What should the new document do with it? (optional)", + ), + + /** Researches a web page and cites it (`context.url`). */ + RESEARCH_URL( + apiValue = "research_url", + label = "Research a URL", + instructionHint = "What should the write-up focus on? (optional)", + ); + + /** + * Used as the instruction when the user leaves the field empty. `input` is + * required by the endpoint, and for the three source-backed modes the source + * already says most of what is needed — only [ARTICLE] genuinely has nothing + * to go on, so it has no default and the UI insists on a topic. + */ + val defaultInstruction: String? + get() = when (this) { + ARTICLE -> null + FROM_LIST -> "Write an article based on this list." + FROM_ARTICLE -> "Write a new article based on this document." + RESEARCH_URL -> "Write an article based on this web page." + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentScreen.kt new file mode 100644 index 0000000..31706ee --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentScreen.kt @@ -0,0 +1,517 @@ +package com.interlinedlist.android.feature.documents.ui.powered + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.ListSource +import com.interlinedlist.android.feature.documents.ui.browser.DocumentSearchOverlay +import com.interlinedlist.android.feature.documents.ui.common.MarkdownText + +/** Stable test tags for the Powered Document surface. */ +object PoweredDocumentTestTags { + const val FORM = "poweredDocForm" + const val INSTRUCTION = "poweredDocInstruction" + const val URL_FIELD = "poweredDocUrl" + const val PICK_LIST = "poweredDocPickList" + const val PICK_DOCUMENT = "poweredDocPickDocument" + const val SUGGEST = "poweredDocSuggest" + const val PREVIEW = "poweredDocPreview" + const val PREVIEW_TITLE = "poweredDocPreviewTitle" + const val SAVE = "poweredDocSave" + const val DISCARD = "poweredDocDiscard" + const val PROGRESS = "poweredDocProgress" + const val ERROR = "poweredDocError" + const val QUOTA = "poweredDocQuota" + const val UNAVAILABLE = "poweredDocUnavailable" + fun modeChip(mode: PoweredDocumentMode) = "poweredDocMode_${mode.apiValue}" + fun listRow(id: String) = "poweredDocListRow_$id" +} + +/** + * Hilt-wired Powered Document route. [onOpenDocument] receives the id of the + * document `/generate` created, so the caller can open it in the editor. + */ +@Composable +fun PoweredDocumentRoute( + onOpenDocument: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PoweredDocumentViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(state.createdDocumentId) { + state.createdDocumentId?.let(onOpenDocument) + } + + PoweredDocumentScreen( + state = state, + onSelectMode = viewModel::selectMode, + onInstructionChange = viewModel::onInstructionChange, + onResearchUrlChange = viewModel::onResearchUrlChange, + onOpenListPicker = viewModel::openListPicker, + onSelectList = viewModel::selectList, + onOpenDocumentPicker = viewModel::openDocumentPicker, + onDocumentQueryChange = viewModel::onDocumentQueryChange, + onSelectDocument = viewModel::selectDocument, + onClosePicker = viewModel::closePicker, + onSuggest = viewModel::suggest, + onEditedTitleChange = viewModel::onEditedTitleChange, + onConfirm = viewModel::confirm, + onDiscard = viewModel::discard, + onBack = onBack, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PoweredDocumentScreen( + state: PoweredDocumentUiState, + onSelectMode: (PoweredDocumentMode) -> Unit, + onInstructionChange: (String) -> Unit, + onResearchUrlChange: (String) -> Unit, + onOpenListPicker: () -> Unit, + onSelectList: (ListSource) -> Unit, + onOpenDocumentPicker: () -> Unit, + onDocumentQueryChange: (String) -> Unit, + onSelectDocument: (String) -> Unit, + onClosePicker: () -> Unit, + onSuggest: () -> Unit, + onEditedTitleChange: (String) -> Unit, + onConfirm: () -> Unit, + onDiscard: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Powered Document") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + // The entry point is already gated, but the status can lapse while + // the screen is open; say so rather than offering a failing control. + !state.isAiEnabled -> Unavailable() + + state.previewing != null -> DraftPreview( + state = state, + onEditedTitleChange = onEditedTitleChange, + onConfirm = onConfirm, + onDiscard = onDiscard, + ) + + else -> PoweredDocumentForm( + state = state, + onSelectMode = onSelectMode, + onInstructionChange = onInstructionChange, + onResearchUrlChange = onResearchUrlChange, + onOpenListPicker = onOpenListPicker, + onOpenDocumentPicker = onOpenDocumentPicker, + onSuggest = onSuggest, + ) + } + } + } + + when (state.picker) { + SourcePicker.NONE -> Unit + + SourcePicker.LISTS -> ListSourcePickerDialog( + lists = state.listSources, + isLoading = state.isLoadingListSources, + onSelect = onSelectList, + onDismiss = onClosePicker, + ) + + // The documents picker is the browser's own search overlay, reused as-is. + SourcePicker.DOCUMENTS -> DocumentSearchOverlay( + query = state.documentQuery, + isSearching = state.isSearchingDocuments, + results = state.documentResults, + onQueryChange = onDocumentQueryChange, + onOpenDocument = onSelectDocument, + onClose = onClosePicker, + ) + } +} + +@Composable +private fun PoweredDocumentForm( + state: PoweredDocumentUiState, + onSelectMode: (PoweredDocumentMode) -> Unit, + onInstructionChange: (String) -> Unit, + onResearchUrlChange: (String) -> Unit, + onOpenListPicker: () -> Unit, + onOpenDocumentPicker: () -> Unit, + onSuggest: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .testTag(PoweredDocumentTestTags.FORM), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Draft a full document, then review it before anything is saved.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ModeChips(selected = state.mode, onSelectMode = onSelectMode) + + when (state.mode) { + PoweredDocumentMode.ARTICLE -> Unit + + PoweredDocumentMode.FROM_LIST -> SourceButton( + label = state.selectedList?.title ?: "Choose a list", + selected = state.selectedList != null, + onClick = onOpenListPicker, + tag = PoweredDocumentTestTags.PICK_LIST, + ) + + PoweredDocumentMode.FROM_ARTICLE -> SourceButton( + label = state.selectedDocument?.title ?: "Choose a document", + selected = state.selectedDocument != null, + onClick = onOpenDocumentPicker, + tag = PoweredDocumentTestTags.PICK_DOCUMENT, + ) + + PoweredDocumentMode.RESEARCH_URL -> OutlinedTextField( + value = state.researchUrl, + onValueChange = onResearchUrlChange, + label = { Text("Web address") }, + placeholder = { Text("https://example.com/article") }, + singleLine = true, + isError = state.researchUrl.isNotBlank() && !ResearchUrl.isValid(state.researchUrl), + supportingText = { Text("An http:// or https:// page to research.") }, + modifier = Modifier.fillMaxWidth().testTag(PoweredDocumentTestTags.URL_FIELD), + ) + } + + OutlinedTextField( + value = state.instruction, + onValueChange = onInstructionChange, + label = { Text(state.mode.instructionHint) }, + minLines = 3, + modifier = Modifier.fillMaxWidth().testTag(PoweredDocumentTestTags.INSTRUCTION), + ) + + state.errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(PoweredDocumentTestTags.ERROR), + ) + } + + if (state.isBusy) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.testTag(PoweredDocumentTestTags.PROGRESS)) + } + } else { + Button( + onClick = onSuggest, + enabled = state.canSuggest, + modifier = Modifier.fillMaxWidth().testTag(PoweredDocumentTestTags.SUGGEST), + ) { + Icon(Icons.Default.AutoAwesome, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Draft a preview") + } + } + + QuotaNote(state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModeChips( + selected: PoweredDocumentMode, + onSelectMode: (PoweredDocumentMode) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PoweredDocumentMode.entries.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { mode -> + FilterChip( + selected = mode == selected, + onClick = { onSelectMode(mode) }, + label = { Text(mode.label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + modifier = Modifier.testTag(PoweredDocumentTestTags.modeChip(mode)), + ) + } + } + } + } +} + +@Composable +private fun SourceButton(label: String, selected: Boolean, onClick: () -> Unit, tag: String) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth().testTag(tag), + ) { + Text( + text = if (selected) "Source: $label" else label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The draft, before anything has been written. Confirming here is the only path + * to `/generate`; leaving is free because `/suggest` persisted nothing. + */ +@Composable +private fun DraftPreview( + state: PoweredDocumentUiState, + onEditedTitleChange: (String) -> Unit, + onConfirm: () -> Unit, + onDiscard: () -> Unit, +) { + val preview = state.previewing ?: return + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .testTag(PoweredDocumentTestTags.PREVIEW), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Nothing is saved yet. Review the draft, then save it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = state.editedTitle, + onValueChange = onEditedTitleChange, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(PoweredDocumentTestTags.PREVIEW_TITLE), + ) + + val outline = preview.artifact.documentOutline + if (outline.isNotEmpty()) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Outline", style = MaterialTheme.typography.labelLarge) + outline.forEach { heading -> + Text("• $heading", style = MaterialTheme.typography.bodyMedium) + } + } + } + } + + state.errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(PoweredDocumentTestTags.ERROR), + ) + } + + MarkdownText( + markdown = preview.artifact.documentMarkdown, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + + if (state.isBusy) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.testTag(PoweredDocumentTestTags.PROGRESS)) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onDiscard, + modifier = Modifier.weight(1f).testTag(PoweredDocumentTestTags.DISCARD), + ) { Text("Discard") } + Button( + onClick = onConfirm, + enabled = state.canConfirm, + modifier = Modifier.weight(1f).testTag(PoweredDocumentTestTags.SAVE), + ) { Text("Save document") } + } + } + + QuotaNote(state) + } +} + +@Composable +private fun QuotaNote(state: PoweredDocumentUiState) { + val remaining = state.quota?.remainingActions ?: return + Text( + text = if (remaining <= 0) { + "Daily AI limit reached. Try again tomorrow." + } else { + "$remaining AI actions left today." + }, + style = MaterialTheme.typography.bodySmall, + color = if (remaining <= 0) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.testTag(PoweredDocumentTestTags.QUOTA), + ) +} + +@Composable +private fun Unavailable() { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .testTag(PoweredDocumentTestTags.UNAVAILABLE), + contentAlignment = Alignment.Center, + ) { + Text( + "AI writing assistance isn't available on this account.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Minimal in-module list picker. The lists browser lives in `:feature:lists`, + * which this module deliberately does not depend on, so the picker is fed by this + * module's own read-only `/api/lists` client. + */ +@Composable +private fun ListSourcePickerDialog( + lists: List, + isLoading: Boolean, + onSelect: (ListSource) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Choose a list") }, + text = { + when { + isLoading -> Box( + Modifier.fillMaxWidth().height(96.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + + lists.isEmpty() -> Text("You don't have any lists yet.") + + else -> LazyColumn(Modifier.fillMaxWidth().heightIn(max = 320.dp)) { + items(lists, key = { it.id }) { list -> + Column( + Modifier + .fillMaxWidth() + .clickable { onSelect(list) } + .testTag(PoweredDocumentTestTags.listRow(list.id)) + .padding(vertical = 12.dp), + ) { + Text(list.title, style = MaterialTheme.typography.titleSmall) + list.description?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun PoweredDocumentPreview() { + InterlinedListTheme { + PoweredDocumentScreen( + state = PoweredDocumentUiState( + availability = com.interlinedlist.android.feature.ai.domain.AiAvailability.Available( + com.interlinedlist.android.feature.ai.domain.AiQuota(8, 50, 42), + ), + mode = PoweredDocumentMode.RESEARCH_URL, + researchUrl = "https://example.com/widgets", + ), + onSelectMode = {}, + onInstructionChange = {}, + onResearchUrlChange = {}, + onOpenListPicker = {}, + onSelectList = {}, + onOpenDocumentPicker = {}, + onDocumentQueryChange = {}, + onSelectDocument = {}, + onClosePicker = {}, + onSuggest = {}, + onEditedTitleChange = {}, + onConfirm = {}, + onDiscard = {}, + onBack = {}, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentViewModel.kt new file mode 100644 index 0000000..8404d2b --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentViewModel.kt @@ -0,0 +1,345 @@ +package com.interlinedlist.android.feature.documents.ui.powered + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.ai.data.AiRepository +import com.interlinedlist.android.feature.ai.domain.AiAvailability +import com.interlinedlist.android.feature.ai.domain.AiCreated +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGate +import com.interlinedlist.android.feature.ai.domain.AiPreview +import com.interlinedlist.android.feature.ai.domain.AiQuota +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import com.interlinedlist.android.feature.ai.ui.AiPreviewSession +import com.interlinedlist.android.feature.ai.ui.AiPreviewState +import com.interlinedlist.android.feature.ai.ui.toUserMessage +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.data.ListSourcesRepository +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.ListSource +import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import javax.inject.Inject + +/** Which source picker is open over the form, if any. */ +enum class SourcePicker { NONE, LISTS, DOCUMENTS } + +/** + * Everything the Powered Document surface renders. [preview] and [availability] + * are overlaid from `:feature:ai` (the preview session and the gate); the rest is + * the form the user is filling in. + */ +data class PoweredDocumentUiState( + val availability: AiAvailability = AiAvailability.Unknown, + val preview: AiPreviewState = AiPreviewState.Idle, + val mode: PoweredDocumentMode = PoweredDocumentMode.ARTICLE, + val instruction: String = "", + val researchUrl: String = "", + val selectedList: ListSource? = null, + val selectedDocument: Document? = null, + val listSources: List = emptyList(), + val isLoadingListSources: Boolean = false, + val picker: SourcePicker = SourcePicker.NONE, + val documentQuery: String = "", + val isSearchingDocuments: Boolean = false, + val documentResults: List = emptyList(), + /** The title the user may adjust before confirming; seeded from the draft. */ + val editedTitle: String = "", + /** Refused locally — no request was issued and no quota was spent. */ + val validationMessage: String? = null, + /** A failure loading the source pickers (not an AI failure). */ + val sourceErrorMessage: String? = null, + val createdDocumentId: String? = null, +) { + /** The single check that decides whether any AI control is drawn at all. */ + val isAiEnabled: Boolean get() = availability.isEnabled + + /** Today's remaining allowance, when the server reported one. */ + val quota: AiQuota? get() = (availability as? AiAvailability.Available)?.quota + + val isQuotaExhausted: Boolean get() = availability.isQuotaExhausted + + val isBusy: Boolean get() = preview.isBusy + + /** The draft awaiting approval. Non-null means **nothing has been written yet**. */ + val previewing: AiPreview? get() = (preview as? AiPreviewState.Previewing)?.preview + + /** Whether the mode's required source (or topic) is present and usable. */ + val hasRequiredSource: Boolean + get() = when (mode) { + PoweredDocumentMode.ARTICLE -> instruction.isNotBlank() + PoweredDocumentMode.FROM_LIST -> selectedList != null + PoweredDocumentMode.FROM_ARTICLE -> selectedDocument != null + PoweredDocumentMode.RESEARCH_URL -> ResearchUrl.isValid(researchUrl) + } + + val canSuggest: Boolean + get() = isAiEnabled && !isBusy && previewing == null && hasRequiredSource + + val canConfirm: Boolean get() = isAiEnabled && previewing != null && !isBusy + + /** + * One message for the screen. An AI failure wins — it is the most recent + * thing that happened — and each `code` keeps its own wording, so a spent + * daily allowance reads as the daily limit and not as a generic error. + */ + val errorMessage: String? + get() = (preview as? AiPreviewState.Failed)?.error?.toUserMessage() + ?: sourceErrorMessage + ?: validationMessage +} + +/** + * Drives the Powered Document flow: pick a mode and its source, preview the draft + * via `/suggest`, and only then persist it via `/generate`. + * + * The preview → confirm rule is not re-implemented here: [AiPreviewSession] owns + * it, and `/generate` is unreachable except through a `ConfirmedPreview` that only + * an on-screen preview can produce. Backing out ([discard]) therefore cannot write + * anything, by construction. + */ +@HiltViewModel +class PoweredDocumentViewModel @Inject constructor( + private val documentsRepository: DocumentsRepository, + private val listSourcesRepository: ListSourcesRepository, + aiRepository: AiRepository, + private val aiGate: AiGate, +) : ViewModel() { + + private val session = AiPreviewSession(aiRepository, aiGate, viewModelScope) + + private val _uiState = MutableStateFlow(PoweredDocumentUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var searchJob: Job? = null + + init { + // The control that opened this screen is already gated on `Available`; + // re-reading keeps the quota figure honest if the screen is revisited. + viewModelScope.launch { aiGate.ensureResolved() } + viewModelScope.launch { + aiGate.availability.collect { availability -> + _uiState.update { it.copy(availability = availability) } + } + } + viewModelScope.launch { + session.state.collect { preview -> + _uiState.update { it.copy(preview = preview) } + onPreviewStateChanged(preview) + } + } + } + + // --- Mode and inputs --------------------------------------------------- + + fun selectMode(mode: PoweredDocumentMode) { + if (_uiState.value.mode == mode) return + _uiState.update { it.copy(mode = mode, validationMessage = null, sourceErrorMessage = null) } + } + + fun onInstructionChange(text: String) = + _uiState.update { it.copy(instruction = text, validationMessage = null) } + + fun onResearchUrlChange(text: String) = + _uiState.update { it.copy(researchUrl = text, validationMessage = null) } + + fun onEditedTitleChange(text: String) = _uiState.update { it.copy(editedTitle = text) } + + // --- Source pickers ---------------------------------------------------- + + /** + * Opens the list picker, loading the user's lists on first use. The lists + * live in another feature module, so they are read here through this module's + * own one-endpoint `/api/lists` client rather than by depending on + * `:feature:lists`. + */ + fun openListPicker() { + _uiState.update { it.copy(picker = SourcePicker.LISTS, sourceErrorMessage = null) } + if (_uiState.value.listSources.isNotEmpty() || _uiState.value.isLoadingListSources) return + _uiState.update { it.copy(isLoadingListSources = true) } + viewModelScope.launch { + when (val result = listSourcesRepository.getListSources()) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoadingListSources = false, listSources = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isLoadingListSources = false, + sourceErrorMessage = result.error.toUserMessage(), + ) + } + } + } + } + + fun selectList(list: ListSource) = _uiState.update { + it.copy(selectedList = list, picker = SourcePicker.NONE, validationMessage = null) + } + + /** Opens the shared documents search picker (the browser's own overlay). */ + fun openDocumentPicker() = _uiState.update { + it.copy( + picker = SourcePicker.DOCUMENTS, + documentQuery = "", + documentResults = emptyList(), + sourceErrorMessage = null, + ) + } + + fun onDocumentQueryChange(query: String) { + _uiState.update { it.copy(documentQuery = query) } + searchJob?.cancel() + if (query.isBlank()) { + _uiState.update { it.copy(documentResults = emptyList(), isSearchingDocuments = false) } + return + } + _uiState.update { it.copy(isSearchingDocuments = true) } + searchJob = viewModelScope.launch { + when (val result = documentsRepository.searchDocuments(query.trim())) { + is ApiResult.Success -> _uiState.update { + it.copy(isSearchingDocuments = false, documentResults = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isSearchingDocuments = false, + sourceErrorMessage = result.error.toUserMessage(), + ) + } + } + } + } + + /** Picks the source document by the id the shared picker reports. */ + fun selectDocument(documentId: String) { + val picked = _uiState.value.documentResults.firstOrNull { it.id == documentId } ?: return + _uiState.update { + it.copy( + selectedDocument = picked, + picker = SourcePicker.NONE, + validationMessage = null, + ) + } + } + + fun closePicker() = _uiState.update { it.copy(picker = SourcePicker.NONE) } + + // --- Preview and confirm ---------------------------------------------- + + /** + * Runs `/suggest`. Refuses locally — issuing no request, and spending none of + * the daily allowance — when AI is not available, the mode's source is missing + * or unusable, or the instruction is over the endpoint's word cap. + */ + fun suggest() { + if (!aiGate.availability.value.isEnabled) return + val state = _uiState.value + val context = contextFor(state) ?: return + val input = state.instruction.trim().ifBlank { state.mode.defaultInstruction.orEmpty() } + if (wordCount(input) > MAX_INPUT_WORDS) { + refuse("Shorten the instruction to $MAX_INPUT_WORDS words or fewer.") + return + } + _uiState.update { it.copy(validationMessage = null, sourceErrorMessage = null, editedTitle = "") } + session.suggest(AiFeature.POWERED_DOCUMENT, AiSuggestInput(input = input, context = context)) + } + + /** Approves the draft on screen and persists it via `/generate`. */ + fun confirm() { + val preview = (session.state.value as? AiPreviewState.Previewing)?.preview ?: return + val title = _uiState.value.editedTitle.trim() + val edited = title + .takeIf { it.isNotBlank() && it != preview.artifact.documentTitle } + ?.let { preview.artifact.withTitle(it) } + session.confirm(edited = edited) + } + + /** + * Backs out of the draft. Nothing was ever written — `/suggest` persists + * nothing — so this only clears the screen. + */ + fun discard() { + session.discard() + _uiState.update { it.copy(editedTitle = "", validationMessage = null, sourceErrorMessage = null) } + } + + // --- Internals --------------------------------------------------------- + + private fun onPreviewStateChanged(state: AiPreviewState) { + when (state) { + is AiPreviewState.Previewing -> + _uiState.update { it.copy(editedTitle = state.preview.artifact.documentTitle.orEmpty()) } + + is AiPreviewState.Generated -> { + val created = state.generation.created + if (created is AiCreated.DocumentCreated) { + _uiState.update { it.copy(createdDocumentId = created.documentId) } + // The new document exists server-side only; pull it into the cache + // so the browser shows it the moment the user lands back there. + viewModelScope.launch { documentsRepository.refreshTree() } + } + } + + else -> Unit + } + } + + /** + * Builds `context` for the chosen mode, or refuses with a message naming what + * is missing. Only ids and the URL are sent: the server resolves the source + * itself, under the owning user. + */ + private fun contextFor(state: PoweredDocumentUiState): JsonObject? = when (state.mode) { + PoweredDocumentMode.ARTICLE -> + if (state.instruction.isBlank()) { + refuse("Describe what the article should be about.") + } else { + buildJsonObject { put("mode", state.mode.apiValue) } + } + + PoweredDocumentMode.FROM_LIST -> state.selectedList?.let { list -> + buildJsonObject { + put("mode", state.mode.apiValue) + put("listId", list.id) + } + } ?: refuse("Choose a list to write from.") + + PoweredDocumentMode.FROM_ARTICLE -> state.selectedDocument?.let { document -> + buildJsonObject { + put("mode", state.mode.apiValue) + put("documentId", document.id) + } + } ?: refuse("Choose a document to write from.") + + PoweredDocumentMode.RESEARCH_URL -> ResearchUrl.normalise(state.researchUrl)?.let { url -> + buildJsonObject { + put("mode", state.mode.apiValue) + put("url", url) + } + } ?: refuse("Enter a full http:// or https:// web address.") + } + + /** Records why nothing was sent, and returns null so the caller bails out. */ + private fun refuse(message: String): JsonObject? { + _uiState.update { it.copy(validationMessage = message) } + return null + } + + private fun wordCount(text: String): Int = + text.trim().split(WHITESPACE).count { it.isNotEmpty() } + + private companion object { + /** `powered_document` caps `input` at 500 words server-side. */ + const val MAX_INPUT_WORDS = 500 + val WHITESPACE = Regex("\\s+") + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/ResearchUrl.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/ResearchUrl.kt new file mode 100644 index 0000000..e652bc4 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/powered/ResearchUrl.kt @@ -0,0 +1,32 @@ +package com.interlinedlist.android.feature.documents.ui.powered + +import java.net.URI + +/** + * Local validation for the Research URL mode. + * + * The check is worth doing on the client because an AI call is not free: a + * rejected `/suggest` still consumes one of the 50 daily generations, so a + * typo'd address must be refused before any request is issued. The server-side + * fetcher only accepts `http`/`https` (and is SSRF-guarded), so the same two + * schemes are all this accepts. + */ +object ResearchUrl { + + /** Returns the trimmed URL when it is one the server would fetch, else null. */ + fun normalise(raw: String): String? { + val trimmed = raw.trim() + if (trimmed.isEmpty() || trimmed.any { it.isWhitespace() }) return null + val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null + val scheme = uri.scheme?.lowercase() ?: return null + if (scheme != "http" && scheme != "https") return null + val host = uri.host ?: return null + // A bare hostname with no dot is either a local name or a typo; the + // fetcher would refuse it, so do not spend a generation finding out. + if (!host.contains('.') || host.startsWith('.') || host.endsWith('.')) return null + return trimmed + } + + /** True when [raw] is an address the Research URL mode can be run against. */ + fun isValid(raw: String): Boolean = normalise(raw) != null +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt index fdb5378..7b4e2fb 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt @@ -4,10 +4,14 @@ import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.ai.domain.AiAvailability +import com.interlinedlist.android.feature.ai.domain.AiGate +import com.interlinedlist.android.feature.ai.domain.AiQuota import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserViewModel import com.interlinedlist.android.feature.documents.ui.browser.FOLDER_ID_ARG +import com.interlinedlist.android.feature.documents.ui.powered.FakeAiRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -24,21 +28,54 @@ class DocumentsBrowserViewModelTest { private val dispatcher = StandardTestDispatcher() private lateinit var repo: FakeDocumentsRepository + private lateinit var ai: FakeAiRepository @Before fun setUp() { Dispatchers.setMain(dispatcher) repo = FakeDocumentsRepository() + ai = FakeAiRepository() } @After fun tearDown() = Dispatchers.resetMain() private fun rootViewModel() = - DocumentsBrowserViewModel(repo, SavedStateHandle()) + DocumentsBrowserViewModel(repo, AiGate(ai), SavedStateHandle()) private fun folderViewModel(folderId: String) = - DocumentsBrowserViewModel(repo, SavedStateHandle(mapOf(FOLDER_ID_ARG to folderId))) + DocumentsBrowserViewModel(repo, AiGate(ai), SavedStateHandle(mapOf(FOLDER_ID_ARG to folderId))) + + @Test + fun `a subscriber with AI configured sees the Powered Document control`() = runTest(dispatcher) { + ai.availability = AiAvailability.Available(AiQuota(0, 50, 50)) + + val vm = rootViewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.isAiEnabled).isTrue() + } + + @Test + fun `a free account never sees the Powered Document control`() = runTest(dispatcher) { + ai.availability = AiAvailability.NotSubscribed + + val vm = rootViewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.isAiEnabled).isFalse() + } + + @Test + fun `a deployment with no AI provider never shows the Powered Document control`() = + runTest(dispatcher) { + ai.availability = AiAvailability.Unavailable + + val vm = rootViewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.isAiEnabled).isFalse() + } @Test fun `root level shows top-level folders and unfiled documents`() = runTest(dispatcher) { diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentFakes.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentFakes.kt new file mode 100644 index 0000000..6235b05 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentFakes.kt @@ -0,0 +1,64 @@ +package com.interlinedlist.android.feature.documents.ui.powered + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.ai.data.AiRepository +import com.interlinedlist.android.feature.ai.domain.AiAvailability +import com.interlinedlist.android.feature.ai.domain.AiCreated +import com.interlinedlist.android.feature.ai.domain.AiError +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGenerateOptions +import com.interlinedlist.android.feature.ai.domain.AiGeneration +import com.interlinedlist.android.feature.ai.domain.AiPreview +import com.interlinedlist.android.feature.ai.domain.AiQuota +import com.interlinedlist.android.feature.ai.domain.AiResult +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import com.interlinedlist.android.feature.ai.domain.ConfirmedPreview +import com.interlinedlist.android.feature.documents.data.ListSourcesRepository +import com.interlinedlist.android.feature.documents.domain.ListSource + +/** + * Records every `/suggest` and `/generate` the surface issues, so a test can + * assert that backing out of a preview writes nothing at all. + */ +class FakeAiRepository : AiRepository { + + var availability: AiAvailability = AiAvailability.Available(AiQuota(0, 50, 50)) + var suggestResult: AiResult = AiResult.Failure(AiError.Unknown("not stubbed")) + var generateResult: AiResult = + AiResult.Success(AiGeneration(AiFeature.POWERED_DOCUMENT, AiCreated.DocumentCreated("doc-new"))) + + val suggestCalls = mutableListOf>() + val generateCalls = mutableListOf() + + override suspend fun availability(): AiAvailability = availability + + override suspend fun suggest(feature: AiFeature, input: AiSuggestInput): AiResult { + suggestCalls += feature to input + return suggestResult + } + + override suspend fun generate( + confirmed: ConfirmedPreview, + options: AiGenerateOptions, + ): AiResult { + generateCalls += confirmed + return generateResult + } +} + +/** Replays a canned `/api/lists` page for the list-source picker. */ +class FakeListSourcesRepository : ListSourcesRepository { + + var result: ApiResult> = ApiResult.Success(emptyList()) + var calls = 0 + + override suspend fun getListSources(): ApiResult> { + calls++ + return result + } + + fun failWith(error: AppError) { + result = ApiResult.Failure(error) + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentViewModelTest.kt new file mode 100644 index 0000000..9de71bc --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/PoweredDocumentViewModelTest.kt @@ -0,0 +1,473 @@ +package com.interlinedlist.android.feature.documents.ui.powered + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.ai.domain.AiArtifact +import com.interlinedlist.android.feature.ai.domain.AiAvailability +import com.interlinedlist.android.feature.ai.domain.AiCreated +import com.interlinedlist.android.feature.ai.domain.AiError +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGate +import com.interlinedlist.android.feature.ai.domain.AiGeneration +import com.interlinedlist.android.feature.ai.domain.AiPreview +import com.interlinedlist.android.feature.ai.domain.AiQuota +import com.interlinedlist.android.feature.ai.domain.AiResult +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import com.interlinedlist.android.feature.documents.domain.ListSource +import com.interlinedlist.android.feature.documents.ui.FakeDocumentsRepository +import com.interlinedlist.android.feature.documents.ui.testDocument +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The Powered Document flow: pick a mode and its source, preview, then confirm. + * The load-bearing assertions are that `/generate` is reached only through an + * approved preview, and that anything refusable is refused before a request (and + * therefore a quota unit) is spent. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PoweredDocumentViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var docs: FakeDocumentsRepository + private lateinit var lists: FakeListSourcesRepository + private lateinit var ai: FakeAiRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + docs = FakeDocumentsRepository() + lists = FakeListSourcesRepository() + ai = FakeAiRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = PoweredDocumentViewModel(docs, lists, ai, AiGate(ai)) + + private fun draft(title: String = "Getting Started with Widgets") = AiPreview( + feature = AiFeature.POWERED_DOCUMENT, + artifact = AiArtifact( + buildJsonObject { + put("kind", "document") + put("title", title) + put("markdown", "# $title\n\nBody.") + }, + ), + quota = AiQuota(usedToday = 1, dailyLimit = 50, remaining = 49), + ) + + private fun lastContext(): JsonObject? = ai.suggestCalls.last().second.context + + private fun JsonObject.str(key: String): String? = (this[key] as? JsonPrimitive)?.content + + // --- One test per mode: source selection -> suggest -> confirm ---------- + + @Test + fun `standalone article mode previews from the topic then persists on confirm`() = + runTest(dispatcher) { + ai.suggestResult = AiResult.Success(draft()) + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.ARTICLE) + vm.onInstructionChange("An intro to widgets for new users") + assertThat(vm.uiState.value.canSuggest).isTrue() + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).hasSize(1) + assertThat(ai.suggestCalls.single().first).isEqualTo(AiFeature.POWERED_DOCUMENT) + assertThat(lastContext()?.str("mode")).isEqualTo("article") + assertThat(ai.suggestCalls.single().second.input) + .isEqualTo("An intro to widgets for new users") + // A suggestion alone writes nothing. + assertThat(ai.generateCalls).isEmpty() + assertThat(vm.uiState.value.previewing).isNotNull() + + vm.confirm() + advanceUntilIdle() + + assertThat(ai.generateCalls).hasSize(1) + assertThat(ai.generateCalls.single().feature).isEqualTo(AiFeature.POWERED_DOCUMENT) + assertThat(vm.uiState.value.createdDocumentId).isEqualTo("doc-new") + assertThat(docs.refreshTreeCount).isAtLeast(1) + } + + @Test + fun `from list mode sends the picked list id then persists on confirm`() = + runTest(dispatcher) { + lists.result = ApiResult.Success( + listOf(ListSource("list-7", "Reading list", "Books to read")), + ) + ai.suggestResult = AiResult.Success(draft("My Reading Overview")) + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.FROM_LIST) + vm.openListPicker() + advanceUntilIdle() + assertThat(vm.uiState.value.listSources.map { it.id }).containsExactly("list-7") + + vm.selectList(vm.uiState.value.listSources.single()) + assertThat(vm.uiState.value.picker).isEqualTo(SourcePicker.NONE) + assertThat(vm.uiState.value.canSuggest).isTrue() + + vm.suggest() + advanceUntilIdle() + + assertThat(lastContext()?.str("mode")).isEqualTo("from_list") + assertThat(lastContext()?.str("listId")).isEqualTo("list-7") + // Blank instruction falls back to the mode's default, never an empty input. + assertThat(ai.suggestCalls.single().second.input).isNotEmpty() + assertThat(ai.generateCalls).isEmpty() + + vm.confirm() + advanceUntilIdle() + + assertThat(ai.generateCalls).hasSize(1) + assertThat(vm.uiState.value.createdDocumentId).isEqualTo("doc-new") + } + + @Test + fun `from article mode sends the picked document id then persists on confirm`() = + runTest(dispatcher) { + docs.searchResult = ApiResult.Success(listOf(testDocument("doc-42", title = "Widgets 101"))) + ai.suggestResult = AiResult.Success(draft()) + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.FROM_ARTICLE) + vm.openDocumentPicker() + vm.onDocumentQueryChange("widgets") + advanceUntilIdle() + assertThat(vm.uiState.value.documentResults.map { it.id }).containsExactly("doc-42") + + vm.selectDocument("doc-42") + assertThat(vm.uiState.value.selectedDocument?.title).isEqualTo("Widgets 101") + assertThat(vm.uiState.value.canSuggest).isTrue() + + vm.suggest() + advanceUntilIdle() + + assertThat(lastContext()?.str("mode")).isEqualTo("from_article") + assertThat(lastContext()?.str("documentId")).isEqualTo("doc-42") + assertThat(ai.generateCalls).isEmpty() + + vm.confirm() + advanceUntilIdle() + + assertThat(ai.generateCalls).hasSize(1) + assertThat(vm.uiState.value.createdDocumentId).isEqualTo("doc-new") + } + + @Test + fun `research url mode sends the url then persists on confirm`() = runTest(dispatcher) { + ai.suggestResult = AiResult.Success(draft()) + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.RESEARCH_URL) + vm.onResearchUrlChange("https://example.com/widgets") + assertThat(vm.uiState.value.canSuggest).isTrue() + + vm.suggest() + advanceUntilIdle() + + assertThat(lastContext()?.str("mode")).isEqualTo("research_url") + assertThat(lastContext()?.str("url")).isEqualTo("https://example.com/widgets") + assertThat(ai.generateCalls).isEmpty() + + vm.confirm() + advanceUntilIdle() + + assertThat(ai.generateCalls).hasSize(1) + assertThat(vm.uiState.value.createdDocumentId).isEqualTo("doc-new") + } + + // --- Nothing is written without approval -------------------------------- + + @Test + fun `backing out of a preview persists nothing`() = runTest(dispatcher) { + ai.suggestResult = AiResult.Success(draft()) + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange("An intro to widgets") + vm.suggest() + advanceUntilIdle() + assertThat(vm.uiState.value.previewing).isNotNull() + + vm.discard() + advanceUntilIdle() + + assertThat(ai.generateCalls).isEmpty() + assertThat(vm.uiState.value.previewing).isNull() + + // And a confirm after backing out still writes nothing. + vm.confirm() + advanceUntilIdle() + assertThat(ai.generateCalls).isEmpty() + } + + @Test + fun `confirming sends the user's edited title with the artifact`() = runTest(dispatcher) { + ai.suggestResult = AiResult.Success(draft("Draft title")) + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange("An intro to widgets") + vm.suggest() + advanceUntilIdle() + assertThat(vm.uiState.value.editedTitle).isEqualTo("Draft title") + + vm.onEditedTitleChange("Widgets, properly explained") + vm.confirm() + advanceUntilIdle() + + val confirmed = ai.generateCalls.single() + assertThat(confirmed.artifact.documentTitle).isEqualTo("Widgets, properly explained") + // The rest of the envelope round-trips untouched. + assertThat(confirmed.artifact.documentMarkdown).contains("Draft title") + } + + // --- Gating ------------------------------------------------------------- + + @Test + fun `a free account sees no control and can issue nothing`() = runTest(dispatcher) { + ai.availability = AiAvailability.NotSubscribed + val vm = viewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.isAiEnabled).isFalse() + + vm.onInstructionChange("An intro to widgets") + assertThat(vm.uiState.value.canSuggest).isFalse() + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).isEmpty() + assertThat(ai.generateCalls).isEmpty() + } + + @Test + fun `an unconfigured deployment sees no control and can issue nothing`() = runTest(dispatcher) { + ai.availability = AiAvailability.Unavailable + val vm = viewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.isAiEnabled).isFalse() + vm.onInstructionChange("An intro to widgets") + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).isEmpty() + } + + @Test + fun `the remaining daily allowance is surfaced`() = runTest(dispatcher) { + ai.availability = AiAvailability.Available(AiQuota(usedToday = 8, dailyLimit = 50, remaining = 42)) + val vm = viewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.quota?.remainingActions).isEqualTo(42) + assertThat(vm.uiState.value.isQuotaExhausted).isFalse() + } + + // --- Local validation, before a request is spent ------------------------- + + @Test + fun `an invalid research url is refused before any request`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.RESEARCH_URL) + vm.onResearchUrlChange("not a url") + assertThat(vm.uiState.value.canSuggest).isFalse() + + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).isEmpty() + assertThat(vm.uiState.value.validationMessage).contains("http") + } + + @Test + fun `a non-http research url is refused before any request`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.RESEARCH_URL) + vm.onResearchUrlChange("ftp://example.com/file.txt") + + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).isEmpty() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `a derived mode with no source picked issues nothing`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.FROM_LIST) + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).isEmpty() + assertThat(vm.uiState.value.validationMessage).isEqualTo("Choose a list to write from.") + } + + @Test + fun `an over-length instruction is refused before any request`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange(List(501) { "word" }.joinToString(" ")) + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).isEmpty() + assertThat(vm.uiState.value.validationMessage).contains("500 words") + } + + // --- Error wording ------------------------------------------------------ + + @Test + fun `quota exceeded reads as the daily limit, not a generic failure`() = runTest(dispatcher) { + ai.suggestResult = AiResult.Failure(AiError.QuotaExceeded("Daily quota reached")) + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange("An intro to widgets") + vm.suggest() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("Daily AI limit reached. Try again tomorrow.") + assertThat(vm.uiState.value.previewing).isNull() + assertThat(ai.generateCalls).isEmpty() + } + + @Test + fun `a rate limit keeps its own wording and the retry delay`() = runTest(dispatcher) { + ai.suggestResult = AiResult.Failure(AiError.RateLimited(retryAfterSeconds = 30)) + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange("An intro to widgets") + vm.suggest() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).contains("30 seconds") + } + + @Test + fun `a quota exceeded on confirm keeps the draft on screen unwritten`() = runTest(dispatcher) { + ai.suggestResult = AiResult.Success(draft()) + ai.generateResult = AiResult.Failure(AiError.QuotaExceeded(null)) + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange("An intro to widgets") + vm.suggest() + advanceUntilIdle() + vm.confirm() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("Daily AI limit reached. Try again tomorrow.") + assertThat(vm.uiState.value.createdDocumentId).isNull() + } + + @Test + fun `an unrecognised created payload does not claim a document was opened`() = + runTest(dispatcher) { + ai.suggestResult = AiResult.Success(draft()) + ai.generateResult = + AiResult.Success(AiGeneration(AiFeature.POWERED_DOCUMENT, AiCreated.Unrecognised)) + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange("An intro to widgets") + vm.suggest() + advanceUntilIdle() + vm.confirm() + advanceUntilIdle() + + assertThat(ai.generateCalls).hasSize(1) + assertThat(vm.uiState.value.createdDocumentId).isNull() + } + + // --- Source picker failures -------------------------------------------- + + @Test + fun `a failure loading lists is reported without blocking the other modes`() = + runTest(dispatcher) { + lists.failWith(com.interlinedlist.android.core.common.result.AppError.Network(null)) + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.FROM_LIST) + vm.openListPicker() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).contains("No connection") + assertThat(ai.suggestCalls).isEmpty() + } + + @Test + fun `suggest is ignored while a call is already in flight`() = runTest(dispatcher) { + ai.suggestResult = AiResult.Success(draft()) + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange("An intro to widgets") + vm.suggest() + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).hasSize(1) + } + + @Test + fun `an empty suggest input is never sent`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + + vm.selectMode(PoweredDocumentMode.ARTICLE) + vm.suggest() + advanceUntilIdle() + + assertThat(ai.suggestCalls).isEmpty() + assertThat(vm.uiState.value.validationMessage).isNotNull() + } + + @Test + fun `suggest input honours the endpoint's shape`() = runTest(dispatcher) { + ai.suggestResult = AiResult.Success(draft()) + val vm = viewModel() + advanceUntilIdle() + + vm.onInstructionChange(" An intro to widgets ") + vm.suggest() + advanceUntilIdle() + + val sent: AiSuggestInput = ai.suggestCalls.single().second + assertThat(sent.input).isEqualTo("An intro to widgets") + assertThat(sent.context).isNotNull() + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/ResearchUrlTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/ResearchUrlTest.kt new file mode 100644 index 0000000..e0ab124 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/powered/ResearchUrlTest.kt @@ -0,0 +1,40 @@ +package com.interlinedlist.android.feature.documents.ui.powered + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * A rejected `/suggest` still burns one of the 50 daily generations, so the + * Research URL is checked here before any request leaves the device. + */ +class ResearchUrlTest { + + @Test + fun `accepts http and https addresses`() { + assertThat(ResearchUrl.normalise("https://example.com/a/b?c=d")) + .isEqualTo("https://example.com/a/b?c=d") + assertThat(ResearchUrl.normalise("http://sub.example.co.uk")).isEqualTo("http://sub.example.co.uk") + } + + @Test + fun `trims surrounding whitespace`() { + assertThat(ResearchUrl.normalise(" https://example.com ")).isEqualTo("https://example.com") + } + + @Test + fun `refuses anything the server's fetcher would not fetch`() { + val refused = listOf( + "", + " ", + "example.com", + "not a url", + "ftp://example.com/file.txt", + "file:///etc/passwd", + "javascript:alert(1)", + "https://", + "https://localhost", + "https://example .com", + ) + refused.forEach { assertThat(ResearchUrl.normalise(it)).isNull() } + } +}