diff options
-rw-r--r-- | src/components/smart_summary.rs | 84 | ||||
-rw-r--r-- | src/lib.rs | 107 |
2 files changed, 120 insertions, 71 deletions
diff --git a/src/components/smart_summary.rs b/src/components/smart_summary.rs new file mode 100644 index 0000000..37bbd74 --- /dev/null +++ b/src/components/smart_summary.rs @@ -0,0 +1,84 @@ +use adw::prelude::*; +use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentParts}, AsyncComponentSender}; + +#[derive(Debug, Default)] +pub(crate) struct SmartSummaryButton { + content_buffer: gtk::TextBuffer, + busy: bool, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + +} + +#[derive(Debug)] +pub(crate) enum Output { + Start, + Chunk(String), + Done, + + Error(Error) +} + +#[relm4::component(pub(crate) async)] +impl AsyncComponent for SmartSummaryButton { + type Input = (); + type Output = Output; + + type Init = gtk::TextBuffer; + type CommandOutput = (); + + view! { + #[root] + #[name = "button"] + gtk::Button { + connect_clicked => (), + #[watch] + set_sensitive: !model.busy, + set_tooltip_markup: Some("<b>Smart Summary</b>\nAsk a language model for a single-sentence summary."), + + if model.busy { + gtk::Spinner { set_spinning: true } + } else { + gtk::Label { set_markup: "✨" } + } + + } + } + + async fn init(init: Self::Init, root: Self::Root, sender: AsyncComponentSender<Self>) -> AsyncComponentParts<Self> { + let model = SmartSummaryButton { + content_buffer: init, + ..Default::default() + }; + let widgets = view_output!(); + + AsyncComponentParts { model, widgets } + } + + async fn update_with_view(&mut self, widgets: &mut Self::Widgets, _msg: Self::Input, sender: AsyncComponentSender<Self>, _root: &Self::Root) { + log::debug!("Starting Smart Summary generation"); + self.busy = true; + let _ = sender.output(Output::Start); + self.update_view(widgets, sender.clone()); + + let text = self.content_buffer.text( + &self.content_buffer.start_iter(), + &self.content_buffer.end_iter(), + false + ); + log::debug!("Would generate summary for the following text:\n{}", text); + tokio::time::sleep(std::time::Duration::from_millis(450)).await; + + for i in ["I'", "m ", "sorry,", " I", " am ", "unable", " to", " ", "write", " you ", "a summary.", " I", " am", " not ", "really ", "an ", "LLM."] { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let _ = sender.output(Output::Chunk(i.to_string())); + } + + log::debug!("Done with the summary."); + self.busy = false; + let _ = sender.output(Output::Done); + self.update_view(widgets, sender.clone()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 320bf80..f95a40a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,9 +2,14 @@ use std::sync::Arc; use adw::prelude::*; use gtk::GridLayoutChild; -use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentParts}, AsyncComponentSender, RelmWidgetExt}; +use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController}, AsyncComponentSender, RelmWidgetExt}; +pub mod components { + pub(crate) mod smart_summary; + pub(crate) use smart_summary::{SmartSummaryButton, Output as SmartSummaryOutput}; +} mod widgets; +pub mod secrets; pub mod micropub; pub mod util; pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; @@ -16,7 +21,7 @@ pub const VISIBILITY: [&str; 2] = ["public", "private"]; pub struct PostComposerModel { /// Busy guard for generating the summary using an LLM. /// Makes the summary field read-only and blocks the Smart Summary button. - #[no_eq] ai_summary_busy_guard: Option<gtk::gio::ApplicationBusyGuard>, + #[no_eq] smart_summary_busy_guard: Option<gtk::gio::ApplicationBusyGuard>, #[no_eq] submit_busy_guard: Option<gtk::gio::ApplicationBusyGuard>, #[do_not_track] name_buffer: gtk::EntryBuffer, @@ -27,36 +32,20 @@ pub struct PostComposerModel { #[do_not_track] narrow_layout: gtk::BoxLayout, #[do_not_track] micropub: Arc<micropub::Client>, -} -impl PostComposerModel { - async fn ai_generate_summary(_content: glib::GString, _sender: AsyncComponentSender<Self>) -> PostComposerCommandOutput { - // This is just a UI mock-up. In real-life conditions, this - // would send a request to a summarizer API of some sort. - // - // Perhaps this API could be created using Ollama. - // - // Alternatively, one could probably launch a small local LLM - // to do this, without network access. - for i in ["I'", "m ", "sorry,", " I", " am ", "unable", " to", " ", "write", " you ", "a summary.", " I", " am", " not ", "really ", "an ", "LLM."] { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - _sender.input(PostComposerInput::AiGenSummaryProgress(i.to_string())); - } - - PostComposerCommandOutput::AiSummaryDone(Ok(())) - } + #[do_not_track] smart_summary: AsyncController<components::SmartSummaryButton>, } + #[derive(Debug)] +#[allow(private_interfaces)] pub enum PostComposerInput { - AiGenSummaryBegin, - AiGenSummaryProgress(String), + #[doc(hidden)] SmartSummary(components::smart_summary::Output), Submit, } #[derive(Debug)] pub enum PostComposerCommandOutput { - AiSummaryDone(Result<(), ()>) } #[relm4::component(pub async)] @@ -139,30 +128,11 @@ impl AsyncComponent for PostComposerModel { gtk::Entry { set_hexpand: true, set_buffer: &model.summary_buffer, - #[track = "model.changed(Self::ai_summary_busy_guard() | Self::submit_busy_guard())"] - set_sensitive: model.ai_summary_busy_guard.is_none() && model.submit_busy_guard.is_none(), + #[track = "model.changed(Self::smart_summary_busy_guard() | Self::submit_busy_guard())"] + set_sensitive: model.smart_summary_busy_guard.is_none() && model.submit_busy_guard.is_none(), }, - #[name = "ai_summary_button"] - gtk::Button { - connect_clicked => Self::Input::AiGenSummaryBegin, - #[track = "model.changed(Self::ai_summary_busy_guard() | Self::submit_busy_guard())"] - set_sensitive: model.ai_summary_busy_guard.is_none() && model.submit_busy_guard.is_none(), - set_tooltip_markup: Some("<b>Smart Summary</b>\nAsk a language model to summarize the content of your post in a single sentence."), - - gtk::Stack { - gtk::Label { - #[track = "model.changed(Self::ai_summary_busy_guard())"] - set_visible: model.ai_summary_busy_guard.is_none(), - set_markup: "✨" - }, - gtk::Spinner { - #[track = "model.changed(Self::ai_summary_busy_guard())"] - set_visible: model.ai_summary_busy_guard.is_some(), - set_spinning: true, - } - } - }, + model.smart_summary.widget(), }, #[name = "tag_label"] @@ -280,21 +250,26 @@ impl AsyncComponent for PostComposerModel { window: Self::Root, sender: AsyncComponentSender<Self>, ) -> AsyncComponentParts<Self> { + let content_buffer = gtk::TextBuffer::default(); let model = PostComposerModel { - ai_summary_busy_guard: None, + smart_summary_busy_guard: None, submit_busy_guard: None, name_buffer: gtk::EntryBuffer::default(), summary_buffer: gtk::EntryBuffer::default(), - content_buffer: gtk::TextBuffer::default(), + content_buffer: content_buffer.clone(), wide_layout: gtk::GridLayout::new(), narrow_layout: gtk::BoxLayout::new(gtk::Orientation::Vertical), micropub: Arc::new(init), + smart_summary: components::SmartSummaryButton::builder() + .launch(content_buffer) + .forward(sender.input_sender(), PostComposerInput::SmartSummary), tracker: Default::default() }; + let widgets = view_output!(); #[cfg(debug_assertions)] @@ -356,26 +331,27 @@ impl AsyncComponent for PostComposerModel { self.reset(); // Reset the tracker match message { - PostComposerInput::AiGenSummaryBegin => { - self.set_ai_summary_busy_guard( + PostComposerInput::SmartSummary(components::SmartSummaryOutput::Start) => { + self.set_smart_summary_busy_guard( Some(relm4::main_adw_application().mark_busy()) ); self.summary_buffer.set_text(""); - sender.oneshot_command( - Self::ai_generate_summary( - // TODO: apparently this thing may keep inline images. Investigate. - self.content_buffer.text( - &self.content_buffer.start_iter(), - &self.content_buffer.end_iter(), - false - ), - sender.clone() - ) - ) }, - PostComposerInput::AiGenSummaryProgress(text) => { + PostComposerInput::SmartSummary(components::SmartSummaryOutput::Chunk(text)) => { self.summary_buffer.insert_text(self.summary_buffer.length(), text); }, + PostComposerInput::SmartSummary(components::SmartSummaryOutput::Done) => { + self.set_smart_summary_busy_guard(None); + } + PostComposerInput::SmartSummary(components::SmartSummaryOutput::Error(err)) => { + self.set_smart_summary_busy_guard(None); + + let toast = adw::Toast::new(&format!("Smart Summary error: {}", err)); + toast.set_timeout(0); + toast.set_priority(adw::ToastPriority::High); + widgets.toast_overlay.add_toast(toast); + + }, PostComposerInput::Submit => { if self.content_buffer.char_count() == 0 { self.update_view(widgets, sender); @@ -453,15 +429,4 @@ impl AsyncComponent for PostComposerModel { self.update_view(widgets, sender); } - - async fn update_cmd(&mut self, msg: Self::CommandOutput, _sender: AsyncComponentSender<Self>, _root: &Self::Root) { - match msg { - Self::CommandOutput::AiSummaryDone(res) => { - self.set_ai_summary_busy_guard(None); - if let Err(err) = res { - log::warn!("AI summary generation failed: {:?}", err); - }; - }, - } - } } |