J.A.R.V.I.S-rust/resources/commands/currency/convert.lua
Bossiara13 45243c3e3c feat: Ollama LLM backend + 5 utility packs (daily briefing, pomodoro, currency convert, stocks, habits, translate clipboard)
Local-LLM support so J.A.R.V.I.S. works offline without GROQ_TOKEN.

LLM multi-backend (crates/jarvis-core/src/llm/client.rs)
  - LlmBackend enum {Groq, Ollama}. Both use OpenAI-compatible /v1/chat/completions
    (Ollama exposes one natively at :11434/v1).
  - LlmClient::from_env() now dispatches on JARVIS_LLM=groq|ollama. Auto-detect:
    Groq if GROQ_TOKEN set, else Ollama.
  - New helpers: LlmClient::groq() (was: from_env), LlmClient::ollama().
  - Reqwest client now has a 60s timeout (was: unbounded — Groq freezes could hang).
  - api_key skipped when empty (Ollama doesn't need auth).
  - Defaults: OLLAMA_BASE_URL=http://localhost:11434/v1, OLLAMA_MODEL=qwen2.5:3b.
    Override via OLLAMA_BASE_URL / OLLAMA_MODEL env.
  - llm_fallback + llm_router log the active backend on init.
  - Tests: 32/32 pass. Renamed `from_env_fails` → `groq_fails_when_token_missing`,
    added `ollama_works_without_token`.

Daily briefing pack (resources/commands/daily_briefing/, 3 commands)
  - setup.lua: "настрой утренний брифинг на 9:00" → adds a daily scheduled task
    that runs now.lua via the scheduler's Lua-action support.
  - now.lua: greeting + time + active profile + scheduled task count + memory recap
    + LLM motivational nudge. Voice-triggered ("утренний брифинг") works the same.
  - off.lua: removes the scheduled task.

Pomodoro pack (resources/commands/pomodoro/, 2 commands)
  - start.lua / stop.lua + tick.lua (re-scheduled by itself). State stored in
    jarvis.state — phase alternates work (25 min) / break (5 min) until stop.

Currency conversion (resources/commands/currency/convert.lua)
  - "сколько будет 1000 долларов в рублях" — fetches CBR daily rates from
    cbr-xml-daily.ru, normalises by Nominal, prints both directions.
  - Recognises USD/EUR/CNY/GBP/JPY/RUB by substring. Pluralises russian units.

Stocks pack (resources/commands/stocks/, 1 command)
  - "сколько Сбер" → MOEX ISS /iss/engines/stock/markets/shares/securities/<TKR>.
  - Mapping of 22 popular Russian tickers to common names (Сбер→SBER, Газпром→GAZP,
    Лукойл→LKOH, Яндекс→YDEX, Т-банк→T, etc).
  - Picks TQBR (main board) row, reports LAST + LASTTOPREVPRICE%.

Habit nudges (resources/commands/habit_nudge/, 4 commands)
  - "напоминай мне пить воду"     → every 2 hours
  - "напоминай мне размяться"     → every 50 minutes
  - "напоминай отдыхать глазам"  → every 20 minutes (20-20-20 rule)
  - "отключи все привычки"        → stops all three by id.

Translate clipboard (resources/commands/translate/clipboard.lua)
  - "переведи буфер на английский" → grabs clipboard, sends to LLM, speaks first
    sentence, replaces clipboard with full translation, fires notification.

Build: cargo build --release -p jarvis-app -p jarvis-gui both green. 32/32 tests.

To use Ollama:
  1. Install + run Ollama (https://ollama.com).
  2. `ollama pull qwen2.5:3b` (or any chat model).
  3. Optional: `set JARVIS_LLM=ollama` (auto-picked if GROQ_TOKEN unset).
2026-05-15 15:51:24 +03:00

121 lines
4.5 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- "Сколько будет 1000 долларов в рублях" / "переведи 50 евро в доллары"
-- Uses cbr-xml-daily.ru (CBR daily rates, JSON, no key, mirrors RU central bank).
local phrase = (jarvis.context.phrase or ""):lower()
-- Strip trigger words
local body = phrase:gsub("сколько будет", "")
:gsub("переведи", "")
:gsub("конвертируй", "")
:gsub("convert", "")
:gsub("how much is", "")
:gsub("^[%s,:%.]+", "")
:gsub("%s+$", "")
-- Extract amount (digit or russian "сто", "тысяча" — keep simple, digits only)
local amount_str = body:match("([%d%.,]+)")
if not amount_str then
jarvis.speak("Не понял сумму.")
jarvis.audio.play_error()
return { chain = false }
end
local amount = tonumber((amount_str:gsub(",", ".")))
if not amount then
jarvis.speak("Не понял сумму.")
jarvis.audio.play_error()
return { chain = false }
end
-- Detect source + target currencies via heuristic substring matches.
local function detect(text)
if text:find("доллар") or text:find("usd") or text:find("dollar") then return "USD" end
if text:find("евро") or text:find("eur") or text:find("euro") then return "EUR" end
if text:find("юан") or text:find("cny") or text:find("yuan") then return "CNY" end
if text:find("фунт") or text:find("gbp") or text:find("pound") then return "GBP" end
if text:find("иен") or text:find("jpy") or text:find("yen") then return "JPY" end
if text:find("рубл") or text:find("rub") or text:find("ruble") then return "RUB" end
return nil
end
-- Split on the conversion word "в"/"в"/"to"/"into" to detect from/to.
local from_part, to_part
local in_idx = body:find("%s+в%s+") or body:find("%s+to%s+") or body:find("%s+into%s+")
if in_idx then
from_part = body:sub(1, in_idx - 1)
to_part = body:sub(in_idx + 1)
else
-- Heuristic: only one currency named → assume target RUB.
from_part = body
to_part = "рубли"
end
local from_ccy = detect(from_part)
local to_ccy = detect(to_part) or "RUB"
if not from_ccy then
jarvis.speak("Не понял исходную валюту.")
jarvis.audio.play_error()
return { chain = false }
end
-- Fetch CBR rates (RUB-denominated).
local body_json = jarvis.http.get("https://www.cbr-xml-daily.ru/daily_json.js")
if not body_json or body_json == "" then
jarvis.speak("Не получилось получить курсы.")
jarvis.audio.play_error()
return { chain = false }
end
-- We can't safely parse JSON without a Lua JSON lib. Use jarvis.http.json instead.
local data = jarvis.http.json("https://www.cbr-xml-daily.ru/daily_json.js")
if not data or not data.Valute then
jarvis.speak("Не получилось разобрать ответ.")
jarvis.audio.play_error()
return { chain = false }
end
-- Each Valute entry: { Value=rub_per_unit, Nominal=N }. Effective rub_per_unit = Value/Nominal.
local function rub_per(ccy)
if ccy == "RUB" then return 1.0 end
local v = data.Valute[ccy]
if not v then return nil end
return v.Value / v.Nominal
end
local rub_from = rub_per(from_ccy)
local rub_to = rub_per(to_ccy)
if not rub_from or not rub_to then
jarvis.speak("Этой валюты нет в курсах ЦБ.")
jarvis.audio.play_error()
return { chain = false }
end
local result = amount * rub_from / rub_to
-- Format pretty
local function fmt(n)
if n >= 1000 then
return string.format("%d", math.floor(n + 0.5))
elseif n >= 10 then
return string.format("%.1f", n)
else
return string.format("%.2f", n)
end
end
local function unit(ccy, n)
if ccy == "RUB" then return (n == 1 and "рубль" or (n < 5 and "рубля" or "рублей")) end
if ccy == "USD" then return (n == 1 and "доллар" or (n < 5 and "доллара" or "долларов")) end
if ccy == "EUR" then return "евро" end
if ccy == "CNY" then return (n == 1 and "юань" or (n < 5 and "юаня" or "юаней")) end
if ccy == "GBP" then return (n == 1 and "фунт" or (n < 5 and "фунта" or "фунтов")) end
if ccy == "JPY" then return "иен" end
return ccy
end
local result_int = math.floor(result + 0.5)
local line = string.format("%s %s — это %s %s.",
fmt(amount), unit(from_ccy, amount),
fmt(result), unit(to_ccy, result_int))
jarvis.speak(line)
jarvis.audio.play_ok()
return { chain = false }