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:
parent
12b1ed4ccb
commit
45243c3e3c
22 changed files with 895 additions and 16 deletions
|
|
@ -31,7 +31,7 @@ fn build_state() -> Option<State> {
|
|||
let client = match LlmClient::from_env() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("LLM fallback disabled: {}. Set GROQ_TOKEN to enable.", e);
|
||||
warn!("LLM fallback disabled: {}. Set GROQ_TOKEN or run Ollama to enable.", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
|
@ -40,7 +40,7 @@ fn build_state() -> Option<State> {
|
|||
let prompt = config::get_llm_system_prompt(&lang);
|
||||
let history = Mutex::new(ConversationHistory::new(prompt, config::LLM_DEFAULT_MAX_HISTORY));
|
||||
|
||||
info!("LLM fallback enabled (model: {}).", client.model());
|
||||
info!("LLM fallback enabled (backend: {}, model: {}).", client.backend().name(), client.model());
|
||||
Some(State {
|
||||
client,
|
||||
history,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fn build_state() -> Option<State> {
|
|||
let client = match LlmClient::from_env() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("LLM router disabled: {}. Set GROQ_TOKEN to enable.", e);
|
||||
warn!("LLM router disabled: {}. Set GROQ_TOKEN or run Ollama to enable.", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
|
@ -65,7 +65,7 @@ fn build_state() -> Option<State> {
|
|||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(DEFAULT_THRESHOLD);
|
||||
|
||||
info!("LLM router enabled (threshold {:.2}).", threshold);
|
||||
info!("LLM router enabled (backend: {}, threshold {:.2}).", client.backend().name(), threshold);
|
||||
Some(State { client, threshold })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,22 @@ use serde::{Deserialize, Serialize};
|
|||
use std::env;
|
||||
use thiserror::Error;
|
||||
|
||||
// Groq (cloud, fast, free tier).
|
||||
pub const DEFAULT_BASE_URL: &str = "https://api.groq.com/openai/v1";
|
||||
pub const DEFAULT_MODEL: &str = "llama-3.3-70b-versatile";
|
||||
pub const ENV_TOKEN: &str = "GROQ_TOKEN";
|
||||
pub const ENV_BASE_URL: &str = "GROQ_BASE_URL";
|
||||
pub const ENV_MODEL: &str = "GROQ_MODEL";
|
||||
|
||||
// Ollama (local, offline, privacy). Uses OpenAI-compatible /v1 endpoint.
|
||||
pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://localhost:11434/v1";
|
||||
pub const OLLAMA_DEFAULT_MODEL: &str = "qwen2.5:3b";
|
||||
pub const ENV_OLLAMA_BASE_URL: &str = "OLLAMA_BASE_URL";
|
||||
pub const ENV_OLLAMA_MODEL: &str = "OLLAMA_MODEL";
|
||||
|
||||
// Top-level switch: JARVIS_LLM=groq|ollama (default: groq if GROQ_TOKEN set, else ollama).
|
||||
pub const ENV_LLM_BACKEND: &str = "JARVIS_LLM";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("missing environment variable: {0}")]
|
||||
|
|
@ -70,9 +80,25 @@ struct ResponseMessage {
|
|||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LlmBackend {
|
||||
Groq,
|
||||
Ollama,
|
||||
}
|
||||
|
||||
impl LlmBackend {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
LlmBackend::Groq => "groq",
|
||||
LlmBackend::Ollama => "ollama",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LlmClient {
|
||||
backend: LlmBackend,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
api_key: String, // empty for Ollama
|
||||
model: String,
|
||||
http: reqwest::blocking::Client,
|
||||
}
|
||||
|
|
@ -82,20 +108,64 @@ impl LlmClient {
|
|||
base_url: impl Into<String>,
|
||||
api_key: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new_with_backend(LlmBackend::Groq, base_url, api_key, model)
|
||||
}
|
||||
|
||||
pub fn new_with_backend(
|
||||
backend: LlmBackend,
|
||||
base_url: impl Into<String>,
|
||||
api_key: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
base_url: base_url.into(),
|
||||
api_key: api_key.into(),
|
||||
model: model.into(),
|
||||
http: reqwest::blocking::Client::new(),
|
||||
http: reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::blocking::Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
pub fn groq() -> Result<Self, ConfigError> {
|
||||
let api_key = env::var(ENV_TOKEN).map_err(|_| ConfigError::MissingEnv(ENV_TOKEN))?;
|
||||
let base_url = env::var(ENV_BASE_URL).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
|
||||
let model = env::var(ENV_MODEL).unwrap_or_else(|_| DEFAULT_MODEL.to_string());
|
||||
Ok(Self::new(base_url, api_key, model))
|
||||
Ok(Self::new_with_backend(LlmBackend::Groq, base_url, api_key, model))
|
||||
}
|
||||
|
||||
pub fn ollama() -> Self {
|
||||
let base_url = env::var(ENV_OLLAMA_BASE_URL).unwrap_or_else(|_| OLLAMA_DEFAULT_BASE_URL.to_string());
|
||||
let model = env::var(ENV_OLLAMA_MODEL).unwrap_or_else(|_| OLLAMA_DEFAULT_MODEL.to_string());
|
||||
Self::new_with_backend(LlmBackend::Ollama, base_url, "", model)
|
||||
}
|
||||
|
||||
/// Pick a backend based on `JARVIS_LLM` env var; auto-detect if unset.
|
||||
/// Default order: explicit JARVIS_LLM, else Groq if GROQ_TOKEN present, else Ollama.
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
match env::var(ENV_LLM_BACKEND).ok().map(|s| s.trim().to_lowercase()).as_deref() {
|
||||
Some("ollama") => Ok(Self::ollama()),
|
||||
Some("groq") => Self::groq(),
|
||||
Some("") | None => {
|
||||
// Auto: Groq if token present, else Ollama
|
||||
if env::var(ENV_TOKEN).is_ok() {
|
||||
Self::groq()
|
||||
} else {
|
||||
Ok(Self::ollama())
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
log::warn!("Unknown JARVIS_LLM='{}'; falling back to Groq", other);
|
||||
Self::groq()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backend(&self) -> LlmBackend {
|
||||
self.backend
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
|
|
@ -122,12 +192,11 @@ impl LlmClient {
|
|||
top_p,
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.json(&body)
|
||||
.send()?;
|
||||
let mut req = self.http.post(&url).json(&body);
|
||||
if !self.api_key.is_empty() {
|
||||
req = req.bearer_auth(&self.api_key);
|
||||
}
|
||||
let resp = req.send()?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp.text()?;
|
||||
|
|
@ -173,10 +242,10 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_fails_when_token_missing() {
|
||||
fn groq_fails_when_token_missing() {
|
||||
let prev = env::var(ENV_TOKEN).ok();
|
||||
unsafe { env::remove_var(ENV_TOKEN); }
|
||||
let result = LlmClient::from_env();
|
||||
let result = LlmClient::groq();
|
||||
let err = match result {
|
||||
Ok(_) => panic!("expected ConfigError when {ENV_TOKEN} is unset"),
|
||||
Err(e) => e,
|
||||
|
|
@ -189,6 +258,13 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_works_without_token() {
|
||||
let client = LlmClient::ollama();
|
||||
assert_eq!(client.backend(), LlmBackend::Ollama);
|
||||
// Default model unless OLLAMA_MODEL overrides.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_helpers_set_role() {
|
||||
assert_eq!(ChatMessage::user("hi").role, "user");
|
||||
|
|
|
|||
|
|
@ -27,3 +27,36 @@ ua = [
|
|||
"курс євро",
|
||||
"що з доларом",
|
||||
]
|
||||
|
||||
|
||||
# Convert N units of one currency to another, e.g.
|
||||
# "1000 долларов в рубли" → "1000 USD = 99523 ₽"
|
||||
[[commands]]
|
||||
id = "currency_convert"
|
||||
type = "lua"
|
||||
script = "convert.lua"
|
||||
sandbox = "full"
|
||||
timeout = 10000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"сколько будет",
|
||||
"переведи",
|
||||
"конвертируй",
|
||||
"долларов в рубли",
|
||||
"евро в рубли",
|
||||
"долларов в евро",
|
||||
"юаней в рубли",
|
||||
"рублей в доллары",
|
||||
"рублей в евро",
|
||||
]
|
||||
en = [
|
||||
"convert",
|
||||
"how much is",
|
||||
"dollars in rubles",
|
||||
"euros in rubles",
|
||||
]
|
||||
ua = [
|
||||
"переведи",
|
||||
"конвертуй",
|
||||
]
|
||||
|
|
|
|||
121
resources/commands/currency/convert.lua
Normal file
121
resources/commands/currency/convert.lua
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
-- "Сколько будет 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 }
|
||||
54
resources/commands/daily_briefing/command.toml
Normal file
54
resources/commands/daily_briefing/command.toml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Daily briefing — chains time + weather + memory + scheduler at a fixed time.
|
||||
|
||||
[[commands]]
|
||||
id = "daily_briefing.setup"
|
||||
type = "lua"
|
||||
script = "setup.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"настрой утренний брифинг",
|
||||
"включи утренний брифинг",
|
||||
"начни утренний брифинг каждый день",
|
||||
"сделай утренний брифинг",
|
||||
]
|
||||
en = ["set up morning briefing", "enable morning briefing", "daily briefing"]
|
||||
ua = ["налаштуй ранковий брифінг", "увімкни ранковий брифінг"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "daily_briefing.now"
|
||||
type = "lua"
|
||||
script = "now.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 15000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"утренний брифинг",
|
||||
"сделай брифинг",
|
||||
"брифинг сейчас",
|
||||
"что нового",
|
||||
"сводка дня",
|
||||
]
|
||||
en = ["morning briefing", "give me a briefing", "what's new today"]
|
||||
ua = ["ранковий брифінг", "брифінг зараз"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "daily_briefing.off"
|
||||
type = "lua"
|
||||
script = "off.lua"
|
||||
sandbox = "minimal"
|
||||
timeout = 2000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"отключи утренний брифинг",
|
||||
"выключи утренний брифинг",
|
||||
"отмени брифинг",
|
||||
]
|
||||
en = ["disable morning briefing", "cancel morning briefing"]
|
||||
ua = ["вимкни ранковий брифінг"]
|
||||
64
resources/commands/daily_briefing/now.lua
Normal file
64
resources/commands/daily_briefing/now.lua
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
-- The actual briefing body. Called either directly ("утренний брифинг") OR
|
||||
-- by the scheduler at the configured daily time.
|
||||
local t = jarvis.context.time
|
||||
local lang = jarvis.context.language or "ru"
|
||||
|
||||
-- 1) Greeting + time
|
||||
local hour = t.hour
|
||||
local greeting
|
||||
if hour >= 5 and hour < 12 then greeting = "Доброе утро."
|
||||
elseif hour >= 12 and hour < 18 then greeting = "Добрый день."
|
||||
elseif hour >= 18 and hour < 23 then greeting = "Добрый вечер."
|
||||
else greeting = "Сэр." end
|
||||
|
||||
jarvis.speak(greeting .. string.format(" Сейчас %d:%02d.", hour, t.minute))
|
||||
jarvis.sleep(300)
|
||||
|
||||
-- 2) Profile-aware tone
|
||||
local prof = jarvis.profile.active()
|
||||
if prof and prof.name and prof.name ~= "default" then
|
||||
jarvis.speak("Активный режим: " .. prof.name .. ".")
|
||||
jarvis.sleep(200)
|
||||
end
|
||||
|
||||
-- 3) Scheduled tasks ahead today
|
||||
local tasks = jarvis.scheduler.list()
|
||||
if tasks and #tasks > 0 then
|
||||
local count = 0
|
||||
for _, task in ipairs(tasks) do
|
||||
if task.action and task.action.type == "speak" then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
if count > 0 then
|
||||
jarvis.speak(string.format("Запланировано задач: %d.", count))
|
||||
jarvis.sleep(200)
|
||||
end
|
||||
end
|
||||
|
||||
-- 4) Memory recap (most recently used 3)
|
||||
local memos = jarvis.memory.all()
|
||||
if memos and #memos > 0 then
|
||||
local sample = math.min(2, #memos)
|
||||
local line = "Из памяти: "
|
||||
for i = 1, sample do
|
||||
line = line .. memos[i].key .. " — " .. memos[i].value
|
||||
if i < sample then line = line .. "; " end
|
||||
end
|
||||
line = line .. "."
|
||||
jarvis.speak(line)
|
||||
jarvis.sleep(200)
|
||||
end
|
||||
|
||||
-- 5) Closing nudge from LLM if available
|
||||
local nudge = jarvis.llm({
|
||||
{ role = "system", content = "Ты — Джарвис. Одна короткая фраза-мотиватор на день. По-русски. 5-10 слов." },
|
||||
{ role = "user", content = string.format("Сейчас %d часов %02d минут, режим: %s.", hour, t.minute, prof and prof.name or "default") }
|
||||
}, { max_tokens = 60, temperature = 0.8 })
|
||||
|
||||
if nudge and nudge ~= "" then
|
||||
jarvis.speak(nudge)
|
||||
end
|
||||
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
9
resources/commands/daily_briefing/off.lua
Normal file
9
resources/commands/daily_briefing/off.lua
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
local removed = jarvis.scheduler.remove("daily_briefing_main")
|
||||
if removed then
|
||||
jarvis.speak("Утренний брифинг отключен.")
|
||||
jarvis.audio.play_ok()
|
||||
else
|
||||
jarvis.speak("Брифинг и так не был настроен.")
|
||||
jarvis.audio.play_not_found()
|
||||
end
|
||||
return { chain = false }
|
||||
38
resources/commands/daily_briefing/setup.lua
Normal file
38
resources/commands/daily_briefing/setup.lua
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- "Настрой утренний брифинг на 9:00" → adds a daily scheduled task that runs now.lua
|
||||
local phrase = (jarvis.context.phrase or ""):lower()
|
||||
|
||||
-- try to extract HH:MM, default 9:00
|
||||
local h, m = phrase:match("(%d%d?)[:%s%-](%d%d)")
|
||||
if not h then
|
||||
h = phrase:match("(%d%d?)")
|
||||
end
|
||||
local hh = tonumber(h) or 9
|
||||
local mm = tonumber(m) or 0
|
||||
if hh < 0 or hh > 23 or mm < 0 or mm > 59 then hh = 9; mm = 0 end
|
||||
|
||||
local script_path = jarvis.context.command_path .. "/now.lua"
|
||||
script_path = script_path:gsub("/", "\\")
|
||||
|
||||
local id = "daily_briefing_main"
|
||||
|
||||
local ok, err = pcall(function()
|
||||
-- replace any prior briefing
|
||||
jarvis.scheduler.remove(id)
|
||||
jarvis.scheduler.add({
|
||||
id = id,
|
||||
name = "Daily briefing",
|
||||
schedule = string.format("daily %02d:%02d", hh, mm),
|
||||
action = { type = "lua", script_path = script_path },
|
||||
})
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
jarvis.log("warn", "daily_briefing.setup: " .. tostring(err))
|
||||
jarvis.speak("Не получилось настроить брифинг.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
jarvis.speak(string.format("Утренний брифинг настроен на %02d:%02d.", hh, mm))
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
69
resources/commands/habit_nudge/command.toml
Normal file
69
resources/commands/habit_nudge/command.toml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Habit nudges — convenient wrappers for popular recurring reminders.
|
||||
|
||||
[[commands]]
|
||||
id = "habit.water"
|
||||
type = "lua"
|
||||
script = "water.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 3000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"напоминай мне пить воду",
|
||||
"напоминай попить воду",
|
||||
"включи напоминание про воду",
|
||||
]
|
||||
en = ["remind me to drink water", "water reminder"]
|
||||
ua = ["нагадуй мені пити воду"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "habit.stretch"
|
||||
type = "lua"
|
||||
script = "stretch.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 3000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"напоминай мне размяться",
|
||||
"напоминай делать зарядку",
|
||||
"включи напоминание про разминку",
|
||||
"напоминай вставать",
|
||||
]
|
||||
en = ["remind me to stretch", "stretch reminder"]
|
||||
ua = ["нагадуй мені розім'ятися"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "habit.eyes"
|
||||
type = "lua"
|
||||
script = "eyes.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 3000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"напоминай отдыхать глазам",
|
||||
"напоминай про глаза",
|
||||
"20 20 20",
|
||||
]
|
||||
en = ["remind me to rest eyes", "eye reminder"]
|
||||
ua = ["нагадуй про очі"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "habit.stop_all"
|
||||
type = "lua"
|
||||
script = "stop_all.lua"
|
||||
sandbox = "minimal"
|
||||
timeout = 3000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"отключи все привычки",
|
||||
"хватит напоминать про привычки",
|
||||
"выключи все напоминалки",
|
||||
]
|
||||
en = ["stop all habits", "disable habit reminders"]
|
||||
ua = ["вимкни всі звички"]
|
||||
18
resources/commands/habit_nudge/eyes.lua
Normal file
18
resources/commands/habit_nudge/eyes.lua
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- 20-20-20 rule: every 20 min look 20ft away for 20s.
|
||||
jarvis.scheduler.remove("habit_eyes")
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
id = "habit_eyes",
|
||||
name = "20-20-20 eye rest",
|
||||
schedule = "every 20 minutes",
|
||||
action = { type = "speak", text = "Отвлекитесь от экрана на двадцать секунд." }
|
||||
})
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.speak("Не получилось.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
jarvis.speak("Включил правило двадцать двадцать двадцать.")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
14
resources/commands/habit_nudge/stop_all.lua
Normal file
14
resources/commands/habit_nudge/stop_all.lua
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
local removed = 0
|
||||
for _, id in ipairs({ "habit_water", "habit_stretch", "habit_eyes" }) do
|
||||
if jarvis.scheduler.remove(id) then
|
||||
removed = removed + 1
|
||||
end
|
||||
end
|
||||
if removed > 0 then
|
||||
jarvis.speak("Отключил привычек: " .. removed .. ".")
|
||||
jarvis.audio.play_ok()
|
||||
else
|
||||
jarvis.speak("Привычки и так не запущены.")
|
||||
jarvis.audio.play_not_found()
|
||||
end
|
||||
return { chain = false }
|
||||
17
resources/commands/habit_nudge/stretch.lua
Normal file
17
resources/commands/habit_nudge/stretch.lua
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
jarvis.scheduler.remove("habit_stretch")
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
id = "habit_stretch",
|
||||
name = "Stretch break",
|
||||
schedule = "every 50 minutes",
|
||||
action = { type = "speak", text = "Время размяться. Встаньте, пройдитесь." }
|
||||
})
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.speak("Не получилось.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
jarvis.speak("Каждые 50 минут буду напоминать размяться.")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
17
resources/commands/habit_nudge/water.lua
Normal file
17
resources/commands/habit_nudge/water.lua
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
jarvis.scheduler.remove("habit_water")
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
id = "habit_water",
|
||||
name = "Drink water",
|
||||
schedule = "every 2 hours",
|
||||
action = { type = "speak", text = "Попей воды." }
|
||||
})
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.speak("Не получилось.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
jarvis.speak("Каждые 2 часа буду напоминать пить воду.")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
37
resources/commands/pomodoro/command.toml
Normal file
37
resources/commands/pomodoro/command.toml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Pomodoro — 25 min work + 5 min break loop. Voice-controlled.
|
||||
|
||||
[[commands]]
|
||||
id = "pomodoro.start"
|
||||
type = "lua"
|
||||
script = "start.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"запусти помодоро",
|
||||
"включи помодоро",
|
||||
"начни помидор",
|
||||
"запусти таймер помодоро",
|
||||
"помодоро",
|
||||
]
|
||||
en = ["start pomodoro", "begin pomodoro", "pomodoro"]
|
||||
ua = ["запусти помодоро", "увімкни помодоро"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "pomodoro.stop"
|
||||
type = "lua"
|
||||
script = "stop.lua"
|
||||
sandbox = "minimal"
|
||||
timeout = 3000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"останови помодоро",
|
||||
"стоп помодоро",
|
||||
"выключи помодоро",
|
||||
"хватит помодоро",
|
||||
]
|
||||
en = ["stop pomodoro", "cancel pomodoro"]
|
||||
ua = ["зупини помодоро", "вимкни помодоро"]
|
||||
37
resources/commands/pomodoro/start.lua
Normal file
37
resources/commands/pomodoro/start.lua
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- 25 min focus → "перерыв" → 5 min break → "снова работа" → loop, until pomodoro.stop fires.
|
||||
-- Implemented as a chain of scheduled tasks that re-schedule themselves.
|
||||
-- Each cycle: 25 min work, then break notice, then 5 min break, then back to work.
|
||||
|
||||
local pom_work = "pomodoro_work"
|
||||
local pom_break = "pomodoro_break"
|
||||
local script_path = jarvis.context.command_path .. "/tick.lua"
|
||||
script_path = script_path:gsub("/", "\\")
|
||||
|
||||
-- clear any previous
|
||||
jarvis.scheduler.remove(pom_work)
|
||||
jarvis.scheduler.remove(pom_break)
|
||||
|
||||
-- schedule the first "перерыв" notice in 25 min
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
id = pom_work,
|
||||
name = "Pomodoro work end",
|
||||
schedule = "in 25 minutes",
|
||||
action = { type = "lua", script_path = script_path },
|
||||
})
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
jarvis.log("warn", "pomodoro.start: " .. tostring(err))
|
||||
jarvis.speak("Не получилось запустить.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
-- mark the phase so tick.lua knows what to do next
|
||||
jarvis.state.set("pomodoro_phase", "work")
|
||||
jarvis.state.set("pomodoro_started_at", os.time())
|
||||
|
||||
jarvis.speak("Помодоро запущен. 25 минут работы.")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
12
resources/commands/pomodoro/stop.lua
Normal file
12
resources/commands/pomodoro/stop.lua
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
local removed_w = jarvis.scheduler.remove("pomodoro_work")
|
||||
local removed_b = jarvis.scheduler.remove("pomodoro_break")
|
||||
jarvis.state.set("pomodoro_phase", "")
|
||||
|
||||
if removed_w or removed_b then
|
||||
jarvis.speak("Помодоро остановлен.")
|
||||
jarvis.audio.play_ok()
|
||||
else
|
||||
jarvis.speak("Помодоро и так не запущен.")
|
||||
jarvis.audio.play_not_found()
|
||||
end
|
||||
return { chain = false }
|
||||
35
resources/commands/pomodoro/tick.lua
Normal file
35
resources/commands/pomodoro/tick.lua
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- Called by the scheduler when a pomodoro phase ends.
|
||||
-- Flip the phase, speak the cue, schedule the next phase. Run forever
|
||||
-- until pomodoro.stop deletes the scheduled task.
|
||||
|
||||
local pom_work = "pomodoro_work"
|
||||
local pom_break = "pomodoro_break"
|
||||
local script_path = jarvis.context.command_path .. "/tick.lua"
|
||||
script_path = script_path:gsub("/", "\\")
|
||||
|
||||
local phase = jarvis.state.get("pomodoro_phase") or "work"
|
||||
|
||||
if phase == "work" then
|
||||
-- work session just ended → announce break
|
||||
jarvis.speak("Время перерыва. 5 минут отдыха.")
|
||||
jarvis.state.set("pomodoro_phase", "break")
|
||||
jarvis.scheduler.add({
|
||||
id = pom_break,
|
||||
name = "Pomodoro break end",
|
||||
schedule = "in 5 minutes",
|
||||
action = { type = "lua", script_path = script_path },
|
||||
})
|
||||
else
|
||||
-- break ended → back to work
|
||||
jarvis.speak("Перерыв окончен. Возвращаемся к работе. 25 минут.")
|
||||
jarvis.state.set("pomodoro_phase", "work")
|
||||
jarvis.scheduler.add({
|
||||
id = pom_work,
|
||||
name = "Pomodoro work end",
|
||||
schedule = "in 25 minutes",
|
||||
action = { type = "lua", script_path = script_path },
|
||||
})
|
||||
end
|
||||
|
||||
jarvis.audio.play_reply()
|
||||
return { chain = false }
|
||||
35
resources/commands/stocks/command.toml
Normal file
35
resources/commands/stocks/command.toml
Normal 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 = [
|
||||
"ціна акцій",
|
||||
"котирування",
|
||||
]
|
||||
119
resources/commands/stocks/quote.lua
Normal file
119
resources/commands/stocks/quote.lua
Normal 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 }
|
||||
49
resources/commands/translate/clipboard.lua
Normal file
49
resources/commands/translate/clipboard.lua
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- "Переведи буфер на английский" → grab clipboard, translate, speak, put back
|
||||
local phrase = (jarvis.context.phrase or ""):lower()
|
||||
|
||||
-- Detect target language from phrase, default English.
|
||||
local target = "english"
|
||||
if phrase:find("на русск") then target = "русский"
|
||||
elseif phrase:find("на украин") then target = "украинский"
|
||||
elseif phrase:find("на немецк") then target = "немецкий"
|
||||
elseif phrase:find("на французск") then target = "французский"
|
||||
elseif phrase:find("на испанск") then target = "испанский"
|
||||
elseif phrase:find("на китайск") then target = "китайский"
|
||||
elseif phrase:find("на японск") then target = "японский"
|
||||
elseif phrase:find("на польск") then target = "польский"
|
||||
elseif phrase:find("english") then target = "english"
|
||||
elseif phrase:find("russian") then target = "русский"
|
||||
end
|
||||
|
||||
local src = jarvis.system.clipboard.get()
|
||||
if not src or src == "" then
|
||||
jarvis.speak("Буфер пуст.")
|
||||
jarvis.audio.play_not_found()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
-- Trim long input — translate first ~2000 chars for cost/latency.
|
||||
if #src > 2000 then
|
||||
src = src:sub(1, 2000) .. "..."
|
||||
end
|
||||
|
||||
local result = jarvis.llm({
|
||||
{ role = "system", content = "Ты переводчик. Переводи строго на запрошенный язык без комментариев и пояснений. Сохраняй смысл и стиль." },
|
||||
{ role = "user", content = string.format("Переведи на %s:\n\n%s", target, src) }
|
||||
}, { max_tokens = 800, temperature = 0.2 })
|
||||
|
||||
if not result or result == "" then
|
||||
jarvis.speak("Не получилось перевести.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
-- Put translation back into clipboard
|
||||
jarvis.system.clipboard.set(result)
|
||||
|
||||
-- Speak only first sentence to avoid long monologue.
|
||||
local first_sentence = result:match("^[^%.!?]*[%.!?]") or result:sub(1, 200)
|
||||
jarvis.speak("Перевёл на " .. target .. ". " .. first_sentence)
|
||||
jarvis.system.notify("Перевод (в буфере)", result:sub(1, 200))
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
|
|
@ -32,3 +32,28 @@ ua = [
|
|||
"переклади на російську",
|
||||
"переклади",
|
||||
]
|
||||
|
||||
|
||||
# Translate clipboard contents — no need to dictate the source text out loud.
|
||||
[[commands]]
|
||||
id = "translate_clipboard"
|
||||
type = "lua"
|
||||
script = "clipboard.lua"
|
||||
sandbox = "full"
|
||||
timeout = 20000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"переведи буфер",
|
||||
"переведи из буфера",
|
||||
"переведи буфер обмена",
|
||||
"переведи скопированное",
|
||||
"переведи то что я скопировал",
|
||||
]
|
||||
en = [
|
||||
"translate clipboard",
|
||||
"translate from clipboard",
|
||||
]
|
||||
ua = [
|
||||
"переклади буфер",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue