arch + features: Lua jarvis.speak() / jarvis.llm() API, /commands GUI page, games / mouse / random_choice packs

Architecture — DRY at the Lua API surface:

  lua/api/tts.rs (new): jarvis.speak(text, opts?). opts.lang = ISO
  two-letter code (default "ru"); auto-picks first installed voice
  matching the culture. opts.async = true|false (default true). The
  9 packs that previously inlined a 60-character PS-SAPI string for
  every spoken reply can now reduce to one line. Refactored fun/,
  dice/, clipboard_read/ as proof — fun/ask.lua went from 75 lines
  of HTTP+SAPI boilerplate to ~28 lines of intent code.

  lua/api/llm.rs (new): jarvis.llm(messages, opts?). messages is a
  list of {role, content} tables, opts.max_tokens / .temperature /
  .top_p. Returns the assistant string or nil. Gated by sandbox
  allows_http() (so Standard+ packs only). Internally uses the
  existing jarvis_core::llm::LlmClient::complete_with, no duplicated
  HTTP plumbing.

  llm/client.rs: split complete() into complete_with(messages,
  max_tokens, temperature, top_p) for the new caller; old complete()
  is now a thin wrapper at temp=0.7 top_p=1.0 for backward compat.

GUI — /commands page rewrite:

  Replaces the "Раздел в разработке" placeholder. Calls Tauri command
  get_commands_list (already existed in tauri_commands/commands.rs,
  was wired but unused). Renders a search-filterable card grid:
    - cmd id (monospace)
    - type badge with colour by kind (lua=blue, ahk=red, cli=grey,
      voice=green, terminate=red, stop_chaining=violet)
    - sandbox badge
    - description line (if non-empty)
    - all phrases for currentLanguage, fallback to en, fallback to
      first available; each phrase in a small inline pill
  Toolbar: text input with magnifier icon + reload button (↻).
  Counter line shows visible/total + "Скажи Джарвис + любую фразу"
  hint. Counts down as you type the filter.

New packs (3, total 33 → 36):

  games/ — launch_game / list_games. Reads or creates
  %USERPROFILE%\Documents\jarvis-games.json (the sample preloads dota,
  cs2, elden ring, witcher 3, factorio + minecraft launcher path +
  fortnite epic URI). Trigger-strip + fuzzy name/alias match, then
  launches via steam://rungameid/N (Steam), epic:// (Epic Games), or
  start "" "path" (anything else). list_games speaks the configured
  library.

  mouse/ — left/right/middle/double click + scroll up/down. Single
  dispatch.lua + _mouse_helper.ps1. PS P/Invokes user32!mouse_event
  with the right flag pair (LEFTDOWN+LEFTUP / etc.), 30ms gap, doubles
  do two LEFT cycles with a 50ms gap, wheel uses ±360 ticks.

  random_choice/ — "выбери из X или Y или Z". Strips the trigger,
  splits on " или " / " or " / ", " / " либо " / " чи ", math.random
  picks one, speaks "Я выбираю N".

cargo test commands::tests still 3/3. 36 packs verified via
jarvis-gui startup log.
This commit is contained in:
Bossiara13 2026-05-15 12:58:16 +03:00
parent 9d8b917149
commit a16d2401e7
17 changed files with 765 additions and 83 deletions

View file

@ -17,17 +17,10 @@ if #to_speak > preview_chars then
to_speak = to_speak:sub(1, preview_chars) .. (lang == "ru" and " ... и так далее" or " ... and so on")
end
local sapi_text = to_speak: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_text
)
local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"'))
jarvis.system.exec(cmd)
jarvis.system.notify(
lang == "ru" and "Буфер" or "Clipboard",
text:sub(1, 120)
)
jarvis.speak(to_speak, { lang = lang })
jarvis.audio.play_ok()
return { chain = false }

View file

@ -23,13 +23,6 @@ else
end
jarvis.system.notify(title, text)
local sapi = say:gsub("'", "''")
local sps = 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"', sps:gsub('"', '\\"')))
jarvis.speak(say, { lang = lang })
jarvis.audio.play_ok()
return { chain = false }

View file

