summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/components/smart_summary.rs84
-rw-r--r--src/lib.rs107
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);
-                };
-            },
-        }
-    }
 }