feature/ai-assistant #2

Merged
anas merged 6 commits from feature/ai-assistant into main 2026-10-04 17:16:54 +00:00
5 changed files with 110 additions and 21 deletions
Showing only changes of commit 65bc9ed0fc - Show all commits

View File

@ -8,6 +8,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.edit import androidx.core.content.edit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@ -129,6 +130,25 @@ private data class ChatResponse(val choices: List<Choice> = emptyList())
@Serializable @Serializable
private data class Choice(val message: ChatMessage? = null) private data class Choice(val message: ChatMessage? = null)
/**
* Anthropic's own shape. Everything else worth talking to speaks the OpenAI one, so this is the
* single exception the client makes — a different path, a different header, and a reply that
* arrives as a list of content blocks rather than a message.
*/
@Serializable
private data class ClaudeRequest(
val model: String,
val system: String,
val messages: List<ChatMessage>,
@SerialName("max_tokens") val maxTokens: Int = 2048,
)
@Serializable
private data class ClaudeResponse(val content: List<ClaudeBlock> = emptyList())
@Serializable
private data class ClaudeBlock(val type: String = "", val text: String = "")
/** /**
* Talks to anything that speaks the OpenAI chat-completions shape, which Ollama, LM Studio, * Talks to anything that speaks the OpenAI chat-completions shape, which Ollama, LM Studio,
* llama.cpp and LocalAI all do. That one shape is why no vendor library is needed: it is an * llama.cpp and LocalAI all do. That one shape is why no vendor library is needed: it is an
@ -146,6 +166,21 @@ object Assistant {
.build() .build()
} }
/** Anthropic is the one service that does not speak the OpenAI shape. */
internal fun isClaude(baseUrl: String) = baseUrl.contains("anthropic.com", ignoreCase = true)
/** Pulls the reply out of whichever shape came back, so the callers never see the difference. */
internal fun reply(body: String, claude: Boolean): String {
val text = if (claude) {
json.decodeFromString<ClaudeResponse>(body)
.content.firstOrNull { it.type == "text" }?.text
} else {
json.decodeFromString<ChatResponse>(body)
.choices.firstOrNull()?.message?.content
}
return text?.trim().orEmpty().ifEmpty { throw IOException("empty reply") }
}
suspend fun ask( suspend fun ask(
settings: AssistantSettings, settings: AssistantSettings,
system: String, system: String,
@ -154,31 +189,47 @@ object Assistant {
if (!settings.serverReady) { if (!settings.serverReady) {
return@withContext Result.failure(IllegalStateException("not configured")) return@withContext Result.failure(IllegalStateException("not configured"))
} }
val url = settings.baseUrl.trimEnd('/') + "/chat/completions" val claude = isClaude(settings.baseUrl)
val body = json.encodeToString( val base = settings.baseUrl.trimEnd('/')
ChatRequest( val body = if (claude) {
model = settings.model, json.encodeToString(
messages = listOf( ClaudeRequest(
ChatMessage("system", system), model = settings.model,
ChatMessage("user", user), system = system,
), messages = listOf(ChatMessage("user", user)),
)
) )
).toRequestBody(media) } else {
json.encodeToString(
ChatRequest(
model = settings.model,
messages = listOf(
ChatMessage("system", system),
ChatMessage("user", user),
),
)
)
}.toRequestBody(media)
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(if (claude) "$base/messages" else "$base/chat/completions")
.post(body) .post(body)
.apply { if (settings.apiKey.isNotBlank()) header("Authorization", "Bearer ${settings.apiKey}") } .apply {
if (settings.apiKey.isBlank()) return@apply
if (claude) {
header("x-api-key", settings.apiKey)
header("anthropic-version", "2023-06-01")
} else {
header("Authorization", "Bearer ${settings.apiKey}")
}
}
.build() .build()
runCatching { runCatching {
http.newCall(request).execute().use { response -> http.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty() val text = response.body?.string().orEmpty()
if (!response.isSuccessful) throw IOException("HTTP ${response.code}: ${text.take(200)}") if (!response.isSuccessful) throw IOException("HTTP ${response.code}: ${text.take(200)}")
json.decodeFromString<ChatResponse>(text) reply(text, claude)
.choices.firstOrNull()?.message?.content?.trim()
.orEmpty()
.ifEmpty { throw IOException("empty reply") }
} }
} }
} }

