From 04397011a604cf67eff4f8a736d787a19f89943d Mon Sep 17 00:00:00 2001 From: Xavier Lau Date: Thu, 20 Jun 2024 02:38:04 +0800 Subject: [PATCH] Stage 4 --- Cargo.lock | 111 ++++++++++++++++--------------------- Cargo.toml | 14 ++--- asset/retry.svg | 1 + build.rs | 19 ------- src/component.rs | 29 +++++++--- src/component/openai.rs | 15 ++--- src/component/setting.rs | 9 +-- src/component/tokenizer.rs | 1 + src/component/util.rs | 1 + src/main.rs | 44 +++++++++++++-- src/os/macos.rs | 3 +- src/service/hotkey.rs | 4 +- src/ui/panel/chat.rs | 87 +++++++++++++++++++++-------- src/ui/panel/setting.rs | 55 +++++++++++------- 14 files changed, 230 insertions(+), 163 deletions(-) create mode 100644 asset/retry.svg delete mode 100644 build.rs diff --git a/Cargo.lock b/Cargo.lock index d19a4d8..aa5dbdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,8 +192,8 @@ dependencies = [ "tokio", "toml", "tracing", + "tracing-appender", "tracing-subscriber", - "vergen", ] [[package]] @@ -959,7 +959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" dependencies = [ "memchr", - "regex-automata", + "regex-automata 0.4.7", "serde", ] @@ -1039,38 +1039,6 @@ dependencies = [ "wayland-client", ] -[[package]] -name = "camino" -version = "1.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0ec6b951b160caa93cc0c7b209e5a3bff7aae9062213451ac99493cd844c239" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo-platform" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror", -] - [[package]] name = "cc" version = "1.0.99" @@ -2131,8 +2099,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ "bit-set", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", ] [[package]] @@ -3401,6 +3369,15 @@ dependencies = [ "tendril", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4633,8 +4610,17 @@ checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -4645,9 +4631,15 @@ checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.4", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.4" @@ -5029,15 +5021,6 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" -dependencies = [ - "serde", -] - [[package]] name = "serde" version = "1.0.203" @@ -5643,7 +5626,7 @@ dependencies = [ "rayon", "rayon-cond", "regex", - "regex-syntax", + "regex-syntax 0.8.4", "serde", "serde_json", "spm_precompiled", @@ -5820,6 +5803,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.27" @@ -5868,10 +5863,14 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] @@ -6093,20 +6092,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vergen" -version = "8.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e27d6bdd219887a9eadd19e1c34f32e47fa332301184935c6d9bca26f3cca525" -dependencies = [ - "anyhow", - "cargo_metadata", - "cfg-if", - "regex", - "rustversion", - "time", -] - [[package]] name = "version-compare" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 74e9616..4d3a595 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,5 @@ [package] authors = ["Xavier Lau "] -build = "build.rs" description = "AI with Rust." edition = "2021" homepage = "https://hack.ink/air" @@ -23,9 +22,10 @@ inherits = "dev" inherits = "release" lto = true -[build-dependencies] -# crates.io -vergen = { version = "8.2", features = ["build", "cargo", "git", "gitcl"] } +[features] +default = [] +dev = [] +tokenizer = ["llm_utils"] [dependencies] # crates.io @@ -40,15 +40,15 @@ enigo = { version = "0.2" } futures = { version = "0.3" } get-selected-text = { version = "0.1" } global-hotkey = { version = "0.5" } -# TODO?: remove it since it requires libxml2. -llm_utils = { version = "0.0.6" } +llm_utils = { version = "0.0.6", optional = true } reqwew = { version = "0.2" } serde = { version = "1.0", features = ["derive"] } thiserror = { version = "1.0" } tokio = { version = "1.38", features = ["rt-multi-thread"] } toml = { version = "0.8" } tracing = { version = "0.1" } -tracing-subscriber = { version = "0.3" } +tracing-appender = { version = "0.2" } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [target.'cfg(target_os = "macos")'.dependencies] # accessibility = { version = "0.1" } diff --git a/asset/retry.svg b/asset/retry.svg new file mode 100644 index 0000000..30ecd65 --- /dev/null +++ b/asset/retry.svg @@ -0,0 +1 @@ + reload diff --git a/build.rs b/build.rs deleted file mode 100644 index 5f8f3b2..0000000 --- a/build.rs +++ /dev/null @@ -1,19 +0,0 @@ -// crates.io -use vergen::EmitBuilder; - -fn main() { - let mut emitter = EmitBuilder::builder(); - - emitter.cargo_target_triple(); - - // Disable the git version if installed from . - if emitter.clone().git_sha(true).fail_on_error().emit().is_err() { - println!("cargo:rustc-env=VERGEN_GIT_SHA=crates.io"); - - emitter - } else { - *emitter.git_sha(true) - } - .emit() - .unwrap(); -} diff --git a/src/component.rs b/src/component.rs index b3e347a..c43ea16 100644 --- a/src/component.rs +++ b/src/component.rs @@ -1,7 +1,5 @@ -// TODO?: refresh trait. - -pub mod tokenizer; -use tokenizer::Tokenizer; +#[cfg(feature = "tokenizer")] pub mod tokenizer; +#[cfg(feature = "tokenizer")] use tokenizer::Tokenizer; pub mod function; @@ -21,22 +19,37 @@ pub mod util; // std use std::sync::Arc; +// crates.io +use tokio::sync::Mutex; // self use crate::prelude::*; #[derive(Debug)] pub struct Components { pub setting: Setting, + #[cfg(feature = "tokenizer")] pub tokenizer: Tokenizer, - pub openai: Arc, + pub openai: Arc>, } impl Components { pub fn init() -> Result { let setting = Setting::load()?; + #[cfg(feature = "tokenizer")] let tokenizer = Tokenizer::new(setting.ai.model.as_str()); - // TODO: no clone. - let openai = Arc::new(OpenAi::new(setting.ai.clone())); + let openai = Arc::new(Mutex::new(OpenAi::new(setting.ai.clone()))); + + Ok(Self { + setting, + #[cfg(feature = "tokenizer")] + tokenizer, + openai, + }) + } + + // TODO?: move to somewhere else. + pub fn reload_openai(&self) { + tracing::info!("reloading openai component"); - Ok(Self { setting, tokenizer, openai }) + self.openai.blocking_lock().reload(self.setting.ai.clone()); } } diff --git a/src/component/openai.rs b/src/component/openai.rs index 350e647..1e1e5d9 100644 --- a/src/component/openai.rs +++ b/src/component/openai.rs @@ -1,5 +1,3 @@ -// std -use std::sync::Arc; // crates.io use async_openai::{ config::OpenAIConfig, @@ -11,27 +9,26 @@ use async_openai::{ }; use eframe::egui::WidgetText; use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock as TokioRwLock; // self use super::setting::Ai; use crate::prelude::*; -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct OpenAi { - pub client: Arc>>, + pub client: Client, pub setting: Ai, } impl OpenAi { pub fn new(setting: Ai) -> Self { - let client = Arc::new(TokioRwLock::new(Client::with_config( + let client = Client::with_config( OpenAIConfig::new().with_api_base(&setting.api_base).with_api_key(&setting.api_key), - ))); + ); Self { client, setting } } pub fn reload(&mut self, setting: Ai) { - *self.client.blocking_write() = Client::with_config( + self.client = Client::with_config( OpenAIConfig::new().with_api_base(&setting.api_base).with_api_key(&setting.api_key), ); self.setting = setting; @@ -48,7 +45,7 @@ impl OpenAi { .max_tokens(4_096_u16) .messages(&msg) .build()?; - let stream = self.client.read().await.chat().create_stream(req).await?; + let stream = self.client.chat().create_stream(req).await?; Ok(stream) } diff --git a/src/component/setting.rs b/src/component/setting.rs index 8f04e39..0992136 100644 --- a/src/component/setting.rs +++ b/src/component/setting.rs @@ -1,15 +1,13 @@ // std use std::{fs, path::PathBuf}; // crates.io -use app_dirs2::{AppDataType, AppInfo}; +use app_dirs2::AppDataType; use async_openai::config::OPENAI_API_BASE; use eframe::egui::WidgetText; use serde::{Deserialize, Serialize}; // self use super::openai::Model; -use crate::prelude::*; - -const APP: AppInfo = AppInfo { name: "AiR", author: "xavier@inv.cafe" }; +use crate::{prelude::*, APP_INFO}; #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -20,7 +18,7 @@ pub struct Setting { } impl Setting { pub fn path() -> Result { - Ok(app_dirs2::get_app_root(AppDataType::UserConfig, &APP).map(|p| p.join(".airrc"))?) + Ok(app_dirs2::get_app_root(AppDataType::UserConfig, &APP_INFO).map(|p| p.join(".airrc"))?) } pub fn load() -> Result { @@ -57,7 +55,6 @@ impl Default for General { } } -// TODO: support Google Gemini. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct Ai { diff --git a/src/component/tokenizer.rs b/src/component/tokenizer.rs index b204cfb..acff545 100644 --- a/src/component/tokenizer.rs +++ b/src/component/tokenizer.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, RwLock}; // crates.io use llm_utils::tokenizer::LlmTokenizer; +// TODO: get rid of the `Arc>` wrapper. #[derive(Debug)] pub struct Tokenizer(Arc>); impl Tokenizer { diff --git a/src/component/util.rs b/src/component/util.rs index 4cee521..262d665 100644 --- a/src/component/util.rs +++ b/src/component/util.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "tokenizer")] pub fn price_rounded(value: f32) -> f32 { (value * 1_000_000.).round() / 1_000_000. } diff --git a/src/main.rs b/src/main.rs index 99ee901..c8e383c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,23 +14,55 @@ mod component; mod error; mod os; mod service; -mod ui; mod state; +mod ui; mod prelude { pub type Result = std::result::Result; pub use crate::error::*; } -use prelude::*; // Only used for enable the svg support. use egui_extras as _; -fn main() -> Result<()> { +// std +use std::panic; +// crates.io +use app_dirs2::{AppDataType, AppInfo}; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::{ + filter::LevelFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, +}; + +const APP_INFO: AppInfo = AppInfo { name: "AiR", author: "xavier@inv.cafe" }; + +fn main() { color_eyre::install().unwrap(); - tracing_subscriber::fmt::init(); - air::launch()?; - Ok(()) + let (non_blocking, _guard) = tracing_appender::non_blocking( + RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_suffix("log") + .build(app_dirs2::get_app_root(AppDataType::UserData, &APP_INFO).unwrap()) + .unwrap(), + ); + let file_layer = fmt::layer().with_ansi(false).with_writer(non_blocking); + let console_layer = fmt::layer(); + + tracing_subscriber::registry() + .with( + EnvFilter::builder().with_default_directive(LevelFilter::INFO.into()).from_env_lossy(), + ) + .with(file_layer) + .with(console_layer) + .init(); + panic::set_hook(Box::new(|p| { + if let Some(p) = p.payload().downcast_ref::<&str>() { + tracing::error!("{p}"); + } else { + tracing::error!("panic occurred"); + } + })); + air::launch().unwrap(); } diff --git a/src/os/macos.rs b/src/os/macos.rs index 563e0e0..bbc671c 100644 --- a/src/os/macos.rs +++ b/src/os/macos.rs @@ -75,10 +75,9 @@ impl AppKit for Os { // let window: *mut AnyObject = objc2::msg_send![app, mainWindow]; // let _: () = objc2::msg_send![window, setCollectionBehavior: 1_u64<<1]; - // TODO: handle the error. NSApplication::sharedApplication(MainThreadMarker::new_unchecked()) .mainWindow() - .unwrap() + .expect("no main window") .setCollectionBehavior(NSWindowCollectionBehavior::MoveToActiveSpace); } } diff --git a/src/service/hotkey.rs b/src/service/hotkey.rs index de126e0..aeeff62 100644 --- a/src/service/hotkey.rs +++ b/src/service/hotkey.rs @@ -102,7 +102,7 @@ impl Hotkey { input.write().unwrap().clone_from(&content); output.write().unwrap().clear(); - let openai = openai.clone(); + // TODO: avoid clone. let output = output.clone(); // TODO: sometimes, we don't need this. let translation = translation.read().unwrap().to_owned(); @@ -110,6 +110,8 @@ impl Hotkey { // TODO?: task spawn. // TODO: handle the error. let mut stream = openai + .lock() + .await .chat(&func.prompt(&translation), &content) .await .unwrap(); diff --git a/src/ui/panel/chat.rs b/src/ui/panel/chat.rs index ebb5b57..183e465 100644 --- a/src/ui/panel/chat.rs +++ b/src/ui/panel/chat.rs @@ -3,23 +3,27 @@ use eframe::egui::*; use egui_commonmark::*; // self use super::super::UiT; -use crate::{air::AiRContext, component::util}; +use crate::air::AiRContext; +#[cfg(feature = "tokenizer")] use crate::component::util; #[derive(Debug, Default)] pub struct Chat { // TODO: use widgets instead. pub input: String, + pub shortcut: ShortcutWidget, pub output: OutputWidget, } impl UiT for Chat { fn draw(&mut self, ui: &mut Ui, ctx: &mut AiRContext) { + // TODO: other running cases. + let is_running = ctx.services.hotkey.is_running(); let size = ui.available_size(); ScrollArea::vertical().id_source("Input").max_height((size.y - 50.) / 2.).show(ui, |ui| { - ui.add_sized( + let input = ui.add_sized( (size.x, ui.available_height()), TextEdit::multiline({ - if ctx.services.hotkey.is_running() { + if is_running { if let Ok(i) = ctx.state.chat.input.try_read() { i.clone_into(&mut self.input); } @@ -29,15 +33,34 @@ impl UiT for Chat { }) .hint_text(&*ctx.state.chat.quote.read().unwrap()), ); + + if input.has_focus() { + self.shortcut.copy.triggered = false; + + let to_send = input.ctx.input(|i| { + let modifier = if cfg!(target_os = "macos") { + i.modifiers.mac_cmd + } else { + i.modifiers.ctrl + }; + + modifier && i.key_pressed(Key::Enter) + }); + + // TODO: send. + if to_send { + tracing::info!("to send"); + } + } }); // Indicators. + #[cfg(feature = "tokenizer")] ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - // TODO: when to show the spinner. - ui.spinner(); ui.vertical(|ui| { - ui.add_space(4.5); + // TODO: maybe don't need this. + // ui.add_space(4.5); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let (ic, oc) = ctx.components.tokenizer.count_token(&self.input, &self.output.value); @@ -58,20 +81,24 @@ impl UiT for Chat { // Shortcuts. ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if !self.output.widget.triggered { - if ui.add(self.output.widget.copy.clone()).clicked() { - self.output.widget.triggered = true; + if is_running { + ui.spinner(); + } else { + // TODO: retry. + if ui.add(self.shortcut.retry.clone()).clicked() {} + } + if !self.shortcut.copy.triggered { + if ui.add(self.shortcut.copy.copy_img.clone()).clicked() { + self.shortcut.copy.triggered = true; } } else { - ui.add(self.output.widget.copied.clone()); + ui.add(self.shortcut.copy.copied_img.clone()); } }); }); - // TODO: the cache gets some problems if the content is large. - // TODO?: use markdown. - CommonMarkViewer::new("Output").show_scrollable(ui, &mut CommonMarkCache::default(), { - if ctx.services.hotkey.is_running() { + CommonMarkViewer::new("Output").show_scrollable(ui, &mut self.output.cache, { + if is_running { if let Ok(o) = ctx.state.chat.output.try_read() { o.clone_into(&mut self.output.value); } @@ -82,27 +109,43 @@ impl UiT for Chat { } } -#[derive(Debug, Default)] -pub struct OutputWidget { - value: String, - widget: CopyWidget, +#[derive(Debug)] +pub struct ShortcutWidget { + retry: Image<'static>, + copy: CopyWidget, +} +impl Default for ShortcutWidget { + fn default() -> Self { + Self { + retry: Image::new(include_image!("../../../asset/retry.svg")) + .max_size((16., 16.).into()) + .sense(Sense::click()), + copy: Default::default(), + } + } } // TODO: https://github.com/emilk/egui/issues/3453. #[derive(Debug)] pub struct CopyWidget { - copy: Image<'static>, - copied: Image<'static>, + copy_img: Image<'static>, + copied_img: Image<'static>, triggered: bool, } impl Default for CopyWidget { fn default() -> Self { Self { - copy: Image::new(include_image!("../../../asset/copy.svg")) + copy_img: Image::new(include_image!("../../../asset/copy.svg")) .max_size((16., 16.).into()) .sense(Sense::click()), - copied: Image::new(include_image!("../../../asset/check.svg")) + copied_img: Image::new(include_image!("../../../asset/check.svg")) .max_size((16., 16.).into()), triggered: false, } } } + +#[derive(Debug, Default)] +pub struct OutputWidget { + cache: CommonMarkCache, + value: String, +} diff --git a/src/ui/panel/setting.rs b/src/ui/panel/setting.rs index 712aaff..9326a76 100644 --- a/src/ui/panel/setting.rs +++ b/src/ui/panel/setting.rs @@ -7,7 +7,6 @@ use crate::{ component::{openai::Model, setting::Language}, }; -// TODO: when to reload the API client. #[derive(Debug, Default)] pub struct Setting { pub api_key: ApiKeyWidget, @@ -45,6 +44,8 @@ impl UiT for Setting { }); ui.collapsing("AI", |ui| { + let mut changed = false; + Grid::new("AI").num_columns(2).striped(true).show(ui, |ui| { ui.label("API Base"); let size = ui @@ -53,10 +54,12 @@ impl UiT for Setting { size.x -= 56.; - ui.add_sized( - size, - TextEdit::singleline(&mut ctx.components.setting.ai.api_base), - ); + changed |= ui + .add_sized( + size, + TextEdit::singleline(&mut ctx.components.setting.ai.api_base), + ) + .changed(); size }) @@ -65,11 +68,13 @@ impl UiT for Setting { ui.label("API Key"); ui.horizontal(|ui| { - ui.add_sized( - size, - TextEdit::singleline(&mut ctx.components.setting.ai.api_key) - .password(self.api_key.visibility), - ); + changed |= ui + .add_sized( + size, + TextEdit::singleline(&mut ctx.components.setting.ai.api_key) + .password(self.api_key.visibility), + ) + .changed(); if ui.button(&self.api_key.label).clicked() { self.api_key.clicked(); @@ -77,29 +82,39 @@ impl UiT for Setting { }); ui.end_row(); + // TODO: we might not need to reload the client if only the model changed. ui.label("Model"); ComboBox::from_id_source("Model") .selected_text(&ctx.components.setting.ai.model) .show_ui(ui, |ui| { Model::all().iter().for_each(|m| { - ui.selectable_value( - &mut ctx.components.setting.ai.model, - m.to_owned(), - m.as_str(), - ); + changed |= ui + .selectable_value( + &mut ctx.components.setting.ai.model, + m.to_owned(), + m.as_str(), + ) + .changed(); }); }); ui.end_row(); + // TODO: we might not need to reload the client if only the temperature changed. ui.label("Temperature"); ui.spacing_mut().slider_width = size.x; - ui.add( - Slider::new(&mut ctx.components.setting.ai.temperature, 0_f32..=2.) - .fixed_decimals(1) - .step_by(0.1), - ); + changed |= ui + .add( + Slider::new(&mut ctx.components.setting.ai.temperature, 0_f32..=2.) + .fixed_decimals(1) + .step_by(0.1), + ) + .changed(); ui.end_row(); }); + + if changed { + ctx.components.reload_openai(); + } }); ui.collapsing("Translation", |ui| {