mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 08:17:53 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bae87b04e | ||
|
|
c47c82bf79 | ||
|
|
fc32a31515 | ||
|
|
c54c8e0fb5 |
Generated
+10
@@ -3429,6 +3429,7 @@ dependencies = [
|
|||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"insta",
|
"insta",
|
||||||
"ironclaw_common",
|
"ironclaw_common",
|
||||||
|
"ironclaw_frontend",
|
||||||
"ironclaw_safety",
|
"ironclaw_safety",
|
||||||
"json5",
|
"json5",
|
||||||
"libsql",
|
"libsql",
|
||||||
@@ -3496,6 +3497,15 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironclaw_frontend"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw_safety"
|
name = "ironclaw_safety"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"]
|
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_frontend"]
|
||||||
exclude = [
|
exclude = [
|
||||||
"channels-src/discord",
|
"channels-src/discord",
|
||||||
"channels-src/telegram",
|
"channels-src/telegram",
|
||||||
@@ -105,6 +105,7 @@ cron = "0.13"
|
|||||||
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
|
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
|
||||||
|
|
||||||
# Safety/sanitization
|
# Safety/sanitization
|
||||||
|
ironclaw_frontend = { path = "crates/ironclaw_frontend", version = "0.1.0" }
|
||||||
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
|
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
aho-corasick = "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 _ghostSuggestion = '';
|
||||||
let currentSettingsSubtab = 'inference';
|
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 ---
|
// --- Streaming Debounce State ---
|
||||||
let _streamBuffer = '';
|
let _streamBuffer = '';
|
||||||
let _streamDebounceTimer = null;
|
let _streamDebounceTimer = null;
|
||||||
@@ -197,6 +309,8 @@ function authenticate() {
|
|||||||
loadThreads();
|
loadThreads();
|
||||||
loadMemoryTree();
|
loadMemoryTree();
|
||||||
loadJobs();
|
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
|
// Apply URL log_level param if present, otherwise just sync the dropdown
|
||||||
if (urlLogLevel) {
|
if (urlLogLevel) {
|
||||||
setServerLogLevel(urlLogLevel);
|
setServerLogLevel(urlLogLevel);
|
||||||
@@ -1020,6 +1134,128 @@ function sanitizeRenderedHtml(html) {
|
|||||||
return '';
|
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) {
|
function copyCodeBlock(btn) {
|
||||||
const pre = btn.parentElement;
|
const pre = btn.parentElement;
|
||||||
const code = pre.querySelector('code');
|
const code = pre.querySelector('code');
|
||||||
@@ -1824,6 +2060,8 @@ function createMessageElement(role, content) {
|
|||||||
} else {
|
} else {
|
||||||
div.setAttribute('data-raw', content);
|
div.setAttribute('data-raw', content);
|
||||||
contentEl.innerHTML = renderMarkdown(content);
|
contentEl.innerHTML = renderMarkdown(content);
|
||||||
|
// Upgrade structured data (JSON objects, etc.) into styled cards
|
||||||
|
upgradeStructuredData(contentEl);
|
||||||
// Syntax highlighting for code blocks
|
// Syntax highlighting for code blocks
|
||||||
if (typeof hljs !== 'undefined') {
|
if (typeof hljs !== 'undefined') {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -2012,6 +2250,19 @@ function loadThreads() {
|
|||||||
list.appendChild(item);
|
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
|
// Default to assistant thread on first load if no thread selected
|
||||||
if (!currentThreadId && assistantThreadId) {
|
if (!currentThreadId && assistantThreadId) {
|
||||||
switchToAssistant();
|
switchToAssistant();
|
||||||
@@ -2051,6 +2302,7 @@ function switchToAssistant() {
|
|||||||
oldestTimestamp = null;
|
oldestTimestamp = null;
|
||||||
loadHistory();
|
loadHistory();
|
||||||
loadThreads();
|
loadThreads();
|
||||||
|
updateHash();
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
const sidebar = document.getElementById('thread-sidebar');
|
const sidebar = document.getElementById('thread-sidebar');
|
||||||
sidebar.classList.remove('expanded-mobile');
|
sidebar.classList.remove('expanded-mobile');
|
||||||
@@ -2067,6 +2319,7 @@ function switchThread(threadId) {
|
|||||||
oldestTimestamp = null;
|
oldestTimestamp = null;
|
||||||
loadHistory();
|
loadHistory();
|
||||||
loadThreads();
|
loadThreads();
|
||||||
|
updateHash();
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
const sidebar = document.getElementById('thread-sidebar');
|
const sidebar = document.getElementById('thread-sidebar');
|
||||||
sidebar.classList.remove('expanded-mobile');
|
sidebar.classList.remove('expanded-mobile');
|
||||||
@@ -2080,6 +2333,7 @@ function createNewThread() {
|
|||||||
document.getElementById('chat-messages').innerHTML = '';
|
document.getElementById('chat-messages').innerHTML = '';
|
||||||
showWelcomeCard();
|
showWelcomeCard();
|
||||||
loadThreads();
|
loadThreads();
|
||||||
|
updateHash();
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
showToast('Failed to create thread: ' + err.message, 'error');
|
showToast('Failed to create thread: ' + err.message, 'error');
|
||||||
});
|
});
|
||||||
@@ -2227,6 +2481,7 @@ function switchTab(tab) {
|
|||||||
stopPairingPoll();
|
stopPairingPoll();
|
||||||
}
|
}
|
||||||
updateTabIndicator();
|
updateTabIndicator();
|
||||||
|
updateHash();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTabIndicator() {
|
function updateTabIndicator() {
|
||||||
@@ -2372,6 +2627,7 @@ function toggleExpand(node) {
|
|||||||
|
|
||||||
function readMemoryFile(path) {
|
function readMemoryFile(path) {
|
||||||
currentMemoryPath = path;
|
currentMemoryPath = path;
|
||||||
|
updateHash();
|
||||||
// Update breadcrumb
|
// Update breadcrumb
|
||||||
document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path);
|
document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path);
|
||||||
document.getElementById('memory-edit-btn').style.display = 'inline-block';
|
document.getElementById('memory-edit-btn').style.display = 'inline-block';
|
||||||
@@ -3685,6 +3941,7 @@ function restartJob(jobId) {
|
|||||||
function openJobDetail(jobId) {
|
function openJobDetail(jobId) {
|
||||||
currentJobId = jobId;
|
currentJobId = jobId;
|
||||||
currentJobSubTab = 'activity';
|
currentJobSubTab = 'activity';
|
||||||
|
updateHash();
|
||||||
apiFetch('/api/jobs/' + jobId).then((job) => {
|
apiFetch('/api/jobs/' + jobId).then((job) => {
|
||||||
renderJobDetail(job);
|
renderJobDetail(job);
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -3697,6 +3954,7 @@ function closeJobDetail() {
|
|||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
jobFilesTreeState = null;
|
jobFilesTreeState = null;
|
||||||
loadJobs();
|
loadJobs();
|
||||||
|
updateHash();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobDetail(job) {
|
function renderJobDetail(job) {
|
||||||
@@ -4193,6 +4451,7 @@ function renderRoutinesList(routines) {
|
|||||||
|
|
||||||
function openRoutineDetail(id) {
|
function openRoutineDetail(id) {
|
||||||
currentRoutineId = id;
|
currentRoutineId = id;
|
||||||
|
updateHash();
|
||||||
apiFetch('/api/routines/' + id).then((routine) => {
|
apiFetch('/api/routines/' + id).then((routine) => {
|
||||||
renderRoutineDetail(routine);
|
renderRoutineDetail(routine);
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -4203,6 +4462,7 @@ function openRoutineDetail(id) {
|
|||||||
function closeRoutineDetail() {
|
function closeRoutineDetail() {
|
||||||
currentRoutineId = null;
|
currentRoutineId = null;
|
||||||
loadRoutines();
|
loadRoutines();
|
||||||
|
updateHash();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRoutineDetail(routine) {
|
function renderRoutineDetail(routine) {
|
||||||
@@ -5180,6 +5440,7 @@ function switchSettingsSubtab(subtab) {
|
|||||||
document.querySelector('.settings-layout').classList.add('settings-detail-active');
|
document.querySelector('.settings-layout').classList.add('settings-detail-active');
|
||||||
}
|
}
|
||||||
loadSettingsSubtab(subtab);
|
loadSettingsSubtab(subtab);
|
||||||
|
updateHash();
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingsBack() {
|
function settingsBack() {
|
||||||
@@ -6324,3 +6585,177 @@ document.getElementById('settings-search-input').addEventListener('input', funct
|
|||||||
activePanel.appendChild(empty);
|
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);
|
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 */
|
/* Clickable job rows */
|
||||||
.job-row {
|
.job-row {
|
||||||
cursor: pointer;
|
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;
|
pub mod chat;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod extensions;
|
pub mod extensions;
|
||||||
|
pub mod frontend;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ use crate::channels::web::types::*;
|
|||||||
// --- Static file handlers ---
|
// --- Static file handlers ---
|
||||||
|
|
||||||
pub async fn index_handler() -> Html<&'static str> {
|
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 {
|
pub async fn css_handler() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "text/css")],
|
[(header::CONTENT_TYPE, "text/css")],
|
||||||
include_str!("../static/style.css"),
|
ironclaw_frontend::assets::STYLE_CSS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn js_handler() -> impl IntoResponse {
|
pub async fn js_handler() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "application/javascript")],
|
[(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::{
|
use crate::channels::web::auth::{
|
||||||
AuthenticatedUser, CombinedAuthState, UserIdentity, auth_middleware,
|
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::{
|
use crate::channels::web::handlers::jobs::{
|
||||||
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
|
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,
|
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
|
||||||
@@ -580,6 +584,16 @@ pub async fn start_server(
|
|||||||
"/api/tokens/{id}",
|
"/api/tokens/{id}",
|
||||||
axum::routing::delete(super::handlers::tokens::tokens_revoke_handler),
|
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
|
// Gateway control plane
|
||||||
.route("/api/gateway/status", get(gateway_status_handler))
|
.route("/api/gateway/status", get(gateway_status_handler))
|
||||||
// OpenAI-compatible API
|
// OpenAI-compatible API
|
||||||
@@ -726,6 +740,11 @@ pub async fn start_server(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Static file handlers ---
|
// --- 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 {
|
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::CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
(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::CONTENT_TYPE, "text/css"),
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
(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::CONTENT_TYPE, "application/javascript"),
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
(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::CONTENT_TYPE, "application/javascript"),
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
(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::CONTENT_TYPE, "image/x-icon"),
|
||||||
(header::CACHE_CONTROL, "public, max-age=86400"),
|
(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::CONTENT_TYPE, "application/javascript"),
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
(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::CONTENT_TYPE, "application/javascript"),
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
(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::CONTENT_TYPE, "application/javascript"),
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
(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::CONTENT_TYPE, "application/javascript"),
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
(header::CACHE_CONTROL, "no-cache"),
|
||||||
],
|
],
|
||||||
include_str!("static/i18n-app.js"),
|
assets::I18N_APP_JS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user