Compare commits

...
Author SHA1 Message Date
Anas RashidandClaude Opus 5 ec05989f7d Release 0.2.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:51:20 +02:00
Anas RashidandClaude Opus 5 5daebcd87a Show the assistant's answers in the page, and make a couplet's options findable
Translations appear under the summary they translate. Leaving the poem to read
one — to a sheet over the top, or to another app entirely — breaks the reading,
which is the thing the feature is meant to help with. Ganjoor prints a summary
under each couplet as well as under the poem, so both now carry a translate
button, the couplet one only once a server is configured: a button under every
couplet earns its space only if it can answer.

A couplet's own actions were effectively unreachable. They opened on a tap that
landed between words, and on a full line of poetry a tap almost always lands on
a word, which opens the dictionary instead — three attempts here failed before
one worked. There is now a chevron at the end of every couplet, and the same
actions sit at the foot of the word sheet, inside its scroll rather than after
it, where they were pushed past the bottom of the sheet with no way to reach
them.

Answers are remembered. A LazyColumn disposes what scrolls out of view, which
restarted the request behind it: one tap on translate sent two, confirmed
against a stub server, and scrolling away and back would have sent more. On a
metered API that is money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:49:15 +02:00
Anas RashidandClaude Opus 5 d92445f21e Release 0.2.0
Pre-1.0 while the app is still being shaped: 0.1.0 was the build after the
dictionary work, this is the one with share and the optional assistant.