View File

@ -78,13 +78,13 @@
<string name="assistant_off">خاموش</string> <string name="assistant_off">خاموش</string>
<string name="assistant_off_note">بدون دستیار. خلاصه‌های فارسی خود گنجور، اگر در تنظیمات خواندن روشن باشند، نمایش داده می‌شوند.</string> <string name="assistant_off_note">بدون دستیار. خلاصه‌های فارسی خود گنجور، اگر در تنظیمات خواندن روشن باشند، نمایش داده می‌شوند.</string>
<string name="assistant_server">سرور خودتان</string> <string name="assistant_server">سرور خودتان</string>
<string name="assistant_server_note">با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI یا سرویسی پولی اگر ترجیح می‌دهید.</string> <string name="assistant_server_note">با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI — و با کلاد که API خودش را دارد.</string>
<string name="assistant_share">ارسال به یک برنامه</string> <string name="assistant_share">ارسال به یک برنامه</string>
<string name="assistant_share_note">متن را به هر دستیاری که روی گوشی دارید می‌سپارد. تا خودتان یکی را انتخاب نکنید چیزی از این برنامه بیرون نمی‌رود.</string> <string name="assistant_share_note">متن را به هر دستیاری که روی گوشی دارید می‌سپارد. تا خودتان یکی را انتخاب نکنید چیزی از این برنامه بیرون نمی‌رود.</string>
<string name="assistant_language">پاسخ به زبان</string> <string name="assistant_language">پاسخ به زبان</string>
<string name="assistant_presets">آماده‌ها</string> <string name="assistant_presets">آماده‌ها</string>
<string name="assistant_base_url">نشانی پایه</string> <string name="assistant_base_url">نشانی پایه</string>
<string name="assistant_base_url_note">برای نمونه http://127.0.0.1:11434/v1 برای Ollama روی همین دستگاه</string> <string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">مدل</string> <string name="assistant_model">مدل</string>
<string name="assistant_api_key">کلید API (اختیاری)</string> <string name="assistant_api_key">کلید API (اختیاری)</string>
<string name="assistant_api_key_note">سرور روی دستگاه خودتان معمولاً کلید نمی‌خواهد. بدون رمزگذاری روی همین دستگاه ذخیره می‌شود.</string> <string name="assistant_api_key_note">سرور روی دستگاه خودتان معمولاً کلید نمی‌خواهد. بدون رمزگذاری روی همین دستگاه ذخیره می‌شود.</string>

View File

@ -78,13 +78,13 @@
<string name="assistant_off">بند</string> <string name="assistant_off">بند</string>
<string name="assistant_off_note">کوئی معاون نہیں۔ گنجور کے اپنے فارسی خلاصے، اگر مطالعے کی ترتیبات میں آن ہوں، پھر بھی دکھائے جائیں گے۔</string> <string name="assistant_off_note">کوئی معاون نہیں۔ گنجور کے اپنے فارسی خلاصے، اگر مطالعے کی ترتیبات میں آن ہوں، پھر بھی دکھائے جائیں گے۔</string>
<string name="assistant_server">آپ کا اپنا سرور</string> <string name="assistant_server">آپ کا اپنا سرور</string>
<string name="assistant_server_note">ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI، یا کوئی ادائیگی والی سروس اگر آپ چاہیں۔</string> <string name="assistant_server_note">ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI — اور Claude سے بھی، جس کا اپنا API ہے۔</string>
<string name="assistant_share">کسی ایپ کو بھیجیں</string> <string name="assistant_share">کسی ایپ کو بھیجیں</string>
<string name="assistant_share_note">متن آپ کے فون پر موجود کسی بھی معاون کو دے دیتا ہے۔ جب تک آپ خود انتخاب نہ کریں کچھ باہر نہیں جاتا۔</string> <string name="assistant_share_note">متن آپ کے فون پر موجود کسی بھی معاون کو دے دیتا ہے۔ جب تک آپ خود انتخاب نہ کریں کچھ باہر نہیں جاتا۔</string>
<string name="assistant_language">جواب کی زبان</string> <string name="assistant_language">جواب کی زبان</string>
<string name="assistant_presets">تیار ترتیبات</string> <string name="assistant_presets">تیار ترتیبات</string>
<string name="assistant_base_url">بنیادی یو آر ایل</string> <string name="assistant_base_url">بنیادی یو آر ایل</string>
<string name="assistant_base_url_note">مثلاً اسی آلے پر Ollama کے لیے http://127.0.0.1:11434/v1</string> <string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">ماڈل</string> <string name="assistant_model">ماڈل</string>
<string name="assistant_api_key">API کلید (اختیاری)</string> <string name="assistant_api_key">API کلید (اختیاری)</string>
<string name="assistant_api_key_note">آپ کی اپنی مشین کے سرور کو عموماً کلید کی ضرورت نہیں۔ اسی آلے پر بغیر خفیہ کاری محفوظ ہوتی ہے۔</string> <string name="assistant_api_key_note">آپ کی اپنی مشین کے سرور کو عموماً کلید کی ضرورت نہیں۔ اسی آلے پر بغیر خفیہ کاری محفوظ ہوتی ہے۔</string>

