mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bae87b04e | ||
|
|
c47c82bf79 | ||
|
|
fc32a31515 | ||
|
|
c54c8e0fb5 |
Generated
+10
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ironclaw_frontend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Frontend assets, layout configuration, and widget extension system for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
@@ -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");
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 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;"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 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,
|
||||
/// Custom inline renderer for structured data in chat messages.
|
||||
/// Registered via `IronClaw.registerChatRenderer()` on the browser side.
|
||||
ChatRenderer,
|
||||
}
|
||||
|
||||
/// 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(¤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"), "");
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,118 @@ let authFlowPending = false;
|
||||
let _ghostSuggestion = '';
|
||||
let currentSettingsSubtab = 'inference';
|
||||
|
||||
// --- Hash-based URL Navigation ---
|
||||
//
|
||||
// Encodes navigation state in window.location.hash so refreshing
|
||||
// the page restores the current tab, thread, memory file, job detail, etc.
|
||||
//
|
||||
// Hash format: #/{tab}[/{detail}[/{subtab}]]
|
||||
// #/chat → chat tab, assistant thread
|
||||
// #/chat/{threadId} → chat tab, specific thread
|
||||
// #/memory → memory tab, tree root
|
||||
// #/memory/{path/to/file} → memory tab, specific file
|
||||
// #/jobs → jobs list
|
||||
// #/jobs/{jobId} → job detail
|
||||
// #/routines → routines list
|
||||
// #/routines/{id} → routine detail
|
||||
// #/settings/{subtab} → settings tab with specific sub-tab
|
||||
// #/logs → logs tab
|
||||
|
||||
/** Suppress hash-change handling while we're programmatically updating. */
|
||||
let _suppressHashChange = false;
|
||||
|
||||
/** Update the URL hash to reflect current navigation state. */
|
||||
function updateHash() {
|
||||
var parts = [currentTab];
|
||||
|
||||
switch (currentTab) {
|
||||
case 'chat':
|
||||
if (currentThreadId && currentThreadId !== assistantThreadId) {
|
||||
parts.push(currentThreadId);
|
||||
}
|
||||
break;
|
||||
case 'memory':
|
||||
if (typeof currentMemoryPath === 'string' && currentMemoryPath) {
|
||||
parts.push(currentMemoryPath);
|
||||
}
|
||||
break;
|
||||
case 'jobs':
|
||||
if (typeof currentJobId !== 'undefined' && currentJobId) {
|
||||
parts.push(currentJobId);
|
||||
}
|
||||
break;
|
||||
case 'routines':
|
||||
if (typeof currentRoutineId !== 'undefined' && currentRoutineId) {
|
||||
parts.push(currentRoutineId);
|
||||
}
|
||||
break;
|
||||
case 'settings':
|
||||
if (currentSettingsSubtab && currentSettingsSubtab !== 'inference') {
|
||||
parts.push(currentSettingsSubtab);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var hash = '#/' + parts.join('/');
|
||||
_suppressHashChange = true;
|
||||
if (window.location.hash !== hash) {
|
||||
window.history.replaceState(null, '', hash);
|
||||
}
|
||||
_suppressHashChange = false;
|
||||
}
|
||||
|
||||
/** Parse the current URL hash into navigation state. */
|
||||
function parseHash() {
|
||||
var hash = window.location.hash || '';
|
||||
if (!hash.startsWith('#/')) return null;
|
||||
var parts = hash.substring(2).split('/');
|
||||
return {
|
||||
tab: parts[0] || 'chat',
|
||||
detail: parts.slice(1).join('/') || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore navigation state from the URL hash.
|
||||
* Called once after authentication and on hashchange events.
|
||||
*/
|
||||
function restoreFromHash() {
|
||||
var state = parseHash();
|
||||
if (!state) return;
|
||||
|
||||
// Switch tab (without recursively updating hash)
|
||||
if (state.tab && state.tab !== currentTab) {
|
||||
switchTab(state.tab);
|
||||
}
|
||||
|
||||
// Restore detail state within the tab
|
||||
if (state.detail) {
|
||||
switch (state.tab) {
|
||||
case 'chat':
|
||||
// Defer thread switch until threads are loaded
|
||||
window._pendingThreadRestore = state.detail;
|
||||
break;
|
||||
case 'memory':
|
||||
readMemoryFile(state.detail);
|
||||
break;
|
||||
case 'jobs':
|
||||
openJobDetail(state.detail);
|
||||
break;
|
||||
case 'routines':
|
||||
openRoutineDetail(state.detail);
|
||||
break;
|
||||
case 'settings':
|
||||
switchSettingsSubtab(state.detail);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', function() {
|
||||
if (_suppressHashChange) return;
|
||||
restoreFromHash();
|
||||
});
|
||||
|
||||
// --- Streaming Debounce State ---
|
||||
let _streamBuffer = '';
|
||||
let _streamDebounceTimer = null;
|
||||
@@ -197,6 +309,8 @@ function authenticate() {
|
||||
loadThreads();
|
||||
loadMemoryTree();
|
||||
loadJobs();
|
||||
// Restore navigation state from URL hash (tab, thread, memory file, etc.)
|
||||
restoreFromHash();
|
||||
// Apply URL log_level param if present, otherwise just sync the dropdown
|
||||
if (urlLogLevel) {
|
||||
setServerLogLevel(urlLogLevel);
|
||||
@@ -1020,6 +1134,128 @@ function sanitizeRenderedHtml(html) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// ==================== Structured Data Rendering ====================
|
||||
//
|
||||
// Detects JSON objects and key-value data in assistant messages and
|
||||
// renders them as styled cards instead of raw text. Also supports
|
||||
// extensible chat renderers via IronClaw.registerChatRenderer().
|
||||
|
||||
/**
|
||||
* Post-process a .message-content element to upgrade structured data into cards.
|
||||
* Runs registered chat renderers first, then falls back to built-in JSON detection.
|
||||
*/
|
||||
function upgradeStructuredData(contentEl) {
|
||||
// 1. Run registered chat renderers
|
||||
var renderers = (window.IronClaw && IronClaw._chatRenderers) || [];
|
||||
for (var i = 0; i < renderers.length; i++) {
|
||||
try {
|
||||
if (renderers[i].match(contentEl.textContent, contentEl)) {
|
||||
renderers[i].render(contentEl, contentEl.textContent);
|
||||
return; // First matching renderer wins
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[IronClaw] Chat renderer "' + renderers[i].id + '" failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Built-in: detect and upgrade inline JSON objects
|
||||
upgradeInlineJson(contentEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find JSON-like objects in text nodes and replace them with styled cards.
|
||||
*/
|
||||
function upgradeInlineJson(contentEl) {
|
||||
// Walk text content looking for JSON objects: {...} patterns
|
||||
// Only process <p> and top-level text, not code blocks
|
||||
var paragraphs = contentEl.querySelectorAll('p');
|
||||
if (paragraphs.length === 0) {
|
||||
// No <p> tags — markdown might have produced bare text
|
||||
paragraphs = [contentEl];
|
||||
}
|
||||
|
||||
paragraphs.forEach(function(p) {
|
||||
// Skip code blocks
|
||||
if (p.closest('pre') || p.closest('code')) return;
|
||||
|
||||
var html = p.innerHTML;
|
||||
// Match JSON-like objects: {...} (including Python-style single quotes)
|
||||
var jsonRegex = /(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})/g;
|
||||
var match;
|
||||
var replaced = false;
|
||||
|
||||
while ((match = jsonRegex.exec(html)) !== null) {
|
||||
var raw = match[1];
|
||||
// Normalize Python-style single quotes to double quotes for parsing
|
||||
var normalized = raw.replace(/'/g, '"');
|
||||
try {
|
||||
var obj = JSON.parse(normalized);
|
||||
if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) {
|
||||
var card = buildDataCard(obj);
|
||||
html = html.substring(0, match.index) + card + html.substring(match.index + match[0].length);
|
||||
replaced = true;
|
||||
// Reset regex since we modified the string
|
||||
jsonRegex.lastIndex = match.index + card.length;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not valid JSON — leave as text
|
||||
}
|
||||
}
|
||||
|
||||
if (replaced) {
|
||||
p.innerHTML = html;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an HTML data card from a plain object.
|
||||
*/
|
||||
function buildDataCard(obj) {
|
||||
var keys = Object.keys(obj);
|
||||
if (keys.length === 0) return '';
|
||||
|
||||
var rows = '';
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var value = obj[key];
|
||||
var displayKey = key.replace(/_/g, ' ');
|
||||
var valueClass = 'data-card-value';
|
||||
var valueHtml;
|
||||
|
||||
// Special rendering for known value types
|
||||
if (key === 'status' || key === 'state') {
|
||||
var badgeClass = 'status-badge';
|
||||
var sv = String(value).toLowerCase();
|
||||
if (sv === 'created' || sv === 'active' || sv === 'success' || sv === 'completed' || sv === 'ok' || sv === 'running') {
|
||||
badgeClass += ' status-success';
|
||||
} else if (sv === 'failed' || sv === 'error' || sv === 'cancelled' || sv === 'rejected') {
|
||||
badgeClass += ' status-error';
|
||||
} else if (sv === 'pending' || sv === 'waiting' || sv === 'queued') {
|
||||
badgeClass += ' status-pending';
|
||||
}
|
||||
valueHtml = '<span class="' + badgeClass + '">' + escapeHtml(String(value)) + '</span>';
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
valueHtml = '<code>' + escapeHtml(JSON.stringify(value)) + '</code>';
|
||||
} else {
|
||||
// Check if value looks like a UUID or ID
|
||||
var strVal = String(value);
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(strVal)) {
|
||||
valueHtml = '<code class="data-card-id">' + escapeHtml(strVal) + '</code>';
|
||||
} else {
|
||||
valueHtml = '<span>' + escapeHtml(strVal) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
rows += '<div class="data-card-row">' +
|
||||
'<span class="data-card-label">' + escapeHtml(displayKey) + '</span>' +
|
||||
'<span class="' + valueClass + '">' + valueHtml + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<div class="data-card">' + rows + '</div>';
|
||||
}
|
||||
|
||||
function copyCodeBlock(btn) {
|
||||
const pre = btn.parentElement;
|
||||
const code = pre.querySelector('code');
|
||||
@@ -1824,6 +2060,8 @@ function createMessageElement(role, content) {
|
||||
} else {
|
||||
div.setAttribute('data-raw', content);
|
||||
contentEl.innerHTML = renderMarkdown(content);
|
||||
// Upgrade structured data (JSON objects, etc.) into styled cards
|
||||
upgradeStructuredData(contentEl);
|
||||
// Syntax highlighting for code blocks
|
||||
if (typeof hljs !== 'undefined') {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -2012,6 +2250,19 @@ function loadThreads() {
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
// Restore thread from URL hash if pending (deferred from restoreFromHash)
|
||||
if (window._pendingThreadRestore) {
|
||||
var pendingId = window._pendingThreadRestore;
|
||||
window._pendingThreadRestore = null;
|
||||
// Verify the thread exists in the loaded list
|
||||
var found = (pendingId === assistantThreadId) ||
|
||||
threads.some(function(t) { return t.id === pendingId; });
|
||||
if (found) {
|
||||
switchThread(pendingId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to assistant thread on first load if no thread selected
|
||||
if (!currentThreadId && assistantThreadId) {
|
||||
switchToAssistant();
|
||||
@@ -2051,6 +2302,7 @@ function switchToAssistant() {
|
||||
oldestTimestamp = null;
|
||||
loadHistory();
|
||||
loadThreads();
|
||||
updateHash();
|
||||
if (window.innerWidth <= 768) {
|
||||
const sidebar = document.getElementById('thread-sidebar');
|
||||
sidebar.classList.remove('expanded-mobile');
|
||||
@@ -2067,6 +2319,7 @@ function switchThread(threadId) {
|
||||
oldestTimestamp = null;
|
||||
loadHistory();
|
||||
loadThreads();
|
||||
updateHash();
|
||||
if (window.innerWidth <= 768) {
|
||||
const sidebar = document.getElementById('thread-sidebar');
|
||||
sidebar.classList.remove('expanded-mobile');
|
||||
@@ -2080,6 +2333,7 @@ function createNewThread() {
|
||||
document.getElementById('chat-messages').innerHTML = '';
|
||||
showWelcomeCard();
|
||||
loadThreads();
|
||||
updateHash();
|
||||
}).catch((err) => {
|
||||
showToast('Failed to create thread: ' + err.message, 'error');
|
||||
});
|
||||
@@ -2227,6 +2481,7 @@ function switchTab(tab) {
|
||||
stopPairingPoll();
|
||||
}
|
||||
updateTabIndicator();
|
||||
updateHash();
|
||||
}
|
||||
|
||||
function updateTabIndicator() {
|
||||
@@ -2372,6 +2627,7 @@ function toggleExpand(node) {
|
||||
|
||||
function readMemoryFile(path) {
|
||||
currentMemoryPath = path;
|
||||
updateHash();
|
||||
// Update breadcrumb
|
||||
document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path);
|
||||
document.getElementById('memory-edit-btn').style.display = 'inline-block';
|
||||
@@ -3685,6 +3941,7 @@ function restartJob(jobId) {
|
||||
function openJobDetail(jobId) {
|
||||
currentJobId = jobId;
|
||||
currentJobSubTab = 'activity';
|
||||
updateHash();
|
||||
apiFetch('/api/jobs/' + jobId).then((job) => {
|
||||
renderJobDetail(job);
|
||||
}).catch((err) => {
|
||||
@@ -3697,6 +3954,7 @@ function closeJobDetail() {
|
||||
currentJobId = null;
|
||||
jobFilesTreeState = null;
|
||||
loadJobs();
|
||||
updateHash();
|
||||
}
|
||||
|
||||
function renderJobDetail(job) {
|
||||
@@ -4193,6 +4451,7 @@ function renderRoutinesList(routines) {
|
||||
|
||||
function openRoutineDetail(id) {
|
||||
currentRoutineId = id;
|
||||
updateHash();
|
||||
apiFetch('/api/routines/' + id).then((routine) => {
|
||||
renderRoutineDetail(routine);
|
||||
}).catch((err) => {
|
||||
@@ -4203,6 +4462,7 @@ function openRoutineDetail(id) {
|
||||
function closeRoutineDetail() {
|
||||
currentRoutineId = null;
|
||||
loadRoutines();
|
||||
updateHash();
|
||||
}
|
||||
|
||||
function renderRoutineDetail(routine) {
|
||||
@@ -5180,6 +5440,7 @@ function switchSettingsSubtab(subtab) {
|
||||
document.querySelector('.settings-layout').classList.add('settings-detail-active');
|
||||
}
|
||||
loadSettingsSubtab(subtab);
|
||||
updateHash();
|
||||
}
|
||||
|
||||
function settingsBack() {
|
||||
@@ -6324,3 +6585,177 @@ 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 = [];
|
||||
IronClaw._chatRenderers = [];
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a chat renderer for custom inline rendering of structured data.
|
||||
*
|
||||
* Chat renderers run against each assistant message. The first renderer
|
||||
* whose `match()` returns true gets to transform the content.
|
||||
*
|
||||
* @param {Object} def - Renderer definition
|
||||
* @param {string} def.id - Unique identifier
|
||||
* @param {Function} def.match - (textContent, element) => boolean
|
||||
* @param {Function} def.render - (element, textContent) => void (mutate element in place)
|
||||
* @param {number} [def.priority=0] - Higher priority runs first
|
||||
*/
|
||||
IronClaw.registerChatRenderer = function(def) {
|
||||
if (!def.id || !def.match || !def.render) {
|
||||
console.error('[IronClaw] Chat renderer requires id, match, and render:', def);
|
||||
return;
|
||||
}
|
||||
IronClaw._chatRenderers.push(def);
|
||||
// Sort by priority (higher first)
|
||||
IronClaw._chatRenderers.sort(function(a, b) {
|
||||
return (b.priority || 0) - (a.priority || 0);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 |
@@ -2106,6 +2106,90 @@ body {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* --- Data Cards (inline structured data) --- */
|
||||
|
||||
.data-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin: 8px 0;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
border-left: 3px solid var(--accent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-card-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
padding: 6px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-card-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-card-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: capitalize;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.data-card-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.data-card-value code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.data-card-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: var(--success, #34d399);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: var(--error, #f87171);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: var(--warning, #fbbf24);
|
||||
}
|
||||
|
||||
/* Clickable job rows */
|
||||
.job-row {
|
||||
cursor: pointer;
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 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 {
|
||||
match serde_json::from_str::<WidgetManifest>(&doc.content) {
|
||||
Ok(manifest) => manifests.push(manifest),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %manifest_path,
|
||||
"skipping widget with invalid manifest: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
))
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod users;
|
||||
pub mod chat;
|
||||
#[allow(dead_code)]
|
||||
pub mod extensions;
|
||||
pub mod frontend;
|
||||
#[allow(dead_code)]
|
||||
pub mod settings;
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ use crate::channels::relay::DEFAULT_RELAY_NAME;
|
||||
use crate::channels::web::auth::{
|
||||
AuthenticatedUser, CombinedAuthState, UserIdentity, auth_middleware,
|
||||
};
|
||||
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::jobs::{
|
||||
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
|
||||
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user