The APK is named for its version, because a file called app-release.apk tells
whoever receives it nothing. Changelogs in all three languages, one per
versionCode, which is the layout F-Droid reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:21:53 +02:00
anas 2f9cb0e211 Merge pull request 'feature/ai-assistant' (#2) from feature/ai-assistant into main
Reviewed-on: #2
2026-10-04 17:16:53 +00:00
15 changed files with 306 additions and 91 deletions

No files matched your search

+10 -2
View File
@@ -14,6 +14,11 @@ val keystoreProperties = Properties().apply {
if (file.exists()) file.inputStream().use { load(it) }
}
// A delivered file should say what it is without being opened.
base {
archivesName = "ganjoor-0.2.1"
}
android {
namespace = "com.ganjoor.android"
compileSdk {
@@ -24,8 +29,11 @@ android {
applicationId = "com.ganjoor.android"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
// Pre-1.0 while the app is still being shaped. versionCode only ever climbs: F-Droid
// refuses an update that does not, and one changelog file per code lives in
// fastlane/metadata/android/*/changelogs/.
versionCode = 3
versionName = "0.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -166,6 +166,27 @@ object Assistant {
.build()
}
/**
* Answers already received, so scrolling a poem does not ask twice for the same thing. A
* LazyColumn disposes what scrolls out of view, which restarts the request behind it; on a
* metered API that is money, and on a model running locally it is a wait the reader already
* sat through. Access-ordered, so the oldest falls out first.
*/
private val answers = object : LinkedHashMap<String, String>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, String>) = size > 32
}
internal fun cacheKey(prompt: String, language: AssistantLanguage, text: String) =
"$prompt|${language.code}|$text"
@Synchronized
internal fun cached(key: String): String? = answers[key]
@Synchronized
internal fun remember(key: String, reply: String) {
answers[key] = reply
}
/** Anthropic is the one service that does not speak the OpenAI shape. */
internal fun isClaude(baseUrl: String) = baseUrl.contains("anthropic.com", ignoreCase = true)
@@ -1,17 +1,19 @@
package com.ganjoor.android.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -20,10 +22,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -36,57 +38,59 @@ import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Prompts
/**
* Asks the reader's own assistant and shows what comes back.
* Asking the reader's own assistant, and showing what comes back.
*
* The answer isn't stored: it's a reading aid, and a model's paraphrase of a thousand-year-old
* poem has no business being cached next to the poem itself. Sharing it on is one tap, for anyone
* who wants to keep it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantResultSheet(prompt: String, text: String, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
AssistantAnswer(text = text, prompt = prompt)
}
}
/**
* A button that asks the reader's own assistant, or hands the question to another app when no
* server is configured. Both paths are one tap and neither requires any setting up, which is the
* point: the feature is optional, so it cannot assume anyone switched it on.
* The reply, in the page. A translation belongs under the thing it translates — leaving the poem
* to read it, whether to a sheet over the top or to another app entirely, breaks the reading.
*
* With a server configured the answer arrives here. Without one there is nothing to ask, so the
* question goes to whichever assistant is installed; that hand-off is the only path that leaves
* the app, and only because the alternative is no answer at all.
*/
@Composable
fun AssistantAction(prompt: String, text: String, label: Int, instruction: Int) {
fun AssistantInline(prompt: String, text: String, label: Int, instruction: Int) {
val assistant = LocalAssistant.current
val context = LocalContext.current
val ask = stringResource(instruction)
var open by remember { mutableStateOf(false) }
var asked by remember(text) { mutableStateOf(false) }
TextButton(onClick = {
if (assistant.serverReady) open = true else context.shareText("$ask\n\n$text")
}) {
Icon(
painter = painterResource(R.drawable.ic_ask),
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(label))
Column(modifier = Modifier.fillMaxWidth()) {
if (!asked) {
TextButton(onClick = {
if (assistant.serverReady) asked = true else context.shareText("$ask\n\n$text")
}) {
Icon(
painter = painterResource(R.drawable.ic_ask),
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(label))
}
}
AnimatedVisibility(visible = asked) {
AssistantReply(prompt = prompt, text = text)
}
}
if (open) AssistantResultSheet(prompt = prompt, text = text, onDismiss = { open = false })
}
/** The same answer as a plain screen, for the selection-menu activity. */
/** The question and its answer, with nothing around them. */
@Composable
fun AssistantAnswer(text: String, prompt: String = "translate") {
fun AssistantReply(prompt: String, text: String, modifier: Modifier = Modifier) {
val settings = LocalAssistant.current
val context = LocalContext.current
val language = settings.language
val answer by produceState<Result<String>?>(null, prompt, text, language) {
value = Assistant.ask(
val key = Assistant.cacheKey(prompt, language, text)
val answer by produceState<Result<String>?>(Assistant.cached(key)?.let(Result.Companion::success), key) {
// Already answered once: an item scrolling back into view must not ask again.
if (value != null) return@produceState
val result = Assistant.ask(
settings = settings,
system = Prompts.system(language),
user = when (prompt) {
@@ -97,14 +101,68 @@ fun AssistantAnswer(text: String, prompt: String = "translate") {
else -> text
},
)
result.getOrNull()?.let { Assistant.remember(key, it) }
value = result
}
Column(
modifier = Modifier
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
Column(modifier = modifier.fillMaxWidth().padding(top = 4.dp)) {
when (val result = answer) {
null -> Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp).padding(end = 8.dp),
)
Text(
text = stringResource(R.string.assistant_working),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> if (result.isSuccess) {
// A reply in Urdu or English needs its own direction, not the reader's.
val reply = result.getOrDefault("")
InDirectionOf(reply) {
Text(
text = reply,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
}
TextButton(onClick = { context.shareText(reply) }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(R.string.share))
}
} else {
Text(
text = result.exceptionOrNull()?.message
?: stringResource(R.string.assistant_not_set_up),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
/** The same reply as a sheet, for the selection-menu screen, which has no page to sit in. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantResultSheet(prompt: String, text: String, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
@@ -112,34 +170,7 @@ fun AssistantAnswer(text: String, prompt: String = "translate") {
maxLines = 4,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
)
val result = answer
when {
result == null -> Column {
CircularProgressIndicator(modifier = Modifier.padding(bottom = 8.dp))
Text(stringResource(R.string.assistant_working))
}
result.isSuccess -> {
// A reply in Urdu or English needs its own direction, not the reader's.
val reply = result.getOrDefault("")
InDirectionOf(reply) {
Text(reply, style = MaterialTheme.typography.bodyLarge)
}
TextButton(onClick = { context.shareText(reply) }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(R.string.share))
}
}
else -> Text(
text = result.exceptionOrNull()?.message
?: stringResource(R.string.assistant_not_set_up),
color = MaterialTheme.colorScheme.error,
)
}
AssistantReply(prompt = prompt, text = text)
}
}
}
@@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
@@ -53,6 +55,7 @@ import com.ganjoor.android.data.Bookmark
import com.ganjoor.android.data.breadcrumbs
import com.ganjoor.android.data.wordAt
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.data.Poem
import com.ganjoor.android.data.PoemRef
@@ -71,7 +74,9 @@ fun PoemScreen(
) {
BackHandler(onBack = onUp)
var tappedWord by remember { mutableStateOf<String?>(null) }
// The couplet travels with the word: the dictionary sheet is the one gesture every reader
// finds, so the couplet's own actions live at its foot rather than behind a tap between words.
var tapped by remember { mutableStateOf<Pair<String, Bookmark>?>(null) }
Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem ->
val prefs = LocalSettings.current.value
@@ -148,7 +153,7 @@ fun PoemScreen(
style = style,
showSummaries = prefs.showSummaries,
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
onWord = { tappedWord = it },
onWord = { word, passage -> tapped = word to passage },
)
}
@@ -181,8 +186,12 @@ fun PoemScreen(
}
}
tappedWord?.let { word ->
WordSheet(word = word, onDismiss = { tappedWord = null })
tapped?.let { (word, passage) ->
WordSheet(
word = word,
passage = passage,
onDismiss = { tapped = null },
)
}
}
@@ -262,17 +271,18 @@ private fun Couplet(
style: androidx.compose.ui.text.TextStyle,
showSummaries: Boolean,
source: Bookmark,
onWord: (String) -> Unit,
onWord: (String, Bookmark) -> Unit,
) {
// Tap, not long-press: long-press belongs to the text selection this sits inside.
var actionsOpen by remember(couplet) { mutableStateOf(false) }
val passage = source.copy(excerpt = couplet.joinToString("\n") { it.text })
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
couplet.forEach { verse ->
VerseText(
verse = verse,
style = style,
onWord = onWord,
onWord = { onWord(it, passage) },
// A tap that lands between words still opens the couplet's own actions.
onElsewhere = { actionsOpen = !actionsOpen },
)
@@ -285,12 +295,49 @@ private fun Couplet(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
modifier = Modifier.padding(top = 4.dp),
)
// Ganjoor writes these in Persian. Offered only once an assistant is set up:
// a button under every couplet earns its space only if it can answer, and the
// couplet's own actions are behind a tap between words that few will find.
if (LocalAssistant.current.serverReady) {
AssistantInline(
prompt = "summary",
text = summary,
label = R.string.assistant_translate,
instruction = R.string.assistant_translate_prompt,
)
}
}
}
// A visible way in. The tap between words still works, but on a full line of poetry it
// almost never lands there, so the actions were effectively unreachable without this.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = { actionsOpen = !actionsOpen },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = if (actionsOpen) Icons.Default.KeyboardArrowUp
else Icons.Default.KeyboardArrowDown,
contentDescription = stringResource(R.string.couplet_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
if (actionsOpen) {
PassageActions(source.copy(excerpt = couplet.joinToString("\n") { it.text }))
PassageActions(passage)
// Below the buttons rather than among them: the answer needs the full width.
AssistantInline(
prompt = "explain",
text = passage.excerpt.orEmpty(),
label = R.string.assistant_explain,
instruction = R.string.assistant_ask_prompt,
)
}
}
}
@@ -358,7 +405,7 @@ internal fun wordTappedAt(
/** Save this passage, or copy it. Saving keeps the link back to the poem; copying doesn't. */
@Composable
private fun PassageActions(passage: Bookmark) {
internal fun PassageActions(passage: Bookmark) {
val bookmarks = LocalBookmarks.current
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
@@ -395,12 +442,6 @@ private fun PassageActions(passage: Bookmark) {
)
Text(stringResource(R.string.share))
}
AssistantAction(
prompt = "explain",
text = passage.excerpt.orEmpty(),
label = R.string.assistant_explain,
instruction = R.string.assistant_ask_prompt,
)
}
}
@@ -419,7 +460,7 @@ private fun PoemSummary(summary: String) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AssistantAction(
AssistantInline(
prompt = "summary",
text = summary,
label = R.string.assistant_translate,
@@ -2,6 +2,7 @@ package com.ganjoor.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -31,7 +32,9 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Definition
import com.ganjoor.android.data.Bookmark
import com.ganjoor.android.data.Dictionary
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Pronunciation
import com.ganjoor.android.ui.theme.readingStyle
@@ -62,11 +65,38 @@ private fun sourceLabel(source: String) = when (source) {
else -> R.string.source_daneshjoo
}
/** What the dictionary knows about a tapped word, as a sheet over the poem. */
/**
* What the dictionary knows about a tapped word, as a sheet over the poem.
*
* When the word came from a couplet, that couplet's own actions sit at the foot. Tapping a word
* is the one gesture every reader finds; saving, copying or sharing the line used to be behind a
* tap that landed between words, which on a full line of poetry almost never happens.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WordSheet(word: String, onDismiss: () -> Unit) {
ModalBottomSheet(onDismissRequest = onDismiss) { WordLookup(word) }
fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
ModalBottomSheet(onDismissRequest = onDismiss) {
// Inside the lookup's own scroll, not after it: the lookup scrolls, so anything placed
// below it is pushed past the bottom of the sheet with no way to reach it.
WordLookup(word) {
if (passage == null) return@WordLookup
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
Text(
text = stringResource(R.string.this_couplet),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
PassageActions(passage)
if (LocalAssistant.current.serverReady) {
AssistantInline(
prompt = "explain",
text = passage.excerpt.orEmpty(),
label = R.string.assistant_explain,
instruction = R.string.assistant_ask_prompt,
)
}
}
}
}
/**
@@ -75,7 +105,7 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
* to sit over, and a scrim with no content under it is just a grey window.
*/
@Composable
fun WordLookup(word: String) {
fun WordLookup(word: String, footer: @Composable ColumnScope.() -> Unit = {}) {
val prefs = LocalSettings.current.value
// A suggestion replaces what is being looked up, so the sheet can be followed like a trail.
var current by remember(word) { mutableStateOf(word) }
@@ -191,5 +221,8 @@ fun WordLookup(word: String) {
}
}
}
}
footer()
}
}
+2
View File
@@ -105,4 +105,6 @@
<string name="assistant_ask_prompt">این شعر فارسی را ترجمه کن و معنایش را توضیح بده:</string>
<string name="assistant_title">هوش مصنوعی</string>
<string name="assistant_translate_prompt">این متن را ترجمه کن:</string>
<string name="this_couplet">این بیت</string>
<string name="couplet_options">گزینه‌های این بیت</string>
</resources>
+2
View File
@@ -105,4 +105,6 @@
<string name="assistant_ask_prompt">اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں:</string>
<string name="assistant_title">مصنوعی ذہانت</string>
<string name="assistant_translate_prompt">اس کا ترجمہ کریں:</string>
<string name="this_couplet">یہ شعر</string>
<string name="couplet_options">اس شعر کے اختیارات</string>
</resources>
+2
View File
@@ -110,4 +110,6 @@
<string name="assistant_ask_prompt">Translate this Persian poetry and explain what it means:</string>
<string name="assistant_title">AI assistant</string>
<string name="assistant_translate_prompt">Translate this:</string>
<string name="this_couplet">This couplet</string>
<string name="couplet_options">Options for this couplet</string>
</resources>
@@ -1,8 +1,11 @@
package com.ganjoor.android
import com.ganjoor.android.data.Assistant
import com.ganjoor.android.data.AssistantLanguage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -30,6 +33,31 @@ class AssistantTest {
assertEquals("the rose", Assistant.reply(body, claude = true))
}
@Test
fun `the cache keeps answers apart by prompt, language and text`() {
val a = Assistant.cacheKey("translate", AssistantLanguage.Urdu, "x")
assertEquals(a, Assistant.cacheKey("translate", AssistantLanguage.Urdu, "x"))
assertNotEquals(a, Assistant.cacheKey("explain", AssistantLanguage.Urdu, "x"))
assertNotEquals(a, Assistant.cacheKey("translate", AssistantLanguage.English, "x"))
assertNotEquals(a, Assistant.cacheKey("translate", AssistantLanguage.Urdu, "y"))
}
/** Scrolling a long poem must not re-ask for something already answered. */
@Test
fun `a remembered answer comes back`() {
val key = Assistant.cacheKey("summary", AssistantLanguage.Urdu, "remembered")
assertNull(Assistant.cached(key))
Assistant.remember(key, "the answer")
assertEquals("the answer", Assistant.cached(key))
}
@Test
fun `the cache does not grow without bound`() {
repeat(40) { Assistant.remember("bulk-$it", "reply $it") }
assertNull(Assistant.cached("bulk-0"))
assertEquals("reply 39", Assistant.cached("bulk-39"))
}
@Test
fun `an empty reply is an error, not an empty answer`() {
assertThrows(IOException::class.java) { Assistant.reply("""{"choices":[]}""", false) }
@@ -0,0 +1,8 @@
Share a poem, a couplet, or any selected text, with a link back to ganjoor.net.
Look up a word from the text-selection menu, in this app or any other.
An optional AI assistant, off by default: connect your own Ollama, LM Studio,
llama.cpp or LocalAI server, or a service such as OpenAI, DeepSeek, Gemini or
Claude, to translate Ganjoor's summaries and explain a couplet. Nothing is sent
anywhere until you set one up, and no key ships with the app.
@@ -0,0 +1,8 @@
An assistant's translation now appears under the summary it translates, in the
page, instead of over it. Ganjoor writes a summary under each couplet as well
as under the poem, and both can be translated.
A couplet's save, copy, share and explain are reachable again: a chevron at the
end of every couplet opens them, and they also sit at the foot of the word
lookup. They used to need a tap that landed between words, which on a line of
poetry almost never happens.
@@ -0,0 +1,8 @@
هم‌رسانی شعر، بیت، یا هر متنی که انتخاب کرده‌اید، همراه با پیوند به گنجور.
جست‌وجوی معنی واژه از منوی انتخاب متن، در این برنامه و در هر برنامهٔ دیگری.
دستیار هوش مصنوعی، به‌صورت اختیاری و به‌طور پیش‌فرض خاموش: سرور خودتان
(Ollama، LM Studio، llama.cpp، LocalAI) یا سرویسی مانند OpenAI، DeepSeek،
Gemini یا Claude را وصل کنید تا خلاصه‌های گنجور را ترجمه و بیت‌ها را شرح دهد.
تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمی‌شود و هیچ کلیدی همراه برنامه نیست.
@@ -0,0 +1,7 @@
ترجمهٔ دستیار حالا زیر همان خلاصه‌ای که ترجمه می‌کند، در خودِ صفحه نشان داده
می‌شود، نه روی آن. گنجور هم زیر هر بیت خلاصه می‌نویسد و هم زیر شعر؛ هر دو را
می‌توان ترجمه کرد.
ذخیره، رونوشت، هم‌رسانی و شرحِ هر بیت دوباره در دسترس است: نشانهٔ پیکان در
انتهای هر بیت آن‌ها را باز می‌کند و در پای جست‌وجوی واژه هم هست. پیش از این
باید جایی میان دو واژه ضربه می‌زدید، که در یک مصراع شعر تقریباً هرگز رخ نمی‌دهد.
@@ -0,0 +1,8 @@
کلام، شعر، یا کوئی بھی منتخب متن شیئر کریں، گنجور کے لنک کے ساتھ۔
متن منتخب کرنے کے مینو سے لفظ کا مطلب دیکھیں — اس ایپ میں اور کسی بھی دوسری ایپ میں۔
مصنوعی ذہانت کا اختیاری معاون، جو پہلے سے بند ہے: اپنا سرور (Ollama، LM Studio،
llama.cpp، LocalAI) یا OpenAI، DeepSeek، Gemini یا Claude جیسی سروس جوڑیں تاکہ
گنجور کے خلاصوں کا ترجمہ ہو اور اشعار کی تشریح ہو۔ جب تک آپ خود ترتیب نہ دیں کچھ
کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی کلید نہیں آتی۔
@@ -0,0 +1,8 @@
معاون کا ترجمہ اب اسی خلاصے کے نیچے، صفحے ہی میں دکھایا جاتا ہے جس کا ترجمہ ہو
رہا ہے — اوپر کسی الگ پردے میں نہیں۔ گنجور ہر شعر کے نیچے بھی خلاصہ لکھتا ہے
اور پورے کلام کے نیچے بھی؛ دونوں کا ترجمہ ہو سکتا ہے۔
ہر شعر کے محفوظ کرنے، نقل، شیئر اور تشریح کے بٹن دوبارہ قابلِ رسائی ہیں: ہر شعر
کے آخر میں ایک نشان انہیں کھولتا ہے، اور وہ لفظ کی لغت کے نیچے بھی موجود ہیں۔
پہلے ان کے لیے دو الفاظ کے درمیان ٹیپ کرنا پڑتا تھا، جو شعر کی سطر میں تقریباً
کبھی نہیں ہوتا۔