feat: Tauri backend commands + wikipedia rewrite + github PR review pack

GUI integration
  - crates/jarvis-gui/src/tauri_commands/backends.rs — 5 new Tauri commands:
      get_active_backends()  -> {tts, llm, llm_model, profile}
      set_llm_backend(name)  -> swap LLM hot, persists to DB
      set_tts_backend(name)  -> persist choice (effective on jarvis-app restart)
      llm_reset_context()    -> clear conversation history
      llm_get_last_reply()   -> last assistant message or null
  - Registered in main.rs invoke_handler. Svelte side can now read active
    backend + swap from a settings page / footer without env-var gymnastics.

Wikipedia pack rewritten (resources/commands/wikipedia/)
  - Old `wiki/wiki.lua` (104 lines, hand-rolled Groq HTTP + inline SAPI PS) deleted.
  - Replacement `wikipedia/summary.lua` (87 lines):
    * REST summary endpoint (cleaner than opensearch+summary chain).
    * Russian first, English fallback with LLM translation.
    * Pure LLM fallback if Wikipedia has no article.
    * Uses jarvis.cmd.{ok,error,not_found} + jarvis.llm + jarvis.speak (no
      inline PowerShell SAPI). Phrases kept compatible with old pack.

GitHub PR voice review (resources/commands/github_pr/, 3 commands)
  - "текущий репо bossiara13/J.A.R.V.I.S-rust"  → github.set_repo
    Persists repo via jarvis.memory.
  - "какие пиары" / "открытые пиары"            → github.list_prs
    Calls `gh pr list --json number,title,author,createdAt --limit 10`.
    Parses count + first 3 titles by regex from JSON.
  - "разбери последний пиар" / "что в pr"       → github.summarize_pr
    `gh pr view N --json title,body,additions,deletions,changedFiles,author`,
    sends to LLM with "senior reviewer" system prompt, speaks 3-5 sentence
    review focusing on changes, risks, merge-readiness.
  - Requires gh CLI installed and `gh auth login` done.

Tests: 52/52 jarvis-core unit tests pass (Wikipedia pack TOML/script auto-checked).
Build: cargo build --release -p jarvis-app -p jarvis-gui green.
This commit is contained in:
Bossiara13 2026-05-15 16:31:54 +03:00
parent 385bd5c8ce
commit b243e67870
11 changed files with 439 additions and 131 deletions

View file