@ -16,55 +16,24 @@ local prompts = {
or "Compliment the user briefly, in JARVIS-the-butler tone.",
}
local sys = prompts[cmd_id] or prompts.joke
local token = jarvis.system.env("GROQ_TOKEN")
if not token or token == "" then
jarvis.system.notify("Fun", "GROQ_TOKEN не задан")
jarvis.audio.play_error()
return { chain = false }
end
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
math.randomseed(os.time() + jarvis.context.time.second * 7919)
local seed_word = (math.random(1, 9999)) .. ""
local payload = {
model = model,
messages = {
{ role = "system", content = sys },
{ role = "user", content = "seed " .. seed_word },
local reply = jarvis.llm(
{
{ role = "system", content = prompts[cmd_id] or prompts.joke },
{ role = "user", content = "seed " .. tostring(math.random(1, 9999)) },
},
max_tokens = 220,
temperature = 1.0,
}
local res = jarvis.http.post_json(base .. "/chat/completions", payload, { Authorization = "Bearer " .. token })
{ max_tokens = 220, temperature = 1.0 }
)
if not res.ok then
jarvis.system.notify("Fun", "Groq не ответил")
if not reply or reply == "" then
jarvis.system.notify("Fun", lang == "ru" and "Не получилось" or "Failed")
jarvis.audio.play_error()
return { chain = false }
end
local content = (res.body or ""):match('"content"%s*:%s*"(.-[^\\])"')
if not content then
jarvis.audio.play_error()
return { chain = false }
end
content = content:gsub('\\n', ' '):gsub('\\"', '"'):gsub('\\\\', '\\'):gsub("%s+", " ")
content = content:gsub("^%s+", ""):gsub("%s+$", "")
local titles = { joke = "Анекдот", fact = "Факт", quote = "Цитата", compliment = "Комплимент" }
jarvis.system.notify(titles[cmd_id] or "Fun", content:sub(1, 240))
local sapi = content: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.system.notify(titles[cmd_id] or "Fun", reply:sub(1, 240))
jarvis.speak(reply, { lang = lang })
jarvis.audio.play_ok()
return { chain = false }

View file

@ -0,0 +1,41 @@
[[commands]]
id = "launch_game"
type = "lua"
script = "launch.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"запусти игру",
"включи игру",
"поиграем",
"хочу поиграть",
"запусти доту",
"включи кс",
"включи фортнайт",
"включи майнкрафт",
]
en = [
"launch game",
"play game",
"launch dota",
"launch cs",
]
ua = [
"запусти гру",
"увімкни гру",
]
[[commands]]
id = "list_games"
type = "lua"
script = "list.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["какие у меня игры", "список игр", "покажи игры"]
en = ["list games", "show games"]
ua = ["які в мене ігри"]

View file

