feat(frontend): extract frontend into ironclaw_frontend crate with widget extension system

Moves all frontend static assets (app.js, style.css, index.html, i18n/*,
theme-init.js, favicon.ico) from src/channels/web/static/ into a dedicated
ironclaw_frontend crate. The crate also adds:

- Layout configuration types (branding, tab order, chat features, per-widget config)
- Widget manifest types with named slot system (tab, chat_header, sidebar, etc.)
- CSS scoping utility (auto-prefixes selectors with [data-widget="id"])
- Bundle assembly (injects layout config, widgets, and custom CSS into HTML)
- Frontend API endpoints (GET/PUT layout, list widgets, serve widget files)
- Browser-side IronClaw.registerWidget() API with authenticated fetch,
  event subscription, theme access, and i18n

Widgets are stored in workspace at frontend/widgets/{id}/ and served via
the API. Layout config is stored at frontend/layout.json. The agent can
create/edit both using existing memory_write/memory_read tools.

Gateway handlers now reference ironclaw_frontend::assets constants instead
of include_str!() with local paths, completing the separation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-28 12:27:41 -07:00
co-authored by Claude Opus 4.6
parent ebdae267a0
commit c54c8e0fb5
21 changed files with 1014 additions and 13 deletions
Generated
+10
View File
@@ -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"
+2 -1
View File
@@ -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"
+14
View File
@@ -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"
+37
View File
@@ -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 `<head>` 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");
+240
View File
@@ -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<ResolvedWidget>,
/// Custom CSS to append after the base stylesheet.
pub custom_css: Option<String>,
}
/// 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<String>,
}
/// Inject frontend customizations into the base HTML template.
///
/// Modifications:
///
/// **Before `</head>`:**
/// - Branding CSS custom property overrides
/// - Title override (replaces `<title>` 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("</title>") {
let end = start + end + "</title>".len();
result.replace_range(start..end, &format!("<title>{}</title>", title));
}
}
}
// Inject before </body>
if !body_injections.is_empty() {
let body_block = body_injections.join("\n");
if let Some(pos) = result.rfind("</body>") {
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 =
"<!DOCTYPE html><html><head><title>IronClaw</title></head><body></body></html>";
#[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("<title>Acme AI</title>"));
assert!(!result.contains("<title>IronClaw</title>"));
}
#[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;"));
}
}
+179
View File
@@ -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<String, WidgetInstanceConfig>,
}
/// 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<String>,
/// Subtitle shown below the title.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subtitle: Option<String>,
/// URL to a logo image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_url: Option<String>,
/// URL to a custom favicon.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub favicon_url: Option<String>,
/// Color overrides (injected as CSS custom properties on `:root`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub colors: Option<BrandingColors>,
}
/// 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<String>,
/// Accent color.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
}
/// 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<Vec<String>>,
/// Tab IDs to hide from the tab bar.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hidden: Option<Vec<String>>,
/// Default tab to show on load.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_tab: Option<String>,
}
/// 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<bool>,
/// Enable image upload in the chat input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_upload: Option<bool>,
}
/// 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());
}
}
+36
View File
@@ -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 },
}
+175
View File
@@ -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<String>,
/// Positioning hint (e.g., `"after:memory"`, `"before:jobs"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<String>,
}
/// 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<String> = 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(&current_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"), "");
}
}
@@ -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 = '<div style="padding:2rem;color:var(--color-error,red);">Widget "' +
def.id + '" failed to load: ' + (e.message || e) + '</div>';
}
}
// 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';
});
}
})();
}

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

+140
View File
@@ -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<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<LayoutConfig>, (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<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Json(layout): Json<LayoutConfig>,
) -> Result<StatusCode, (StatusCode, String)> {
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<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<Vec<WidgetManifest>>, (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::<WidgetManifest>(&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<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Path((id, file)): Path<(String, String)>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// 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,
))
}
+1
View File
@@ -20,4 +20,5 @@ pub mod extensions;
pub mod settings;
#[allow(dead_code)]
pub mod static_files;
pub mod frontend;
pub mod webhooks;
+3 -3
View File
@@ -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,
)
}
+28 -9
View File
@@ -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,
)
}