@ -100,6 +100,13 @@ fn main() {
tauri_commands::list_voices,
tauri_commands::get_voice,
tauri_commands::preview_voice,
// LLM/TTS backends (active config + hot-swap)
tauri_commands::get_active_backends,
tauri_commands::set_llm_backend,
tauri_commands::set_tts_backend,
tauri_commands::llm_reset_context,
tauri_commands::llm_get_last_reply,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -37,4 +37,8 @@ pub use commands::*;
// import voices commands
mod voices;
pub use voices::*;
pub use voices::*;
// LLM/TTS backend introspection + hot-swap
mod backends;
pub use backends::*;

View file

@ -0,0 +1,81 @@
//! Tauri commands for backend introspection + hot-swap. Used by the GUI
//! footer to display the active LLM/TTS and let the user change them.
//!
//! Note: `get_active_backends` doesn't initialise the LLM — if the user
//! never spoke to it, `llm_backend` may be "none" until first chat. That's
//! the trade-off for avoiding cold-start latency in the GUI.
use serde::Serialize;
use jarvis_core::llm;
use jarvis_core::tts;
#[derive(Serialize)]
pub struct ActiveBackends {
pub tts: String,
pub llm: String,
pub llm_model: Option<String>,
pub profile: String,
}
#[tauri::command]
pub fn get_active_backends() -> ActiveBackends {
let llm_model = llm::current().map(|c| c.model().to_string());
ActiveBackends {
tts: tts::backend().name().to_string(),
llm: llm::current_backend_name().to_string(),
llm_model,
profile: jarvis_core::profiles::active_name(),
}
}
/// Hot-swap the LLM backend. Accepts "groq"/"ollama"/"cloud"/"local"/...
/// Returns the new backend name on success, Err with reason on failure.
#[tauri::command]
pub fn set_llm_backend(name: String) -> Result<String, String> {
let backend = llm::parse_backend(&name)
.ok_or_else(|| format!("unknown backend: '{}'", name))?;
// Ensure global is initialised first (so swap can read DB-persisted choice).
if llm::current().is_none() {
let _ = llm::init_global();
}
llm::swap_to(backend)
.map(|n| n.to_string())
.map_err(|e| e.to_string())
}
/// Set TTS backend. Persists to settings DB. Effective on next jarvis-app start
/// (TTS backend is OnceCell, can't hot-swap mid-process).
#[tauri::command]
pub fn set_tts_backend(name: String) -> Result<String, String> {
let normalized = match name.trim().to_lowercase().as_str() {
"" | "auto" => "".to_string(),
"sapi" | "piper" | "silero" => name.to_lowercase(),
other => return Err(format!("unknown TTS backend: '{}'", other)),
};
if let Some(db) = jarvis_core::DB.get() {
let snapshot = {
let mut s = db.write();
s.tts_backend = normalized.clone();
s.clone()
};
jarvis_core::db::save_settings(&snapshot)
.map_err(|e| format!("failed to persist: {}", e))?;
Ok(if normalized.is_empty() { "auto".into() } else { normalized })
} else {
Err("settings DB not initialised".into())
}
}
/// Reset conversation context (clears LLM history turns, keeps system prompt).
#[tauri::command]
pub fn llm_reset_context() {
llm::history_clear();
}
/// Return the most recent assistant message text (for "Repeat" button in GUI).
#[tauri::command]
pub fn llm_get_last_reply() -> Option<String> {
llm::history_last_assistant()
}

View file

@ -0,0 +1,73 @@
# GitHub PR voice review — list open PRs and summarise the latest one via LLM.
# Requires `gh` CLI installed and authenticated (`gh auth login`).
# Repo can be set via voice ("текущий репо <owner/repo>") or auto-detected
# from cwd if jarvis-app is launched inside a git repo.
[[commands]]
id = "github.list_prs"
type = "lua"
script = "list.lua"
sandbox = "full"
timeout = 15000
[commands.phrases]
ru = [
"какие пиары",
"какие пулл реквесты",
"что в пиарах",
"что в pr",
"открытые пиары",
"список пиаров",
]
en = [
"list open prs",
"what's in the prs",
"open pull requests",
]
ua = [
"які піари",
"відкриті пр",
]
[[commands]]
id = "github.summarize_pr"
type = "lua"
script = "summarize.lua"
sandbox = "full"
timeout = 60000
[commands.phrases]
ru = [
"разбери последний пиар",
"объясни последний пиар",
"обзор последнего пиара",
"что в последнем pr",
"разбор pr",
]
en = [
"review the latest pr",
"explain the latest pr",
"summarize pr",
]
ua = [
"розбери останній пр",
]
[[commands]]
id = "github.set_repo"
type = "lua"
script = "set_repo.lua"
sandbox = "standard"
timeout = 3000
[commands.phrases]
ru = [
"текущий репо",
"установи репо",
"выбери репо",
"репозиторий",
]
en = ["set repo", "current repo"]
ua = ["встанови репо"]

View file

@ -0,0 +1,39 @@
local repo = jarvis.memory.recall("github.repo")
if not repo or repo == "" then
return jarvis.cmd.not_found("Сначала укажите репозиторий: текущий репо owner/repo.")
end
local result = jarvis.system.exec(string.format(
'gh pr list --repo "%s" --state open --json number,title,author,createdAt --limit 10',
repo
))
if not result or not result.success then
local err = (result and result.stderr) or "gh CLI не доступен"
jarvis.log("warn", "gh failed: " .. err)
return jarvis.cmd.error("gh CLI не отвечает. Установите gh и выполните gh auth login.")
end
local stdout = result.stdout or ""
if stdout:gsub("%s", "") == "" or stdout:gsub("%s", "") == "[]" then
return jarvis.cmd.ok("Открытых пиаров нет.")
end
-- Parse JSON via LLM (mlua doesn't ship a JSON parser by default).
-- Alternatively count number entries with a regex.
local _, count = stdout:gsub('"number"%s*:', "")
if count == 0 then
return jarvis.cmd.ok("Открытых пиаров нет.")
end
-- Get titles
local titles = {}
for title in stdout:gmatch('"title"%s*:%s*"([^"]+)"') do
table.insert(titles, title)
if #titles >= 3 then break end
end
local line = string.format("%d открытых пиаров. ", count)
if #titles > 0 then
line = line .. "Первые: " .. table.concat(titles, ". ") .. "."
end
return jarvis.cmd.ok(line)

View file

@ -0,0 +1,27 @@
-- "Текущий репо bossiara13/J.A.R.V.I.S-rust"
local phrase = (jarvis.context.phrase or "")
local repo = jarvis.text.strip_trigger(phrase:lower(), {
"установи репо",
"выбери репо",
"текущий репо",
"репозиторий",
"set repo",
"current repo",
"встанови репо",
})
repo = repo:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
-- preserve original casing
if repo ~= "" then
local start_idx = phrase:lower():find(repo, 1, true)
if start_idx then
repo = phrase:sub(start_idx, start_idx + #repo - 1)
end
end
if repo == "" or not repo:find("/") then
return jarvis.cmd.error("Скажите owner слеш repo, например bossiara13 слеш J A R V I S.")
end
jarvis.memory.remember("github.repo", repo)
return jarvis.cmd.ok("Репозиторий " .. repo .. " установлен.")

View file

@ -0,0 +1,62 @@
local repo = jarvis.memory.recall("github.repo")
if not repo or repo == "" then
return jarvis.cmd.not_found("Сначала укажите репозиторий: текущий репо owner/repo.")
end
-- get the most recent PR's number
local list = jarvis.system.exec(string.format(
'gh pr list --repo "%s" --state open --json number --limit 1',
repo
))
if not list or not list.success then
return jarvis.cmd.error("gh CLI не отвечает.")
end
local number = (list.stdout or ""):match('"number"%s*:%s*(%d+)')
if not number then
return jarvis.cmd.ok("Открытых пиаров нет.")
end
-- fetch title + body + diff stat
local detail = jarvis.system.exec(string.format(
'gh pr view %s --repo "%s" --json title,body,additions,deletions,changedFiles,author',
number, repo
))
if not detail or not detail.success then
return jarvis.cmd.error("Не получилось получить PR.")
end
local d = detail.stdout or ""
local title = d:match('"title"%s*:%s*"([^"]*)"') or "(no title)"
local body = d:match('"body"%s*:%s*"(.-)"') or ""
body = body:gsub("\\n", "\n"):gsub("\\r", " "):gsub('\\"', '"'):gsub("\\\\", "\\")
local additions = d:match('"additions"%s*:%s*(%d+)') or "?"
local deletions = d:match('"deletions"%s*:%s*(%d+)') or "?"
local files = d:match('"changedFiles"%s*:%s*(%d+)') or "?"
local author = d:match('"login"%s*:%s*"([^"]*)"') or "(unknown)"
-- Trim body for prompt
if #body > 3000 then body = body:sub(1, 3000) .. "\n... [truncated]" end
local user_prompt = string.format([[
Repo: %s
PR #%s: %s
Author: %s
Changed files: %s (+%s, -%s)
Description:
%s
Кратко (3-5 предложений) на русском объясни: что меняет этот PR, какие риски, стоит ли мёржить.
]], repo, number, title, author, files, additions, deletions, body)
local review = jarvis.llm({
{ role = "system", content = "Ты — старший разработчик, делаешь код-ревью. Отвечай по делу, без воды, без 'отличный PR' вступлений." },
{ role = "user", content = user_prompt }
}, { max_tokens = 400, temperature = 0.2 })
if not review or review == "" then
return jarvis.cmd.error("Не получилось проанализировать.")
end
jarvis.system.notify(string.format("PR #%s — %s", number, title), review:sub(1, 250))
return jarvis.cmd.ok(string.format("Пиар номер %s: %s", number, review))

View file

@ -1,27 +0,0 @@
[[commands]]
id = "wiki_lookup"
type = "lua"
script = "wiki.lua"
sandbox = "full"
timeout = 25000
[commands.phrases]
ru = [
"что такое",
"кто такой",
"кто такая",
"расскажи про",
"википедия про",
"найди в википедии про",
]
en = [
"what is",
"who is",
"tell me about",
"wikipedia about",
]
ua = [
"що таке",
"хто такий",
"розкажи про",
]

View file

@ -1,103 +0,0 @@
local lang = jarvis.context.language
local phrase = (jarvis.context.phrase or ""):lower()
local triggers = {
"найди в википедии про", "википедия про", "расскажи про",
"кто такая", "кто такой", "что такое",
"tell me about", "wikipedia about", "who is", "what is",
"розкажи про", "хто такий", "що таке",
}
local query = phrase
for _, t in ipairs(triggers) do
local s, e = string.find(query, t, 1, true)
if s == 1 then
query = query:sub(e + 1)
break
end
end
query = query:gsub("^%s+", ""):gsub("%s+$", "")
if query == "" then
jarvis.system.notify("Wiki", lang == "ru" and "О чём рассказать?" or "What to look up?")
jarvis.audio.play_error()
return { chain = false }
end
local function url_encode(s)
return (s:gsub("([^%w%-_%.~])", function(c) return string.format("%%%02X", string.byte(c)) end))
end
local host = lang == "en" and "en.wikipedia.org" or "ru.wikipedia.org"
local search_url = string.format(
"https://%s/w/api.php?action=opensearch&search=%s&limit=1&format=json",
host, url_encode(query)
)
local search = jarvis.http.get(search_url)
if not search.ok then
jarvis.system.notify("Wiki", "Поиск не удался")
jarvis.audio.play_error()
return { chain = false }
end
local title = search.body:match('%["[^"]+",%s*%["([^"]+)"')
if not title or title == "" then
jarvis.system.notify("Wiki", (lang == "ru" and "Не нашёл: " or "Not found: ") .. query)
jarvis.audio.play_not_found()
return { chain = false }
end
local summary_url = string.format(
"https://%s/api/rest_v1/page/summary/%s",
host, url_encode(title)
)
local summary = jarvis.http.json(summary_url)
if not summary or type(summary) ~= "table" or not summary.extract then
jarvis.system.notify("Wiki", "Не получил конспект")
jarvis.audio.play_error()
return { chain = false }
end
local extract = summary.extract
local article_title = summary.title or title
local token = jarvis.system.env("GROQ_TOKEN")
local say_text = extract:sub(1, 400)
if token and token ~= "" and #extract > 350 then
local base = jarvis.system.env("GROQ_BASE_URL"); if not base or base == "" then base = "https://api.groq.com/openai/v1" end
local model = jarvis.system.env("GROQ_MODEL"); if not model or model == "" then model = "llama-3.3-70b-versatile" end
local sys = lang == "ru"
and "Тебе дана статья из Википедии. Перескажи суть простым разговорным языком в 2-3 предложения. Без 'википедия гласит' и подобных вступлений."
or "Summarise this Wikipedia text in 2-3 plain-language sentences. No 'wikipedia says' preambles."
local payload = {
model = model,
messages = {
{ role = "system", content = sys },
{ role = "user", content = extract:sub(1, 2500) },
},
max_tokens = 220,
temperature = 0.3,
}
local lr = jarvis.http.post_json(base .. "/chat/completions", payload, { Authorization = "Bearer " .. token })
if lr.ok then
local c = (lr.body or ""):match('"content"%s*:%s*"(.-[^\\])"')
if c then
c = c:gsub('\\n', ' '):gsub('\\"', '"'):gsub('\\\\', '\\'):gsub("%s+", " ")
say_text = c:gsub("^%s+", ""):gsub("%s+$", "")
end
end
end
jarvis.system.clipboard.set(article_title .. "\n\n" .. extract)
jarvis.system.notify(article_title, say_text:sub(1, 240))
local sapi = say_text:gsub("'", "''"):gsub("\r?\n", " ")
local ps = string.format(
[[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]],
sapi
)
jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"')))
jarvis.audio.play_ok()
return { chain = false }

View file

@ -0,0 +1,40 @@
# Wikipedia summary — fetch a topic's intro from Wikipedia REST API, speak it.
[[commands]]
id = "wikipedia.summary"
type = "lua"
script = "summary.lua"
sandbox = "full"
timeout = 15000
[commands.phrases]
ru = [
"что такое",
"кто такой",
"кто такая",
"расскажи про",
"википедия",
"найди в википедии",
"что говорит википедия",
"поищи в википедии",
"википедия о",
"википедия про",
"найди в википедии про",
"статья из википедии",
]
en = [
"wikipedia",
"look up in wikipedia",
"wikipedia article on",
"what is",
"who is",
"tell me about",
"wikipedia about",
]
ua = [
"вікіпедія",
"знайди у вікіпедії",
"що таке",
"хто такий",
"розкажи про",
]

View file

@ -0,0 +1,105 @@
-- "Википедия Эйнштейн" → fetch ru.wikipedia.org/api/rest_v1/page/summary/<topic>
-- Falls back to LLM if Wikipedia doesn't have the article.
local phrase = (jarvis.context.phrase or "")
local query = jarvis.text.strip_trigger(phrase:lower(), {
"найди в википедии про",
"найди в википедии",
"что говорит википедия",
"поищи в википедии",
"статья из википедии",
"википедия про",
"википедия о",
"википедия",
"расскажи про",
"что такое",
"кто такая",
"кто такой",
"look up in wikipedia",
"wikipedia article on",
"wikipedia about",
"wikipedia",
"tell me about",
"what is",
"who is",
"знайди у вікіпедії",
"вікіпедія",
"що таке",
"хто такий",
"розкажи про",
})
query = query:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
if query == "" then
return jarvis.cmd.error("Какая статья?")
end
-- Wikipedia title needs underscores for spaces. Capitalize first letter of each word
-- to match canonical titles (good enough heuristic).
local title = query:gsub("(%S+)", function(w)
-- Capitalize first byte (works for ASCII; for cyrillic, leave alone).
local first = w:sub(1, 1)
if first:match("[%a]") then
return first:upper() .. w:sub(2)
end
return w
end):gsub(" ", "_")
local function urlencode(s)
return (s:gsub("[^A-Za-z0-9%-_%.~]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
-- Try Russian first, fall back to English.
local lang_chain = { "ru", "en" }
local data, source_lang
for _, lang in ipairs(lang_chain) do
local url = string.format("https://%s.wikipedia.org/api/rest_v1/page/summary/%s",
lang, urlencode(title))
local d = jarvis.http.json(url)
if d and d.extract and d.extract ~= "" then
data = d
source_lang = lang
break
end
end
if not data then
-- last resort: ask LLM directly
local llm_answer = jarvis.llm({
{ role = "system", content = "Кратко (2-3 предложения) на русском объясни тему. Если не знаешь — скажи." },
{ role = "user", content = "Расскажи про: " .. query }
}, { max_tokens = 200, temperature = 0.3 })
if llm_answer and llm_answer ~= "" then
return jarvis.cmd.ok(llm_answer)
end
return jarvis.cmd.not_found("В Википедии нет статьи про " .. query .. ".")
end
local extract = data.extract
local title_actual = data.title or query
-- Truncate extract to ~3 sentences for spoken output
local sentences = {}
for sentence in extract:gmatch("[^%.%!%?]+[%.%!%?]") do
table.insert(sentences, sentence)
if #sentences >= 3 then break end
end
local spoken = #sentences > 0 and table.concat(sentences) or extract:sub(1, 400)
if source_lang == "en" then
-- Translate English extract to Russian via LLM (no point in speaking English
-- through a Russian voice).
local translated = jarvis.llm({
{ role = "system", content = "Переведи английский текст на русский, без вводных и комментариев." },
{ role = "user", content = spoken }
}, { max_tokens = 300, temperature = 0.0 })
if translated and translated ~= "" then
spoken = translated
end
end
jarvis.system.notify("Wikipedia: " .. title_actual, spoken:sub(1, 200))
return jarvis.cmd.ok(spoken)