From b243e67870a3cb8dd28efeb4bf31f4f91918d878 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 16:31:54 +0300 Subject: [PATCH] feat: Tauri backend commands + wikipedia rewrite + github PR review pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/jarvis-gui/src/main.rs | 7 ++ crates/jarvis-gui/src/tauri_commands.rs | 6 +- .../jarvis-gui/src/tauri_commands/backends.rs | 81 ++++++++++++++ resources/commands/github_pr/command.toml | 73 ++++++++++++ resources/commands/github_pr/list.lua | 39 +++++++ resources/commands/github_pr/set_repo.lua | 27 +++++ resources/commands/github_pr/summarize.lua | 62 +++++++++++ resources/commands/wiki/command.toml | 27 ----- resources/commands/wiki/wiki.lua | 103 ----------------- resources/commands/wikipedia/command.toml | 40 +++++++ resources/commands/wikipedia/summary.lua | 105 ++++++++++++++++++ 11 files changed, 439 insertions(+), 131 deletions(-) create mode 100644 crates/jarvis-gui/src/tauri_commands/backends.rs create mode 100644 resources/commands/github_pr/command.toml create mode 100644 resources/commands/github_pr/list.lua create mode 100644 resources/commands/github_pr/set_repo.lua create mode 100644 resources/commands/github_pr/summarize.lua delete mode 100644 resources/commands/wiki/command.toml delete mode 100644 resources/commands/wiki/wiki.lua create mode 100644 resources/commands/wikipedia/command.toml create mode 100644 resources/commands/wikipedia/summary.lua diff --git a/crates/jarvis-gui/src/main.rs b/crates/jarvis-gui/src/main.rs index 0b32c8b..32daea3 100644 --- a/crates/jarvis-gui/src/main.rs +++ b/crates/jarvis-gui/src/main.rs @@ -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"); diff --git a/crates/jarvis-gui/src/tauri_commands.rs b/crates/jarvis-gui/src/tauri_commands.rs index c6e757e..3a07df0 100644 --- a/crates/jarvis-gui/src/tauri_commands.rs +++ b/crates/jarvis-gui/src/tauri_commands.rs @@ -37,4 +37,8 @@ pub use commands::*; // import voices commands mod voices; -pub use voices::*; \ No newline at end of file +pub use voices::*; + +// LLM/TTS backend introspection + hot-swap +mod backends; +pub use backends::*; \ No newline at end of file diff --git a/crates/jarvis-gui/src/tauri_commands/backends.rs b/crates/jarvis-gui/src/tauri_commands/backends.rs new file mode 100644 index 0000000..65a448f --- /dev/null +++ b/crates/jarvis-gui/src/tauri_commands/backends.rs @@ -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, + 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 { + 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 { + 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 { + llm::history_last_assistant() +} diff --git a/resources/commands/github_pr/command.toml b/resources/commands/github_pr/command.toml new file mode 100644 index 0000000..b676a7b --- /dev/null +++ b/resources/commands/github_pr/command.toml @@ -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 ("текущий репо ") 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 = ["встанови репо"] diff --git a/resources/commands/github_pr/list.lua b/resources/commands/github_pr/list.lua new file mode 100644 index 0000000..f9bb4fc --- /dev/null +++ b/resources/commands/github_pr/list.lua @@ -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) diff --git a/resources/commands/github_pr/set_repo.lua b/resources/commands/github_pr/set_repo.lua new file mode 100644 index 0000000..1c3c8a7 --- /dev/null +++ b/resources/commands/github_pr/set_repo.lua @@ -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 .. " установлен.") diff --git a/resources/commands/github_pr/summarize.lua b/resources/commands/github_pr/summarize.lua new file mode 100644 index 0000000..f2b3be8 --- /dev/null +++ b/resources/commands/github_pr/summarize.lua @@ -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)) diff --git a/resources/commands/wiki/command.toml b/resources/commands/wiki/command.toml deleted file mode 100644 index 683c730..0000000 --- a/resources/commands/wiki/command.toml +++ /dev/null @@ -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 = [ - "що таке", - "хто такий", - "розкажи про", -] diff --git a/resources/commands/wiki/wiki.lua b/resources/commands/wiki/wiki.lua deleted file mode 100644 index fd37408..0000000 --- a/resources/commands/wiki/wiki.lua +++ /dev/null @@ -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 } diff --git a/resources/commands/wikipedia/command.toml b/resources/commands/wikipedia/command.toml new file mode 100644 index 0000000..8971801 --- /dev/null +++ b/resources/commands/wikipedia/command.toml @@ -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 = [ + "вікіпедія", + "знайди у вікіпедії", + "що таке", + "хто такий", + "розкажи про", +] diff --git a/resources/commands/wikipedia/summary.lua b/resources/commands/wikipedia/summary.lua new file mode 100644 index 0000000..9a90517 --- /dev/null +++ b/resources/commands/wikipedia/summary.lua @@ -0,0 +1,105 @@ +-- "Википедия Эйнштейн" → fetch ru.wikipedia.org/api/rest_v1/page/summary/ +-- 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)