icebreaker 0.1.0

A local AI chat app powered by 🦀Rust, 🧊iced, 🤗Hugging Face, and 🦙llama.cpp
use crate::data::assistant::Model;
use crate::data::Error;
use crate::icon;

use iced::time::Duration;
use iced::widget::{
    self, button, center, column, container, horizontal_space, hover, iced, row, scrollable, text,
    text_input, value,
};
use iced::window;
use iced::{Center, Element, Fill, Font, Right, Size, Subscription, Task, Theme};

pub struct Search {
    models: Vec<Model>,
    search: String,
    search_temperature: usize,
    is_searching: bool,
    error: Option<Error>,
    window_size: Size,
}

#[derive(Debug, Clone)]
pub enum Message {
    ModelsListed(Result<Vec<Model>, Error>),
    SearchChanged(String),
    SearchCooled,
    RunModel(Model),
    LinkPressed(Link),
    WindowResized(Size),
}

#[derive(Debug, Clone)]
pub enum Link {
    Rust,
    Iced,
    HuggingFace,
    LlamaCpp,
}

pub enum Action {
    None,
    Run(Task<Message>),
    Boot(Model),
}

impl Search {
    pub fn new() -> (Self, Task<Message>) {
        (
            Self {
                models: Vec::new(),
                search: String::new(),
                search_temperature: 0,
                is_searching: true,
                error: None,
                window_size: Size::ZERO,
            },
            Task::batch([
                Task::perform(Model::list(), Message::ModelsListed),
                widget::focus_next(),
                window::get_latest()
                    .and_then(window::get_size)
                    .map(Message::WindowResized),
            ]),
        )
    }

    pub fn title(&self) -> String {
        "Icebreaker".to_owned()
    }

    pub fn update(&mut self, message: Message) -> Action {
        match message {
            Message::ModelsListed(Ok(models)) => {
                self.models = models;
                self.is_searching = false;

                Action::None
            }
            Message::ModelsListed(Err(error)) => {
                self.error = Some(dbg!(error));

                Action::None
            }
            Message::SearchChanged(search) => {
                self.search = search;
                self.search_temperature += 1;

                Action::Run(Task::perform(
                    tokio::time::sleep(Duration::from_secs(1)),
                    |_| Message::SearchCooled,
                ))
            }
            Message::SearchCooled => {
                self.search_temperature = self.search_temperature.saturating_sub(1);

                if self.search_temperature == 0 {
                    self.is_searching = true;

                    Action::Run(Task::perform(
                        Model::search(self.search.clone()),
                        Message::ModelsListed,
                    ))
                } else {
                    Action::None
                }
            }
            Message::RunModel(model) => Action::Boot(model),
            Message::LinkPressed(link) => {
                let _ = open::that_in_background(match link {
                    Link::Rust => "https://rust-lang.org",
                    Link::Iced => "https://iced.rs",
                    Link::HuggingFace => "https://huggingface.co",
                    Link::LlamaCpp => "https://github.com/ggerganov/llama.cpp",
                });

                Action::None
            }
            Message::WindowResized(size) => {
                self.window_size = size;

                Action::None
            }
        }
    }

    pub fn view(&self) -> Element<Message> {
        let search = text_input("Search language models...", &self.search)
            .size(20)
            .padding(10)
            .on_input(Message::SearchChanged);

        let models: Element<_> =
            {
                let search_terms: Vec<_> = self
                    .search
                    .trim()
                    .split(' ')
                    .map(str::to_lowercase)
                    .collect();

                let mut filtered_models = self
                    .models
                    .iter()
                    .filter(|model| {
                        self.search.is_empty()
                            || search_terms
                                .iter()
                                .all(|term| model.name().to_lowercase().contains(term))
                    })
                    .peekable();

                if filtered_models.peek().is_none() {
                    center(text(if self.is_searching || self.search_temperature > 0 {
                        "Searching..."
                    } else {
                        "No models found!"
                    }))
                    .into()
                } else {
                    use itertools::Itertools;
                    const MIN_CARD_WIDTH: f32 = 450.0;

                    let n_columns = (self.window_size.width / MIN_CARD_WIDTH).max(1.0) as usize;

                    let cards =
                        column(filtered_models.chunks(n_columns).into_iter().map(|chunk| {
                            row(chunk.into_iter().map(model_card)).spacing(10).into()
                        }))
                        .spacing(10);

                    scrollable(cards).height(Fill).spacing(10).into()
                }
            };

        let footer = {
            let text = |content| text(content).font(Font::MONOSPACE).size(12);

            let link = |button: button::Button<'static, Message>, link| {
                button
                    .on_press(Message::LinkPressed(link))
                    .padding(0)
                    .style(button::text)
            };

            let rust = link(
                button(text("🦀 Rust").shaping(text::Shaping::Advanced)),
                Link::Rust,
            );

            let iced = link(button(iced(12)), Link::Iced);

            let hugging_face = link(
                button(text("🤗 Hugging Face").shaping(text::Shaping::Advanced)),
                Link::HuggingFace,
            );

            let llama_cpp = link(
                button(text("🦙 llama.cpp").shaping(text::Shaping::Advanced)),
                Link::LlamaCpp,
            );

            row![
                text("Made with"),
                rust,
                text("and"),
                iced,
                horizontal_space(),
                text("Powered by"),
                hugging_face,
                text("and"),
                llama_cpp,
            ]
            .spacing(7)
            .align_y(Center)
        };

        container(column![search, models, footer].spacing(10))
            .padding(10)
            .into()
    }

    pub fn subscription(&self) -> Subscription<Message> {
        window::resize_events().map(|(_id, size)| Message::WindowResized(size))
    }
}

fn model_card(model: &Model) -> Element<Message> {
    use iced::widget::Text;

    fn stat<'a>(
        icon: Text<'a>,
        value: Text<'a>,
        style: fn(&Theme) -> text::Style,
    ) -> Element<'a, Message> {
        row![
            icon.size(10).style(style),
            value.size(12).font(Font::MONOSPACE).style(style)
        ]
        .align_y(Center)
        .spacing(5)
        .into()
    }

    let title = {
        const LIMIT: usize = 40;

        let name = model.name();

        if name.len() < LIMIT {
            text(name)
        } else {
            text!("{}...", &name[0..LIMIT])
        }
        .font(Font::MONOSPACE)
    };

    let separator = || text("•").size(12);

    let metadata = row![
        stat(icon::user(), text(model.author()), text::default),
        separator(),
        stat(icon::download(), value(model.downloads), text::primary),
        separator(),
        stat(icon::heart(), value(model.likes), text::danger),
        separator(),
        stat(
            icon::clock(),
            value(model.last_modified.format("%-e %B, %y")),
            text::default,
        ),
    ]
    .spacing(10);

    let chat = container(
        button(row![icon::chat(), "Run"].spacing(10)).on_press(Message::RunModel(model.clone())),
    )
    .width(Fill)
    .padding(10)
    .align_x(Right)
    .center_y(Fill);

    let card = container(column![title, metadata].spacing(10))
        .width(Fill)
        .padding(10)
        .style(container::rounded_box);

    hover(card, chat)
}