feature/ai-assistant #2
@ -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") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -83,13 +83,13 @@
|
|||||||
<string name="assistant_off">Off</string>
|
<string name="assistant_off">Off</string>
|
||||||
<string name="assistant_off_note">No assistant. Ganjoor’s own Persian summaries are still shown if you enable them in reading settings.</string>
|
<string name="assistant_off_note">No assistant. Ganjoor’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>
|
||||||
|
|||||||
38
app/src/test/java/com/ganjoor/android/AssistantTest.kt
Normal file
38
app/src/test/java/com/ganjoor/android/AssistantTest.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user