Add RSS parsing for news, update system prompt with RSS URLs, add current year dynamic substitution, update AGENTS.md with context optimization discussion

This commit is contained in:
Алексей Будаев 2026-04-10 00:04:25 +08:00
parent 5d59c5e351
commit ae5907c45f
4 changed files with 1323 additions and 52 deletions

View file

@ -0,0 +1,488 @@
package com.mistral.chat.api
import com.google.gson.JsonObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class GetTimeTool(
private val getDefaultTimezone: () -> String
) : Tool(
name = "get_local_time",
description = "Получить текущую дату и время.",
inputSchema = JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("object"))
add("properties", JsonObject().apply {
add("timezone", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("Часовой пояс (опционально)"))
})
add("format", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("Формат даты/времени (опционально). Пример: 'dd MMMM yyyy, HH:mm'"))
})
})
}
) {
override suspend fun execute(arguments: JsonObject): String {
val timezone = arguments.get("timezone")?.asString ?: getDefaultTimezone()
val format = arguments.get("format")?.asString ?: "dd MMMM yyyy, HH:mm"
return try {
val sdf = SimpleDateFormat(format, Locale("ru", "RU"))
sdf.timeZone = java.util.TimeZone.getTimeZone(timezone)
val now = Date()
val formatted = sdf.format(now)
"""{"status": "success", "time": "$formatted", "timezone": "$timezone"}"""
} catch (e: Exception) {
"""{"status": "error", "message": "${e.message}"}"""
}
}
}
class GetDateTool(
private val getDefaultTimezone: () -> String
) : Tool(
name = "get_date",
description = "Получить текущую дату. Используй когда пользователь спрашивает какое сегодня число.",
inputSchema = JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("object"))
add("properties", JsonObject().apply {
add("format", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("Формат даты (опционально). Пример: 'dd MMMM yyyy'"))
})
})
}
) {
override suspend fun execute(arguments: JsonObject): String {
val format = arguments.get("format")?.asString ?: "dd MMMM yyyy"
val timezone = getDefaultTimezone()
return try {
val sdf = SimpleDateFormat(format, Locale("ru", "RU"))
sdf.timeZone = java.util.TimeZone.getTimeZone(timezone)
val now = Date()
"""{"status": "success", "date": "${sdf.format(now)}"}"""
} catch (e: Exception) {
"""{"status": "error", "message": "${e.message}"}"""
}
}
}
class GetWeatherTool(
private val getDefaultCity: () -> String
) : Tool(
name = "get_weather",
description = "Получить текущую погоду и прогноз на 7 дней в указанном городе.",
inputSchema = JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("object"))
add("properties", JsonObject().apply {
add("city", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("Город для прогноза погоды"))
})
})
}
) {
private val httpClient = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) {
val city = arguments.get("city")?.asString ?: getDefaultCity()
getWeather(city)
}
private fun getCityCoords(cityName: String): Pair<Double, Double>? {
return try {
val encodedCity = java.net.URLEncoder.encode(cityName, "UTF-8")
val request = okhttp3.Request.Builder()
.url("https://geocoding-api.open-meteo.com/v1/search?name=$encodedCity&count=1&language=ru&format=json")
.get()
.build()
val response = httpClient.newCall(request).execute()
val body = response.body?.string() ?: ""
val json = com.google.gson.JsonParser.parseString(body).asJsonObject
val results = json.get("results")?.asJsonArray
if (results != null && results.size() > 0) {
val lat = results[0].asJsonObject.get("latitude").asDouble
val lon = results[0].asJsonObject.get("longitude").asDouble
Pair(lat, lon)
} else null
} catch (e: Exception) {
null
}
}
private fun getWeather(cityName: String): String {
val coords = getCityCoords(cityName) ?: return """{"status": "error", "message": "Город не найден"}"""
return try {
val (lat, lon) = coords
// Запрашиваем текущую погоду + прогноз на 7 дней
val request = okhttp3.Request.Builder()
.url("https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&current=temperature_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7")
.get()
.build()
val response = httpClient.newCall(request).execute()
val body = response.body?.string() ?: ""
val json = com.google.gson.JsonParser.parseString(body).asJsonObject
val current = json.get("current")?.asJsonObject
val daily = json.get("daily")?.asJsonObject
if (current != null && daily != null) {
// Текущая погода
val temp = current.get("temperature_2m")?.asDouble ?: 0.0
val wind = current.get("wind_speed_10m")?.asDouble ?: 0.0
val code = current.get("weather_code")?.asInt ?: 0
val weather = getWeatherDescription(code)
// Прогноз на 7 дней
val dailyTime = daily.get("time")?.asJsonArray
val dailyMaxTemp = daily.get("temperature_2m_max")?.asJsonArray
val dailyMinTemp = daily.get("temperature_2m_min")?.asJsonArray
val dailyCode = daily.get("weather_code")?.asJsonArray
val dailyPrecip = daily.get("precipitation_sum")?.asJsonArray
val dailyPrecipProb = daily.get("precipitation_probability_max")?.asJsonArray
val forecastLines = mutableListOf<String>()
if (dailyTime != null && dailyMaxTemp != null) {
for (i in 0 until minOf(dailyTime.size(), 7)) {
val date = dailyTime.get(i).asString?.takeLast(5) ?: ""
val maxTemp = dailyMaxTemp.get(i).asDouble ?: 0.0
val minTemp = dailyMinTemp?.get(i)?.asDouble ?: maxTemp
val dayCode = dailyCode?.get(i)?.asInt ?: 0
val precip = dailyPrecip?.get(i)?.asDouble ?: 0.0
val precipProb = dailyPrecipProb?.get(i)?.asInt ?: 0
val dayWeather = getWeatherDescription(dayCode)
val dayName = getDayName(i)
forecastLines.add("$dayName ($date): макс $maxTemp°C, мин $minTemp°C, $dayWeather, осадки ${precip}мм ($precipProb%)")
}
}
val forecastText = if (forecastLines.isNotEmpty()) {
"\n\nПрогноз на 7 дней:\n" + forecastLines.joinToString("\n")
} else {
""
}
"""Текущая погода в $cityName: $temp°C, $weather, ветер ${wind}km/h$forecastText"""
} else {
"""{"status": "error", "message": "Не удалось получить погоду"}"""
}
} catch (e: Exception) {
android.util.Log.e("Weather", "Error: ${e.message}", e)
"""{"status": "error", "message": "Ошибка получения погоды: ${e.message}"}"""
}
}
private fun getDayName(dayIndex: Int): String {
return when (dayIndex) {
0 -> "Сегодня"
1 -> "Завтра"
2 -> "Послезавтра"
else -> "День ${dayIndex + 1}"
}
}
private fun getWeatherDescription(code: Int): String {
return when (code) {
0 -> "Ясно"
1, 2, 3 -> "Облачно"
45, 48 -> "Туман"
51, 53, 55 -> "Морось"
56, 57 -> "Ледяная морось"
61, 63, 65 -> "Дождь"
66, 67 -> "Ледяной дождь"
71, 73, 75 -> "Снег"
77 -> "Снежные зёрна"
80, 81, 82 -> "Ливень"
85, 86 -> "Снегопад"
95 -> "Гроза"
96, 99 -> "Гроза с градом"
else -> "Неизвестно"
}
}
}
class WebSearchTool(
private val getDefaultCity: () -> String
) : Tool(
name = "web_search",
description = "Поиск информации в Wikipedia (русской и английской). Проверяй обе версии для получения актуальной информации.",
inputSchema = JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("object"))
add("properties", JsonObject().apply {
add("query", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("Поисковый запрос"))
})
add("location", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("Место (опционально). По умолчанию - ${getDefaultCity()}"))
})
add("num_results", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("number"))
add("description", com.google.gson.JsonPrimitive("Количество результатов (по умолчанию 10)"))
})
})
add("required", com.google.gson.JsonArray().apply {
add("query")
})
}
) {
private val httpClient = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) {
val query = arguments.get("query")?.asString
val location = arguments.get("location")?.asString ?: getDefaultCity()
val numResults = arguments.get("num_results")?.asInt ?: 10
if (query.isNullOrEmpty()) {
return@withContext """{"status": "error", "message": "Query is required"}"""
}
try {
// Ищем и в русской, и в английской Wikipedia параллельно
val ruResults = coroutineScope {
async { searchWikipedia(query, numResults, "ru") }.await()
}
val enResults = coroutineScope {
async { searchWikipedia(query, numResults, "en") }.await()
}
val allResults = mutableListOf<String>()
if (ruResults.isNotEmpty()) {
allResults.add("=== РУССКАЯ WIKIPEDIA ===")
allResults.addAll(ruResults)
}
if (enResults.isNotEmpty()) {
allResults.add("=== ENGLISH WIKIPEDIA ===")
allResults.addAll(enResults)
}
if (allResults.isEmpty()) {
"""{"status": "success", "message": "Ничего не найдено по запросу '$query'"}"""
} else {
val responseText = allResults.joinToString("\n\n").take(4000)
"""Найденная информация:\n\n$responseText"""
}
} catch (e: Exception) {
"""{"status": "error", "message": "Search failed: ${e.message}"}"""
}
}
private fun searchWikipedia(query: String, limit: Int, lang: String): List<String> {
val results = mutableListOf<String>()
try {
val encodedQuery = java.net.URLEncoder.encode(query, "UTF-8")
val wikiLang = if (lang == "en") "en" else "ru"
val searchRequest = okhttp3.Request.Builder()
.url("https://$wikiLang.wikipedia.org/w/api.php?action=query&list=search&srsearch=$encodedQuery&srlimit=$limit&format=json&origin=*")
.header("User-Agent", "MistralChat/1.0")
.header("Accept", "application/json")
.get()
.build()
val searchResponse = httpClient.newCall(searchRequest).execute()
val searchBody = searchResponse.body?.string() ?: ""
val json = com.google.gson.JsonParser.parseString(searchBody).asJsonObject
val queryObj = json.get("query")?.asJsonObject
val searchArray = queryObj?.get("search")?.asJsonArray
if (searchArray != null && searchArray.size() > 0) {
for (i in 0 until minOf(searchArray.size(), limit)) {
val item = searchArray[i].asJsonObject
val title = item.get("title")?.asString ?: ""
val snippet = item.get("snippet")?.asString ?: ""
if (title.isNotEmpty()) {
val cleanSnippet = snippet
.replace(Regex("<[^>]*>"), "")
.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
val text = if (cleanSnippet.isNotEmpty()) {
"Статья: $title\nСодержание: $cleanSnippet"
} else {
"Статья: $title"
}
results.add(text)
}
}
}
} catch (e: Exception) {
// Игнорируем ошибки поиска
}
return results
}
}
class OpenUrlTool : Tool(
name = "open_url",
description = "Получить текст с веб-страницы или RSS-ленты по URL. Автоматически определяет RSS и парсит заголовки новостей.",
inputSchema = JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("object"))
add("properties", JsonObject().apply {
add("url", JsonObject().apply {
add("type", com.google.gson.JsonPrimitive("string"))
add("description", com.google.gson.JsonPrimitive("URL страницы или RSS-ленты (например: https://lenta.ru/rss/, https://www.kommersant.ru/rss/news.xml)"))
})
})
add("required", com.google.gson.JsonArray().apply {
add("url")
})
}
) {
private val httpClient = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
override suspend fun execute(arguments: JsonObject): String = withContext(Dispatchers.IO) {
val url = arguments.get("url")?.asString
if (url.isNullOrEmpty()) {
return@withContext """{"status": "error", "message": "URL is required"}"""
}
val normalizedUrl = url.lowercase()
if (normalizedUrl.startsWith("javascript:") ||
normalizedUrl.startsWith("file:") ||
normalizedUrl.startsWith("data:") ||
normalizedUrl.startsWith("mailto:") ||
normalizedUrl.startsWith("tel:")) {
return@withContext """{"status": "error", "message": "Недопустимый тип URL"}"""
}
try {
val request = okhttp3.Request.Builder()
.url(url)
.header("User-Agent", "Mozilla/5.0 (Android)")
.header("Accept", "application/rss+xml,application/atom+xml,application/xml,text/xml,text/html,application/xhtml+xml")
.get()
.build()
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext """{"status": "error", "message": "Ошибка HTTP: ${response.code}"}"""
}
val contentType = response.header("Content-Type") ?: ""
val body = response.body?.string() ?: ""
val isRss = contentType.contains("xml") || body.trim().startsWith("<?xml") || body.trim().startsWith("<rss") || body.trim().startsWith("<feed")
val textOnly = if (isRss) {
parseRssFeed(body)
} else {
body
.replace(Regex("<script[^>]*>.*?</script>", RegexOption.DOT_MATCHES_ALL), "")
.replace(Regex("<style[^>]*>.*?</style>", RegexOption.DOT_MATCHES_ALL), "")
.replace(Regex("<[^>]+>"), " ")
.replace(Regex("\\s+"), " ")
.replace("&nbsp;", " ")
.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&mdash;", "")
.replace("&ndash;", "")
.trim()
}
val result = textOnly.take(2000)
"""{"status": "success", "content": "$result"}"""
} catch (e: Exception) {
"""{"status": "error", "message": "Ошибка загрузки: ${e.message}"""
}
}
private fun parseRssFeed(xml: String): String {
val items = mutableListOf<String>()
try {
val cleanXml = xml
.replace(Regex("<!\\[CDATA\\[", RegexOption.DOT_MATCHES_ALL), "")
.replace(Regex("]]>", RegexOption.DOT_MATCHES_ALL), "")
val titleMatch = Regex("<title><!\\[CDATA\\[(.*?)\\]\\]></title>|<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL).find(cleanXml)
val feedTitle = titleMatch?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: ""
val itemRegex = Regex(
"<item>|<entry>",
RegexOption.DOT_MATCHES_ALL
)
val itemMatches = itemRegex.findAll(cleanXml)
for ((index, match) in itemMatches.withIndex()) {
if (index >= 15) break
val start = match.range.first
val endRange = if (index + 1 < itemMatches.count()) {
itemRegex.findAll(cleanXml).toList()[index + 1].range.first
} else {
cleanXml.length
}
val itemXml = cleanXml.substring(start, endRange)
val itemTitle = Regex("<title><!\\[CDATA\\[(.*?)\\]\\]></title>|<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
.find(itemXml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: ""
val itemLink = Regex("<link>(.*?)</link>").find(itemXml)?.groupValues?.getOrNull(1) ?: ""
val itemDesc = Regex("<description><!\\[CDATA\\[(.*?)\\]\\]></description>|<description>(.*?)</description>", RegexOption.DOT_MATCHES_ALL)
.find(itemXml)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } ?: ""
val itemDate = Regex("<pubDate>|<published>").find(itemXml)?.let { dateMatch ->
val dateStart = dateMatch.range.last + 1
val dateEnd = minOf(dateStart + 50, cleanXml.length)
val dateSection = cleanXml.substring(dateStart, dateEnd)
Regex("(<[^>]+>)").replace(dateSection, "").trim()
} ?: ""
if (itemTitle.isNotEmpty()) {
val itemText = buildString {
append("$itemTitle")
if (itemDate.isNotEmpty()) append(" [$itemDate]")
if (itemLink.isNotEmpty()) append(" | ${itemLink}")
if (itemDesc.isNotEmpty() && itemDesc.length < 150) append(" - $itemDesc")
}
items.add(itemText)
}
}
if (feedTitle.isNotEmpty()) {
return "=== $feedTitle ===\n\n" + items.joinToString("\n")
}
return items.joinToString("\n")
} catch (e: Exception) {
return "Ошибка парсинга RSS: ${e.message}"
}
}
}

View file

@ -33,9 +33,11 @@ import com.google.android.material.button.MaterialButton
import com.google.gson.Gson
import com.mistral.chat.R
import com.mistral.chat.api.MistralClient
import com.mistral.chat.api.ToolExecutor
import com.mistral.chat.data.ChatDatabase
import com.mistral.chat.data.Message
import com.mistral.chat.data.MessageEntity
import com.mistral.chat.data.MemoryRepository
import com.mistral.chat.data.Profile
import com.mistral.chat.data.Session
import com.mistral.chat.data.toMessage
@ -47,6 +49,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
@ -66,12 +71,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private var currentJob: kotlinx.coroutines.Job? = null
private var client: MistralClient? = null
private var toolExecutor: ToolExecutor? = null
private val messages = mutableListOf<Message>()
private var availableModels: List<Pair<String, String>> = emptyList()
private var selectedModelName: String = "mistral-small-latest"
private var selectedModelName: String = "mistral-medium-latest"
private lateinit var prefs: SharedPreferences
private lateinit var encryptedPrefs: SharedPreferences
private lateinit var database: ChatDatabase
private lateinit var memoryRepository: MemoryRepository
private val profiles = mutableListOf<Profile>()
private val sessions = mutableListOf<Session>()
@ -94,6 +101,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private const val KEY_LAST_PROFILE_ID = "last_profile_id"
private const val KEY_SELECTED_MODEL = "selected_model"
private const val KEY_THEME_MODE = "theme_mode"
private const val KEY_DEFAULT_TIMEZONE = "default_timezone"
private const val KEY_DEFAULT_CITY = "default_city"
private const val MAX_PROFILES = 10
}
@ -128,8 +137,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
showApiKeyDialog()
}
database = ChatDatabase.getInstance(this)
memoryRepository = MemoryRepository(database.memoryDao())
client = MistralClient(getApiKey())
toolExecutor = ToolExecutor(memoryRepository, this, getDefaultTimezone(), getDefaultCity())
client?.setToolExecutor(toolExecutor!!)
logoButton = findViewById(R.id.logoButton)
menuButton = findViewById(R.id.menuButton)
hamburgerButton = findViewById(R.id.hamburgerButton)
@ -141,8 +155,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
drawerLayout = findViewById(R.id.drawerLayout)
navigationView = findViewById(R.id.navigationView)
rightPanel = findViewById(R.id.rightPanelContainer)
database = ChatDatabase.getInstance(this)
setupToolbar()
setupRecyclerView()
@ -203,6 +215,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
R.id.action_session -> {
showSettingsDialog()
}
R.id.action_location -> {
showLocationDialog()
}
R.id.action_about -> {
showAboutDialog()
}
@ -260,6 +275,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
if (deleteProfiles) {
database.profileDao().deleteAll()
currentProfileId = null
memoryRepository.setCurrentProfile(null)
memoryRepository.deleteAnonymous()
prefs.edit().remove(KEY_LAST_PROFILE_ID).apply()
profiles.clear()
}
@ -338,6 +355,35 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
.show()
}
private fun showLocationDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_location, null)
val timezoneInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.timezoneInput)
val cityInput = dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.cityInput)
timezoneInput.setText(getDefaultTimezone())
cityInput.setText(getDefaultCity())
AlertDialog.Builder(this)
.setTitle(R.string.location_title)
.setView(dialogView)
.setPositiveButton(R.string.save) { _, _ ->
val timezone = timezoneInput.text.toString().trim()
val city = cityInput.text.toString().trim()
if (timezone.isNotEmpty()) {
setDefaultTimezone(timezone)
}
if (city.isNotEmpty()) {
setDefaultCity(city)
}
toolExecutor?.updateSettings(timezone = getDefaultTimezone(), city = getDefaultCity())
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun setupToolbar() {
hamburgerButton.isVisible = true
@ -406,6 +452,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
} else {
null
}
memoryRepository.setCurrentProfile(currentProfileId)
val profileId = currentProfileId
if (profileId != null) {
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply()
@ -462,6 +509,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun selectProfile(profile: Profile) {
currentProfileId = profile.id
memoryRepository.setCurrentProfile(profile.id)
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profile.id).apply()
profilesAdapter?.refresh()
val profileName = profiles.find { it.id == currentProfileId }?.name ?: profile.name
@ -491,7 +539,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
.setMessage("Удалить профиль ${profile.name}?")
.setPositiveButton(R.string.yes) { _, _ ->
lifecycleScope.launch {
memoryRepository.deleteByProfile(profile.id)
database.profileDao().delete(profile)
if (currentProfileId == profile.id) {
currentProfileId = null
memoryRepository.setCurrentProfile(null)
}
}
}
.setNegativeButton(R.string.no, null)
@ -499,20 +552,49 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}
private fun selectSession(session: Session) {
currentJob?.cancel()
currentSessionId = session.id
userMessageCount = 0
userScrolledAfterSend = false
prefs.edit().putLong("last_session_id", session.id).apply()
messages.clear()
adapter.notifyDataSetChanged()
loadSessionMessages(session.id)
updateRightPanel()
drawerLayout.closeDrawer(GravityCompat.END)
}
private var loadMessagesJob: Job? = null
private fun loadSessionMessages(sessionId: Long) {
lifecycleScope.launch {
val dbMessages = database.messageDao().getMessagesBySessionSync(sessionId)
loadMessagesJob?.cancel()
loadMessagesJob = lifecycleScope.launch {
val targetSessionId = sessionId
if (!isActive || currentSessionId != targetSessionId) {
return@launch
}
val dbMessages = database.messageDao().getMessagesBySessionSync(targetSessionId)
if (!isActive || currentSessionId != targetSessionId) {
return@launch
}
messages.clear()
messages.addAll(dbMessages.map { it.toMessage() })
adapter.notifyDataSetChanged()
if (messages.isNotEmpty()) {
recyclerView.postDelayed({
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(messages.size - 1, 0)
}, 100)
}
}
}
@ -597,7 +679,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}
private fun setupRecyclerView() {
adapter = MessageAdapter(messages)
adapter = MessageAdapter(
messages,
onMessageEdit = { position, message -> editMessage(position, message) },
onMessageDelete = { position, message -> deleteMessage(position, message) }
)
recyclerView.layoutManager = LinearLayoutManager(this).apply {
stackFromEnd = true
}
@ -617,6 +703,49 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
})
}
private fun editMessage(position: Int, message: Message) {
val editText = EditText(this)
editText.setText(message.content)
AlertDialog.Builder(this)
.setTitle("Редактировать сообщение")
.setView(editText)
.setPositiveButton(R.string.save) { _, _ ->
val newContent = editText.text.toString().trim()
if (newContent.isNotEmpty() && newContent != message.content) {
val sessionId = currentSessionId
val timestamp = message.timestamp
messages[position] = message.copy(content = newContent)
adapter.notifyItemChanged(position)
lifecycleScope.launch {
if (sessionId != null) {
database.messageDao().updateContent(sessionId, timestamp, newContent)
}
}
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun deleteMessage(position: Int, message: Message) {
AlertDialog.Builder(this)
.setTitle("Удалить сообщение")
.setMessage("Вы уверены, что хотите удалить это сообщение?")
.setPositiveButton(R.string.yes) { _, _ ->
val sessionId = currentSessionId
val timestamp = message.timestamp
lifecycleScope.launch {
if (sessionId != null) {
database.messageDao().deleteByTimestamp(sessionId, timestamp)
}
}
messages.removeAt(position)
adapter.notifyItemRemoved(position)
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun setupInput() {
sendButton.setOnClickListener {
sendInput()
@ -643,12 +772,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
return
}
// Отменяем предыдущий запрос перед новым
currentJob?.cancel()
if (currentSessionId == null) {
createNewSessionAndSend(userInput)
return
}
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), currentSessionId)
inputField.text?.clear()
@ -656,13 +788,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
lastUserMessagePosition = messages.size - 1
recyclerView.postDelayed({
recyclerView.scrollToPosition(lastUserMessagePosition)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(lastUserMessagePosition, 0)
}, 100)
sendMessage(userInput)
}
private fun createNewSessionAndSend(userInput: String) {
// Отменяем предыдущий запрос
currentJob?.cancel()
lifecycleScope.launch {
val session = Session(
profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null,
@ -671,11 +807,12 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val sessionId = database.sessionDao().insert(session)
currentSessionId = sessionId
userMessageCount = 0
userScrolledAfterSend = false
messages.clear()
adapter.notifyDataSetChanged()
updateRightPanel()
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()), sessionId)
inputField.text?.clear()
sendMessage(userInput)
}
@ -755,38 +892,40 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
if (!hasUserSelectedModel) {
runOnUiThread {
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-medium-latest"
}
}
}
}
}
private fun addMessage(message: Message) {
val newPosition = messages.size - 1
private fun addMessage(message: Message, expectedSessionId: Long? = null) {
val targetSessionId = expectedSessionId ?: currentSessionId
messages.add(message)
val newPosition = messages.size - 1
adapter.notifyItemInserted(newPosition)
if (!message.isUser && !userScrolledAfterSend) {
recyclerView.postDelayed({
if (!userScrolledAfterSend) {
recyclerView.scrollToPosition(newPosition)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(newPosition, 0)
}
}, 150)
}
val sessionId = currentSessionId
if (sessionId != null) {
if (targetSessionId != null) {
lifecycleScope.launch {
val entity = MessageEntity(
sessionId = sessionId,
sessionId = targetSessionId,
content = message.content,
isUser = message.isUser,
timestamp = message.timestamp
)
database.messageDao().insert(entity)
database.sessionDao().updateTimestamp(sessionId)
database.sessionDao().updateTimestamp(targetSessionId)
}
}
}
@ -809,6 +948,22 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
return encryptedPrefs.getString(KEY_API_KEY, null) ?: ""
}
private fun getDefaultTimezone(): String {
return prefs.getString(KEY_DEFAULT_TIMEZONE, "Europe/Moscow") ?: "Europe/Moscow"
}
private fun setDefaultTimezone(timezone: String) {
prefs.edit().putString(KEY_DEFAULT_TIMEZONE, timezone).apply()
}
private fun getDefaultCity(): String {
return prefs.getString(KEY_DEFAULT_CITY, "Москва") ?: "Москва"
}
private fun setDefaultCity(city: String) {
prefs.edit().putString(KEY_DEFAULT_CITY, city).apply()
}
private fun hasApiKey(): Boolean {
return encryptedPrefs.contains(KEY_API_KEY)
}
@ -816,10 +971,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun saveApiKey(apiKey: String) {
encryptedPrefs.edit().putString(KEY_API_KEY, apiKey).apply()
client = MistralClient(apiKey)
client?.setToolExecutor(toolExecutor!!)
}
private fun deleteApiKey() {
encryptedPrefs.edit().remove(KEY_API_KEY).apply()
client = MistralClient("")
client?.setToolExecutor(toolExecutor!!)
Toast.makeText(this, getString(R.string.api_key_deleted), Toast.LENGTH_SHORT).show()
showApiKeyDialog()
}
@ -856,6 +1014,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
saveApiKey(newKey)
client = MistralClient(newKey)
client?.setToolExecutor(toolExecutor!!)
apiKeyDialog?.dismiss()
Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show()
} else {
@ -880,6 +1039,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun sendMessage(userInput: String) {
val selectedModel = selectedModelName
val sessionIdAtStart = currentSessionId
sendButton.isEnabled = false
progressIndicator.isVisible = true
@ -887,56 +1047,137 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
currentJob = lifecycleScope.launch {
try {
val profileContext = getSelectedProfileContext()
val systemPrompt = getSelectedSystemPrompt()
val memoryContext = memoryRepository.buildMemoryContext()
val tools = toolExecutor?.getToolsSchema()
val apiMessages = messages.map { msg ->
Message(
content = msg.content,
isUser = msg.isUser
)
}.toMutableList()
// Автоматически получаем текущую дату
val currentDateResult = client?.executeTool("get_date", com.google.gson.JsonObject())
?: """{"status": "error", "message": "Tool failed"}"""
val apiMessages = mutableListOf<Message>()
var finalSystemPrompt = systemPrompt
if (finalSystemPrompt.isNotEmpty()) {
apiMessages.add(Message(content = finalSystemPrompt, isUser = false, role = "system"))
}
// Добавляем информацию о текущей дате и местоположении
apiMessages.add(Message(
content = "Текущая дата: $currentDateResult",
isUser = true,
role = "user"
))
// Добавляем информацию о часовом поясе и городе
val timezone = getDefaultTimezone()
val city = getDefaultCity()
apiMessages.add(Message(
content = "Мое местоположение: часовой пояс $timezone, город $city",
isUser = true,
role = "user"
))
if (profileContext.isNotEmpty()) {
apiMessages.add(0, Message(content = profileContext, isUser = true))
apiMessages.add(Message(content = profileContext, isUser = true, role = "user"))
}
val result = withTimeout(15000L) {
client?.chat(selectedModel, apiMessages) ?: Result.failure(Exception("Client not initialized"))
if (memoryContext.isNotEmpty()) {
apiMessages.add(Message(content = memoryContext, isUser = true, role = "user"))
}
if (!isActive) return@launch
apiMessages.addAll(messages.map { msg ->
Message(
content = msg.content,
isUser = msg.isUser,
role = if (msg.isUser) "user" else "assistant"
)
})
result.onSuccess { (response, usedModel) ->
val displayModel = usedModel.ifEmpty { "Assistant" }
addMessage(Message(content = response, isUser = false, senderName = displayModel))
lifecycleScope.launch {
saveMessageToDatabase(currentSessionId, response, false, displayModel)
// Tool loop - до 15 итераций
var iteration = 0
val maxIterations = 15
var finalResponse: String? = null
while (iteration < maxIterations) {
iteration++
val result = withTimeout(30000L) {
client?.chat(selectedModel, apiMessages, tools)
?: Result.failure(Exception("Client not initialized"))
}
val count = userMessageCount + 1
userMessageCount = count
if (count == 2 && titleGenerationJob?.isActive != true) {
titleGenerationJob = generateSessionTitle()
}
}.onFailure { error ->
if (!isActive) return@launch
val errorMessage = error.message ?: "Unknown error"
if (!errorMessage.contains("cancelled", ignoreCase = true)) {
val userFriendlyMessage = getUserFriendlyError(errorMessage)
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
result.onSuccess { chatResponse ->
if (chatResponse.toolCalls.isNotEmpty()) {
// Выполняем все tool calls и добавляем результаты в историю
for (toolCall in chatResponse.toolCalls) {
val toolResult = client?.executeTool(toolCall.name, toolCall.arguments)
?: """{"status": "error", "message": "Tool failed"}"""
apiMessages.add(Message(
content = """[${toolCall.name}] result: $toolResult""",
isUser = true,
role = "user"
))
}
// Продолжаем цикл - AI решит нужен ли еще поиск
} else {
// Нет tool calls - это финальный ответ
finalResponse = chatResponse.content
}
}.onFailure { error ->
finalResponse = "Ошибка: ${error.message}"
}
// Если есть финальный ответ или превышен лимит - выходим
if (finalResponse != null || iteration >= maxIterations) {
break
}
}
if (finalResponse == null && iteration >= maxIterations) {
finalResponse = "Превышен лимит итераций (${maxIterations}). Попробуйте более конкретный запрос."
}
// Показываем финальный ответ
if (finalResponse.isNullOrEmpty()) {
finalResponse = "Не удалось получить ответ. Попробуйте ещё раз."
}
sendButton.isEnabled = true
progressIndicator.isVisible = false
} catch (e: kotlinx.coroutines.CancellationException) {
if (!isActive) return@launch
addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System"))
val responseToShow = finalResponse!!
// Проверяем что sessionId не изменился пока работал запрос
if (currentSessionId == sessionIdAtStart) {
addMessage(Message(content = responseToShow, isUser = false, senderName = selectedModel), sessionIdAtStart)
// Прокрутка к началу нового сообщения ИИ
recyclerView.post {
if (!userScrolledAfterSend) {
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(messages.size - 1, 0)
}
}
if (!responseToShow.startsWith("Ошибка:")) {
// Генерируем название сессии после второго сообщения
userMessageCount++
if (userMessageCount == 2) {
generateSessionTitle()
}
}
}
sendButton.isEnabled = true
progressIndicator.isVisible = false
} catch (e: Exception) {
if (!isActive) return@launch
val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error")
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
android.util.Log.e("MainActivity", "Exception: ${e.message}", e)
if (currentSessionId == sessionIdAtStart) {
addMessage(Message(content = "Произошла ошибка: ${e.message}", isUser = false, senderName = "Error"), sessionIdAtStart)
}
sendButton.isEnabled = true
progressIndicator.isVisible = false
}
@ -956,6 +1197,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}
}
private fun getSelectedSystemPrompt(): String {
if (currentProfileId == null) return ""
val profile = profiles.find { it.id == currentProfileId }
val profilePrompt = profile?.systemPrompt ?: ""
val defaultPrompt = getString(R.string.profile_system_prompt_default)
val currentYear = SimpleDateFormat("yyyy", Locale.getDefault()).format(Date())
return if (profilePrompt.isNotEmpty()) profilePrompt
else defaultPrompt.replace("{CURRENT_YEAR}", currentYear)
}
private fun getCurrentProfileName(): String {
if (currentProfileId == null) return "Вы"
val profileName = profiles.find { it.id == currentProfileId }?.name
@ -973,11 +1224,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
val systemPromptInput = dialogView.findViewById<TextInputEditText>(R.id.systemPromptInput)
existingProfile?.let {
nameInput.setText(it.name)
bioInput.setText(it.bio)
preferencesInput.setText(it.preferences)
systemPromptInput.setText(it.systemPrompt)
} ?: run {
if (systemPromptInput.text.isNullOrEmpty()) {
systemPromptInput.setText(getString(R.string.profile_system_prompt_default))
}
}
val dialog = AlertDialog.Builder(this)
@ -992,16 +1249,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
name = name,
bio = bioInput.text?.toString() ?: "",
preferences = preferencesInput.text?.toString() ?: "",
systemPrompt = systemPromptInput.text?.toString() ?: "",
updatedAt = System.currentTimeMillis()
))
} else {
val defaultSystemPrompt = getString(R.string.profile_system_prompt_default)
val newId = database.profileDao().insert(Profile(
name = name,
bio = bioInput.text?.toString() ?: "",
preferences = preferencesInput.text?.toString() ?: ""
preferences = preferencesInput.text?.toString() ?: "",
systemPrompt = systemPromptInput.text?.toString()?.ifEmpty { defaultSystemPrompt } ?: defaultSystemPrompt
))
if (currentProfileId == null) {
currentProfileId = newId
memoryRepository.setCurrentProfile(newId)
}
}
}
@ -1015,6 +1276,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
database.profileDao().delete(existingProfile)
if (currentProfileId == existingProfile.id) {
currentProfileId = null
memoryRepository.setCurrentProfile(null)
}
}
}