@ -0,0 +1,110 @@
local lang = jarvis.context.language
local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public"
local config_path = userprofile .. "\\Documents\\jarvis-games.json"
if not jarvis.fs.exists(config_path) then
local sample = [[[
{"name": "dota", "aliases": ["доту", "доту 2", "dota 2"], "steam_appid": 570},
{"name": "cs2", "aliases": ["кс", "контру", "контр страйк"], "steam_appid": 730},
{"name": "elden ring", "aliases": ["элден", "элден ринг"], "steam_appid": 1245620},
{"name": "witcher 3", "aliases": ["ведьмак", "ведьмака 3"], "steam_appid": 292030},
{"name": "factorio", "aliases": ["факторио"], "steam_appid": 427520},
{"name": "minecraft", "aliases": ["майнкрафт", "майн"], "path": "C:\\Program Files (x86)\\Minecraft Launcher\\MinecraftLauncher.exe"},
{"name": "fortnite", "aliases": ["фортнайт"], "epic_uri": "com.epicgames.launcher://apps/Fortnite?action=launch&silent=true"}
]
]]
jarvis.fs.write(config_path, sample)
jarvis.system.notify(
lang == "ru" and "Игры" or "Games",
lang == "ru" and "Создан шаблон ~/Documents/jarvis-games.json — добавь свои игры и повтори"
or "Template at Documents/jarvis-games.json — edit and retry"
)
jarvis.system.open(config_path)
jarvis.audio.play_not_found()
return { chain = false }
end
local content = jarvis.fs.read(config_path)
local entries = {}
local cursor = 1
while true do
local s, e, block = content:find("({[^{}]+})", cursor)
if not s then break end
cursor = e + 1
local name = block:match('"name"%s*:%s*"([^"]+)"')
if name then
local entry = { name = name, name_lower = name:lower(), aliases = {} }
for alias in block:gmatch('"([^"]+)"') do
entry.aliases[#entry.aliases + 1] = alias:lower()
end
entry.steam_appid = tonumber(block:match('"steam_appid"%s*:%s*(%d+)'))
entry.path = block:match('"path"%s*:%s*"([^"]+)"')
entry.epic_uri = block:match('"epic_uri"%s*:%s*"([^"]+)"')
table.insert(entries, entry)
end
end
if #entries == 0 then
jarvis.system.notify("Games", lang == "ru" and "Пусто в конфиге" or "Empty config")
jarvis.audio.play_not_found()
return { chain = false }
end
local phrase = (jarvis.context.phrase or ""):lower()
local triggers = {
"запусти игру", "включи игру", "хочу поиграть", "поиграем", "запусти", "включи",
"launch game", "play game", "launch",
"запусти гру", "увімкни гру",
}
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+$", "")
local function name_matches(e, q)
if e.name_lower == q then return true end
if string.find(e.name_lower, q, 1, true) then return true end
for _, a in ipairs(e.aliases) do
if a == q or string.find(q, a, 1, true) then return true end
end
return false
end
local match
for _, e in ipairs(entries) do
if name_matches(e, query) then match = e; break end
end
if not match then
jarvis.system.notify("Games", (lang == "ru" and "Не нашёл: " or "Not found: ") .. query)
jarvis.audio.play_not_found()
return { chain = false }
end
local launched = false
if match.steam_appid then
jarvis.system.open("steam://rungameid/" .. tostring(match.steam_appid))
launched = true
elseif match.epic_uri then
jarvis.system.open(match.epic_uri)
launched = true
elseif match.path and jarvis.fs.exists(match.path) then
jarvis.system.exec(string.format('start "" "%s"', match.path))
launched = true
end
if launched then
jarvis.system.notify(lang == "ru" and "Запускаю" or "Launching", match.name)
jarvis.speak((lang == "ru" and "Запускаю " or "Launching ") .. match.name .. ".", { lang = lang })
jarvis.audio.play_ok()
else
jarvis.system.notify("Games", (lang == "ru" and "Не настроен запуск: " or "No launch info: ") .. match.name)
jarvis.audio.play_error()
end
return { chain = false }

View file

@ -0,0 +1,26 @@
local lang = jarvis.context.language
local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public"
local config_path = userprofile .. "\\Documents\\jarvis-games.json"
if not jarvis.fs.exists(config_path) then
jarvis.system.notify("Games", lang == "ru"
and "Конфиг не создан — скажи «запусти игру» один раз" or "No config yet")
jarvis.audio.play_not_found()
return { chain = false }
end
local names = {}
for name in jarvis.fs.read(config_path):gmatch('"name"%s*:%s*"([^"]+)"') do
table.insert(names, name)
end
if #names == 0 then
jarvis.system.notify("Games", "Empty")
jarvis.audio.play_not_found()
return { chain = false }
end
local body = table.concat(names, ", ")
jarvis.system.notify((lang == "ru" and "Игр: " or "Games: ") .. #names, body)
jarvis.speak((lang == "ru" and "В библиотеке: " or "Library: ") .. body, { lang = lang })
jarvis.audio.play_ok()
return { chain = false }

View file

@ -0,0 +1,33 @@
param(
[Parameter(Mandatory=$true)]
[ValidateSet("left","right","middle","double","scroll_up","scroll_down")]
[string]$Action
)
Add-Type -Name MouseM -Namespace Win32 -MemberDefinition @'
[DllImport("user32.dll")]
public static extern void mouse_event(uint flags, int dx, int dy, int data, System.UIntPtr extra);
'@
$LEFTDOWN = 0x0002
$LEFTUP = 0x0004
$RIGHTDOWN = 0x0008
$RIGHTUP = 0x0010
$MIDDOWN = 0x0020
$MIDUP = 0x0040
$WHEEL = 0x0800
function Click([uint32]$down, [uint32]$up) {
[Win32.MouseM]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 30
[Win32.MouseM]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
}
switch ($Action) {
"left" { Click $LEFTDOWN $LEFTUP }
"right" { Click $RIGHTDOWN $RIGHTUP }
"middle" { Click $MIDDOWN $MIDUP }
"double" { Click $LEFTDOWN $LEFTUP; Start-Sleep -Milliseconds 50; Click $LEFTDOWN $LEFTUP }
"scroll_up" { [Win32.MouseM]::mouse_event($WHEEL, 0, 0, 360, [UIntPtr]::Zero) }
"scroll_down" { [Win32.MouseM]::mouse_event($WHEEL, 0, 0, -360, [UIntPtr]::Zero) }
}

View file

@ -0,0 +1,63 @@
[[commands]]
id = "mouse_left_click"
type = "lua"
script = "dispatch.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["клик", "кликни", "нажми мышкой"]
en = ["click", "left click"]
ua = ["клік", "клікни"]
[[commands]]
id = "mouse_right_click"
type = "lua"
script = "dispatch.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["правый клик", "клик правой", "правой кнопкой"]
en = ["right click"]
ua = ["правий клік"]
[[commands]]
id = "mouse_double_click"
type = "lua"
script = "dispatch.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["двойной клик", "дабл клик", "двойное нажатие"]
en = ["double click"]
ua = ["подвійний клік"]
[[commands]]
id = "mouse_scroll_down"
type = "lua"
script = "dispatch.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["промотай вниз", "прокрути вниз", "вниз промотай"]
en = ["scroll down"]
ua = ["прокрути вниз"]
[[commands]]
id = "mouse_scroll_up"
type = "lua"
script = "dispatch.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["промотай вверх", "прокрути вверх", "наверх промотай"]
en = ["scroll up"]
ua = ["прокрути вгору"]

View file

@ -0,0 +1,31 @@
local cmd_id = jarvis.context.command_id
local helper = jarvis.context.command_path .. "\\_mouse_helper.ps1"
local actions = {
mouse_left_click = "left",
mouse_right_click = "right",
mouse_middle_click = "middle",
mouse_double_click = "double",
mouse_scroll_up = "scroll_up",
mouse_scroll_down = "scroll_down",
}
local action = actions[cmd_id]
if not action then
jarvis.audio.play_error()
return { chain = false }
end
local res = jarvis.system.exec(string.format(
'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action %s',
helper, action
))
if res.success then
jarvis.audio.play_ok()
else
jarvis.log("error", "mouse " .. action .. " failed: " .. tostring(res.stderr))
jarvis.audio.play_error()
end
return { chain = false }

View file

@ -0,0 +1,23 @@
[[commands]]
id = "random_choice"
type = "lua"
script = "pick.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = [
"выбери из",
"выбери",
"решай за меня",
"выбрать",
]
en = [
"pick from",
"choose from",
"decide between",
]
ua = [
"обери з",
"вибери",
]

View file

@ -0,0 +1,45 @@
local lang = jarvis.context.language
local phrase = (jarvis.context.phrase or ""):lower()
local triggers = { "выбери из", "выбери", "решай за меня", "выбрать",
"pick from", "choose from", "decide between",
"обери з", "вибери" }
local rest = phrase
for _, t in ipairs(triggers) do
local s, e = string.find(rest, t, 1, true)
if s == 1 then
rest = rest:sub(e + 1)
break
end
end
rest = rest:gsub("^%s+", ""):gsub("%s+$", "")
local separators = { " или ", " or ", ", ", " либо ", " чи " }
local options = nil
for _, sep in ipairs(separators) do
local pattern = sep:gsub("([%-%.%+%[%]%(%)%$%^%%%?%*])", "%%%1")
if rest:find(pattern) then
options = {}
for token in (rest .. sep):gmatch("(.-)" .. pattern) do
token = token:gsub("^%s+", ""):gsub("%s+$", "")
if token ~= "" then table.insert(options, token) end
end
break
end
end
if not options or #options < 2 then
jarvis.system.notify("Choice", lang == "ru"
and "Скажи: выбери из X или Y или Z"
or "Say: pick from X or Y or Z")
jarvis.audio.play_error()
return { chain = false }
end
math.randomseed(os.time() + math.floor(jarvis.context.time.second * 7919))
local pick = options[math.random(1, #options)]
jarvis.system.notify(lang == "ru" and "Выбираю" or "Choice", pick)
jarvis.speak((lang == "ru" and "Я выбираю " or "I pick ") .. pick, { lang = lang })
jarvis.audio.play_ok()
return { chain = false }