Multilingual support via Fluent + Frontend improvements + Rewrite of ArcReactor

This commit is contained in:
Priler
2026-01-07 05:04:04 +05:00
parent adec595cfa
commit 412acb7e2d
41 changed files with 2436 additions and 723 deletions

108
Cargo.lock generated
View File

@@ -1768,6 +1768,51 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "fluent"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477"
dependencies = [
"fluent-bundle",
"unic-langid",
]
[[package]]
name = "fluent-bundle"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4"
dependencies = [
"fluent-langneg",
"fluent-syntax",
"intl-memoizer",
"intl_pluralrules",
"rustc-hash",
"self_cell",
"smallvec",
"unic-langid",
]
[[package]]
name = "fluent-langneg"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0"
dependencies = [
"unic-langid",
]
[[package]]
name = "fluent-syntax"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198"
dependencies = [
"memchr",
"thiserror 2.0.17",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2968,6 +3013,25 @@ dependencies = [
"syn 2.0.113",
]
[[package]]
name = "intl-memoizer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f"
dependencies = [
"type-map",
"unic-langid",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -3059,6 +3123,8 @@ dependencies = [
name = "jarvis-core"
version = "0.1.0"
dependencies = [
"fluent",
"fluent-bundle",
"futures-util",
"hound",
"intent-classifier",
@@ -3080,6 +3146,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"toml 0.9.10+spec-1.1.0",
"unic-langid",
"vosk",
]
@@ -5338,6 +5405,12 @@ dependencies = [
"realfft",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -5526,6 +5599,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "self_cell"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
[[package]]
name = "semver"
version = "1.0.27"
@@ -6826,6 +6905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -7125,6 +7205,15 @@ dependencies = [
"utf-8",
]
[[package]]
name = "type-map"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
dependencies = [
"rustc-hash",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -7190,6 +7279,24 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]]
name = "unic-ucd-ident"
version = "0.9.0"
@@ -8509,6 +8616,7 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
"serde",
"yoke 0.8.1",
"zerofrom",
"zerovec-derive",

View File

@@ -36,4 +36,7 @@ sha2 = "0.10"
nnnoiseless = "0.5"
sysinfo = "0.37.2"
tokio-tungstenite = "0.28.0"
futures-util = "0.3"
futures-util = "0.3"
fluent = "0.17.0"
fluent-bundle = "0.16.0"
unic-langid = "0.9"

View File

@@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use jarvis_core::{
audio, audio_processing, commands, config, db, listener, recorder, stt, intent,
ipc::{self, IpcAction},
i18n,
APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB,
};
@@ -40,6 +41,9 @@ fn main() -> Result<(), String> {
DB.set(Arc::new(RwLock::new(db::init_settings())))
.expect("DB already initialized");
// init i18n
i18n::init(&DB.get().unwrap().read().language);
// initialize tray
// @TODO. macOS currently not supported for tray functionality,
// due to the separate thread in which tray processing works,

View File

@@ -10,7 +10,7 @@ use image;
#[cfg(target_os="windows")]
use winit::platform::windows::EventLoopBuilderExtWindows;
use jarvis_core::config;
use jarvis_core::{config, i18n};
const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png");
@@ -21,22 +21,22 @@ pub fn init_blocking() {
let icon = load_icon_from_bytes(TRAY_ICON_BYTES);
// form tray menu
let tray_menu = Menu::with_items(&[
&MenuItem::new("Перезапуск", true, None),
&MenuItem::new("Настройки", true, None),
&MenuItem::new("Выход", true, None),
])
.unwrap();
// let tray_menu = Menu::with_items(&[
// &MenuItem::new("Перезапуск", true, None),
// &MenuItem::new("Настройки", true, None),
// &MenuItem::new("Выход", true, None),
// ])
// .unwrap();
let tray_menu = Menu::with_items(&[
&MenuItem::with_id("restart", "Перезапуск", true, None),
&MenuItem::with_id("settings", "Настройки", true, None),
&MenuItem::with_id("exit", "Выход", true, None),
&MenuItem::with_id("restart", i18n::t("tray-restart"), true, None),
&MenuItem::with_id("settings", i18n::t("tray-settings"), true, None),
&MenuItem::with_id("exit", i18n::t("tray-exit"), true, None),
]).unwrap();
let _tray_icon = TrayIconBuilder::new()
.with_menu(Box::new(tray_menu))
.with_tooltip(config::TRAY_TOOLTIP)
.with_tooltip(i18n::t("tray-tooltip"))
.with_icon(icon)
.build()
.unwrap();

View File

@@ -26,6 +26,9 @@ sha2.workspace = true
nnnoiseless = { workspace = true, optional = true }
tokio-tungstenite = { workspace = true, optional = true }
futures-util = { workspace = true, optional = true }
fluent.workspace = true
fluent-bundle.workspace = true
unic-langid.workspace = true
# pv_recorder = { workspace = true, optional = true }
vosk = { version = "0.3.1", optional = true }

View File

@@ -80,6 +80,8 @@ pub const AUTHOR_NAME: Option<&str> = option_env!("CARGO_PKG_AUTHORS");
pub const REPOSITORY_LINK: Option<&str> = option_env!("CARGO_PKG_REPOSITORY");
pub const TG_OFFICIAL_LINK: Option<&str> = Some("https://t.me/howdyho_official");
pub const FEEDBACK_LINK: Option<&str> = Some("https://t.me/jarvis_feedback_bot");
pub const SUPPORT_BOOSTY_LINK: Option<&str> = Some("https://boosty.to/howdyho");
pub const SUPPORT_PATREON_LINK: Option<&str> = Some("https://www.patreon.com/user?u=22843414");
/*
Tray.

View File

@@ -22,6 +22,8 @@ pub struct Settings {
pub vad: VadBackend,
pub gain_normalizer: bool,
pub language: String,
pub api_keys: ApiKeys,
}
@@ -41,6 +43,8 @@ impl Default for Settings {
vad: config::DEFAULT_VAD,
gain_normalizer: config::DEFAULT_GAIN_NORMALIZER,
language: String::from("ru"),
api_keys: ApiKeys {
picovoice: String::from(""),
openai: String::from(""),

View File

@@ -0,0 +1,176 @@
use fluent_bundle::{FluentBundle, FluentResource, FluentArgs, FluentValue};
use fluent_bundle::concurrent::FluentBundle as ConcurrentFluentBundle;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use std::collections::HashMap;
use unic_langid::LanguageIdentifier;
// locale files embedded at compile time
const LOCALE_RU: &str = include_str!("i18n/locales/ru.ftl");
const LOCALE_EN: &str = include_str!("i18n/locales/en.ftl");
const LOCALE_UA: &str = include_str!("i18n/locales/ua.ftl");
pub const SUPPORTED_LANGUAGES: &[&str] = &["ru", "en", "ua"];
pub const DEFAULT_LANGUAGE: &str = "ru";
// use concurrent bundle (thread-safe)
type Bundle = ConcurrentFluentBundle<FluentResource>;
static BUNDLES: OnceCell<HashMap<String, Bundle>> = OnceCell::new();
static CURRENT_LANG: OnceCell<RwLock<String>> = OnceCell::new();
// Initialize i18n system
pub fn init(lang: &str) {
let bundles = create_bundles();
BUNDLES.set(bundles).ok();
let lang = if SUPPORTED_LANGUAGES.contains(&lang) { lang } else { DEFAULT_LANGUAGE };
CURRENT_LANG.set(RwLock::new(lang.to_string())).ok();
info!("i18n initialized with language: {}", lang);
}
fn create_bundles() -> HashMap<String, Bundle> {
let mut bundles = HashMap::new();
bundles.insert("ru".to_string(), create_bundle("ru", LOCALE_RU));
bundles.insert("en".to_string(), create_bundle("en", LOCALE_EN));
bundles.insert("ua".to_string(), create_bundle("ua", LOCALE_UA));
bundles
}
fn create_bundle(lang: &str, source: &str) -> Bundle {
let langid: LanguageIdentifier = lang.parse().expect("Invalid language identifier");
let mut bundle = ConcurrentFluentBundle::new_concurrent(vec![langid]);
let resource = FluentResource::try_new(source.to_string())
.expect("Failed to parse FTL resource");
bundle.add_resource(resource).expect("Failed to add resource");
bundle
}
// Set current language
pub fn set_language(lang: &str) {
if let Some(current) = CURRENT_LANG.get() {
let lang = if SUPPORTED_LANGUAGES.contains(&lang) { lang } else { DEFAULT_LANGUAGE };
*current.write() = lang.to_string();
info!("Language changed to: {}", lang);
}
}
// Get current language
pub fn get_language() -> String {
CURRENT_LANG.get()
.map(|l| l.read().clone())
.unwrap_or_else(|| DEFAULT_LANGUAGE.to_string())
}
// Translate a key
pub fn t(key: &str) -> String {
t_with_args(key, None)
}
// Translate a key with arguments
pub fn t_with_args(key: &str, args: Option<&FluentArgs>) -> String {
let lang = get_language();
let bundles = match BUNDLES.get() {
Some(b) => b,
None => return key.to_string(),
};
let bundle = match bundles.get(&lang) {
Some(b) => b,
None => bundles.get(DEFAULT_LANGUAGE).unwrap(),
};
let msg = match bundle.get_message(key) {
Some(m) => m,
None => return key.to_string(),
};
let pattern = match msg.value() {
Some(p) => p,
None => return key.to_string(),
};
let mut errors = vec![];
let result = bundle.format_pattern(pattern, args, &mut errors);
if !errors.is_empty() {
warn!("i18n errors for key '{}': {:?}", key, errors);
}
result.to_string()
}
// Translate with a single argument
pub fn t_arg(key: &str, arg_name: &str, arg_value: &str) -> String {
let mut args = FluentArgs::new();
args.set(arg_name, FluentValue::from(arg_value));
t_with_args(key, Some(&args))
}
// Translate with numeric argument
pub fn t_count(key: &str, count: i64) -> String {
let mut args = FluentArgs::new();
args.set("count", FluentValue::from(count));
t_with_args(key, Some(&args))
}
// Get all translations for current language (for frontend)
pub fn get_all_translations() -> HashMap<String, String> {
let lang = get_language();
get_translations_for(&lang)
}
/// Get all translations for a specific language
pub fn get_translations_for(lang: &str) -> HashMap<String, String> {
let mut result = HashMap::new();
let bundles = match BUNDLES.get() {
Some(b) => b,
None => return result,
};
let bundle = match bundles.get(lang) {
Some(b) => b,
None => match bundles.get(DEFAULT_LANGUAGE) {
Some(b) => b,
None => return result,
},
};
// get source for this language and extract all keys
let source = match lang {
"ru" => LOCALE_RU,
"en" => LOCALE_EN,
"ua" => LOCALE_UA,
_ => LOCALE_RU,
};
// parse keys from FTL source (lines that have "=" and don't start with "#" or "-")
for line in source.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
continue;
}
if let Some(key) = line.split('=').next() {
let key = key.trim();
if !key.is_empty() && !key.contains(' ') {
if let Some(msg) = bundle.get_message(key) {
if let Some(pattern) = msg.value() {
let mut errors = vec![];
let value = bundle.format_pattern(pattern, None, &mut errors);
result.insert(key.to_string(), value.to_string());
}
}
}
}
}
result
}

View File

@@ -0,0 +1,119 @@
# ### APP INFO
app-name = JARVIS
app-description = Voice Assistant
# ### TRAY MENU
tray-restart = Restart
tray-settings = Settings
tray-exit = Exit
tray-tooltip = JARVIS - Voice Assistant
# ### HEADER
header-commands = COMMANDS
header-settings = SETTINGS
# ### SEARCH
search-placeholder = Enter a command manually or say «Jarvis» ...
# ### MAIN PAGE
assistant-not-running = ASSISTANT NOT RUNNING
assistant-offline-hint = You can configure it without starting.
btn-start = START
btn-starting = STARTING...
# ### STATUS
status-disconnected = Disconnected
status-standby = Standby
status-listening = Listening...
status-processing = Processing...
# ### STATS
stats-microphone = MICROPHONE
stats-neural-networks = NEURAL NETWORKS
stats-resources = RESOURCES
stats-system-default = System Default
stats-not-selected = Not selected
stats-loading = Loading...
# ### FOOTER
footer-author = Project author
footer-telegram = Our Telegram channel
footer-github = Github repository
footer-support = Support the project on
# ### SETTINGS
settings-title = Settings
settings-general = General
settings-devices = Devices
settings-neural-networks = Neural Networks
settings-audio = Audio
settings-recognition = Recognition
settings-about = About
settings-language = Language
settings-microphone = Microphone
settings-microphone-desc = The assistant will listen to this microphone.
settings-mic-default = Default (System)
settings-voice = Assistant voice
settings-voice-desc = Not all commands work with all sound packs.
settings-wake-word-engine = Wake word engine
settings-wake-word-desc = Choose the engine for wake word recognition.
settings-stt-engine = Speech recognition
settings-intent-engine = Intent recognition
settings-intent-engine-desc = Select neural network for command recognition.
settings-noise-suppression = Noise suppression
settings-noise-suppression-desc = Reduces background noise.
settings-vad = Voice detection (VAD)
settings-vad-desc = Skips silence, saves CPU resources.
settings-gain-normalizer = Gain normalizer
settings-gain-normalizer-desc = Automatically adjusts volume level.
settings-api-keys = API Keys
settings-save = Save
settings-cancel = Cancel
settings-back = Back
settings-enabled = Enabled
settings-disabled = Disabled
# settings - beta notice
settings-beta-title = BETA version!
settings-beta-desc = Some features may not work correctly.
settings-beta-feedback = Report all bugs to
settings-beta-bot = our Telegram bot
settings-open-logs = Open logs folder
# settings - picovoice
settings-attention = Attention!
settings-picovoice-warning = This neural network doesn't work for everyone!
settings-picovoice-waiting = We are waiting for an official patch from the developers.
settings-picovoice-key-desc = Enter your Picovoice key here. It is issued for free upon registration at
settings-picovoice-key = Picovoice Key
# settings - vosk
settings-auto-detect = Auto-detect
settings-vosk-model = Speech recognition model (Vosk)
settings-vosk-model-desc = Select Vosk model for speech recognition.
settings-models-not-found = Models not found
settings-models-hint = Place Vosk models in resources/vosk folder
# settings - openai
settings-openai-key = OpenAI Key
settings-openai-not-supported = ChatGPT is not currently supported. It will be added in future updates.
# ### COMMANDS PAGE
commands-title = Commands
commands-search = Search commands...
commands-count = { $count } commands
commands-wip-title = [404] This section is under development!
commands-wip-desc = Here will be a list of commands + full-featured command editor.
commands-wip-follow = Follow updates in
commands-wip-channel = our Telegram channel
# ### ERRORS
error-generic = An error occurred
error-connection = Connection error
error-not-found = Not found
# ### NOTIFICATIONS
notification-saved = Settings saved!
notification-error = Error
notification-assistant-started = Assistant started
notification-assistant-stopped = Assistant stopped

View File

@@ -0,0 +1,119 @@
# ### APP INFO
app-name = JARVIS
app-description = Голосовой ассистент
# ### TRAY MENU
tray-restart = Перезапустить
tray-settings = Настройки
tray-exit = Выход
tray-tooltip = JARVIS - Голосовой ассистент
# ### HEADER
header-commands = КОМАНДЫ
header-settings = НАСТРОЙКИ
# ### SEARCH
search-placeholder = Введите команду вручную или произнесите «Джарвис» ...
# ### MAIN PAGE
assistant-not-running = АССИСТЕНТ НЕ ЗАПУЩЕН
assistant-offline-hint = Настроить его можно не запуская.
btn-start = ЗАПУСТИТЬ
btn-starting = ЗАПУСК...
# ### STATUS
status-disconnected = Отключен
status-standby = Ожидание
status-listening = Слушаю...
status-processing = Обработка...
# ### STATS
stats-microphone = МИКРОФОН
stats-neural-networks = НЕЙРОСЕТИ
stats-resources = РЕСУРСЫ
stats-system-default = Системный
stats-not-selected = Не выбран
stats-loading = Загрузка...
# ### FOOTER
footer-author = Автор проекта
footer-telegram = Наш телеграм канал
footer-github = Github репозиторий проекта
footer-support = Поддержать проект на
# ### SETTINGS
settings-title = Настройки
settings-general = Основные
settings-devices = Устройства
settings-neural-networks = Нейросети
settings-audio = Аудио
settings-recognition = Распознавание
settings-about = О программе
settings-language = Язык
settings-microphone = Микрофон
settings-microphone-desc = Его будет слушать ассистент.
settings-mic-default = По умолчанию (Система)
settings-voice = Голос ассистента
settings-voice-desc = Не все команды работают со всеми звуковыми пакетами.
settings-wake-word-engine = Движок активации
settings-wake-word-desc = Выберите нейросеть для распознавания активационной фразы.
settings-stt-engine = Распознавание речи
settings-intent-engine = Определение намерения
settings-intent-engine-desc = Выберите нейросеть для распознавания команд.
settings-noise-suppression = Шумоподавление
settings-noise-suppression-desc = Уменьшает фоновый шум.
settings-vad = Определение голоса (VAD)
settings-vad-desc = Пропускает тишину, экономит ресурсы CPU.
settings-gain-normalizer = Нормализация громкости
settings-gain-normalizer-desc = Автоматически регулирует уровень громкости.
settings-api-keys = API Ключи
settings-save = Сохранить
settings-cancel = Отмена
settings-back = Назад
settings-enabled = Включено
settings-disabled = Отключено
# settings - beta notice
settings-beta-title = БЕТА версия!
settings-beta-desc = Часть функций может работать некорректно.
settings-beta-feedback = Сообщайте обо всех найденных багах в
settings-beta-bot = наш телеграм бот
settings-open-logs = Открыть папку с логами
# settings - picovoice
settings-attention = Внимание!
settings-picovoice-warning = Эта нейросеть работает не у всех!
settings-picovoice-waiting = Мы ждем официального патча от разработчиков.
settings-picovoice-key-desc = Введите сюда свой ключ Picovoice. Он выдается бесплатно при регистрации в
settings-picovoice-key = Ключ Picovoice
# settings - vosk
settings-auto-detect = Авто-определение
settings-vosk-model = Модель распознавания речи (Vosk)
settings-vosk-model-desc = Выберите модель Vosk для распознавания речи.
settings-models-not-found = Модели не найдены
settings-models-hint = Поместите модели Vosk в папку resources/vosk
# settings - openai
settings-openai-key = Ключ OpenAI
settings-openai-not-supported = В данный момент ChatGPT не поддерживается. Он будет добавлен в ближайших обновлениях.
# ### COMMANDS PAGE
commands-title = Команды
commands-search = Поиск команд...
commands-count = { $count } команд
commands-wip-title = [404] Этот раздел еще находится в разработке!
commands-wip-desc = Тут будет список команд + полноценный редактор команд.
commands-wip-follow = Следите за обновлениями в
commands-wip-channel = нашем телеграм канале
# ### ERRORS
error-generic = Произошла ошибка
error-connection = Ошибка подключения
error-not-found = Не найдено
# ### NOTIFICATIONS
notification-saved = Настройки сохранены!
notification-error = Ошибка
notification-assistant-started = Ассистент запущен
notification-assistant-stopped = Ассистент остановлен

View File

@@ -0,0 +1,119 @@
# ### APP INFO
app-name = JARVIS
app-description = Голосовий асистент
# ### TRAY MENU
tray-restart = Перезапустити
tray-settings = Налаштування
tray-exit = Вихід
tray-tooltip = JARVIS - Голосовий асистент
# ### HEADER
header-commands = КОМАНДИ
header-settings = НАЛАШТУВАННЯ
# ### SEARCH
search-placeholder = Введіть команду вручну або скажіть «Джарвіс» ...
# ### MAIN PAGE
assistant-not-running = АСИСТЕНТ НЕ ЗАПУЩЕНО
assistant-offline-hint = Налаштувати його можна не запускаючи.
btn-start = ЗАПУСТИТИ
btn-starting = ЗАПУСК...
# ### STATUS
status-disconnected = Відключено
status-standby = Очікування
status-listening = Слухаю...
status-processing = Обробка...
# ### STATS
stats-microphone = МІКРОФОН
stats-neural-networks = НЕЙРОМЕРЕЖІ
stats-resources = РЕСУРСИ
stats-system-default = Системний
stats-not-selected = Не вибрано
stats-loading = Завантаження...
# ### FOOTER
footer-author = Автор проєкту
footer-telegram = Наш телеграм канал
footer-github = Github репозиторій проєкту
footer-support = Підтримати проєкт на
# ### SETTINGS
settings-title = Налаштування
settings-general = Основні
settings-devices = Пристрої
settings-neural-networks = Нейромережі
settings-audio = Аудіо
settings-recognition = Розпізнавання
settings-about = Про програму
settings-language = Мова
settings-microphone = Мікрофон
settings-microphone-desc = Його буде слухати асистент.
settings-mic-default = За замовчуванням (Система)
settings-voice = Голос асистента
settings-voice-desc = Не всі команди працюють з усіма звуковими пакетами.
settings-wake-word-engine = Рушій активації
settings-wake-word-desc = Виберіть нейромережу для розпізнавання активаційної фрази.
settings-stt-engine = Розпізнавання мовлення
settings-intent-engine = Визначення наміру
settings-intent-engine-desc = Виберіть нейромережу для розпізнавання команд.
settings-noise-suppression = Шумозаглушення
settings-noise-suppression-desc = Зменшує фоновий шум.
settings-vad = Визначення голосу (VAD)
settings-vad-desc = Пропускає тишу, економить ресурси CPU.
settings-gain-normalizer = Нормалізація гучності
settings-gain-normalizer-desc = Автоматично регулює рівень гучності.
settings-api-keys = API Ключі
settings-save = Зберегти
settings-cancel = Скасувати
settings-back = Назад
settings-enabled = Увімкнено
settings-disabled = Вимкнено
# settings - beta notice
settings-beta-title = БЕТА версія!
settings-beta-desc = Частина функцій може працювати некоректно.
settings-beta-feedback = Повідомляйте про всі знайдені баги в
settings-beta-bot = наш телеграм бот
settings-open-logs = Відкрити папку з логами
# settings - picovoice
settings-attention = Увага!
settings-picovoice-warning = Ця нейромережа працює не у всіх!
settings-picovoice-waiting = Ми чекаємо офіційного патча від розробників.
settings-picovoice-key-desc = Введіть сюди свій ключ Picovoice. Він видається безкоштовно при реєстрації в
settings-picovoice-key = Ключ Picovoice
# settings - vosk
settings-auto-detect = Авто-визначення
settings-vosk-model = Модель розпізнавання мовлення (Vosk)
settings-vosk-model-desc = Виберіть модель Vosk для розпізнавання мовлення.
settings-models-not-found = Моделі не знайдено
settings-models-hint = Помістіть моделі Vosk в папку resources/vosk
# settings - openai
settings-openai-key = Ключ OpenAI
settings-openai-not-supported = Наразі ChatGPT не підтримується. Він буде доданий у наступних оновленнях.
# ### COMMANDS PAGE
commands-title = Команди
commands-search = Пошук команд...
commands-count = { $count } команд
commands-wip-title = [404] Цей розділ ще в розробці!
commands-wip-desc = Тут буде список команд + повноцінний редактор команд.
commands-wip-follow = Слідкуйте за оновленнями в
commands-wip-channel = нашому телеграм каналі
# ### ERRORS
error-generic = Сталася помилка
error-connection = Помилка підключення
error-not-found = Не знайдено
# ### NOTIFICATIONS
notification-saved = Налаштування збережено!
notification-error = Помилка
notification-assistant-started = Асистент запущено
notification-assistant-stopped = Асистент зупинено

View File

@@ -11,6 +11,7 @@ pub mod audio;
pub mod commands;
pub mod config;
pub mod db;
pub mod i18n;
#[cfg(feature = "jarvis_app")]
pub mod listener;

View File

@@ -1,7 +1,7 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use jarvis_core::{config, db, APP_CONFIG_DIR, APP_LOG_DIR, DB};
use jarvis_core::{config, db, i18n, APP_CONFIG_DIR, APP_LOG_DIR, DB};
use parking_lot::RwLock;
use std::sync::Arc;
@@ -26,6 +26,11 @@ fn main() {
// init db
let settings = db::init_settings();
// init i18n
i18n::init(&settings.language);
// set db
DB.set(Arc::new(RwLock::new(settings)))
.expect("DB already initialized");
let db_arc = DB.get().unwrap().clone();
@@ -67,6 +72,13 @@ fn main() {
// vosk
tauri_commands::list_vosk_models,
// i18n
tauri_commands::get_translations,
tauri_commands::translate,
tauri_commands::get_current_language,
tauri_commands::set_language,
tauri_commands::get_supported_languages,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -25,4 +25,8 @@ pub use sys::*;
// import STT commands
mod stt;
pub use stt::*;
pub use stt::*;
// import i18n commands
mod i18n;
pub use i18n::*;

View File

@@ -15,6 +15,7 @@ pub fn db_read(state: tauri::State<'_, AppState>, key: &str) -> String {
"noise_suppression" => format!("{:?}", settings.noise_suppression),
"vad" => format!("{:?}", settings.vad),
"gain_normalizer" => settings.gain_normalizer.to_string(),
"language" => settings.language.to_string(),
"api_key__picovoice" => settings.api_keys.picovoice.clone(),
"api_key__openai" => settings.api_keys.openai.clone(),
_ => String::new(),
@@ -78,6 +79,9 @@ pub fn db_write(state: tauri::State<'_, AppState>, key: &str, val: &str) -> bool
_ => return false,
}
}
"language" => {
settings.language = val.to_string();
}
"api_key__picovoice" => {
settings.api_keys.picovoice = val.to_string();
}

View File

@@ -38,6 +38,24 @@ pub fn get_tg_official_link() -> String {
}
}
#[tauri::command]
pub fn get_boosty_link() -> String {
if let Some(ver) = config::SUPPORT_BOOSTY_LINK {
ver.to_string()
} else {
String::from("error")
}
}
#[tauri::command]
pub fn get_patreon_link() -> String {
if let Some(ver) = config::SUPPORT_PATREON_LINK {
ver.to_string()
} else {
String::from("error")
}
}
#[tauri::command]
pub fn get_feedback_link() -> String {
if let Some(res) = config::FEEDBACK_LINK {

View File

@@ -0,0 +1,49 @@
use jarvis_core::i18n;
use std::collections::HashMap;
use crate::AppState;
// Get all translations for frontend
#[tauri::command]
pub fn get_translations() -> HashMap<String, String> {
i18n::get_all_translations()
}
// Get single translation
#[tauri::command]
pub fn translate(key: &str) -> String {
i18n::t(key)
}
// Get current language
#[tauri::command]
pub fn get_current_language() -> String {
i18n::get_language()
}
// Set language and get new translations
#[tauri::command]
pub fn set_language(state: tauri::State<'_, AppState>, lang: &str) -> HashMap<String, String> {
// update i18n
i18n::set_language(lang);
// also save to db
{
let mut settings = state.db.write();
settings.language = lang.to_string();
}
// save to disk
let snapshot = state.db.read().clone();
if let Err(e) = jarvis_core::db::save_settings(&snapshot) {
log::error!("Failed to save settings: {}", e);
}
// return new translations
i18n::get_all_translations()
}
// Get supported languages
#[tauri::command]
pub fn get_supported_languages() -> Vec<&'static str> {
i18n::SUPPORTED_LANGUAGES.to_vec()
}

View File

@@ -20,7 +20,7 @@
"resizable": false,
"title": "Jarvis Voice Assistant",
"width": 550,
"height": 700
"height": 800
}
]
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -11,7 +11,8 @@
startStatsPolling,
stopStatsPolling,
connectIpc,
disconnectIpc
disconnectIpc,
loadTranslations
} from "@/stores"
onMount(() => {
@@ -24,6 +25,9 @@
// connect to IPC
connectIpc()
// load language
loadTranslations()
})
onDestroy(() => {

View File

@@ -1,17 +1,23 @@
<script lang="ts">
import { onMount } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { appInfo } from "@/stores"
import { appInfo, currentLanguage, translations, translate } from "@/stores"
$: t = (key: string) => translate($translations, key)
let authorName = ""
let tgLink = ""
let repoLink = ""
let boostyLink = ""
let patreonLink = ""
const currentYear = new Date().getFullYear()
appInfo.subscribe(info => {
tgLink = info.tgOfficialLink
repoLink = info.repositoryLink
boostyLink = info.boostySupportLink
patreonLink = info.patreonSupportLink
})
onMount(async () => {
@@ -24,28 +30,42 @@
</script>
<footer id="footer">
<p>© {currentYear}. Автор проекта: {authorName}</p>
<p>© {currentYear}. {t('footer-author')}: <b>{authorName}</b></p>
<p class="links">
<a href={tgLink} target="_blank" class="special-link">
<img src="/media/icons/howdy-logo.png" alt="Telegram" width="20px" />
&nbsp;&nbsp;Наш телеграм
{#if $currentLanguage === "ru" || $currentLanguage === "ua"}
<a href={tgLink} target="_blank" class="telegram-link">
<img src="/media/icons/telegram.webp" alt="Telegram" width="18px" />
&nbsp;<span>{t('footer-telegram')}</span>
</a>
канал.
&nbsp;&nbsp;
&nbsp;
{/if}
<a href={repoLink} target="_blank">
<img src="/media/icons/github-logo.png" alt="GitHub" width="18px" />
&nbsp;Github репозиторий
&nbsp;<span>{t('footer-github')}</span>
</a>
проекта.
</p>
<p class="links last">
{#if $currentLanguage === "ru"}
{t('footer-support')} <a href={tgLink} target="_blank" class="telegram-link">
<img src="/media/icons/boosty.webp" alt="Boosty" width="18px" />
<span>Boosty</span>
</a>.
{/if}
{#if $currentLanguage === "ua" || $currentLanguage === "en"}
{t('footer-support')} <a href={tgLink} target="_blank" class="telegram-link">
<img src="/media/icons/patreon.png" alt="Patreon" width="18px" />
<span>Patreon</span>
</a>.
{/if}
</p>
</footer>
<style lang="scss">
#footer {
text-align: center;
color: #565759;
font-size: 12px;
font-weight: bold;
color: #6c6e71;
font-size: 13px;
font-weight: normal;
line-height: 1.7em;
margin-top: 15px;
@@ -56,28 +76,54 @@
&.links {
margin-top: 5px;
margin-bottom: 15px;
&.last {
margin-top: -5px;
}
}
}
a {
color: #185876;
color: #555759!important;
text-decoration: none;
transition: opacity 0.5s;
transition: 0.3s;
& > span {
color: #185876;
border-bottom: 1px solid #185876;
transition: 0.3s;
}
img {
opacity: 0.5;
transition: opacity 0.5s;
margin-top: -4px;
margin-top: -3px;
}
&:hover {
color: #2A9CD0;
color: #777a7d!important;
& > span {
color: #2A9CD0;
}
img {
opacity: 1;
}
}
&.telegram-link {
color: #185876;
display: inline-block;
&:hover {
color: #2A9CD0;
// background: url(/media/images/bg/bg24.gif);
// background-repeat: no-repeat;
// background-size: contain;
}
}
&.special-link {
color: #941d92;
display: inline-block;

View File

@@ -1,54 +1,185 @@
<script lang="ts">
import { onMount } from "svelte"
import { goto } from "@roxi/routify"
import { invoke } from "@tauri-apps/api/core"
import { isActive } from "@roxi/routify"
import { Dashboard, Gear } from "radix-icons-svelte"
import { onMount } from "svelte"
import { currentLanguage, setLanguage, translations, translate } from "@/stores"
let appVersion = ""
let commandsCount = 0
let selectedLang = "?"
let langDropdownOpen = false
const languages = [
{ code: "ru", label: "RU", flag: "🇷🇺", name: "Русский" },
{ code: "en", label: "EN", flag: "🇬🇧", name: "English" },
{ code: "ua", label: "UA", flag: "🇺🇦", name: "Українська" },
]
onMount(async () => {
try {
appVersion = await invoke<string>("get_app_version")
} catch (err) {
console.error("failed to get app version:", err)
commandsCount = await invoke<number>("get_commands_count")
// load saved language
const savedLang = await invoke<string>("db_read", { key: "language" })
if (savedLang) {
selectedLang = savedLang
}
} catch {
commandsCount = 0
}
})
async function selectLanguage(code: string) {
await setLanguage(code)
langDropdownOpen = false
}
function toggleLangDropdown() {
langDropdownOpen = !langDropdownOpen
}
function closeLangDropdown(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.lang-selector')) {
langDropdownOpen = false
}
}
$: currentLang = languages.find(l => l.code === $currentLanguage) || languages[0]
$: t = (key: string) => translate($translations, key)
</script>
<header id="header">
<div class="logo">
<a href="/" title="Проект канала Хауди Хо!">
<img src="/media/header-logo.png" alt="Jarvis Logo" />
</a>
<div>
<h1><a href="/">JARVIS</a></h1>
<h2>
v{appVersion}
<small class="beta-badge">BETA</small>
</h2>
<svelte:window on:click={closeLangDropdown} />
<header id="header" class="header">
<div class="header-left">
<div class="logo">
<a href="/" title="JARVIS">
<img src="/media/128x128.png" alt="Jarvis Logo" />
</a>
<div class="logo-text">
<span class="logo-title"><a href="/" id="jarvis-logo">&nbsp;</a></span>
<span class="logo-version"><small>v</small>{appVersion} <span class="v-badge">BETA</span></span>
</div>
</div>
</div>
<div class="header-right">
<button class="header-btn" on:click={() => $goto('/commands')}>
<span class="btn-text">{t('header-commands')}</span>
<span class="btn-badge purple">{commandsCount}+</span>
</button>
<button class="header-btn" on:click={() => $goto('/settings')}>
<span class="btn-text">{t('header-settings')}</span>
</button>
<nav class="top-menu">
<ul>
<li>
<a href="/commands" class:active={$isActive("/commands")}>
<Dashboard /> Команды
</a>
</li>
<li>
<a href="/settings" class:active={$isActive("/settings")}>
<Gear /> Настройки
</a>
</li>
</ul>
</nav>
<div class="lang-selector">
<button class="lang-btn" on:click|stopPropagation={toggleLangDropdown}>
<span class="lang-flag"><img src="/media/flags/{currentLang.label}.png" width="23px" alt="{currentLang.flag}"></span>
</button>
{#if langDropdownOpen}
<div class="lang-dropdown">
{#each languages as lang}
<button
class="lang-option"
class:active={lang.code === $currentLanguage}
on:click|stopPropagation={() => selectLanguage(lang.code)}
>
<span class="lang-flag"><img src="/media/flags/{lang.label}.png" width="20px" alt="{lang.flag}"></span>
<span class="lang-name">{lang.name}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
</header>
<style lang="scss">
.beta-badge {
color: #8AC832;
opacity: 0.9;
font-size: 13px;
.lang-selector {
position: relative;
}
</style>
.lang-btn {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.65rem;
background: transparent;
border: none;
border-radius: 6px;
color: #ffffff;
font-size: 0.7rem;
cursor: pointer;
&:hover {
background: rgba(35, 50, 55, 0.7);
}
}
.lang-flag {
font-size: 0.9rem;
line-height: 1;
}
.lang-code {
font-weight: 600;
letter-spacing: 0.5px;
}
.lang-arrow {
font-size: 0.8rem;
opacity: 0.6;
transition: transform 0.2s ease;
&.open {
transform: rotate(180deg);
}
}
.lang-dropdown {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
background: rgba(20, 30, 35, 0.98);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
z-index: 100;
min-width: 130px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.lang-option {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.85rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.75);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
&:hover {
background: rgba(82, 254, 254, 0.1);
color: #ffffff;
}
&.active {
background: rgba(82, 254, 254, 0.15);
color: #52fefe;
}
}
.lang-name {
font-weight: 500;
}
</style>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { jarvisState } from "@/stores"
// map state to class
$: stateClass = getStateClass($jarvisState)
function getStateClass(state: string): string {
@@ -18,92 +17,72 @@
}
</script>
<!-- Based on: https://github.com/rembertdesigns/Iron-Man-Arc-Reactor-Pure-CSS -->
<!-- and https://codepen.io/FlyingEmu/pen/DZNqEj -->
<div class="arc-reactor-wrapper">
<div id="arc-reactor" class="reactor-container {stateClass}">
<div class="reactor-container-inner circle abs-center">
<ul class="marks">
{#each Array(60) as _, i}
<li></li>
{/each}
</ul>
<div class="e7">
<div class="semi_arc_3 e5_1">
<div class="semi_arc_3 e5_2">
<div class="semi_arc_3 e5_3">
<div class="semi_arc_3 e5_4"></div>
<div id="arc-reactor" class="reactor-container {stateClass} arc-white">
<div class="reactor-container-inner circle abs-center">
<ul class="marks">
{#each Array(60) as _, i}
<li></li>
{/each}
</ul>
<div class="e7">
<div class="semi_arc_3 e5_1">
<div class="semi_arc_3 e5_1_ghost"></div>
<div class="semi_arc_3 e5_2">
<div class="semi_arc_3 e5_2_ghost"></div>
<div class="semi_arc_3 e5_3">
<div class="semi_arc_3 e5_3_ghost"></div>
<div class="semi_arc_3 e5_4">
<div class="semi_arc_3 e5_4_ghost"></div>
</div>
</div>
</div>
</div>
</div>
<div class="tunnel circle abs-center"></div>
<div class="core-wrapper circle abs-center"></div>
<div class="core-outer circle abs-center"></div>
<div class="core-inner circle abs-center"></div>
<div class="coil-container">
{#each Array(8) as _, i}
<div class="coil coil-{i + 1}"></div>
{/each}
</div>
</div>
<div class="state-label">
<span class="status-dot"></span>
<span class="label-text">
{#if $jarvisState === "disconnected"}
Отключен
{:else if $jarvisState === "idle"}
Ожидание
{:else if $jarvisState === "listening"}
Слушаю
{:else if $jarvisState === "processing"}
Обработка
{/if}
</span>
<div class="tunnel circle abs-center"></div>
<div class="core-wrapper circle abs-center"></div>
<div class="core-outer circle abs-center"></div>
<div class="core-inner circle abs-center"></div>
<div class="coil-container">
{#each Array(8) as _, i}
<div class="coil coil-{i + 1}"></div>
{/each}
</div>
</div>
<style lang="scss" global>
// [ Variables ]--
$arc-radius: 130px;
$size3: 6px;
$cshadow: rgba(2, 254, 255, 0.8);
$marks-color-1: rgba(2, 254, 255, 1);
$marks-color-2: rgba(2, 254, 255, 0.3);
$colour1: rgba(2, 255, 255, 0.15);
$colour3: rgba(2, 255, 255, 0.3);
// [ ARC REACTOR VARIABLES ]
$arc-radius: 133px;
$arc-container-size: 195%;
$arc-spacing: 93%;
// [ Wrapper ]--
.arc-reactor-wrapper {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
}
// arc thickness per level
$arc-thickness-1: 1px;
$arc-thickness-2: 2px;
$arc-thickness-3: 3px;
$arc-thickness-4: 4px;
.state-label {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #52fefe;
text-transform: uppercase;
letter-spacing: 2px;
opacity: 0.8;
transition: opacity 0.3s ease;
}
// marks (lines) settings
$mark-width: 2.5px;
$mark-height: 11px;
// [ Base container ]--
// [ DEFAULT THEME - CYAN ]
.reactor-container {
--arc-color: 2, 254, 255; // RGB values for easy rgba()
--arc-glow: #52fefe;
--arc-glow-rgb: 82, 254, 254;
--arc-core-border: #1b4e5f;
--arc-core-bg: #073c4b;
width: 300px;
height: 320px;
height: 300px;
margin: auto;
position: relative;
border-radius: 50%;
transition: scale 1s ease, opacity 0.5s ease;
scale: 0.9;
transition: transform 0.5s ease, opacity 0.5s ease, filter 0.5s ease;
transform: scale(0.95);
opacity: 0.9;
top: 10px;
ul {
list-style: none;
@@ -112,18 +91,113 @@
}
}
// [ COLOR THEMES ]
.reactor-container.arc-cyan {
--arc-color: 2, 254, 255;
--arc-glow: #52fefe;
--arc-glow-rgb: 82, 254, 254;
--arc-core-border: #1b4e5f;
--arc-core-bg: #073c4b;
}
.reactor-container.arc-red {
--arc-color: 255, 50, 50;
--arc-glow: #ff5050;
--arc-glow-rgb: 255, 80, 80;
--arc-core-border: #5f1b1b;
--arc-core-bg: #4b0707;
}
.reactor-container.arc-orange {
--arc-color: 255, 150, 50;
--arc-glow: #ff9632;
--arc-glow-rgb: 255, 150, 50;
--arc-core-border: #5f3c1b;
--arc-core-bg: #4b2a07;
}
.reactor-container.arc-yellow {
--arc-color: 255, 230, 50;
--arc-glow: #ffe632;
--arc-glow-rgb: 255, 230, 50;
--arc-core-border: #5f5a1b;
--arc-core-bg: #4b4507;
}
.reactor-container.arc-green {
--arc-color: 50, 255, 100;
--arc-glow: #32ff64;
--arc-glow-rgb: 50, 255, 100;
--arc-core-border: #1b5f2a;
--arc-core-bg: #074b15;
}
.reactor-container.arc-blue {
--arc-color: 50, 150, 255;
--arc-glow: #3296ff;
--arc-glow-rgb: 50, 150, 255;
--arc-core-border: #1b3c5f;
--arc-core-bg: #072a4b;
}
.reactor-container.arc-purple {
--arc-color: 180, 100, 255;
--arc-glow: #b464ff;
--arc-glow-rgb: 180, 100, 255;
--arc-core-border: #3c1b5f;
--arc-core-bg: #28074b;
}
.reactor-container.arc-pink {
--arc-color: 255, 100, 200;
--arc-glow: #ff64c8;
--arc-glow-rgb: 255, 100, 200;
--arc-core-border: #5f1b4a;
--arc-core-bg: #4b0735;
}
.reactor-container.arc-white {
--arc-color: 255, 255, 255;
--arc-glow: #ffffff;
--arc-glow-rgb: 255, 255, 255;
--arc-core-border: #4a4a4a;
--arc-core-bg: #2a2a2a;
}
// [ BACKGROUND GLOW ]
.reactor-container::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1000px;
height: 1000px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(var(--arc-color), 0.20) 0%,
rgba(var(--arc-color), 0.15) 30%,
rgba(var(--arc-color), 0.10) 40%,
rgba(var(--arc-color), 0.04) 50%,
transparent 70%
);
z-index: -1;
pointer-events: none;
transition: opacity 0.5s ease, transform 0.5s ease;
}
// [ CORE ELEMENTS - using CSS vars ]
.reactor-container-inner {
height: 238px;
width: 238px;
background-color: #161a1b;
box-shadow: 0px 0px 50px 15px $colour3, inset 0px 0px 50px 15px $colour3;
box-shadow: 0px 0px 50px 15px rgba(var(--arc-color), 0.3),
inset 0px 0px 50px 15px rgba(var(--arc-color), 0.3);
transition: box-shadow 0.5s ease;
}
// [ Utility classes ]--
.circle {
border-radius: 50%;
}
.circle { border-radius: 50%; }
.abs-center {
position: absolute;
@@ -134,30 +208,32 @@
margin: auto;
}
// [ Core elements ]--
.core-inner {
width: 70px;
height: 70px;
border: 5px solid #1b4e5f;
border: 5px solid var(--arc-core-border);
background-color: #ffffff;
box-shadow: 0px 0px 7px 5px #52fefe, 0px 0px 10px 10px #52fefe inset;
box-shadow: 0px 0px 7px 5px var(--arc-glow),
0px 0px 10px 10px var(--arc-glow) inset;
transition: box-shadow 0.5s ease;
}
.core-outer {
width: 120px;
height: 120px;
border: 1px solid #52fefe;
border: 1px solid var(--arc-glow);
background-color: #ffffff;
box-shadow: 0px 0px 2px 1px #52fefe, 0px 0px 10px 5px #52fefe inset;
box-shadow: 0px 0px 2px 1px var(--arc-glow),
0px 0px 10px 5px var(--arc-glow) inset;
transition: box-shadow 0.5s ease;
}
.core-wrapper {
width: 180px;
height: 180px;
background-color: #073c4b;
box-shadow: 0px 0px 5px 4px #52fefe, 0px 0px 6px 2px #52fefe inset;
background-color: var(--arc-core-bg);
box-shadow: 0px 0px 5px 4px var(--arc-glow),
0px 0px 6px 2px var(--arc-glow) inset;
transition: box-shadow 0.5s ease;
}
@@ -165,17 +241,16 @@
width: 220px;
height: 220px;
background-color: #ffffff;
box-shadow: 0px 0px 5px 1px #52fefe, 0px 0px 5px 4px #52fefe inset;
box-shadow: 0px 0px 5px 1px var(--arc-glow),
0px 0px 5px 4px var(--arc-glow) inset;
transition: box-shadow 0.5s ease;
}
// [ Coil animation ]--
.coil-container {
position: relative;
width: 100%;
height: 100%;
animation: 10s infinite linear reactor-anim;
transition: animation-duration 0.5s ease;
animation: reactor-anim 10s infinite linear;
}
.coil {
@@ -185,8 +260,8 @@
top: calc(50% - 110px);
left: calc(50% - 15px);
transform-origin: 15px 110px;
background-color: #073c4b;
box-shadow: 0px 0px 5px #52fefe inset;
background-color: var(--arc-core-bg);
box-shadow: 0px 0px 5px var(--arc-glow) inset;
}
@for $i from 1 through 8 {
@@ -200,41 +275,112 @@
to { transform: rotate(360deg); }
}
// [ Arc element ]--
.e7 {
position: relative;
position: absolute;
z-index: 1;
width: 160%;
height: 160%;
left: -32.5%;
top: -32.5%;
right: 0;
bottom: 0;
margin: auto;
border: $size3 solid transparent;
width: $arc-container-size;
height: $arc-container-size;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border: 6px solid transparent;
background: transparent;
border-radius: 50%;
transform: rotateZ(0deg);
transition: box-shadow 3s ease, opacity 0.5s ease;
transition: opacity 0.5s ease;
text-align: center;
opacity: 0.3;
opacity: 0.5;
}
.semi_arc_3 {
content: "";
$offset: calc((100% - #{$arc-spacing}) / 2);
position: absolute;
width: 94%;
height: 94%;
left: 3%;
top: 3%;
border: 5px solid #02feff;
width: $arc-spacing;
height: $arc-spacing;
left: $offset;
top: $offset;
border-style: solid;
border-color: transparent;
border-radius: 50%;
box-sizing: border-box;
animation: rotate 4s linear infinite;
text-align: center;
animation: rotate 6s linear infinite;
overflow: hidden;
}
// [ MAIN ARCS ]
.e5_1 {
border-width: $arc-thickness-1;
border-top-color: rgba(var(--arc-color), 0.25);
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: rgba(var(--arc-color), 0.25);
animation: rotate 8s linear infinite;
}
.e5_2 {
border-width: $arc-thickness-2;
border-top-color: transparent;
border-right-color: rgba(var(--arc-color), 0.4);
border-bottom-color: rgba(var(--arc-color), 0.4);
border-left-color: transparent;
animation: rotate_anti 6s linear infinite;
}
.e5_3 {
border-width: $arc-thickness-3;
border-top-color: rgba(var(--arc-color), 0.6);
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
animation: rotate 4s linear infinite;
}
.e5_4 {
border-width: $arc-thickness-4;
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: rgba(var(--arc-color), 0.8);
border-left-color: transparent;
animation: rotate_anti 5s linear infinite;
}
// [ GHOST ARCS ]
.e5_1_ghost {
border-width: $arc-thickness-1;
border-top-color: rgba(var(--arc-color), 0.08);
border-right-color: rgba(var(--arc-color), 0.08);
border-bottom-color: transparent;
border-left-color: transparent;
animation: rotate_anti 12s linear infinite;
}
.e5_2_ghost {
border-width: $arc-thickness-2;
border-top-color: rgba(var(--arc-color), 0.12);
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: rgba(var(--arc-color), 0.12);
animation: rotate 9s linear infinite;
}
.e5_3_ghost {
border-width: $arc-thickness-3;
border-top-color: transparent;
border-right-color: rgba(var(--arc-color), 0.18);
border-bottom-color: rgba(var(--arc-color), 0.18);
border-left-color: transparent;
animation: rotate_anti 6s linear infinite;
}
.e5_4_ghost {
border-width: $arc-thickness-4;
border-top-color: rgba(var(--arc-color), 0.24);
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: rgba(var(--arc-color), 0.24);
animation: rotate 7.5s linear infinite;
}
@keyframes rotate {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(360deg); }
@@ -245,167 +391,246 @@
100% { transform: rotateZ(-360deg); }
}
// [ Marks ]--
// [ MARKS ]
.marks {
li {
width: 11px;
height: 11px;
background: $cshadow;
position: absolute;
margin-left: 117.5px;
margin-top: 113.5px;
animation: colour_ease2 3s infinite ease-in-out;
}
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.marks li {
width: $mark-width;
height: $mark-height;
background: rgba(var(--arc-color), 0.8);
position: absolute;
left: 50%;
top: 50%;
margin-left: calc(-#{$mark-width} / 2);
margin-top: calc(-#{$mark-height} / 2);
animation: colour_ease2 3s infinite ease-in-out;
}
@keyframes colour_ease2 {
0% { background: $marks-color-1; }
50% { background: $marks-color-2; }
100% { background: $marks-color-1; }
0% { background: rgba(var(--arc-color), 1); }
50% { background: rgba(var(--arc-color), 0.3); }
100% { background: rgba(var(--arc-color), 1); }
}
@for $i from 1 through 60 {
.marks li:nth-child(#{$i}) {
transform: rotate(#{$i * 6}deg) translateY($arc-radius);
transform: rotate(#{$i * 6}deg) translateY(-$arc-radius);
}
}
// [ DISCONNECTED state ]--
// [ DISCONNECTED ]
.reactor-container.disconnected {
transform: scale(0.8);
transform: scale(0.85);
opacity: 0.4;
filter: grayscale(0.8) brightness(0.6);
.coil-container {
animation-duration: 20s;
filter: grayscale(0.7) brightness(0.6);
.coil-container { animation-duration: 20s; }
.e7 { opacity: 0.3; }
.e5_1 {
border-top-color: rgba(var(--arc-color), 0.1);
border-left-color: rgba(var(--arc-color), 0.1);
animation: rotate 35s linear infinite;
}
.e5_2 {
border-right-color: rgba(var(--arc-color), 0.15);
border-bottom-color: rgba(var(--arc-color), 0.15);
animation: rotate_anti 30s linear infinite;
}
.e5_3 {
border-top-color: rgba(var(--arc-color), 0.2);
animation: rotate 25s linear infinite;
}
.e5_4 {
border-bottom-color: rgba(var(--arc-color), 0.25);
animation: rotate_anti 28s linear infinite;
}
.state-label {
opacity: 0.5;
.e5_1_ghost {
border-top-color: rgba(var(--arc-color), 0.03);
border-right-color: rgba(var(--arc-color), 0.03);
animation: rotate_anti 50s linear infinite;
}
.e5_2_ghost {
border-top-color: rgba(var(--arc-color), 0.05);
border-left-color: rgba(var(--arc-color), 0.05);
animation: rotate 45s linear infinite;
}
.e5_3_ghost {
border-right-color: rgba(var(--arc-color), 0.06);
border-bottom-color: rgba(var(--arc-color), 0.06);
animation: rotate_anti 40s linear infinite;
}
.e5_4_ghost {
border-top-color: rgba(var(--arc-color), 0.08);
border-left-color: rgba(var(--arc-color), 0.08);
animation: rotate 42s linear infinite;
}
}
// [ IDLE state ]--
.reactor-container.disconnected::before {
opacity: 0.3;
transform: translate(-50%, -50%) scale(0.7);
}
// [ IDLE ]
.reactor-container.idle {
transform: scale(0.9);
transform: scale(0.95);
opacity: 0.9;
.coil-container {
animation-duration: 10s;
.coil-container { animation-duration: 10s; }
.e7 { opacity: 0.6; }
.e5_1 {
border-top-color: rgba(var(--arc-color), 0.2);
border-left-color: rgba(var(--arc-color), 0.2);
animation: rotate 9s linear infinite;
}
.e5_2 {
border-right-color: rgba(var(--arc-color), 0.35);
border-bottom-color: rgba(var(--arc-color), 0.35);
animation: rotate_anti 15s linear infinite;
}
.e5_3 {
border-top-color: rgba(var(--arc-color), 0.5);
animation: rotate 12s linear infinite;
}
.e5_4 {
border-bottom-color: rgba(var(--arc-color), 0.65);
animation: rotate_anti 14s linear infinite;
}
.reactor-container-inner {
box-shadow: 0px 0px 50px 15px $colour3, inset 0px 0px 50px 15px $colour3;
.e5_1_ghost {
border-top-color: rgba(var(--arc-color), 0.06);
border-right-color: rgba(var(--arc-color), 0.06);
animation: rotate_anti 14s linear infinite;
}
.e5_2_ghost {
border-top-color: rgba(var(--arc-color), 0.1);
border-left-color: rgba(var(--arc-color), 0.1);
animation: rotate 22s linear infinite;
}
.e5_3_ghost {
border-right-color: rgba(var(--arc-color), 0.15);
border-bottom-color: rgba(var(--arc-color), 0.15);
animation: rotate_anti 18s linear infinite;
}
.e5_4_ghost {
border-top-color: rgba(var(--arc-color), 0.2);
border-left-color: rgba(var(--arc-color), 0.2);
animation: rotate 21s linear infinite;
}
}
// [ ACTIVE state (listening/processing) ]--
.reactor-container.idle::before {
opacity: 0.7;
transform: translate(-50%, -50%) scale(0.9);
}
// [ ACTIVE ]
.reactor-container.active {
transform: scale(1);
transform: scale(1.05);
opacity: 1;
.coil-container {
animation-duration: 3s;
.coil-container { animation-duration: 3s; }
.e7 { opacity: 1; }
.e5_1 {
border-top-color: rgba(var(--arc-color), 0.3);
border-left-color: rgba(var(--arc-color), 0.3);
animation: rotate 4s linear infinite;
}
.e5_2 {
border-right-color: rgba(var(--arc-color), 0.5);
border-bottom-color: rgba(var(--arc-color), 0.5);
animation: rotate_anti 3s linear infinite;
}
.e5_3 {
border-top-color: rgba(var(--arc-color), 0.7);
animation: rotate 2s linear infinite;
}
.e5_4 {
border-bottom-color: rgba(var(--arc-color), 0.9);
animation: rotate_anti 2.5s linear infinite;
}
.e5_1_ghost {
border-top-color: rgba(var(--arc-color), 0.1);
border-right-color: rgba(var(--arc-color), 0.1);
animation: rotate_anti 6s linear infinite;
}
.e5_2_ghost {
border-top-color: rgba(var(--arc-color), 0.15);
border-left-color: rgba(var(--arc-color), 0.15);
animation: rotate 4.5s linear infinite;
}
.e5_3_ghost {
border-right-color: rgba(var(--arc-color), 0.2);
border-bottom-color: rgba(var(--arc-color), 0.2);
animation: rotate_anti 3s linear infinite;
}
.e5_4_ghost {
border-top-color: rgba(var(--arc-color), 0.28);
border-left-color: rgba(var(--arc-color), 0.28);
animation: rotate 3.75s linear infinite;
}
.reactor-container-inner {
box-shadow: 0px 0px 70px 25px $colour3, inset 0px 0px 70px 25px $colour3;
box-shadow: 0px 0px 70px 25px rgba(var(--arc-color), 0.3),
inset 0px 0px 70px 25px rgba(var(--arc-color), 0.3);
}
.core-inner {
box-shadow: 0px 0px 15px 10px #52fefe, 0px 0px 20px 15px #52fefe inset;
box-shadow: 0px 0px 15px 10px var(--arc-glow),
0px 0px 20px 15px var(--arc-glow) inset;
}
.core-outer {
box-shadow: 0px 0px 5px 3px #52fefe, 0px 0px 15px 10px #52fefe inset;
box-shadow: 0px 0px 5px 3px var(--arc-glow),
0px 0px 15px 10px var(--arc-glow) inset;
}
.core-wrapper {
box-shadow: 0px 0px 10px 8px #52fefe, 0px 0px 10px 5px #52fefe inset;
box-shadow: 0px 0px 10px 8px var(--arc-glow),
0px 0px 10px 5px var(--arc-glow) inset;
}
.tunnel {
box-shadow: 0px 0px 10px 3px #52fefe, 0px 0px 10px 8px #52fefe inset;
box-shadow: 0px 0px 10px 3px var(--arc-glow),
0px 0px 10px 8px var(--arc-glow) inset;
}
.e7 {
opacity: 0.6;
}
.e5_1 { animation: rotate 3s linear infinite; }
.e5_2 { animation: rotate_anti 2s linear infinite; }
.e5_3 { animation: rotate 2s linear infinite; }
.e5_4 { animation: rotate_anti 2s linear infinite; }
.marks li {
animation: colour_ease2_active 1s infinite ease-in-out;
}
}
@keyframes colour_ease2_active {
0% { background: $marks-color-1; box-shadow: 0 0 5px $marks-color-1; }
50% { background: $marks-color-2; box-shadow: none; }
100% { background: $marks-color-1; box-shadow: 0 0 5px $marks-color-1; }
.reactor-container.active::before {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
animation: bg-pulse 3s ease-in-out infinite;
}
// [ Pulse animation for listening ]--
@keyframes listening-pulse {
@keyframes bg-pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
50% {
transform: scale(1.03);
opacity: 0.85;
transform: translate(-50%, -50%) scale(1.05);
}
}
// [ State Label ]
.state-label {
margin-top: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #52fefe;
box-shadow: 0 0 8px #52fefe;
}
.label-text {
font-size: 0.9rem;
color: rgba(82, 254, 254, 0.8);
text-transform: uppercase;
letter-spacing: 3px;
font-weight: 400;
font-family: "Roboto Condensed", sans-serif; // "Trebuchet MS", Arial, Helvetica, sans-serif
}
.reactor-container.active + .state-label {
.status-dot {
animation: dot-pulse 0.8s ease-in-out infinite;
}
.label-text {
color: #52fefe;
}
}
.reactor-container.disconnected + .state-label {
.status-dot {
background: #445555;
box-shadow: none;
}
.label-text {
color: #445555;
}
}
@keyframes dot-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.7; }
@keyframes colour_ease2_active {
0% { background: rgba(var(--arc-color), 1); box-shadow: 0 0 5px rgba(var(--arc-color), 1); }
50% { background: rgba(var(--arc-color), 0.3); box-shadow: none; }
100% { background: rgba(var(--arc-color), 1); box-shadow: 0 0 5px rgba(var(--arc-color), 1); }
}
</style>

View File

@@ -1,4 +1,8 @@
<script lang="ts">
import { translations, translate } from "@/stores"
$: t = (key: string) => translate($translations, key)
let searchQuery = ""
</script>
@@ -8,12 +12,11 @@
bind:value={searchQuery}
type="text"
name="q"
placeholder="Введите команду или скажите «Джарвис» ..."
placeholder={t('search-placeholder')}
autocomplete="off"
minlength="3"
maxlength="30"
/>
<button type="submit" aria-label="Search"></button>
<small>Enter</small>
</form>
</div>
</div>

View File

@@ -1,346 +1,130 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { capitalizeFirstLetter } from "@/functions"
import { onMount } from "svelte"
import {
isJarvisRunning,
jarvisRamUsage,
jarvisCpuUsage,
ipcConnected,
translations,
translate
} from "@/stores"
import {
Text,
} from "@svelteuidev/core"
$: t = (key: string) => translate($translations, key)
let jarvisStats = { running: false, ram_mb: 0, cpu_usage: 0 }
let microphoneLabel = ""
let wakeWordEngine = ""
let microphoneName = "Загрузка..."
let wakeWordEngine = "Rustpotter"
let sttEngine = "Vosk"
// let ramUsage = "-"
let statsUpdateInterval: number | null = null
async function updateStats() {
try {
jarvisStats = await invoke<{running: boolean, ram_mb: number, cpu_usage: number}>("get_jarvis_app_stats")
//const usage = await invoke<number>("get_current_ram_usage")
//ramUsage = usage.toFixed(2)
} catch (err) {
console.error("failed to get ram usage:", err)
}
}
let vadInfo = "Snip + ChatGPT"
onMount(async () => {
// start polling ram usage
updateStats()
statsUpdateInterval = setInterval(updateStats, 5000) as unknown as number
microphoneName = t('stats-loading')
try {
// load microphone info
const micIndex = Number(await invoke<string>("db_read", { key: "selected_microphone" }))
microphoneLabel = await invoke<string>("pv_get_audio_device_name", { idx: micIndex })
const micIndex = await invoke<string>("db_read", { key: "selected_microphone" })
if (micIndex && micIndex !== "-1") {
const devices = await invoke<string[]>("pv_get_audio_devices")
const idx = parseInt(micIndex)
if (devices[idx]) {
microphoneName = devices[idx]
}
} else {
microphoneName = t('stats-system-default')
}
// load wake word engine
const engine = await invoke<string>("db_read", { key: "selected_wake_word_engine" })
wakeWordEngine = capitalizeFirstLetter(engine)
wakeWordEngine = await invoke<string>("db_read", { key: "selected_wake_word_engine" }) || "Rustpotter"
sttEngine = await invoke<string>("db_read", { key: "selected_stt_engine" }) || "Vosk"
vadInfo = await invoke<string>("db_read", { key: "vad" }) || "Vosk"
} catch (err) {
console.error("failed to load stats:", err)
console.error("Failed to load stats:", err)
microphoneName = t('stats-not-selected')
}
})
onDestroy(() => {
if (statsUpdateInterval) {
clearInterval(statsUpdateInterval)
}
})
function truncate(str: string, max: number): string {
return str.length > max ? str.slice(0, max) + "..." : str
}
</script>
<div class="statistics">
<div class="online">
<div class="pulse"><div class="wave"></div></div>
<div class="info">
<span class="num">Микрофон</span>
<small title={microphoneLabel}>{microphoneLabel}</small>
<div class="stats-bar">
<div class="stat-item">
<span class="stat-dot" class:active={$isJarvisRunning} style="--color: #22c55e;"></span>
<div class="stat-content">
<span class="stat-label">{t('stats-microphone')}</span>
<span class="stat-value" title="{microphoneName}">{truncate(microphoneName, 18)}</span>
</div>
</div>
<div class="files">
<div class="pulse"><div class="wave"></div></div>
<div class="info">
<span class="num">Нейросети</span>
<small>{wakeWordEngine} + {sttEngine}</small>
<div class="stat-item">
<span class="stat-dot" class:active={$ipcConnected} style="--color: #f97316;"></span>
<div class="stat-content">
<span class="stat-label">{t('stats-neural-networks')}</span>
<span class="stat-value"><span title="Wake Word Engine">{wakeWordEngine}</span> + <span title="Speech To Text">{sttEngine}</span></span>
<span class="stat-value-sub">{#if vadInfo != "None"}VAD: {vadInfo}{/if}</span>
</div>
</div>
<div class="downloads hint--bottom" aria-label="Общее количество скачиваний по всему проекту">
<div class="pulse"><div class="wave"></div></div>
<div class="info">
<span class="num">Ресурсы</span>
{#if jarvisStats.running}
<small>RAM: {jarvisStats.ram_mb} MB</small>
<!--<Text>CPU: {jarvisStats.cpu_usage.toFixed(1)}%</Text>-->
{:else}
<Text color="gray">-</Text>
{/if}
<div class="stat-item">
<span class="stat-dot" class:active={$ipcConnected} style="--color: #3b82f6;"></span>
<div class="stat-content">
<span class="stat-label">{t('stats-resources')}</span>
<span class="stat-value">{#if jarvisRamUsage }RAM {$jarvisRamUsage}mb{:else}...{/if}</span>
</div>
</div>
</div>
<style lang="scss">
.statistics {
position: relative;
z-index: 3;
padding: 0 10px;
height: 100px;
.stats-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.1rem 1.5rem;
background: transparent;
}
& > div {
height: 70px;
}
.stat-item {
display: flex;
align-items: flex-start;
gap: 0.6rem;
}
.info {
z-index: 10;
}
.stat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 0.5rem;
background: rgba(70, 70, 70, 0.6);
transition: all 0.3s ease;
// [ Online/Microphone stat ]--
& > .online {
position: relative;
width: 40%;
$base-color: rgba(0, 191, 8, 1);
$mid-color: rgba(0, 191, 8, 0.4);
$end-color: rgba(0, 191, 8, 0);
& > .pulse::before {
background-color: $base-color;
}
& > .pulse::after {
background-color: $base-color;
animation: online-cdot linear 3s infinite forwards;
}
& > .pulse .wave {
background-color: $mid-color;
animation: online-radarWave cubic-bezier(0, 0.54, 0.53, 1) 3s 0s infinite;
}
& > .pulse .wave::after {
background-color: $mid-color;
animation: online-radarWave cubic-bezier(0, 0.54, 0.53, 1) 3s 0.1s infinite;
}
& > .info {
position: absolute;
top: 26px;
left: 26px;
& > span.num {
font-size: 18px;
font-weight: bold;
color: #00bf08;
}
& > small {
display: block;
color: #535a60;
font-size: 12px;
position: relative;
top: 0;
width: 130px;
max-height: 40px;
overflow: hidden;
line-height: 1.5em;
}
}
@keyframes online-cdot {
0% { opacity: 0.3; background: $base-color; }
50% { opacity: 0.5; }
100% { opacity: 1; background: $end-color; }
}
@keyframes online-radarWave {
0% { opacity: 0.1; transform: scale(0); }
5% { background: $mid-color; opacity: 1; }
100% { transform: scale(1.2); background: $end-color; }
}
}
// [ Files/Neural networks stat ]--
& > .files {
position: relative;
width: 35%;
$base-color: rgba(255, 129, 48, 1);
$mid-color: rgba(255, 129, 48, 0.4);
$end-color: rgba(255, 129, 48, 0);
& > .pulse::before {
background-color: $base-color;
}
& > .pulse::after {
background-color: $base-color;
animation: files-cdot linear 5s infinite forwards;
}
& > .pulse .wave {
background-color: $mid-color;
animation: files-radarWave cubic-bezier(0, 0.54, 0.53, 1) 5s 0s infinite;
}
& > .pulse .wave::after {
background-color: $mid-color;
animation: files-radarWave cubic-bezier(0, 0.54, 0.53, 1) 5s 0.1s infinite;
}
& > .info {
position: absolute;
top: 26px;
left: 26px;
& > span.num {
font-size: 18px;
font-weight: bold;
color: #ff8130;
}
& > small {
display: block;
color: #535a60;
font-size: 12px;
position: relative;
top: 0;
}
}
@keyframes files-cdot {
0% { opacity: 0.3; background: $base-color; }
50% { opacity: 0.5; }
100% { opacity: 1; background: $end-color; }
}
@keyframes files-radarWave {
0% { opacity: 0.1; transform: scale(0); }
5% { background: $mid-color; transform: scale(0.2); opacity: 1; }
100% { transform: scale(0.8); background: $end-color; }
}
}
// [ Downloads/Resources stat ]--
& > .downloads {
position: relative;
$base-color: rgba(11, 66, 166, 1);
$mid-color: rgba(32, 150, 243, 0.4);
$end-color: rgba(32, 150, 243, 0);
& > .pulse::before {
background: rgba(32, 150, 243, 1);
}
& > .pulse::after {
background: rgba(32, 150, 243, 1);
animation: downloads-cdot linear 7s infinite forwards;
animation-delay: 1s;
}
& > .pulse .wave {
background-color: $mid-color;
animation: downloads-radarWave cubic-bezier(0, 0.54, 0.53, 1) 7s 0s infinite;
animation-delay: 1s;
}
& > .pulse .wave::after {
background-color: $mid-color;
animation: downloads-radarWave cubic-bezier(0, 0.54, 0.53, 1) 7s 0.1s infinite;
animation-delay: 1s;
}
& > .info {
position: absolute;
top: 26px;
left: 26px;
& > span.num {
font-size: 18px;
font-weight: bold;
color: #1b78a6;
}
& > small {
display: block;
color: #535a60;
font-size: 12px;
position: relative;
top: 0;
}
}
@keyframes downloads-cdot {
0% { opacity: 0.3; background: $base-color; }
50% { opacity: 0.5; }
100% { opacity: 1; background: $end-color; }
}
@keyframes downloads-radarWave {
0% { opacity: 0.1; transform: scale(0); }
5% { background: $mid-color; opacity: 1; }
100% { transform: scale(0.7); background: $end-color; }
}
}
// [ Shared pulse styles ]--
.pulse {
position: relative;
height: 100px;
width: 100px;
margin: 0;
left: -43px;
top: 0px;
z-index: 5;
}
.pulse::before {
content: "";
position: absolute;
width: 11px;
height: 11px;
border-radius: 50%;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
opacity: 0.5;
}
.pulse::after {
content: "";
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.pulse .wave {
position: absolute;
left: 7%;
top: 7%;
width: 86%;
height: 86%;
border-radius: 50%;
opacity: 0;
}
.pulse .wave::after {
content: "";
position: absolute;
left: 7%;
top: 7%;
width: 86%;
height: 86%;
border-radius: 50%;
opacity: 0;
&.active {
background: var(--color);
box-shadow: 0 0 10px var(--color);
}
}
</style>
.stat-content {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.stat-label {
font-size: 0.8rem;
font-weight: 600;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.42);
line-height: 1.35;
font-style: italic;
}
.stat-value-sub {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.28);
}
</style>

View File

@@ -0,0 +1,411 @@
<script lang="ts">
import { jarvisState } from "@/stores"
// map state to class
$: stateClass = getStateClass($jarvisState)
function getStateClass(state: string): string {
switch (state) {
case "listening":
case "processing":
return "active"
case "idle":
return "idle"
case "disconnected":
default:
return "disconnected"
}
}
</script>
<!-- Based on: https://github.com/rembertdesigns/Iron-Man-Arc-Reactor-Pure-CSS -->
<!-- and https://codepen.io/FlyingEmu/pen/DZNqEj -->
<div class="arc-reactor-wrapper">
<div id="arc-reactor" class="reactor-container {stateClass}">
<div class="reactor-container-inner circle abs-center">
<ul class="marks">
{#each Array(60) as _, i}
<li></li>
{/each}
</ul>
<div class="e7">
<div class="semi_arc_3 e5_1">
<div class="semi_arc_3 e5_2">
<div class="semi_arc_3 e5_3">
<div class="semi_arc_3 e5_4"></div>
</div>
</div>
</div>
</div>
</div>
<div class="tunnel circle abs-center"></div>
<div class="core-wrapper circle abs-center"></div>
<div class="core-outer circle abs-center"></div>
<div class="core-inner circle abs-center"></div>
<div class="coil-container">
{#each Array(8) as _, i}
<div class="coil coil-{i + 1}"></div>
{/each}
</div>
</div>
<div class="state-label">
<span class="status-dot"></span>
<span class="label-text">
{#if $jarvisState === "disconnected"}
Отключен
{:else if $jarvisState === "idle"}
Ожидание
{:else if $jarvisState === "listening"}
Слушаю
{:else if $jarvisState === "processing"}
Обработка
{/if}
</span>
</div>
</div>
<style lang="scss" global>
// [ Variables ]--
$arc-radius: 130px;
$size3: 6px;
$cshadow: rgba(2, 254, 255, 0.8);
$marks-color-1: rgba(2, 254, 255, 1);
$marks-color-2: rgba(2, 254, 255, 0.3);
$colour1: rgba(2, 255, 255, 0.15);
$colour3: rgba(2, 255, 255, 0.3);
// [ Wrapper ]--
.arc-reactor-wrapper {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
}
.state-label {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #52fefe;
text-transform: uppercase;
letter-spacing: 2px;
opacity: 0.8;
transition: opacity 0.3s ease;
}
// [ Base container ]--
.reactor-container {
width: 300px;
height: 320px;
margin: auto;
position: relative;
border-radius: 50%;
transition: scale 1s ease, opacity 0.5s ease;
scale: 0.9;
opacity: 0.9;
ul {
list-style: none;
margin: 0;
padding: 0;
}
}
.reactor-container-inner {
height: 238px;
width: 238px;
background-color: #161a1b;
box-shadow: 0px 0px 50px 15px $colour3, inset 0px 0px 50px 15px $colour3;
transition: box-shadow 0.5s ease;
}
// [ Utility classes ]--
.circle {
border-radius: 50%;
}
.abs-center {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
}
// [ Core elements ]--
.core-inner {
width: 70px;
height: 70px;
border: 5px solid #1b4e5f;
background-color: #ffffff;
box-shadow: 0px 0px 7px 5px #52fefe, 0px 0px 10px 10px #52fefe inset;
transition: box-shadow 0.5s ease;
}
.core-outer {
width: 120px;
height: 120px;
border: 1px solid #52fefe;
background-color: #ffffff;
box-shadow: 0px 0px 2px 1px #52fefe, 0px 0px 10px 5px #52fefe inset;
transition: box-shadow 0.5s ease;
}
.core-wrapper {
width: 180px;
height: 180px;
background-color: #073c4b;
box-shadow: 0px 0px 5px 4px #52fefe, 0px 0px 6px 2px #52fefe inset;
transition: box-shadow 0.5s ease;
}
.tunnel {
width: 220px;
height: 220px;
background-color: #ffffff;
box-shadow: 0px 0px 5px 1px #52fefe, 0px 0px 5px 4px #52fefe inset;
transition: box-shadow 0.5s ease;
}
// [ Coil animation ]--
.coil-container {
position: relative;
width: 100%;
height: 100%;
animation: 10s infinite linear reactor-anim;
transition: animation-duration 0.5s ease;
}
.coil {
position: absolute;
width: 30px;
height: 20px;
top: calc(50% - 110px);
left: calc(50% - 15px);
transform-origin: 15px 110px;
background-color: #073c4b;
box-shadow: 0px 0px 5px #52fefe inset;
}
@for $i from 1 through 8 {
.coil-#{$i} {
transform: rotate(#{($i - 1) * 45}deg);
}
}
@keyframes reactor-anim {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// [ Arc element ]--
.e7 {
position: relative;
z-index: 1;
width: 160%;
height: 160%;
left: -32.5%;
top: -32.5%;
right: 0;
bottom: 0;
margin: auto;
border: $size3 solid transparent;
background: transparent;
border-radius: 50%;
transform: rotateZ(0deg);
transition: box-shadow 3s ease, opacity 0.5s ease;
text-align: center;
opacity: 0.3;
}
.semi_arc_3 {
content: "";
position: absolute;
width: 94%;
height: 94%;
left: 3%;
top: 3%;
border: 5px solid #02feff;
border-radius: 50%;
box-sizing: border-box;
animation: rotate 4s linear infinite;
text-align: center;
overflow: hidden;
}
@keyframes rotate {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(360deg); }
}
@keyframes rotate_anti {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(-360deg); }
}
// [ Marks ]--
.marks {
li {
width: 11px;
height: 11px;
background: $cshadow;
position: absolute;
margin-left: 117.5px;
margin-top: 113.5px;
animation: colour_ease2 3s infinite ease-in-out;
}
}
@keyframes colour_ease2 {
0% { background: $marks-color-1; }
50% { background: $marks-color-2; }
100% { background: $marks-color-1; }
}
@for $i from 1 through 60 {
.marks li:nth-child(#{$i}) {
transform: rotate(#{$i * 6}deg) translateY($arc-radius);
}
}
// [ DISCONNECTED state ]--
.reactor-container.disconnected {
transform: scale(0.8);
opacity: 0.4;
filter: grayscale(0.8) brightness(0.6);
.coil-container {
animation-duration: 20s;
}
.state-label {
opacity: 0.5;
}
}
// [ IDLE state ]--
.reactor-container.idle {
transform: scale(0.9);
opacity: 0.9;
.coil-container {
animation-duration: 10s;
}
.reactor-container-inner {
box-shadow: 0px 0px 50px 15px $colour3, inset 0px 0px 50px 15px $colour3;
}
}
// [ ACTIVE state (listening/processing) ]--
.reactor-container.active {
transform: scale(1);
opacity: 1;
.coil-container {
animation-duration: 3s;
}
.reactor-container-inner {
box-shadow: 0px 0px 70px 25px $colour3, inset 0px 0px 70px 25px $colour3;
}
.core-inner {
box-shadow: 0px 0px 15px 10px #52fefe, 0px 0px 20px 15px #52fefe inset;
}
.core-outer {
box-shadow: 0px 0px 5px 3px #52fefe, 0px 0px 15px 10px #52fefe inset;
}
.core-wrapper {
box-shadow: 0px 0px 10px 8px #52fefe, 0px 0px 10px 5px #52fefe inset;
}
.tunnel {
box-shadow: 0px 0px 10px 3px #52fefe, 0px 0px 10px 8px #52fefe inset;
}
.e7 {
opacity: 0.6;
}
.e5_1 { animation: rotate 3s linear infinite; }
.e5_2 { animation: rotate_anti 2s linear infinite; }
.e5_3 { animation: rotate 2s linear infinite; }
.e5_4 { animation: rotate_anti 2s linear infinite; }
.marks li {
animation: colour_ease2_active 1s infinite ease-in-out;
}
}
@keyframes colour_ease2_active {
0% { background: $marks-color-1; box-shadow: 0 0 5px $marks-color-1; }
50% { background: $marks-color-2; box-shadow: none; }
100% { background: $marks-color-1; box-shadow: 0 0 5px $marks-color-1; }
}
// [ Pulse animation for listening ]--
@keyframes listening-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.03);
}
}
// [ State Label ]
.state-label {
margin-top: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #52fefe;
box-shadow: 0 0 8px #52fefe;
}
.label-text {
font-size: 0.9rem;
color: rgba(82, 254, 254, 0.8);
text-transform: uppercase;
letter-spacing: 3px;
font-weight: 400;
font-family: "Roboto Condensed", sans-serif; // "Trebuchet MS", Arial, Helvetica, sans-serif
}
.reactor-container.active + .state-label {
.status-dot {
animation: dot-pulse 0.8s ease-in-out infinite;
}
.label-text {
color: #52fefe;
}
}
.reactor-container.disconnected + .state-label {
.status-dot {
background: #445555;
box-shadow: none;
}
.label-text {
color: #445555;
}
}
@keyframes dot-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.7; }
}
</style>

View File

@@ -1,3 +1,37 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', 'Inter', -apple-system, sans-serif;
background-color: #0a1214;
color: #ffffff;
overflow: hidden;
background-color: darken($color: #090C10, $amount: 0.8)!important;
// background-image: url(../media/bg.png)!important;
background-repeat: no-repeat;
background-size: contain;
// background-attachment: fixed;
background-attachment: none;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(82, 254, 254, 0.3);
border-radius: 3px;
}
// ### FONTS
$prim-font: "Roboto", sans-serif;
$sec-font: "Roboto Condensed", sans-serif;
@@ -46,10 +80,9 @@ body {
justify-content: space-between;
.logo {
margin-top: 12px;
& > a > img {
width: 57px;
width: 55px;
display: inline-block;
transition: 0.5s opacity ease-in;
@@ -60,8 +93,6 @@ body {
& > div {
display: inline-block;
margin-left: 13px;
margin-top: 8px;
vertical-align: top;
}
@@ -95,6 +126,147 @@ body {
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: transparent;
}
.header-left {
display: flex;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 0.85rem;
}
.logo-image {
width: 54px;
height: 54px;
border-radius: 12px;
object-fit: cover;
}
.logo-text {
display: flex;
flex-direction: column;
}
.logo-title {
font-size: 1.5rem;
font-weight: 700;
color: #ffffff;
letter-spacing: -1px;
margin-top: 10px;
display: block;
& > #jarvis-logo {
display: block;
width: 100px;
height: 20px;
background: red;
background: url(../media/jarvis.png);
background-size: 80px;
background-repeat: no-repeat;
transition: all 0.3s ease;
&:hover {
background: url(../media/jarvis-hover.png);
background-size: 80px;
background-repeat: no-repeat;
border: none!important;
text-decoration: none;
}
}
}
.logo-version {
font-size: 0.7rem;
color: #aaa;
text-transform: uppercase;
font-style: italic;
letter-spacing: 1px;
font-weight: 500;
letter-spacing: 1px;
.v-badge {
color: #8AC832;
opacity: 0.9;
letter-spacing: 0;
font-weight: 600;
}
}
.header-right {
display: flex;
align-items: center;
gap: 0.6rem;
}
.header-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
background: transparent;
border: none;
border-radius: 6px;
color: #ffffff;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 1.2px;
cursor: pointer;
font-weight: 600;
font-family: "Trebuchet MS", Arial, Helvetica, sans-serif;
opacity: 0.85;
&:hover {
opacity: 1;
background: rgba(35, 50, 55, 0.95);
border-color: rgba(255, 255, 255, 0.12);
}
}
.btn-icon {
opacity: 0.75;
}
.btn-badge {
padding: 0.25rem 0.3rem;
border-radius: 2px;
font-size: 0.7rem;
font-weight: 700;
color: #ffffff;
letter-spacing: 0;
text-shadow: 0px 0px 13.92px rgba(55, 55, 55, 0.54);
&.purple {
background-image: -moz-linear-gradient( -84deg, rgb(153,41,234) 0%, rgb(88,8,251) 100%);
background-image: -webkit-linear-gradient( -84deg, rgb(153,41,234) 0%, rgb(88,8,251) 100%);
background-image: -ms-linear-gradient( -84deg, rgb(153,41,234) 0%, rgb(88,8,251) 100%);
box-shadow: 0px 12px 14.56px 1.44px rgba(55, 55, 55, 0.15);
}
&.blue {
background: #3b82f6;
}
&.gray {
background: #333;
}
}
.beta-badge {
color: #8AC832;
opacity: 0.9;
font-size: 13px;
}
.top-menu {
vertical-align: top;
margin-top: 18px;
@@ -165,33 +337,67 @@ body {
}
}
.h-divider {
position: relative;
z-index: 1;
}
// ### SEARCH BAR
.search {
display: block;
margin: 20px 0;
margin: 0;
text-align: center;
position: relative;
z-index: 1;
& > .h-divider {
margin: 10px 0!important;
background-position: left!important;
}
& > #search-form {
margin: 0;
position: relative;
}
& > form {
position: relative;
& > input {
width: 380px;
width: 97%;
height: 38px;
margin: 0 auto;
box-shadow: inset 0 0 5px 1px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(6, 6, 6, 0.99);
background-color: #0f1012;
outline: none;
color: #d1d1d1;
font-family: $sec-font;
font-size: 17px;
font-weight: 600;
font-weight: 400;
line-height: 70.58px;
padding-left: 20px;
padding-left: 15px;
padding-right: 45px;
border-style: solid;
border-width: 3px;
border-color: rgba(6, 6, 6, 0.5);
background-color: rgba(25, 26, 28, 0.7);
opacity: 0.99;
box-shadow: inset 0px 0px 18.8px 1.2px rgba(0, 0, 0, 0.6);
&::placeholder {
color: #676767;
font-weight: 400;
font-weight: 300;
font-style: italic;
font-family: "Roboto Condensed", "Trebuchet MS", Arial, Helvetica, sans-serif;
opacity: 0.7;
font-size: 16px;
font-family: "Roboto Condensed";
color: rgb(103, 103, 103);
font-style: italic;
line-height: 4.437;
text-align: left;
}
&:focus + button + small {
@@ -237,17 +443,19 @@ body {
font-family: $sec-font;
font-size: 14px;
font-weight: bold;
line-height: 1.7em;
top: 8px;
right: 75px;
line-height: 1.5em;
top: 9px;
right: 15px;
background-color: #d1d1d1;
color: #080c0f;
color: #161B1F;
padding: 0 7px;
padding-top: 2px;
border-radius: 4px;
padding-top: 0;
border-radius: 2px;
opacity: 0;
transition: opacity 0.3s;
cursor: default;
border: none;
text-transform: uppercase;
}
}
@@ -258,6 +466,109 @@ body {
}
}
// ### MAIN
.search-section {
padding: 0.5rem 1.5rem;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: 1rem;
}
.reactor-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
position: relative;
}
.reactor-wrapper {
transition: opacity 0.5s ease, filter 0.5s ease;
&.dimmed {
opacity: 0.15;
filter: grayscale(0.5);
}
}
.offline-badge {
display: flex;
align-items: center;
gap: 0.75rem;
position: absolute;
top: 52%;
left: 50%;
transform: translate(-50%, -50%);
margin-top: -3rem;
& > small {
display: block;
position: absolute;
top: 27px;
text-align: center;
width: 100%;
opacity: 0.7;
}
}
.offline-icon {
font-size: 1.3rem;
color: #fbbf24;
}
.offline-text {
color: #fbbf24;
font-size: 0.9rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 3px;
white-space: nowrap;
}
.start-button {
background: transparent;
border: 0 solid rgba(82, 254, 254, 0.4);
color: #52fefe;
padding: 0.75rem 2.5rem;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 4px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin-top: 2rem;
background: rgba(82, 254, 254, 0.1);
border-color: rgba(82, 254, 254, 0.7);
box-shadow: 0 0 20px rgba(82, 254, 254, 0.15);
&:hover:not(:disabled) {
background: rgba(82, 254, 254, 0.2);
border-color: rgba(82, 254, 254, 0.9);
box-shadow: 0 0 20px rgba(82, 254, 254, 0.25);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// ### RESPONSIVE
@media (max-width: 1364px) {
#content > .inner > section.materials > header > h1 {

55
frontend/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,55 @@
import { writable, derived } from "svelte/store"
import { invoke } from "@tauri-apps/api/core"
// stores
export const translations = writable<Record<string, string>>({})
export const currentLanguage = writable<string>("ru")
// simple helper function (not a store)
export function translate(translations: Record<string, string>, key: string, fallback?: string): string {
return translations[key] || fallback || key
}
// load translations from backend
export async function loadTranslations() {
try {
const [trans, lang] = await Promise.all([
invoke<Record<string, string>>("get_translations"),
invoke<string>("get_current_language")
])
translations.set(trans)
currentLanguage.set(lang)
} catch (err) {
console.error("Failed to load translations:", err)
}
}
// change language
export async function setLanguage(lang: string) {
try {
const newTranslations = await invoke<Record<string, string>>("set_language", { lang })
translations.set(newTranslations)
currentLanguage.set(lang)
} catch (err) {
console.error("Failed to set language:", err)
}
}
export async function loadLanguage() {
try {
const lang = await invoke<string>("db_read", { key: "language" })
if (lang) {
currentLanguage.set(lang)
}
} catch (err) {
console.error("Failed to load language:", err)
}
}
export async function getSupportedLanguages(): Promise<string[]> {
try {
return await invoke<string[]>("get_supported_languages")
} catch {
return ["ru", "en", "ua"]
}
}

View File

@@ -4,7 +4,9 @@
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import { appInfo } from "@/stores"
import { appInfo, translations, translate } from "@/stores"
$: t = (key: string) => translate($translations, key)
let tgLink = ""
appInfo.subscribe(info => {
@@ -15,13 +17,13 @@
<Space h="xl" />
<Notification
title="[404] Этот раздел еще находится в разработке!"
title={t('commands-wip-title')}
icon={InfoCircled}
color="blue"
withCloseButton={false}
>
Тут будет список команд + полноценный редактор команд.<br />
Следите за обновлениями в <a href={tgLink} target="_blank">нашем телеграм канале</a>!
{t('commands-wip-desc')}<br />
{t('commands-wip-follow')} <a href={tgLink} target="_blank">{t('commands-wip-channel')}</a>!
</Notification>
<div class="placeholder-image">
@@ -36,4 +38,4 @@
text-align: center;
margin-top: 25px;
}
</style>
</style>

View File

@@ -1,46 +1,41 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { Notification, Space, Button } from "@svelteuidev/core"
import { InfoCircled } from "radix-icons-svelte"
import SearchBar from "@/components/elements/SearchBar.svelte"
import ArcReactor from "@/components/elements/ArcReactor.svelte"
import HDivider from "@/components/elements/HDivider.svelte"
import Stats from "@/components/elements/Stats.svelte"
import Footer from "@/components/Footer.svelte"
import {
isJarvisRunning,
updateJarvisStats,
jarvisState,
ipcConnected,
enableIpc,
disableIpc
disableIpc,
translate,
translations
} from "@/stores"
$: t = (key: string) => translate($translations, key)
let processRunning = false
let launching = false
// when process state changes, enable/disable IPC
isJarvisRunning.subscribe((value) => {
processRunning = value
if (value) {
// process is running, enable IPC connection
enableIpc()
} else {
// process stopped, disable IPC (stops reconnect attempts)
disableIpc()
}
})
onMount(() => {
document.body.classList.add("assist-page")
updateJarvisStats()
})
onDestroy(() => {
document.body.classList.remove("assist-page")
disableIpc()
})
@@ -48,8 +43,6 @@
launching = true
try {
await invoke("run_jarvis_app")
// wait for startup
setTimeout(async () => {
await updateJarvisStats()
launching = false
@@ -61,46 +54,35 @@
}
</script>
<HDivider />
<div class="app-container assist-page">
{#if !processRunning}
<Notification
title="Внимание!"
icon={InfoCircled}
color="cyan"
withCloseButton={false}
>
В данный момент ассистент не запущен.<br />
Но вы всё еще можете изменять его настройки.<br />
<br />
<div class="search search-section">
<HDivider />
<SearchBar />
</div>
<Button
color="lime"
radius="md"
size="sm"
uppercase
ripple
fullSize
on:click={runAssistant}
disabled={launching}
>
{launching ? "Запуск..." : "Запустить"}
</Button>
</Notification>
{:else}
<ArcReactor />
<div class="reactor-section">
<div class="reactor-wrapper" class:dimmed={!processRunning}>
<ArcReactor />
</div>
{#if !processRunning}
<div class="offline-badge">
<span class="offline-icon"></span>
<span class="offline-text">{t('assistant-not-running')}</span>
<small>{t('assistant-offline-hint')}</small>
</div>
<button
class="start-button"
on:click={runAssistant}
disabled={launching}
>
{launching ? t('btn-starting') : t('btn-start')}
</button>
{/if}
</div>
{#if !$ipcConnected}
<Notification
title="Подключение..."
color="yellow"
withCloseButton={false}
>
Устанавливается связь с ассистентом...
</Notification>
{/if}
{/if}
<HDivider noMargin />
<Stats />
<Footer />
<HDivider noMargin />
<Stats />
<Footer />
</div>

View File

@@ -5,7 +5,7 @@
import { setTimeout } from "worker-timers"
import { showInExplorer } from "@/functions"
import { appInfo, assistantVoice } from "@/stores"
import { appInfo, assistantVoice, translations, translate } from "@/stores"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
@@ -33,6 +33,8 @@
CrossCircled
} from "radix-icons-svelte"
$: t = (key: string) => translate($translations, key)
// ### STATE
interface MicrophoneOption {
label: string
@@ -164,13 +166,13 @@
<Space h="xl" />
<Notification
title="БЕТА версия!"
title={t('settings-beta-title')}
icon={QuestionMarkCircled}
color="blue"
withCloseButton={false}
>
Часть функций может работать некорректно.<br />
Сообщайте обо всех найденных багах в <a href={feedbackLink} target="_blank">наш телеграм бот</a>.
{t('settings-beta-desc')}<br />
{t('settings-beta-feedback')} <a href={feedbackLink} target="_blank">{t('settings-beta-bot')}</a>.
<Space h="sm" />
<Button
color="gray"
@@ -179,7 +181,7 @@
uppercase
on:click={() => showInExplorer(logFilePath)}
>
Открыть папку с логами
{t('settings-open-logs')}
</Button>
</Notification>
@@ -187,7 +189,7 @@
{#if settingsSaved}
<Notification
title="Настройки сохранены!"
title={t('notification-saved')}
icon={Check}
color="teal"
on:close={() => { settingsSaved = false }}
@@ -196,8 +198,7 @@
{/if}
<Tabs class="form" color="#8AC832" position="left">
<!-- general tab -->
<Tabs.Tab label="Общее" icon={Gear}>
<Tabs.Tab label={t('settings-general')} icon={Gear}>
<Space h="sm" />
<NativeSelect
data={[
@@ -206,61 +207,58 @@
{ label: "Jarvis (от Хауди)", value: "jarvis-howdy" },
{ label: "Jarvis OG (из фильмов)", value: "jarvis-og" }
]}
label="Голос ассистента"
description="Не все команды работают со всеми звуковыми пакетами."
label={t('settings-voice')}
description={t('settings-voice-desc')}
variant="filled"
bind:value={voiceVal}
/>
</Tabs.Tab>
<!-- devices tab -->
<Tabs.Tab label="Устройства" icon={Mix}>
<Tabs.Tab label={t('settings-devices')} icon={Mix}>
<Space h="sm" />
<NativeSelect
data={availableMicrophones}
label="Выберите микрофон"
description="Его будет слушать ассистент."
label={t('settings-microphone')}
description={t('settings-microphone-desc')}
variant="filled"
bind:value={selectedMicrophone}
/>
</Tabs.Tab>
<!-- neural networks tab -->
<Tabs.Tab label="Нейросети" icon={Cube}>
<Tabs.Tab label={t('settings-neural-networks')} icon={Cube}>
<Space h="sm" />
<NativeSelect
data={[
{ label: "Rustpotter", value: "Rustpotter" },
{ label: "Vosk (медленный)", value: "Vosk" },
{ label: "Picovoice Porcupine (требует API ключ)", value: "Picovoice" }
{ label: "Vosk", value: "Vosk" },
{ label: "Picovoice Porcupine", value: "Picovoice" }
]}
label="Распознавание активационной фразы (Wake Word)"
description="Выберите, какая нейросеть будет отвечать за распознавание активационной фразы."
label={t('settings-wake-word-engine')}
description={t('settings-wake-word-desc')}
variant="filled"
bind:value={selectedWakeWordEngine}
/>
{#if selectedWakeWordEngine === "picovoice"}
<Space h="sm" />
<Alert title="Внимание!" color="#868E96" variant="outline">
<Alert title={t('settings-attention')} color="#868E96" variant="outline">
<Notification
title="Эта нейросеть работает не у всех!"
title={t('settings-picovoice-warning')}
icon={CrossCircled}
color="orange"
withCloseButton={false}
>
Мы ждем официального патча от разработчиков.
{t('settings-picovoice-waiting')}
</Notification>
<Space h="sm" />
<Text size="sm" color="gray">
Введите сюда свой ключ Picovoice.<br />
Он выдается бесплатно при регистрации в
{t('settings-picovoice-key-desc')}
<a href="https://console.picovoice.ai/" target="_blank">Picovoice Console</a>.
</Text>
<Space h="sm" />
<Input
icon={Code}
placeholder="Ключ Picovoice"
placeholder={t('settings-picovoice-key')}
variant="filled"
autocomplete="off"
bind:value={apiKeyPicovoice}
@@ -272,11 +270,11 @@
{#key availableVoskModels}
<NativeSelect
data={[
{ label: "Авто-определение", value: "" },
{ label: t('settings-auto-detect'), value: "" },
...availableVoskModels
]}
label="Модель распознавания речи (Vosk)"
description="Выберите модель Vosk для распознавания речи."
label={t('settings-vosk-model')}
description={t('settings-vosk-model-desc')}
variant="filled"
bind:value={selectedVoskModel}
/>
@@ -284,9 +282,9 @@
{#if availableVoskModels.length === 0}
<Space h="sm" />
<Alert title="Модели не найдены" color="orange" variant="outline">
<Alert title={t('settings-models-not-found')} color="orange" variant="outline">
<Text size="sm" color="gray">
Поместите модели Vosk в папку resources/vosk
{t('settings-models-hint')}
</Text>
</Alert>
{/if}
@@ -297,8 +295,8 @@
{ label: "Intent Classifier", value: "IntentClassifier" },
{ label: "Rasa", value: "Rasa" }
]}
label="Распознавание команд (Intent Recognition)"
description="Выберите, какая нейросеть будет отвечать за распознавание команд."
label={t('settings-intent-engine')}
description={t('settings-intent-engine-desc')}
variant="filled"
bind:value={selectedIntentRecognitionEngine}
/>
@@ -307,11 +305,11 @@
<NativeSelect
data={[
{ label: "Отключено", value: "None" },
{ label: t('settings-disabled'), value: "None" },
{ label: "Nnnoiseless", value: "Nnnoiseless" }
]}
label="Шумоподавление"
description="Уменьшает фоновый шум. Может ухудшить распознавание в некоторых случаях."
label={t('settings-noise-suppression')}
description={t('settings-noise-suppression-desc')}
variant="filled"
bind:value={selectedNoiseSuppression}
/>
@@ -320,40 +318,39 @@
<NativeSelect
data={[
{ label: "Отключено", value: "None" },
{ label: "Energy (простой)", value: "Energy" },
{ label: "Nnnoiseless (нейросеть)", value: "Nnnoiseless" }
{ label: t('settings-disabled'), value: "None" },
{ label: "Energy", value: "Energy" },
{ label: "Nnnoiseless", value: "Nnnoiseless" }
]}
label="Определение голосой активности (VAD)"
description="Пропускает тишину, экономит ресурсы CPU."
label={t('settings-vad')}
description={t('settings-vad-desc')}
variant="filled"
bind:value={selectedVad}
/>
<Space h="md" />
<InputWrapper label="Нормализация громкости">
<InputWrapper label={t('settings-gain-normalizer')}>
<Text size="sm" color="gray">
Автоматически регулирует уровень громкости.
{t('settings-gain-normalizer-desc')}
</Text>
<Space h="xs" />
<Switch
label={gainNormalizerEnabled ? "Включено" : "Выключено"}
label={gainNormalizerEnabled ? t('settings-enabled') : t('settings-disabled')}
bind:checked={gainNormalizerEnabled}
/>
</InputWrapper>
<Space h="xl" />
<InputWrapper label="Ключ OpenAI">
<InputWrapper label={t('settings-openai-key')}>
<Text size="sm" color="gray">
В данный момент ChatGPT <u>не поддерживается</u>.
Он будет добавлен в ближайших обновлениях.
{t('settings-openai-not-supported')}
</Text>
<Space h="sm" />
<Input
icon={Code}
placeholder="Ключ OpenAI"
placeholder={t('settings-openai-key')}
variant="filled"
autocomplete="off"
bind:value={apiKeyOpenai}
@@ -375,7 +372,7 @@
on:click={saveSettings}
disabled={saveButtonDisabled}
>
Сохранить
{t('settings-save')}
</Button>
<Space h="sm" />
@@ -388,7 +385,7 @@
fullSize
on:click={() => $goto("/")}
>
Назад
{t('settings-back')}
</Button>
<HDivider />

View File

@@ -17,6 +17,17 @@ export {
reloadCommands
} from "./lib/ipc"
// re-export i18n
export {
translations,
currentLanguage,
translate,
loadTranslations,
setLanguage,
loadLanguage,
getSupportedLanguages
} from "./lib/i18n"
// ### RUNNING STATE
export const isJarvisRunning = writable(false)
export const jarvisRamUsage = writable(0)
@@ -30,6 +41,8 @@ export const appInfo = writable({
tgOfficialLink: "",
feedbackLink: "",
repositoryLink: "",
boostySupportLink: "",
patreonSupportLink: "",
logFilePath: ""
})
@@ -45,10 +58,12 @@ export async function loadVoiceSetting() {
export async function loadAppInfo() {
try {
const [tg, feedback, repo, logPath] = await Promise.all([
const [tg, feedback, repo, boosty, patreon, logPath] = await Promise.all([
invoke<string>("get_tg_official_link"),
invoke<string>("get_feedback_link"),
invoke<string>("get_repository_link"),
invoke<string>("get_boosty_link"),
invoke<string>("get_patreon_link"),
invoke<string>("get_log_file_path")
])
@@ -56,6 +71,8 @@ export async function loadAppInfo() {
tgOfficialLink: tg,
feedbackLink: feedback,
repositoryLink: repo,
boostySupportLink: boosty,
patreonSupportLink: patreon,
logFilePath: logPath
})
} catch (err) {