zeyn 0.0.4

Rust web framework for data heavy applications, written by AI agents and reviewed by people
Documentation
//! The chat panel: markup rendered on the server, behavior in `assistant.js`.

use crate::i18n::Text;
use crate::render::{Render, attr, escape};
use crate::screen::Component;
use serde_json::{Value as Json, json};

/// The assistant's chat as a screen component: a full page (`AssistantChat::page()`) or a panel
/// inside a layout (`AssistantChat::panel()`). Every page also gets a floating launcher unless the
/// app turns it off with `Assistant::launcher(false)`.
///
/// ```
/// use zeyn::prelude::*;
/// use zeyn::assistant::AssistantChat;
///
/// #[screen(route = "/assistant", permission = "app.assistant.use")]
/// pub fn assistant_page() -> Screen {
///     Screen::new("Assistant").body(AssistantChat::page())
/// }
/// ```
pub struct AssistantChat {
    full: bool,
}

impl AssistantChat {
    /// Fills the screen's main area.
    pub fn page() -> Self {
        AssistantChat { full: true }
    }

    /// A panel that fits next to other content.
    pub fn panel() -> Self {
        AssistantChat { full: false }
    }
}

impl From<AssistantChat> for crate::screen::Node {
    fn from(c: AssistantChat) -> Self {
        crate::screen::Node::new(c)
    }
}

const ICON_SEND: &str = r#"<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3.4 20.4 21 12 3.4 3.6 3.4 10l12.6 2-12.6 2z"/></svg>"#;
const ICON_MIC: &str = r#"<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21h2v-3.1A7 7 0 0 0 19 11z"/></svg>"#;
const ICON_STOP: &str = r#"<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>"#;
const ICON_CLOSE: &str = r#"<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M18.3 5.7 12 12l6.3 6.3-1.4 1.4L10.6 13.4 4.3 19.7 2.9 18.3 9.2 12 2.9 5.7l1.4-1.4 6.3 6.3 6.3-6.3z"/></svg>"#;
pub(crate) const ICON_SPARK: &str = r#"<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2l1.9 5.6L19.5 9.5 13.9 11.4 12 17l-1.9-5.6L4.5 9.5l5.6-1.9zM19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"/></svg>"#;

/// Texts the browser script needs, localized on the server.
fn texts(r: &Render) -> Json {
    let t = |k: &'static str| r.i18n.text(r.locale, &Text::key(k));
    json!({
        "you": t("zeyn.assistant.you"),
        "assistant": t("zeyn.assistant.title"),
        "thinking": t("zeyn.assistant.thinking"),
        "stopped": t("zeyn.assistant.stopped"),
        "retry": t("zeyn.assistant.retry"),
        "read": t("zeyn.assistant.read_aloud"),
        "copy": t("zeyn.assistant.copy"),
        "copied": t("zeyn.assistant.copied"),
        "confirm": t("zeyn.assistant.confirm"),
        "decline": t("zeyn.assistant.decline"),
        "confirm_title": t("zeyn.assistant.confirm_title"),
        "declined": t("zeyn.assistant.declined"),
        "listening": t("zeyn.assistant.listening"),
        "mic": t("zeyn.assistant.mic"),
        "mic_stop": t("zeyn.assistant.mic_stop"),
        "error": t("zeyn.assistant.error"),
        "unavailable": t("zeyn.assistant.unavailable"),
        "working": t("zeyn.assistant.working"),
        "empty": t("zeyn.assistant.empty"),
    })
}

/// Markup of a chat panel. `mode` is `page`, `panel` or `dock` (inside the launcher's panel).
pub(crate) fn markup(r: &Render, mode: &str) -> String {
    let t = |k: &'static str| r.text(&Text::key(k));
    let id = format!("z-assistant-{mode}");
    let close = if mode == "dock" {
        format!(
            r#"<button type="button" class="z-icon-button" data-zeyn-assistant-close aria-label="{0}" title="{0}">{ICON_CLOSE}</button>"#,
            t("zeyn.assistant.close")
        )
    } else {
        String::new()
    };
    let heading = if mode == "page" { "h2" } else { "h2" };
    format!(
        r#"<section class="z-assistant" data-zeyn-assistant="{mode}" aria-labelledby="{id}-title" data-zeyn-texts="{texts}"><header class="z-assistant-head"><span class="z-assistant-mark">{ICON_SPARK}</span><{heading} id="{id}-title">{title}</{heading}><button type="button" class="z-button z-assistant-new" data-zeyn-assistant-clear>{new}</button>{close}</header><div class="z-assistant-log" role="log" aria-live="polite" aria-labelledby="{id}-title" tabindex="0" data-zeyn-assistant-log><p class="z-assistant-empty">{empty}</p></div><div class="z-assistant-chips" role="group" aria-label="{suggestions}" data-zeyn-assistant-chips></div><form class="z-assistant-form" data-zeyn-assistant-form><label class="z-sr-only" for="{id}-input">{message}</label><textarea id="{id}-input" rows="1" maxlength="2000" placeholder="{placeholder}" data-zeyn-assistant-input></textarea><button type="button" class="z-icon-button" data-zeyn-assistant-mic aria-pressed="false" aria-label="{mic}" title="{mic}" hidden>{ICON_MIC}</button><button type="button" class="z-icon-button z-assistant-stop" data-zeyn-assistant-stop aria-label="{stop}" title="{stop}" hidden>{ICON_STOP}</button><button type="submit" class="z-icon-button z-assistant-send" aria-label="{send}" title="{send}">{ICON_SEND}</button></form><p class="z-assistant-note">{note}</p><p class="z-sr-only" role="status" data-zeyn-assistant-status></p></section>"#,
        texts = attr(&texts(r)),
        title = t("zeyn.assistant.title"),
        new = t("zeyn.assistant.new_chat"),
        empty = t("zeyn.assistant.empty"),
        suggestions = t("zeyn.assistant.suggestions"),
        message = t("zeyn.assistant.message"),
        placeholder = t("zeyn.assistant.placeholder"),
        mic = t("zeyn.assistant.mic"),
        stop = t("zeyn.assistant.stop"),
        send = t("zeyn.assistant.send"),
        note = t("zeyn.assistant.note"),
    )
}

/// The floating launcher and its panel, placed once in the page shell.
pub(crate) fn launcher(r: &Render) -> String {
    let label = r.text(&Text::key("zeyn.assistant.open"));
    format!(
        r#"<button type="button" class="z-assistant-launch" data-zeyn-assistant-launch aria-expanded="false" aria-controls="z-assistant-dock" aria-label="{label}" title="{label}">{ICON_SPARK}</button><aside id="z-assistant-dock" class="z-assistant-dock" aria-label="{title}" hidden>{panel}</aside>"#,
        title = escape(&r.i18n.text(r.locale, &Text::key("zeyn.assistant.title"))),
        panel = markup(r, "dock"),
    )
}

impl Component for AssistantChat {
    fn render(&self, out: &mut String, r: &Render) {
        out.push_str(&markup(r, if self.full { "page" } else { "panel" }));
    }

    fn schema(&self) -> Json {
        json!({ "type": "assistant_chat", "mode": if self.full { "page" } else { "panel" } })
    }
}