Windows (#291)
* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
This commit is contained in:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -0,0 +1,310 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.AiAdapter
|
||||
import com.aryan.reader.shared.AiDefinitionResult
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.ReaderAiFeature
|
||||
import com.aryan.reader.shared.ReaderByokTextRequest
|
||||
import com.aryan.reader.shared.ReaderByokTextRequestResult
|
||||
import com.aryan.reader.shared.ReaderByokTextRequests
|
||||
import com.aryan.reader.shared.RecapResult
|
||||
import com.aryan.reader.shared.SummarizationResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class DesktopByokAiAdapter(
|
||||
private val settingsProvider: () -> ReaderAiByokSettings
|
||||
) : AiAdapter {
|
||||
override val isAvailable: Boolean
|
||||
get() = settingsProvider().sanitized().areReaderAiFeaturesAvailable
|
||||
|
||||
override suspend fun define(text: String, context: String?): AiDefinitionResult {
|
||||
val result = callTextAi(ReaderAiFeature.DEFINE, text, context)
|
||||
return AiDefinitionResult(definition = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
|
||||
override suspend fun summarize(text: String): SummarizationResult {
|
||||
val result = callTextAi(ReaderAiFeature.SUMMARIZE, text)
|
||||
return SummarizationResult(summary = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
|
||||
override suspend fun recap(textBeforeCurrentLocation: String): RecapResult {
|
||||
val result = callTextAi(ReaderAiFeature.RECAP, textBeforeCurrentLocation)
|
||||
return RecapResult(recap = result.getOrNull(), error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
|
||||
suspend fun callTextAi(
|
||||
feature: ReaderAiFeature,
|
||||
text: String,
|
||||
context: String? = null
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send."))
|
||||
when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) {
|
||||
ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden."))
|
||||
is ReaderByokTextRequestResult.MissingKey -> {
|
||||
Result.failure(IllegalStateException("Add a ${requestResult.provider.replaceFirstChar { it.uppercaseChar() }} API key in AI keys and models."))
|
||||
}
|
||||
is ReaderByokTextRequestResult.MissingModel -> {
|
||||
Result.failure(IllegalStateException("Choose a model for ${requestResult.featureName} in AI keys and models."))
|
||||
}
|
||||
is ReaderByokTextRequestResult.Ready -> runCatching {
|
||||
requestResult.request.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderByokTextRequest.execute(): String {
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
val url = if (model.provider == "groq") {
|
||||
URL("https://api.groq.com/openai/v1/chat/completions")
|
||||
} else {
|
||||
URL("https://generativelanguage.googleapis.com/v1beta/models/${model.name}:streamGenerateContent?key=$apiKey")
|
||||
}
|
||||
connection = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
if (model.provider == "groq") {
|
||||
setRequestProperty("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 120_000
|
||||
doOutput = true
|
||||
doInput = true
|
||||
}
|
||||
val payload = if (model.provider == "groq") buildGroqPayload(this) else buildGeminiPayload(this)
|
||||
connection.outputStream.use { output ->
|
||||
output.write(payload.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
val errorBody = runCatching {
|
||||
connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
}.getOrNull()
|
||||
throw IllegalStateException("AI provider error: $responseCode. ${errorBody.orEmpty().take(300)}")
|
||||
}
|
||||
val text = if (model.provider == "groq") {
|
||||
streamGroqResponse(connection)
|
||||
} else {
|
||||
streamGeminiResponse(connection)
|
||||
}.trim()
|
||||
if (text.isBlank()) throw IllegalStateException("The AI provider returned an empty response.")
|
||||
return text
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildGroqPayload(request: ReaderByokTextRequest): String {
|
||||
return buildJsonObject {
|
||||
put("model", JsonPrimitive(request.model.name))
|
||||
put(
|
||||
"messages",
|
||||
buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("role", JsonPrimitive("system"))
|
||||
put("content", JsonPrimitive(request.systemInstruction))
|
||||
})
|
||||
add(buildJsonObject {
|
||||
put("role", JsonPrimitive("user"))
|
||||
put("content", JsonPrimitive(request.userPrompt))
|
||||
})
|
||||
}
|
||||
)
|
||||
put("temperature", JsonPrimitive(request.temperature))
|
||||
put("top_p", JsonPrimitive(0.95))
|
||||
put("max_tokens", JsonPrimitive(request.maxTokens))
|
||||
put("stream", JsonPrimitive(true))
|
||||
if (request.model.name.contains("qwen")) put("reasoning_effort", JsonPrimitive("none"))
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun buildGeminiPayload(request: ReaderByokTextRequest): String {
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"contents",
|
||||
buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("parts", buildJsonArray {
|
||||
add(buildJsonObject { put("text", JsonPrimitive(request.userPrompt)) })
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
put(
|
||||
"systemInstruction",
|
||||
buildJsonObject {
|
||||
put("parts", buildJsonArray {
|
||||
add(buildJsonObject { put("text", JsonPrimitive(request.systemInstruction)) })
|
||||
})
|
||||
}
|
||||
)
|
||||
put(
|
||||
"generationConfig",
|
||||
buildJsonObject {
|
||||
put("temperature", JsonPrimitive(request.temperature))
|
||||
put("topP", JsonPrimitive(0.95))
|
||||
put("topK", JsonPrimitive(40))
|
||||
put("maxOutputTokens", JsonPrimitive(request.maxTokens))
|
||||
put("response_mime_type", JsonPrimitive("text/plain"))
|
||||
if (request.model.name.startsWith("gemini")) {
|
||||
put(
|
||||
"thinkingConfig",
|
||||
buildJsonObject { put("thinkingBudget", JsonPrimitive(0)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun streamGeminiResponse(connection: HttpURLConnection): String {
|
||||
val output = StringBuilder()
|
||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
var buffer = ""
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
buffer += line
|
||||
while (true) {
|
||||
val start = buffer.indexOf('{')
|
||||
if (start == -1) {
|
||||
buffer = ""
|
||||
break
|
||||
}
|
||||
var depth = 0
|
||||
var end = -1
|
||||
scan@ for (index in start until buffer.length) {
|
||||
when (buffer[index]) {
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) {
|
||||
end = index
|
||||
break@scan
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end == -1) break
|
||||
val jsonObject = buffer.substring(start, end + 1)
|
||||
buffer = buffer.substring(end + 1)
|
||||
val parsed = runCatching { DesktopAiJson.parseToJsonElement(jsonObject).jsonObject }.getOrNull()
|
||||
output.append(parsed.geminiTextChunk())
|
||||
if (parsed.geminiFinishReason() == "SAFETY") {
|
||||
throw IllegalStateException("Blocked for safety reasons.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun streamGroqResponse(connection: HttpURLConnection): String {
|
||||
val output = StringBuilder()
|
||||
var inThink = false
|
||||
var thinkBuffer = ""
|
||||
|
||||
fun cleanChunk(text: String): String {
|
||||
thinkBuffer += text
|
||||
val cleaned = StringBuilder()
|
||||
while (true) {
|
||||
if (inThink) {
|
||||
val end = thinkBuffer.indexOf("</think>")
|
||||
if (end == -1) {
|
||||
if (thinkBuffer.length > 7) thinkBuffer = thinkBuffer.takeLast(7)
|
||||
break
|
||||
}
|
||||
inThink = false
|
||||
thinkBuffer = thinkBuffer.substring(end + 8)
|
||||
} else {
|
||||
val start = thinkBuffer.indexOf("<think>")
|
||||
if (start == -1) {
|
||||
if (thinkBuffer.length > 6) {
|
||||
cleaned.append(thinkBuffer.dropLast(6))
|
||||
thinkBuffer = thinkBuffer.takeLast(6)
|
||||
}
|
||||
break
|
||||
}
|
||||
cleaned.append(thinkBuffer.substring(0, start))
|
||||
inThink = true
|
||||
thinkBuffer = thinkBuffer.substring(start + 7)
|
||||
}
|
||||
}
|
||||
return cleaned.toString()
|
||||
}
|
||||
|
||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
val trimmed = line!!.trim()
|
||||
if (!trimmed.startsWith("data: ")) continue
|
||||
val data = trimmed.removePrefix("data: ").trim()
|
||||
if (data == "[DONE]") continue
|
||||
val chunk = runCatching {
|
||||
DesktopAiJson.parseToJsonElement(data).jsonObject["choices"]
|
||||
?.jsonArray
|
||||
?.firstOrNull()
|
||||
?.jsonObject
|
||||
?.get("delta")
|
||||
?.jsonObject
|
||||
?.get("content")
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
}.getOrNull().orEmpty()
|
||||
output.append(cleanChunk(chunk))
|
||||
}
|
||||
}
|
||||
if (!inThink && thinkBuffer.isNotBlank()) output.append(thinkBuffer)
|
||||
return output.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private val DesktopAiJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun JsonObject?.geminiTextChunk(): String {
|
||||
if (this == null) return ""
|
||||
return this["candidates"]
|
||||
?.jsonArrayOrNull()
|
||||
?.firstOrNull()
|
||||
?.jsonObjectOrNull()
|
||||
?.get("content")
|
||||
?.jsonObjectOrNull()
|
||||
?.get("parts")
|
||||
?.jsonArrayOrNull()
|
||||
?.firstOrNull()
|
||||
?.jsonObjectOrNull()
|
||||
?.get("text")
|
||||
?.jsonPrimitiveOrNull()
|
||||
?.contentOrNull
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun JsonObject?.geminiFinishReason(): String? {
|
||||
if (this == null) return null
|
||||
return this["candidates"]
|
||||
?.jsonArrayOrNull()
|
||||
?.firstOrNull()
|
||||
?.jsonObjectOrNull()
|
||||
?.get("finishReason")
|
||||
?.jsonPrimitiveOrNull()
|
||||
?.contentOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive
|
||||
Loading…
Add table
Add a link
Reference in a new issue