View File

@ -83,13 +83,13 @@
<string name="assistant_off">Off</string> <string name="assistant_off">Off</string>
<string name="assistant_off_note">No assistant. Ganjoor&#8217;s own Persian summaries are still shown if you enable them in reading settings.</string> <string name="assistant_off_note">No assistant. Ganjoor&#8217;s own Persian summaries are still shown if you enable them in reading settings.</string>
<string name="assistant_server">Your own server</string> <string name="assistant_server">Your own server</string>
<string name="assistant_server_note">Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI, or a paid service if you prefer one.</string> <string name="assistant_server_note">Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI — and to Claude, which uses its own.</string>
<string name="assistant_share">Send to an app</string> <string name="assistant_share">Send to an app</string>
<string name="assistant_share_note">Hands the text to any assistant installed on your phone. Nothing leaves this app until you pick one.</string> <string name="assistant_share_note">Hands the text to any assistant installed on your phone. Nothing leaves this app until you pick one.</string>
<string name="assistant_language">Answer in</string> <string name="assistant_language">Answer in</string>
<string name="assistant_presets">Presets</string> <string name="assistant_presets">Presets</string>
<string name="assistant_base_url">Base URL</string> <string name="assistant_base_url">Base URL</string>
<string name="assistant_base_url_note">For example http://127.0.0.1:11434/v1 for Ollama on this device</string> <string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">Model</string> <string name="assistant_model">Model</string>
<string name="assistant_api_key">API key (optional)</string> <string name="assistant_api_key">API key (optional)</string>
<string name="assistant_api_key_note">A server on your own machine usually needs none. Stored unencrypted on this device.</string> <string name="assistant_api_key_note">A server on your own machine usually needs none. Stored unencrypted on this device.</string>

View File

@ -0,0 +1,38 @@
package com.ganjoor.android
import com.ganjoor.android.data.Assistant
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
class AssistantTest {
@Test
fun `recognises anthropic by its host`() {
assertTrue(Assistant.isClaude("https://api.anthropic.com/v1"))
assertFalse(Assistant.isClaude("https://api.openai.com/v1"))
assertFalse(Assistant.isClaude("http://127.0.0.1:11434/v1"))
}
@Test
fun `reads an openai reply`() {
val body = """{"choices":[{"message":{"role":"assistant","content":" the rose "}}]}"""
assertEquals("the rose", Assistant.reply(body, claude = false))
}
/** Anthropic answers with content blocks, and the first is not always the text one. */
@Test
fun `reads a claude reply past a non-text block`() {
val body = """{"content":[{"type":"thinking","text":"x"},{"type":"text","text":" the rose "}]}"""
assertEquals("the rose", Assistant.reply(body, claude = true))
}
@Test
fun `an empty reply is an error, not an empty answer`() {
assertThrows(IOException::class.java) { Assistant.reply("""{"choices":[]}""", false) }
assertThrows(IOException::class.java) { Assistant.reply("""{"content":[]}""", true) }
}
}