diff --git a/Cargo.lock b/Cargo.lock index 0c524704..44aca8d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3429,6 +3429,7 @@ dependencies = [ "iana-time-zone", "insta", "ironclaw_common", + "ironclaw_frontend", "ironclaw_safety", "json5", "libsql", @@ -3496,6 +3497,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ironclaw_frontend" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "ironclaw_safety" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 0382a2a7..adf74389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"] +members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_frontend"] exclude = [ "channels-src/discord", "channels-src/telegram", @@ -105,6 +105,7 @@ cron = "0.13" ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" } # Safety/sanitization +ironclaw_frontend = { path = "crates/ironclaw_frontend", version = "0.1.0" } ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" } regex = "1" aho-corasick = "1" diff --git a/crates/ironclaw_frontend/Cargo.toml b/crates/ironclaw_frontend/Cargo.toml new file mode 100644 index 00000000..b8e8aa15 --- /dev/null +++ b/crates/ironclaw_frontend/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ironclaw_frontend" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "Frontend assets, layout configuration, and widget extension system for IronClaw" + +[package.metadata.dist] +dist = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" diff --git a/crates/ironclaw_frontend/src/assets.rs b/crates/ironclaw_frontend/src/assets.rs new file mode 100644 index 00000000..78fea26d --- /dev/null +++ b/crates/ironclaw_frontend/src/assets.rs @@ -0,0 +1,37 @@ +//! Embedded static assets for the IronClaw web gateway. +//! +//! All frontend files are compiled into the binary via `include_str!()` / +//! `include_bytes!()`. The web gateway serves these as the default baseline; +//! workspace-stored customizations (layout config, widgets, CSS overrides) +//! are layered on top at runtime. + +// ==================== Core Files ==================== + +/// Main HTML page (SPA shell). +pub const INDEX_HTML: &str = include_str!("../static/index.html"); + +/// Main application JavaScript. +pub const APP_JS: &str = include_str!("../static/app.js"); + +/// Base stylesheet. +pub const STYLE_CSS: &str = include_str!("../static/style.css"); + +/// Theme initialization script (runs synchronously in `` to prevent FOUC). +pub const THEME_INIT_JS: &str = include_str!("../static/theme-init.js"); + +/// Favicon. +pub const FAVICON_ICO: &[u8] = include_bytes!("../static/favicon.ico"); + +// ==================== Internationalization ==================== + +/// i18n core library. +pub const I18N_INDEX_JS: &str = include_str!("../static/i18n/index.js"); + +/// English translations. +pub const I18N_EN_JS: &str = include_str!("../static/i18n/en.js"); + +/// Chinese (Simplified) translations. +pub const I18N_ZH_CN_JS: &str = include_str!("../static/i18n/zh-CN.js"); + +/// i18n integration with the app. +pub const I18N_APP_JS: &str = include_str!("../static/i18n-app.js"); diff --git a/crates/ironclaw_frontend/src/bundle.rs b/crates/ironclaw_frontend/src/bundle.rs new file mode 100644 index 00000000..5e25e1a2 --- /dev/null +++ b/crates/ironclaw_frontend/src/bundle.rs @@ -0,0 +1,240 @@ +//! Frontend bundle assembly. +//! +//! Combines the embedded base HTML with workspace customizations (layout +//! config, widgets, CSS overrides) into the final served page. + +use crate::layout::LayoutConfig; +use crate::widget::{WidgetManifest, scope_css}; + +/// A resolved frontend bundle ready for serving. +/// +/// Contains the layout configuration, resolved widgets (with their JS/CSS +/// content loaded), and any custom CSS overrides. +#[derive(Debug, Clone, Default)] +pub struct FrontendBundle { + /// Layout configuration (branding, tabs, chat settings). + pub layout: LayoutConfig, + + /// Resolved widgets with their source code loaded. + pub widgets: Vec, + + /// Custom CSS to append after the base stylesheet. + pub custom_css: Option, +} + +/// A widget with its manifest and source files loaded. +#[derive(Debug, Clone)] +pub struct ResolvedWidget { + /// Widget metadata. + pub manifest: WidgetManifest, + + /// JavaScript source code (`index.js`). + pub js: String, + + /// Optional CSS source code (`style.css`), auto-scoped. + pub css: Option, +} + +/// Inject frontend customizations into the base HTML template. +/// +/// Modifications: +/// +/// **Before ``:** +/// - Branding CSS custom property overrides +/// - Title override (replaces `` content) +/// +/// **Before `</body>`:** +/// - Layout config as `window.__IRONCLAW_LAYOUT__` +/// - Scoped widget `<style>` blocks +/// - Widget `<script type="module">` tags +/// - Custom CSS `<style>` block +pub fn assemble_index(base_html: &str, bundle: &FrontendBundle) -> String { + let mut head_injections = Vec::new(); + let mut body_injections = Vec::new(); + + // --- Head injections --- + + // Branding CSS variables + let css_vars = bundle.layout.branding.to_css_vars(); + if !css_vars.is_empty() { + head_injections.push(format!("<style>{}</style>", css_vars)); + } + + // --- Body injections --- + + // Layout config as global variable + if let Ok(layout_json) = serde_json::to_string(&bundle.layout) { + body_injections.push(format!( + "<script>window.__IRONCLAW_LAYOUT__ = {};</script>", + layout_json + )); + } + + // Widget CSS (scoped) and JS + for widget in &bundle.widgets { + if let Some(ref css) = widget.css { + let scoped = scope_css(css, &widget.manifest.id); + if !scoped.trim().is_empty() { + body_injections.push(format!( + "<style data-widget=\"{}\">{}</style>", + widget.manifest.id, scoped + )); + } + } + + // Widget JS as module script served from API + body_injections.push(format!( + "<script type=\"module\" src=\"/api/frontend/widget/{}/index.js\"></script>", + widget.manifest.id + )); + } + + // Custom CSS + if let Some(ref custom_css) = bundle.custom_css { + if !custom_css.trim().is_empty() { + body_injections.push(format!( + "<style data-custom-css>{}</style>", + custom_css + )); + } + } + + // --- Assemble --- + + let mut result = base_html.to_string(); + + // Inject before </head> + if !head_injections.is_empty() { + let head_block = head_injections.join("\n"); + if let Some(pos) = result.rfind("</head>") { + result.insert_str(pos, &format!("\n{}\n", head_block)); + } + } + + // Override <title> if branding title is set + if let Some(ref title) = bundle.layout.branding.title { + if let Some(start) = result.find("<title>") { + if let Some(end) = result[start..].find("") { + let end = start + end + "".len(); + result.replace_range(start..end, &format!("{}", title)); + } + } + } + + // Inject before + if !body_injections.is_empty() { + let body_block = body_injections.join("\n"); + if let Some(pos) = result.rfind("") { + result.insert_str(pos, &format!("\n{}\n", body_block)); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::*; + use crate::widget::*; + + const MINIMAL_HTML: &str = + "IronClaw"; + + #[test] + fn test_assemble_index_no_customizations() { + let bundle = FrontendBundle::default(); + let result = assemble_index(MINIMAL_HTML, &bundle); + // Layout config is always injected (even when default/empty) + assert!(result.contains("window.__IRONCLAW_LAYOUT__")); + // No branding overrides or custom CSS + assert!(!result.contains("--color-primary")); + assert!(!result.contains("data-custom-css")); + } + + #[test] + fn test_assemble_index_branding_title() { + let bundle = FrontendBundle { + layout: LayoutConfig { + branding: BrandingConfig { + title: Some("Acme AI".to_string()), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + let result = assemble_index(MINIMAL_HTML, &bundle); + assert!(result.contains("Acme AI")); + assert!(!result.contains("IronClaw")); + } + + #[test] + fn test_assemble_index_branding_colors() { + let bundle = FrontendBundle { + layout: LayoutConfig { + branding: BrandingConfig { + colors: Some(BrandingColors { + primary: Some("#0066cc".to_string()), + accent: None, + }), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + let result = assemble_index(MINIMAL_HTML, &bundle); + assert!(result.contains("--color-primary: #0066cc;")); + } + + #[test] + fn test_assemble_index_layout_config_injected() { + let bundle = FrontendBundle { + layout: LayoutConfig { + tabs: TabConfig { + hidden: Some(vec!["routines".to_string()]), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + let result = assemble_index(MINIMAL_HTML, &bundle); + assert!(result.contains("window.__IRONCLAW_LAYOUT__")); + assert!(result.contains("routines")); + } + + #[test] + fn test_assemble_index_widget_script() { + let bundle = FrontendBundle { + widgets: vec![ResolvedWidget { + manifest: WidgetManifest { + id: "dashboard".to_string(), + name: "Dashboard".to_string(), + slot: WidgetSlot::Tab, + icon: None, + position: None, + }, + js: "console.log('hello');".to_string(), + css: Some(".panel { color: red; }".to_string()), + }], + ..Default::default() + }; + let result = assemble_index(MINIMAL_HTML, &bundle); + assert!(result.contains("src=\"/api/frontend/widget/dashboard/index.js\"")); + assert!(result.contains("data-widget=\"dashboard\"")); + assert!(result.contains("[data-widget=\"dashboard\"] .panel")); + } + + #[test] + fn test_assemble_index_custom_css() { + let bundle = FrontendBundle { + custom_css: Some("body { background: #111; }".to_string()), + ..Default::default() + }; + let result = assemble_index(MINIMAL_HTML, &bundle); + assert!(result.contains("data-custom-css")); + assert!(result.contains("background: #111;")); + } +} diff --git a/crates/ironclaw_frontend/src/layout.rs b/crates/ironclaw_frontend/src/layout.rs new file mode 100644 index 00000000..df4b1796 --- /dev/null +++ b/crates/ironclaw_frontend/src/layout.rs @@ -0,0 +1,179 @@ +//! Layout configuration types for frontend customization. +//! +//! A [`LayoutConfig`] is stored as `frontend/layout.json` in the workspace. +//! It controls branding, tab visibility/order, chat features, and per-widget +//! configuration. All fields are optional with sensible defaults. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Top-level layout configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LayoutConfig { + /// Branding overrides (title, logo, colors). + #[serde(default)] + pub branding: BrandingConfig, + + /// Tab bar configuration. + #[serde(default)] + pub tabs: TabConfig, + + /// Chat panel configuration. + #[serde(default)] + pub chat: ChatConfig, + + /// Per-widget instance configuration (keyed by widget ID). + #[serde(default)] + pub widgets: HashMap, +} + +/// Branding overrides for the gateway UI. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BrandingConfig { + /// Page title (replaces default "IronClaw"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// Subtitle shown below the title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subtitle: Option, + + /// URL to a logo image. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logo_url: Option, + + /// URL to a custom favicon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub favicon_url: Option, + + /// Color overrides (injected as CSS custom properties on `:root`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub colors: Option, +} + +/// Color overrides for the UI theme. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BrandingColors { + /// Primary brand color (e.g., `"#0066cc"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary: Option, + + /// Accent color. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accent: Option, +} + +/// Tab bar layout configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TabConfig { + /// Ordered list of tab IDs to display (built-in + widget tabs). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order: Option>, + + /// Tab IDs to hide from the tab bar. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hidden: Option>, + + /// Default tab to show on load. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tab: Option, +} + +/// Chat panel feature flags. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChatConfig { + /// Show suggestion chips below the input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggestions: Option, + + /// Enable image upload in the chat input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_upload: Option, +} + +/// Per-widget instance configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WidgetInstanceConfig { + /// Whether this widget is enabled. + #[serde(default)] + pub enabled: bool, + + /// Arbitrary widget-specific configuration passed to `widget.init()`. + #[serde(default)] + pub config: serde_json::Value, +} + +impl BrandingConfig { + /// Generate CSS custom property overrides for injection into `:root`. + pub fn to_css_vars(&self) -> String { + let mut vars = Vec::new(); + if let Some(ref colors) = self.colors { + if let Some(ref primary) = colors.primary { + vars.push(format!("--color-primary: {};", primary)); + } + if let Some(ref accent) = colors.accent { + vars.push(format!("--color-accent: {};", accent)); + } + } + if vars.is_empty() { + String::new() + } else { + format!(":root {{ {} }}", vars.join(" ")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_layout_config_default_is_empty() { + let config = LayoutConfig::default(); + assert!(config.branding.title.is_none()); + assert!(config.tabs.order.is_none()); + assert!(config.widgets.is_empty()); + } + + #[test] + fn test_layout_config_roundtrip() { + let json = serde_json::json!({ + "branding": { "title": "Acme AI", "colors": { "primary": "#0066cc" } }, + "tabs": { "order": ["chat", "memory"], "hidden": ["routines"] }, + "widgets": { "dashboard": { "enabled": true, "config": { "refresh": 30 } } } + }); + let config: LayoutConfig = serde_json::from_value(json).unwrap(); + assert_eq!(config.branding.title.as_deref(), Some("Acme AI")); + assert_eq!(config.tabs.hidden.as_ref().map(|h| h.len()), Some(1)); + assert!(config.widgets.get("dashboard").is_some_and(|w| w.enabled)); + } + + #[test] + fn test_branding_css_vars_empty() { + let branding = BrandingConfig::default(); + assert!(branding.to_css_vars().is_empty()); + } + + #[test] + fn test_branding_css_vars_with_colors() { + let branding = BrandingConfig { + colors: Some(BrandingColors { + primary: Some("#0066cc".to_string()), + accent: Some("#ff6b00".to_string()), + }), + ..Default::default() + }; + let css = branding.to_css_vars(); + assert!(css.contains("--color-primary: #0066cc;")); + assert!(css.contains("--color-accent: #ff6b00;")); + } + + #[test] + fn test_partial_deserialization() { + let json = serde_json::json!({"branding": {"title": "Test"}}); + let config: LayoutConfig = serde_json::from_value(json).unwrap(); + assert_eq!(config.branding.title.as_deref(), Some("Test")); + assert!(config.chat.suggestions.is_none()); + } +} diff --git a/crates/ironclaw_frontend/src/lib.rs b/crates/ironclaw_frontend/src/lib.rs new file mode 100644 index 00000000..2aea3aa6 --- /dev/null +++ b/crates/ironclaw_frontend/src/lib.rs @@ -0,0 +1,36 @@ +//! IronClaw Frontend — assets, layout configuration, and widget extension system. +//! +//! This crate owns the complete frontend for the IronClaw web gateway: +//! +//! - **Embedded assets** (`assets` module): HTML, JS, CSS, i18n files compiled +//! into the binary for zero-dependency serving. +//! - **Layout configuration** (`layout` module): Branding, tab order, feature +//! flags — customizable per-tenant via workspace. +//! - **Widget system** (`widget` module): Self-contained frontend components +//! that plug into named slots in the UI. +//! - **Bundle assembly** (`bundle` module): Combines base assets with workspace +//! customizations into the final served HTML. + +pub mod assets; +mod bundle; +mod layout; +mod widget; + +pub use bundle::{FrontendBundle, ResolvedWidget, assemble_index}; +pub use layout::{ + BrandingColors, BrandingConfig, ChatConfig, LayoutConfig, TabConfig, WidgetInstanceConfig, +}; +pub use widget::{WidgetManifest, WidgetSlot, scope_css}; + +/// Errors from frontend operations. +#[derive(Debug, thiserror::Error)] +pub enum FrontendError { + #[error("Layout configuration is invalid: {reason}")] + InvalidLayout { reason: String }, + + #[error("Widget '{id}' not found")] + WidgetNotFound { id: String }, + + #[error("Widget manifest is invalid: {reason}")] + InvalidManifest { reason: String }, +} diff --git a/crates/ironclaw_frontend/src/widget.rs b/crates/ironclaw_frontend/src/widget.rs new file mode 100644 index 00000000..ef9a56fa --- /dev/null +++ b/crates/ironclaw_frontend/src/widget.rs @@ -0,0 +1,175 @@ +//! Widget system types and utilities. +//! +//! Widgets are self-contained frontend components that plug into named +//! [`WidgetSlot`]s in the UI. Each widget has a manifest (`widget.json`) +//! and implementation files (`index.js`, optional `style.css`). + +use serde::{Deserialize, Serialize}; + +/// Widget manifest — metadata about a widget component. +/// +/// Stored as `frontend/widgets/{id}/manifest.json` in the workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WidgetManifest { + /// Unique widget identifier (must be a valid HTML attribute value). + pub id: String, + + /// Human-readable widget name. + pub name: String, + + /// Where this widget is rendered in the UI. + pub slot: WidgetSlot, + + /// Optional icon identifier (CSS class or emoji). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + + /// Positioning hint (e.g., `"after:memory"`, `"before:jobs"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +/// Named insertion points in the UI where widgets can be rendered. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum WidgetSlot { + /// Full tab panel (adds a new tab to the tab bar). + Tab, + /// Banner area above the chat message list. + ChatHeader, + /// Area below the chat input. + ChatFooter, + /// Extra action buttons next to the send button. + ChatActions, + /// Right sidebar panel. + Sidebar, + /// Left side of the status bar. + StatusLeft, + /// Right side of the status bar. + StatusRight, + /// Additional section in the Settings tab. + SettingsSection, +} + +/// Prefix every CSS selector with `[data-widget="{widget_id}"]` for style isolation. +/// +/// This prevents widget styles from bleeding into the main app or other widgets. +/// The widget container element gets `data-widget="{id}"` set by the runtime. +/// +/// # Example +/// +/// ``` +/// use ironclaw_frontend::scope_css; +/// +/// let scoped = scope_css(".title { color: red; }", "my-widget"); +/// assert!(scoped.contains("[data-widget=\"my-widget\"] .title")); +/// ``` +pub fn scope_css(css: &str, widget_id: &str) -> String { + let prefix = format!("[data-widget=\"{}\"]", widget_id); + let mut result = String::with_capacity(css.len() + css.len() / 4); + let mut chars = css.chars().peekable(); + let mut in_block = false; + let mut current_selector = String::new(); + + while let Some(ch) = chars.next() { + match ch { + '{' if !in_block => { + // Scope each comma-separated selector + let selectors: Vec<&str> = current_selector.split(',').collect(); + let scoped: Vec = selectors + .iter() + .map(|s| { + let s = s.trim(); + if s.is_empty() || s.starts_with('@') { + s.to_string() + } else { + format!("{} {}", prefix, s) + } + }) + .collect(); + result.push_str(&scoped.join(", ")); + result.push_str(" {"); + current_selector.clear(); + in_block = true; + } + '}' if in_block => { + result.push('}'); + in_block = false; + } + _ if in_block => { + result.push(ch); + } + _ => { + current_selector.push(ch); + } + } + } + + // Append any trailing content + if !current_selector.is_empty() { + result.push_str(¤t_selector); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_widget_manifest_roundtrip() { + let json = serde_json::json!({ + "id": "dashboard", + "name": "Analytics Dashboard", + "slot": "tab", + "icon": "chart-bar", + "position": "after:memory" + }); + let manifest: WidgetManifest = serde_json::from_value(json).unwrap(); + assert_eq!(manifest.id, "dashboard"); + assert_eq!(manifest.slot, WidgetSlot::Tab); + assert_eq!(manifest.icon.as_deref(), Some("chart-bar")); + } + + #[test] + fn test_widget_slot_serialization() { + assert_eq!( + serde_json::to_string(&WidgetSlot::ChatHeader).unwrap(), + "\"chat_header\"" + ); + assert_eq!( + serde_json::to_string(&WidgetSlot::SettingsSection).unwrap(), + "\"settings_section\"" + ); + } + + #[test] + fn test_scope_css_basic() { + let input = ".title { color: red; }"; + let result = scope_css(input, "my-widget"); + assert!(result.contains("[data-widget=\"my-widget\"] .title")); + assert!(result.contains("color: red;")); + } + + #[test] + fn test_scope_css_multiple_selectors() { + let input = ".a, .b { margin: 0; }"; + let result = scope_css(input, "w"); + assert!(result.contains("[data-widget=\"w\"] .a")); + assert!(result.contains("[data-widget=\"w\"] .b")); + } + + #[test] + fn test_scope_css_multiple_rules() { + let input = ".a { color: red; } .b { color: blue; }"; + let result = scope_css(input, "w"); + assert!(result.contains("[data-widget=\"w\"] .a")); + assert!(result.contains("[data-widget=\"w\"] .b")); + } + + #[test] + fn test_scope_css_empty() { + assert_eq!(scope_css("", "w"), ""); + } +} diff --git a/src/channels/web/static/app.js b/crates/ironclaw_frontend/static/app.js similarity index 97% rename from src/channels/web/static/app.js rename to crates/ironclaw_frontend/static/app.js index 9cfd35df..42b590a5 100644 --- a/src/channels/web/static/app.js +++ b/crates/ironclaw_frontend/static/app.js @@ -6324,3 +6324,152 @@ document.getElementById('settings-search-input').addEventListener('input', funct activePanel.appendChild(empty); } }); + +// ==================== Widget Extension System ==================== +// +// Provides a registration API for frontend widgets. Widgets are self-contained +// components that plug into named slots in the UI (tabs, sidebar, status bar, etc.). +// +// Widget authors call IronClaw.registerWidget({ id, name, slot, init, ... }) +// from their module script. The init() function receives a container DOM element +// and the IronClaw.api object for authenticated fetch, event subscription, etc. + +window.IronClaw = window.IronClaw || {}; +IronClaw.widgets = new Map(); +IronClaw._widgetInitQueue = []; + +/** + * Register a widget component. + * @param {Object} def - Widget definition + * @param {string} def.id - Unique widget identifier + * @param {string} def.name - Display name + * @param {string} def.slot - Target slot ('tab', 'chat_header', etc.) + * @param {string} [def.icon] - Icon identifier + * @param {Function} def.init - Called with (container, api) when widget activates + * @param {Function} [def.activate] - Called when widget becomes visible + * @param {Function} [def.deactivate] - Called when widget is hidden + * @param {Function} [def.destroy] - Called when widget is removed + */ +IronClaw.registerWidget = function(def) { + if (!def.id || !def.init) { + console.error('[IronClaw] Widget registration requires id and init:', def); + return; + } + IronClaw.widgets.set(def.id, def); + + if (def.slot === 'tab') { + _addWidgetTab(def); + } +}; + +/** + * API object exposed to widgets for safe interaction with the app. + */ +IronClaw.api = { + /** Authenticated fetch wrapper — injects the session token. */ + fetch: function(path, opts) { + opts = opts || {}; + opts.headers = Object.assign({}, opts.headers || {}, { + 'Authorization': 'Bearer ' + token + }); + return fetch(path, opts); + }, + + /** Subscribe to an SSE/WebSocket event type. Returns an unsubscribe function. */ + subscribe: function(eventType, handler) { + if (!window._widgetEventHandlers) window._widgetEventHandlers = {}; + if (!window._widgetEventHandlers[eventType]) window._widgetEventHandlers[eventType] = []; + window._widgetEventHandlers[eventType].push(handler); + return function() { + var handlers = window._widgetEventHandlers[eventType]; + if (handlers) { + var idx = handlers.indexOf(handler); + if (idx !== -1) handlers.splice(idx, 1); + } + }; + }, + + /** Current theme information. */ + theme: { + get current() { return document.documentElement.dataset.theme || 'dark'; } + }, + + /** Internationalization helper. */ + i18n: { + t: function(key) { return (window.I18n && window.I18n.t) ? window.I18n.t(key) : key; } + }, + + /** Navigate to a tab by ID. */ + navigate: function(tabId) { + if (typeof switchTab === 'function') switchTab(tabId); + } +}; + +/** + * Add a widget as a new tab in the tab bar. + * @private + */ +function _addWidgetTab(def) { + var tabBar = document.querySelector('.tab-bar'); + var tabContent = document.querySelector('.tab-content') || document.getElementById('tab-content'); + if (!tabBar || !tabContent) { + // DOM not ready yet — queue for later + IronClaw._widgetInitQueue.push(def); + return; + } + + // Create tab button + var btn = document.createElement('button'); + btn.className = 'tab-btn'; + btn.dataset.tab = def.id; + btn.textContent = def.name; + if (def.icon) { + btn.dataset.icon = def.icon; + } + btn.addEventListener('click', function() { + if (typeof switchTab === 'function') switchTab(def.id); + }); + // Insert before the settings tab (last built-in tab) or at the end + var settingsBtn = tabBar.querySelector('[data-tab="settings"]'); + if (settingsBtn) { + tabBar.insertBefore(btn, settingsBtn); + } else { + tabBar.appendChild(btn); + } + + // Create container panel + var panel = document.createElement('div'); + panel.className = 'tab-panel'; + panel.dataset.tab = def.id; + panel.dataset.widget = def.id; + panel.style.display = 'none'; + tabContent.appendChild(panel); + + // Initialize the widget + try { + def.init(panel, IronClaw.api); + } catch (e) { + console.error('[IronClaw] Widget "' + def.id + '" init failed:', e); + panel.innerHTML = '
Widget "' + + def.id + '" failed to load: ' + (e.message || e) + '
'; + } +} + +// Apply layout config if injected by the server +if (window.__IRONCLAW_LAYOUT__) { + (function() { + var layout = window.__IRONCLAW_LAYOUT__; + // Apply branding title + if (layout.branding && layout.branding.title) { + var titleEl = document.querySelector('.app-title'); + if (titleEl) titleEl.textContent = layout.branding.title; + } + // Apply tab visibility + if (layout.tabs && layout.tabs.hidden) { + layout.tabs.hidden.forEach(function(tabId) { + var btn = document.querySelector('.tab-btn[data-tab="' + tabId + '"]'); + if (btn) btn.style.display = 'none'; + }); + } + })(); +} diff --git a/src/channels/web/static/favicon.ico b/crates/ironclaw_frontend/static/favicon.ico similarity index 100% rename from src/channels/web/static/favicon.ico rename to crates/ironclaw_frontend/static/favicon.ico diff --git a/src/channels/web/static/i18n-app.js b/crates/ironclaw_frontend/static/i18n-app.js similarity index 100% rename from src/channels/web/static/i18n-app.js rename to crates/ironclaw_frontend/static/i18n-app.js diff --git a/src/channels/web/static/i18n/en.js b/crates/ironclaw_frontend/static/i18n/en.js similarity index 100% rename from src/channels/web/static/i18n/en.js rename to crates/ironclaw_frontend/static/i18n/en.js diff --git a/src/channels/web/static/i18n/index.js b/crates/ironclaw_frontend/static/i18n/index.js similarity index 100% rename from src/channels/web/static/i18n/index.js rename to crates/ironclaw_frontend/static/i18n/index.js diff --git a/src/channels/web/static/i18n/zh-CN.js b/crates/ironclaw_frontend/static/i18n/zh-CN.js similarity index 100% rename from src/channels/web/static/i18n/zh-CN.js rename to crates/ironclaw_frontend/static/i18n/zh-CN.js diff --git a/src/channels/web/static/index.html b/crates/ironclaw_frontend/static/index.html similarity index 100% rename from src/channels/web/static/index.html rename to crates/ironclaw_frontend/static/index.html diff --git a/src/channels/web/static/style.css b/crates/ironclaw_frontend/static/style.css similarity index 100% rename from src/channels/web/static/style.css rename to crates/ironclaw_frontend/static/style.css diff --git a/src/channels/web/static/theme-init.js b/crates/ironclaw_frontend/static/theme-init.js similarity index 100% rename from src/channels/web/static/theme-init.js rename to crates/ironclaw_frontend/static/theme-init.js diff --git a/src/channels/web/handlers/frontend.rs b/src/channels/web/handlers/frontend.rs new file mode 100644 index 00000000..c9be34df --- /dev/null +++ b/src/channels/web/handlers/frontend.rs @@ -0,0 +1,140 @@ +//! Frontend extension API handlers. +//! +//! Provides endpoints for reading/writing layout configuration and +//! discovering/serving widget files from the workspace. + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::{StatusCode, header}, + response::IntoResponse, +}; + +use ironclaw_frontend::{LayoutConfig, WidgetManifest}; + +use crate::channels::web::auth::AuthenticatedUser; +use crate::channels::web::handlers::memory::resolve_workspace; +use crate::channels::web::server::GatewayState; + +/// `GET /api/frontend/layout` — return the current layout configuration. +/// +/// Reads `frontend/layout.json` from the workspace. Returns an empty +/// default config if the file doesn't exist. +pub async fn frontend_layout_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, +) -> Result, (StatusCode, String)> { + let workspace = resolve_workspace(&state, &user).await?; + + let layout = match workspace.read("frontend/layout.json").await { + Ok(doc) => serde_json::from_str(&doc.content).unwrap_or_default(), + Err(_) => LayoutConfig::default(), + }; + + Ok(Json(layout)) +} + +/// `PUT /api/frontend/layout` — update the layout configuration. +/// +/// Writes the provided layout config to `frontend/layout.json` in workspace. +pub async fn frontend_layout_update_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, + Json(layout): Json, +) -> Result { + let workspace = resolve_workspace(&state, &user).await?; + + let content = serde_json::to_string_pretty(&layout).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid layout config: {e}"), + ) + })?; + + workspace + .write("frontend/layout.json", &content) + .await + .map_err(|e| { + tracing::error!("Failed to write layout config: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to write layout config".to_string(), + ) + })?; + + Ok(StatusCode::OK) +} + +/// `GET /api/frontend/widgets` — list all widget manifests. +/// +/// Scans `frontend/widgets/` in workspace for directories containing +/// `manifest.json` and returns their parsed manifests. +pub async fn frontend_widgets_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, +) -> Result>, (StatusCode, String)> { + let workspace = resolve_workspace(&state, &user).await?; + + let entries = workspace.list("frontend/widgets/").await.unwrap_or_default(); + + let mut manifests = Vec::new(); + for entry in entries { + if !entry.is_directory { + continue; + } + let manifest_path = format!("frontend/widgets/{}/manifest.json", entry.name()); + if let Ok(doc) = workspace.read(&manifest_path).await { + if let Ok(manifest) = serde_json::from_str::(&doc.content) { + manifests.push(manifest); + } + } + } + + Ok(Json(manifests)) +} + +/// `GET /api/frontend/widget/{id}/{*file}` — serve a widget file. +/// +/// Serves JS/CSS files from `frontend/widgets/{id}/{file}` in workspace +/// with appropriate MIME types. +pub async fn frontend_widget_file_handler( + State(state): State>, + AuthenticatedUser(user): AuthenticatedUser, + Path((id, file)): Path<(String, String)>, +) -> Result { + // Reject path traversal + if id.contains("..") || file.contains("..") { + return Err((StatusCode::BAD_REQUEST, "Invalid path".to_string())); + } + + let workspace = resolve_workspace(&state, &user).await?; + let path = format!("frontend/widgets/{}/{}", id, file); + + let doc = workspace.read(&path).await.map_err(|_| { + ( + StatusCode::NOT_FOUND, + format!("Widget file not found: {path}"), + ) + })?; + + // Determine MIME type from extension + let content_type = if file.ends_with(".js") { + "application/javascript" + } else if file.ends_with(".css") { + "text/css" + } else if file.ends_with(".json") { + "application/json" + } else { + "text/plain" + }; + + Ok(( + [ + (header::CONTENT_TYPE, content_type), + (header::CACHE_CONTROL, "no-cache"), + ], + doc.content, + )) +} diff --git a/src/channels/web/handlers/mod.rs b/src/channels/web/handlers/mod.rs index b8958527..16ca5673 100644 --- a/src/channels/web/handlers/mod.rs +++ b/src/channels/web/handlers/mod.rs @@ -20,4 +20,5 @@ pub mod extensions; pub mod settings; #[allow(dead_code)] pub mod static_files; +pub mod frontend; pub mod webhooks; diff --git a/src/channels/web/handlers/static_files.rs b/src/channels/web/handlers/static_files.rs index effc7037..9deefff7 100644 --- a/src/channels/web/handlers/static_files.rs +++ b/src/channels/web/handlers/static_files.rs @@ -13,20 +13,20 @@ use crate::channels::web::types::*; // --- Static file handlers --- pub async fn index_handler() -> Html<&'static str> { - Html(include_str!("../static/index.html")) + Html(ironclaw_frontend::assets::INDEX_HTML) } pub async fn css_handler() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "text/css")], - include_str!("../static/style.css"), + ironclaw_frontend::assets::STYLE_CSS, ) } pub async fn js_handler() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "application/javascript")], - include_str!("../static/app.js"), + ironclaw_frontend::assets::APP_JS, ) } diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 8c9ecdde..32c50668 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -46,6 +46,10 @@ use crate::channels::web::handlers::routines::{ routines_delete_handler, routines_detail_handler, routines_list_handler, routines_summary_handler, routines_toggle_handler, routines_trigger_handler, }; +use crate::channels::web::handlers::frontend::{ + frontend_layout_handler, frontend_layout_update_handler, frontend_widget_file_handler, + frontend_widgets_handler, +}; use crate::channels::web::handlers::skills::{ skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler, }; @@ -580,6 +584,16 @@ pub async fn start_server( "/api/tokens/{id}", axum::routing::delete(super::handlers::tokens::tokens_revoke_handler), ) + // Frontend extension API + .route( + "/api/frontend/layout", + get(frontend_layout_handler).put(frontend_layout_update_handler), + ) + .route("/api/frontend/widgets", get(frontend_widgets_handler)) + .route( + "/api/frontend/widget/{id}/{*file}", + get(frontend_widget_file_handler), + ) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) // OpenAI-compatible API @@ -726,6 +740,11 @@ pub async fn start_server( } // --- Static file handlers --- +// +// All frontend assets are embedded in the `ironclaw_frontend` crate. +// These handlers serve them with appropriate MIME types and cache headers. + +use ironclaw_frontend::assets; async fn index_handler() -> impl IntoResponse { ( @@ -733,7 +752,7 @@ async fn index_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache"), ], - include_str!("static/index.html"), + assets::INDEX_HTML, ) } @@ -743,7 +762,7 @@ async fn css_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "text/css"), (header::CACHE_CONTROL, "no-cache"), ], - include_str!("static/style.css"), + assets::STYLE_CSS, ) } @@ -753,7 +772,7 @@ async fn js_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], - include_str!("static/app.js"), + assets::APP_JS, ) } @@ -763,7 +782,7 @@ async fn theme_init_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], - include_str!("static/theme-init.js"), + assets::THEME_INIT_JS, ) } @@ -773,7 +792,7 @@ async fn favicon_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "image/x-icon"), (header::CACHE_CONTROL, "public, max-age=86400"), ], - include_bytes!("static/favicon.ico").as_slice(), + assets::FAVICON_ICO, ) } @@ -783,7 +802,7 @@ async fn i18n_index_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], - include_str!("static/i18n/index.js"), + assets::I18N_INDEX_JS, ) } @@ -793,7 +812,7 @@ async fn i18n_en_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], - include_str!("static/i18n/en.js"), + assets::I18N_EN_JS, ) } @@ -803,7 +822,7 @@ async fn i18n_zh_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], - include_str!("static/i18n/zh-CN.js"), + assets::I18N_ZH_CN_JS, ) } @@ -813,7 +832,7 @@ async fn i18n_app_handler() -> impl IntoResponse { (header::CONTENT_TYPE, "application/javascript"), (header::CACHE_CONTROL, "no-cache"), ], - include_str!("static/i18n-app.js"), + assets::I18N_APP_JS, ) }