diff --git a/crates/jarvis-core/src/lua/api.rs b/crates/jarvis-core/src/lua/api.rs index f0e9a08..65bc9cd 100644 --- a/crates/jarvis-core/src/lua/api.rs +++ b/crates/jarvis-core/src/lua/api.rs @@ -6,4 +6,5 @@ pub mod fs; pub mod state; pub mod system; pub mod tts; -pub mod llm; \ No newline at end of file +pub mod llm; +pub mod text; \ No newline at end of file diff --git a/crates/jarvis-core/src/lua/api/text.rs b/crates/jarvis-core/src/lua/api/text.rs new file mode 100644 index 0000000..ac6cab3 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/text.rs @@ -0,0 +1,65 @@ +use mlua::{Lua, Table, Value}; + +// Tiny text helpers that every trigger-driven pack reinvents. Centralising +// here removes ~10 lines of identical boilerplate per pack. +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let text = lua.create_table()?; + + // jarvis.text.strip_trigger(phrase, triggers) -> remainder + // + // Lowercases `phrase`, finds the FIRST trigger from `triggers` that + // matches at the start (case-insensitive substring), and returns + // everything after it trimmed. If nothing matches, returns the original + // phrase trimmed. Empty input returns "". + // + // Triggers should be ordered longest-first so "напомни мне через" wins + // over "напомни" — the function does NOT re-sort for the caller. + let strip_fn = lua.create_function(|lua, (phrase, triggers): (String, Table)| { + let lowered = phrase.to_lowercase(); + let mut chosen_end: Option = None; + + for v in triggers.sequence_values::() { + let t = match v { + Ok(Value::String(s)) => s.to_str()?.to_lowercase(), + _ => continue, + }; + if t.is_empty() { + continue; + } + if lowered.starts_with(&t) { + let after = lowered[t.len()..].chars().next(); + if after.map_or(true, |c| !c.is_alphanumeric()) { + chosen_end = Some(t.len()); + break; + } + } + } + + let rest = match chosen_end { + Some(end) => &phrase[end..], + None => phrase.as_str(), + }; + Ok(lua.create_string(rest.trim())?) + })?; + text.set("strip_trigger", strip_fn)?; + + // jarvis.text.contains_any(phrase, needles) -> boolean + // case-insensitive "does any needle appear anywhere in the phrase". + let contains_fn = lua.create_function(|_, (phrase, needles): (String, Table)| { + let lowered = phrase.to_lowercase(); + for v in needles.sequence_values::() { + let n = match v { + Ok(Value::String(s)) => s.to_str()?.to_lowercase(), + _ => continue, + }; + if !n.is_empty() && lowered.contains(&n) { + return Ok(true); + } + } + Ok(false) + })?; + text.set("contains_any", contains_fn)?; + + jarvis.set("text", text)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/engine.rs b/crates/jarvis-core/src/lua/engine.rs index 7b0268d..f7380c3 100644 --- a/crates/jarvis-core/src/lua/engine.rs +++ b/crates/jarvis-core/src/lua/engine.rs @@ -77,6 +77,7 @@ impl LuaEngine { api::audio::register(&self.lua, &jarvis)?; api::context::register(&self.lua, &jarvis, context)?; api::tts::register(&self.lua, &jarvis)?; + api::text::register(&self.lua, &jarvis)?; // sandbox-controlled APIs if self.sandbox.allows_http() { diff --git a/resources/commands/brightness/command.toml b/resources/commands/brightness/command.toml new file mode 100644 index 0000000..f9814f7 --- /dev/null +++ b/resources/commands/brightness/command.toml @@ -0,0 +1,50 @@ +[[commands]] +id = "brightness_up" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["ярче", "сделай ярче", "увеличь яркость", "поярче"] +en = ["brighter", "increase brightness"] +ua = ["яскравіше", "збільш яскравість"] + + +[[commands]] +id = "brightness_down" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["темнее", "сделай темнее", "уменьши яркость", "потемнее"] +en = ["darker", "decrease brightness"] +ua = ["темніше", "зменш яскравість"] + + +[[commands]] +id = "brightness_max" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["максимальная яркость", "яркость на максимум"] +en = ["max brightness", "brightness max"] +ua = ["максимальна яскравість"] + + +[[commands]] +id = "brightness_min" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["минимальная яркость", "яркость на минимум"] +en = ["min brightness", "brightness min"] +ua = ["мінімальна яскравість"] diff --git a/resources/commands/brightness/set.lua b/resources/commands/brightness/set.lua new file mode 100644 index 0000000..ee2088b --- /dev/null +++ b/resources/commands/brightness/set.lua @@ -0,0 +1,44 @@ +local lang = jarvis.context.language +local cmd_id = jarvis.context.command_id + +local ps = [[ +$ErrorActionPreference = 'Stop' +try { + $cur = (Get-WmiObject -Namespace root\WMI -Class WmiMonitorBrightness -ErrorAction Stop).CurrentBrightness +} catch { + Write-Output 'no_wmi' + exit 0 +} +$target = switch ('__OP__') { + 'up' { [Math]::Min(100, $cur + 20) } + 'down' { [Math]::Max(0, $cur - 20) } + 'max' { 100 } + 'min' { 10 } + default { $cur } +} +$m = Get-WmiObject -Namespace root\WMI -Class WmiMonitorBrightnessMethods +$m.WmiSetBrightness(1, $target) | Out-Null +Write-Output $target +]] + +local op = ({ + brightness_up = "up", brightness_down = "down", + brightness_max = "max", brightness_min = "min", +})[cmd_id] or "up" + +local script = ps:gsub("__OP__", op) +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', script:gsub('"', '\\"')) +local res = jarvis.system.exec(cmd) +local out = (res.stdout or ""):gsub("[\r\n]+$", ""):gsub("^%s+", "") + +if out == "no_wmi" or not res.success then + jarvis.system.notify("Brightness", + lang == "ru" and "Не поддерживается на этом дисплее" + or "Not supported on this display") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.system.notify(lang == "ru" and "Яркость" or "Brightness", out .. "%") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/network/command.toml b/resources/commands/network/command.toml new file mode 100644 index 0000000..d6cf1af --- /dev/null +++ b/resources/commands/network/command.toml @@ -0,0 +1,37 @@ +[[commands]] +id = "open_wifi_settings" +type = "lua" +script = "open.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["настройки wifi", "настройки вайфай", "wifi настройки", "сеть"] +en = ["wifi settings", "open wifi"] +ua = ["налаштування wi-fi"] + + +[[commands]] +id = "open_bluetooth_settings" +type = "lua" +script = "open.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["настройки блютуз", "блютуз", "включи блютуз", "выключи блютуз"] +en = ["bluetooth settings", "open bluetooth", "toggle bluetooth"] +ua = ["налаштування bluetooth"] + + +[[commands]] +id = "my_ip" +type = "lua" +script = "ip.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["мой ай пи", "мой ip", "какой у меня айпи", "айпишник"] +en = ["my ip", "what is my ip"] +ua = ["мій ip"] diff --git a/resources/commands/network/ip.lua b/resources/commands/network/ip.lua new file mode 100644 index 0000000..0e5635a --- /dev/null +++ b/resources/commands/network/ip.lua @@ -0,0 +1,24 @@ +local lang = jarvis.context.language + +local ps = [[ +$lan = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | + Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.PrefixOrigin -eq 'Manual' } | + Where-Object { $_.IPAddress -notmatch '^127\.' -and $_.IPAddress -notmatch '^169\.254\.' } | + Select-Object -First 1).IPAddress +if (-not $lan) { $lan = '(none)' } +$wan = try { (Invoke-RestMethod -Uri 'https://api.ipify.org?format=json' -TimeoutSec 5).ip } catch { 'unavailable' } +Write-Output ('LAN ' + $lan + ' WAN ' + $wan) +]] +local res = jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) +local out = (res.stdout or ""):gsub("[\r\n]+$", "") + +if out == "" then + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.system.notify("IP", out) +local say = out:gsub("LAN", lang == "ru" and "Локальный" or "LAN"):gsub("WAN", lang == "ru" and ". Внешний" or ". WAN") +jarvis.speak(say, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/network/open.lua b/resources/commands/network/open.lua new file mode 100644 index 0000000..f7334e3 --- /dev/null +++ b/resources/commands/network/open.lua @@ -0,0 +1,16 @@ +local cmd_id = jarvis.context.command_id +local lang = jarvis.context.language + +local uris = { + open_wifi_settings = "ms-settings:network-wifi", + open_bluetooth_settings = "ms-settings:bluetooth", +} + +local uri = uris[cmd_id] or "ms-settings:network" +jarvis.system.open(uri) +jarvis.system.notify( + cmd_id == "open_wifi_settings" and "Wi-Fi" or "Bluetooth", + lang == "ru" and "Открываю настройки" or "Opening settings" +) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/spelling/command.toml b/resources/commands/spelling/command.toml new file mode 100644 index 0000000..7e08b05 --- /dev/null +++ b/resources/commands/spelling/command.toml @@ -0,0 +1,21 @@ +[[commands]] +id = "spell_out" +type = "lua" +script = "spell.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "произнеси по буквам", + "продиктуй по буквам", + "произнеси буквы", + "по буквам", +] +en = [ + "spell out", + "spell it", +] +ua = [ + "вимов по буквах", +] diff --git a/resources/commands/spelling/spell.lua b/resources/commands/spelling/spell.lua new file mode 100644 index 0000000..fbf6b6f --- /dev/null +++ b/resources/commands/spelling/spell.lua @@ -0,0 +1,29 @@ +local lang = jarvis.context.language +local triggers = { + "продиктуй по буквам", "произнеси по буквам", "произнеси буквы", "по буквам", + "spell out", "spell it", + "вимов по буквах", +} +local word = jarvis.text.strip_trigger(jarvis.context.phrase or "", triggers) +if word == "" then + jarvis.system.notify("Spelling", lang == "ru" and "Что произнести?" or "What to spell?") + jarvis.audio.play_error() + return { chain = false } +end + +local letters = {} +for ch in word:gmatch(".") do + if ch ~= " " then table.insert(letters, ch) end +end +if #letters == 0 then + jarvis.audio.play_error() + return { chain = false } +end + +local spoken = table.concat(letters, ". ") .. "." + +jarvis.system.notify(lang == "ru" and "По буквам" or "Spelling", + word .. "\n" .. spoken) +jarvis.speak(spoken, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/summarize/command.toml b/resources/commands/summarize/command.toml new file mode 100644 index 0000000..a0fe93a --- /dev/null +++ b/resources/commands/summarize/command.toml @@ -0,0 +1,25 @@ +[[commands]] +id = "summarize_selection" +type = "lua" +script = "summarize.lua" +sandbox = "full" +timeout = 25000 + +[commands.phrases] +ru = [ + "суммируй", + "перескажи выделенное", + "кратко перескажи", + "о чём это", + "что выделил", + "перескажи это", +] +en = [ + "summarise this", + "summarize selection", + "tldr", +] +ua = [ + "перекажи це", + "стисло перекажи", +] diff --git a/resources/commands/summarize/summarize.lua b/resources/commands/summarize/summarize.lua new file mode 100644 index 0000000..9e7a247 --- /dev/null +++ b/resources/commands/summarize/summarize.lua @@ -0,0 +1,43 @@ +local lang = jarvis.context.language + +local copy_ps = [[Add-Type -Name K -Namespace W -MemberDefinition '[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte sc, uint flags, System.UIntPtr extra);'; [W.K]::keybd_event(0x11,0,0,[UIntPtr]::Zero); [W.K]::keybd_event(0x43,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 40; [W.K]::keybd_event(0x43,0,2,[UIntPtr]::Zero); [W.K]::keybd_event(0x11,0,2,[UIntPtr]::Zero); Start-Sleep -Milliseconds 220]] +jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', copy_ps:gsub('"', '\\"'))) + +local raw = jarvis.system.clipboard.get() or "" +local text = raw:gsub("^%s+", ""):gsub("%s+$", "") + +if #text < 20 then + jarvis.system.notify("Summarize", + lang == "ru" and "Сначала выдели текст и повтори" or "Select text first, then retry") + jarvis.audio.play_not_found() + return { chain = false } +end + +if #text > 8000 then + text = text:sub(1, 8000) .. " ..." +end + +local sys = lang == "ru" + and "Перескажи следующий текст одним абзацем (3-5 предложений). Сохрани ключевые факты и имена, без 'вот суммарно' и других вступлений." + or "Summarise the following text in 3-5 sentences. Keep key facts and names, no preamble." + +local reply = jarvis.llm( + { { role = "system", content = sys }, + { role = "user", content = text } }, + { max_tokens = 400, temperature = 0.3 } +) + +if not reply or reply == "" then + jarvis.system.notify("Summarize", lang == "ru" and "LLM не ответила" or "LLM failed") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.system.clipboard.set(reply) +jarvis.system.notify( + lang == "ru" and "Кратко" or "TL;DR", + reply:sub(1, 280) +) +jarvis.speak(reply, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/window_switch/command.toml b/resources/commands/window_switch/command.toml new file mode 100644 index 0000000..51bf570 --- /dev/null +++ b/resources/commands/window_switch/command.toml @@ -0,0 +1,24 @@ +[[commands]] +id = "switch_to_window" +type = "lua" +script = "switch.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "переключись на", + "переключи на", + "активируй окно", + "покажи окно", + "перейди в", +] +en = [ + "switch to", + "activate window", + "focus window", +] +ua = [ + "перемкни на", + "активуй вікно", +] diff --git a/resources/commands/window_switch/switch.lua b/resources/commands/window_switch/switch.lua new file mode 100644 index 0000000..b03f61d --- /dev/null +++ b/resources/commands/window_switch/switch.lua @@ -0,0 +1,51 @@ +local lang = jarvis.context.language +local triggers = { + "переключись на", "переключи на", "активируй окно", "покажи окно", "перейди в", + "switch to", "activate window", "focus window", + "перемкни на", "активуй вікно", +} +local query = jarvis.text.strip_trigger(jarvis.context.phrase or "", triggers):lower() +if query == "" then + jarvis.system.notify("Switch", lang == "ru" and "Какое окно?" or "Which window?") + jarvis.audio.play_error() + return { chain = false } +end + +local aliases = { + ["хром"] = "chrome", ["хрома"] = "chrome", + ["едж"] = "msedge", ["эдж"] = "msedge", + ["файрфокс"] = "firefox", ["фаерфокс"] = "firefox", + ["вскод"] = "code", ["vs code"] = "code", + ["дискорд"] = "discord", + ["телега"] = "telegram", ["телеграм"] = "telegram", + ["стим"] = "steam", + ["обс"] = "obs64", + ["проводник"] = "explorer", + ["блокнот"] = "notepad", + ["терминал"] = "windowsterminal", + ["спотифай"] = "spotify", + ["яндекс"] = "browser", + ["браузер"] = "browser", +} +local target = aliases[query] or query + +local ps = string.format([[ +$procs = Get-Process | Where-Object { $_.MainWindowTitle -and ($_.ProcessName -like '*%s*' -or $_.MainWindowTitle -like '*%s*') } | Sort-Object -Property StartTime -Descending +if ($procs.Count -eq 0) { Write-Output 'NOT_FOUND'; exit 0 } +$p = $procs[0] +$wsh = New-Object -ComObject WScript.Shell +$null = $wsh.AppActivate($p.Id) +Write-Output $p.MainWindowTitle +]], target:gsub("'", ""), target:gsub("'", "")) + +local res = jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) +local out = (res.stdout or ""):gsub("[\r\n]+$", ""):gsub("^%s+", "") + +if out == "NOT_FOUND" or out == "" then + jarvis.system.notify("Switch", (lang == "ru" and "Не нашёл: " or "Not found: ") .. query) + jarvis.audio.play_not_found() +else + jarvis.system.notify(lang == "ru" and "Переключаюсь" or "Switching", out:sub(1, 80)) + jarvis.audio.play_ok() +end +return { chain = false }