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).
This commit is contained in:
Bossiara13 2026-05-15 15:51:24 +03:00
parent 12b1ed4ccb
commit 45243c3e3c
22 changed files with 895 additions and 16 deletions

View file

@ -0,0 +1,35 @@
# Stocks — query Moscow Exchange (MOEX) for share price.
# API: https://iss.moex.com/iss/engines/stock/markets/shares/securities/<TICKER>.json
# Free, no key, public.
[[commands]]
id = "stocks_quote"
type = "lua"
script = "quote.lua"
sandbox = "full"
timeout = 10000
[commands.phrases]
ru = [
"сколько сейчас стоит",
"цена акций",
"котировка",
"сколько стоит сбер",
"сколько стоит газпром",
"сколько стоит лукойл",
"сколько стоит яндекс",
"сколько стоит тинькофф",
"сколько стоит втб",
"сколько стоит магнит",
"сколько стоит мтс",
"сколько стоит роснефть",
]
en = [
"stock price",
"share price",
"quote for",
]
ua = [
"ціна акцій",
"котирування",
]

View file

@ -0,0 +1,119 @@
-- "Сколько сейчас Сбер" / "котировка газпром" — query MOEX ISS public API.
local phrase = (jarvis.context.phrase or ""):lower()
-- Common name → ticker map.
local TICKERS = {
["сбер"] = "SBER", ["сбербанк"] = "SBER",
["газпром"] = "GAZP",
["лукойл"] = "LKOH",
["яндекс"] = "YDEX", ["yandex"] = "YDEX",
["вк"] = "VKCO", ["вконтакте"] = "VKCO",
["втб"] = "VTBR",
["магнит"] = "MGNT",
["мтс"] = "MTSS",
["роснефть"] = "ROSN",
["новатэк"] = "NVTK",
["норникель"] = "GMKN", ["норильск"] = "GMKN",
["полюс"] = "PLZL",
["тинькофф"] = "T", ["тбанк"] = "T", ["т банк"] = "T",
["сургутнефтегаз"] = "SNGS",
["мосбиржа"] = "MOEX", ["биржа"] = "MOEX",
["x5"] = "X5", ["икс пять"] = "X5",
["алроса"] = "ALRS",
["русал"] = "RUAL",
["мечел"] = "MTLR",
["фосагро"] = "PHOR",
["сегежа"] = "SGZH",
["белуга"] = "BELU",
}
-- find ticker
local ticker, ticker_name
for name, tk in pairs(TICKERS) do
if phrase:find(name, 1, true) then
ticker = tk
ticker_name = name
break
end
end
-- Allow direct ticker mention "сколько SBER" etc.
if not ticker then
local maybe = phrase:upper():match("([A-Z][A-Z][A-Z]+)")
if maybe then ticker = maybe; ticker_name = maybe end
end
if not ticker then
jarvis.speak("Не понял какую бумагу. Скажите название.")
jarvis.audio.play_error()
return { chain = false }
end
local url = string.format(
"https://iss.moex.com/iss/engines/stock/markets/shares/securities/%s.json?iss.meta=off",
ticker
)
local data = jarvis.http.json(url)
if not data then
jarvis.speak("Не получилось получить котировки.")
jarvis.audio.play_error()
return { chain = false }
end
-- MOEX ISS returns parallel arrays: columns + data rows. We want marketdata.LAST.
local md = data.marketdata
if not md or not md.columns or not md.data then
jarvis.speak("Биржа вернула пустой ответ.")
jarvis.audio.play_error()
return { chain = false }
end
local cols = md.columns
local rows = md.data
-- find indexes
local idx_last, idx_change, idx_board
for i, c in ipairs(cols) do
if c == "LAST" then idx_last = i end
if c == "LASTTOPREVPRICE" then idx_change = i end
if c == "BOARDID" then idx_board = i end
end
-- pick the TQBR (main board) row if present, else first non-nil LAST
local last, change
for _, row in ipairs(rows) do
local board = idx_board and row[idx_board]
if board == "TQBR" and row[idx_last] then
last = row[idx_last]
change = idx_change and row[idx_change]
break
end
end
if not last then
for _, row in ipairs(rows) do
if row[idx_last] then
last = row[idx_last]
change = idx_change and row[idx_change]
break
end
end
end
if not last then
jarvis.speak("По " .. ticker_name .. " сегодня нет торгов.")
jarvis.audio.play_not_found()
return { chain = false }
end
local rub = string.format("%.2f", last):gsub("%.", " и ")
local line = string.format("%s — %s рубля.", ticker_name, rub)
if change and tonumber(change) then
local pct = tonumber(change)
local sign = pct >= 0 and "плюс" or "минус"
line = line .. string.format(" Сегодня %s %.2f процента.", sign, math.abs(pct))
end
jarvis.speak(line)
jarvis.audio.play_ok()
return { chain = false }