Skip to main content

chat_echo/
lib.rs

1//! chat-echo — Web chat interface for AI entities.
2//!
3//! Provides a WebSocket-based chat relay between a browser frontend and
4//! a backend LLM service (via bridge-echo). Includes a Nord-themed
5//! responsive web UI with assets embedded in the binary.
6//!
7//! # Usage as a library
8//!
9//! ```no_run
10//! use chat_echo::{ChatEcho, config::Config};
11//!
12//! # fn run() {
13//! let config = Config::from_env();
14//! let mut chat = ChatEcho::new(config);
15//! // chat.start().await.expect("server");
16//! # }
17//! ```
18
19pub mod bridge;
20pub mod config;
21pub mod ws;
22
23use std::any::Any;
24use std::future::Future;
25use std::net::SocketAddr;
26use std::path::Path;
27use std::pin::Pin;
28
29use axum::extract::{State, WebSocketUpgrade};
30use axum::http::{header, StatusCode};
31use axum::response::{Html, IntoResponse, Response};
32use axum::routing::get;
33use axum::Router;
34use pulse_system_types::plugin::{Plugin, PluginContext, PluginResult, PluginRole};
35use pulse_system_types::{HealthStatus, PluginMeta, SetupPrompt};
36use tower_http::services::ServeDir;
37use tower_http::trace::TraceLayer;
38
39use bridge::BridgeClient;
40use config::Config;
41
42// Embed static assets at compile time so the binary is self-contained.
43const EMBEDDED_HTML: &str = include_str!("../static/index.html");
44const EMBEDDED_JS: &str = include_str!("../static/chat.js");
45const EMBEDDED_CSS: &str = include_str!("../static/style.css");
46const EMBEDDED_FONT_REGULAR: &[u8] = include_bytes!("../static/fonts/0xProto-Regular.woff2");
47const EMBEDDED_FONT_BOLD: &[u8] = include_bytes!("../static/fonts/0xProto-Bold.woff2");
48const EMBEDDED_FONT_ITALIC: &[u8] = include_bytes!("../static/fonts/0xProto-Italic.woff2");
49
50/// Shared application state accessible from all handlers.
51#[derive(Clone)]
52pub struct AppState {
53    pub bridge: BridgeClient,
54}
55
56/// The chat-echo plugin. Manages the web chat interface lifecycle.
57pub struct ChatEcho {
58    config: Config,
59    started: bool,
60    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
61}
62
63impl ChatEcho {
64    /// Create a new ChatEcho instance from config.
65    pub fn new(config: Config) -> Self {
66        Self {
67            config,
68            started: false,
69            shutdown_tx: None,
70        }
71    }
72
73    /// Report health status.
74    fn health_check(&self) -> HealthStatus {
75        if self.started {
76            HealthStatus::Healthy
77        } else {
78            HealthStatus::Down("not started".into())
79        }
80    }
81
82    /// Return the Axum router with all chat-echo routes.
83    pub fn routes(&self) -> Router<()> {
84        let state = AppState {
85            bridge: BridgeClient::new(&self.config.bridge_url, self.config.bridge_secret.clone()),
86        };
87        self.build_router(state)
88    }
89
90    /// Configuration prompts for the echo-system init wizard.
91    fn get_setup_prompts() -> Vec<SetupPrompt> {
92        vec![
93            SetupPrompt {
94                key: "bridge_url".into(),
95                question: "Bridge URL (backend LLM endpoint):".into(),
96                required: true,
97                secret: false,
98                default: Some("http://127.0.0.1:3100".into()),
99            },
100            SetupPrompt {
101                key: "bridge_secret".into(),
102                question: "Bridge secret (optional auth header):".into(),
103                required: false,
104                secret: true,
105                default: None,
106            },
107        ]
108    }
109
110    fn build_router(&self, state: AppState) -> Router<()> {
111        let router = Router::new()
112            .route("/ws", get(ws_handler))
113            .route("/health", get(health_handler))
114            .route("/api/dashboard", get(dashboard_proxy))
115            .route("/chat.js", get(serve_js))
116            .route("/style.css", get(serve_css))
117            .route("/fonts/0xProto-Regular.woff2", get(serve_font_regular))
118            .route("/fonts/0xProto-Bold.woff2", get(serve_font_bold))
119            .route("/fonts/0xProto-Italic.woff2", get(serve_font_italic));
120
121        // If static_dir exists on disk, use it as fallback (allows overrides).
122        // Otherwise, serve embedded index.html for all unmatched routes.
123        let router = if Path::new(&self.config.static_dir).is_dir() {
124            router.fallback_service(ServeDir::new(&self.config.static_dir))
125        } else {
126            router.fallback(serve_index)
127        };
128
129        router.layer(TraceLayer::new_for_http()).with_state(state)
130    }
131}
132
133/// Factory function — creates a fully initialized chat-echo plugin.
134pub async fn create(
135    config: &serde_json::Value,
136    _ctx: &PluginContext,
137) -> Result<Box<dyn Plugin>, Box<dyn std::error::Error + Send + Sync>> {
138    Ok(Box::new(ChatEcho::new(Config::from_json(config))))
139}
140
141impl Plugin for ChatEcho {
142    fn meta(&self) -> PluginMeta {
143        PluginMeta {
144            name: "chat-echo".into(),
145            version: env!("CARGO_PKG_VERSION").into(),
146            description: "Web chat UI for pulse-null".into(),
147        }
148    }
149
150    fn role(&self) -> PluginRole {
151        PluginRole::Interface
152    }
153
154    fn start(&mut self) -> PluginResult<'_> {
155        Box::pin(async move {
156            let state = AppState {
157                bridge: BridgeClient::new(
158                    &self.config.bridge_url,
159                    self.config.bridge_secret.clone(),
160                ),
161            };
162
163            let app = self.build_router(state);
164
165            let addr: SocketAddr = format!("{}:{}", self.config.host, self.config.port)
166                .parse()
167                .map_err(|e| format!("Invalid address: {e}"))?;
168
169            tracing::info!(%addr, "Listening");
170
171            let listener = tokio::net::TcpListener::bind(addr).await?;
172
173            let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
174            self.shutdown_tx = Some(shutdown_tx);
175            self.started = true;
176
177            // Spawn into background so start() returns immediately
178            // and doesn't block other plugins from starting.
179            tokio::spawn(async move {
180                if let Err(e) = axum::serve(listener, app)
181                    .with_graceful_shutdown(async {
182                        let _ = shutdown_rx.await;
183                    })
184                    .await
185                {
186                    tracing::error!("chat-echo server error: {e}");
187                }
188            });
189
190            Ok(())
191        })
192    }
193
194    fn stop(&mut self) -> PluginResult<'_> {
195        Box::pin(async move {
196            if let Some(tx) = self.shutdown_tx.take() {
197                let _ = tx.send(());
198            }
199            self.started = false;
200            Ok(())
201        })
202    }
203
204    fn health(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>> {
205        Box::pin(async move { self.health_check() })
206    }
207
208    fn setup_prompts(&self) -> Vec<SetupPrompt> {
209        Self::get_setup_prompts()
210    }
211
212    fn as_any(&self) -> &dyn Any {
213        self
214    }
215}
216
217async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
218    ws.on_upgrade(move |socket| ws::handle_socket(socket, state.bridge))
219}
220
221async fn health_handler() -> &'static str {
222    "ok"
223}
224
225async fn dashboard_proxy(State(state): State<AppState>) -> Response {
226    match state.bridge.dashboard().await {
227        Ok(json) => (
228            StatusCode::OK,
229            [(header::CONTENT_TYPE, "application/json")],
230            json,
231        )
232            .into_response(),
233        Err(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
234    }
235}
236
237async fn serve_index() -> Html<&'static str> {
238    Html(EMBEDDED_HTML)
239}
240
241async fn serve_js() -> Response {
242    (
243        StatusCode::OK,
244        [(header::CONTENT_TYPE, "application/javascript")],
245        EMBEDDED_JS,
246    )
247        .into_response()
248}
249
250async fn serve_css() -> Response {
251    (
252        StatusCode::OK,
253        [(header::CONTENT_TYPE, "text/css")],
254        EMBEDDED_CSS,
255    )
256        .into_response()
257}
258
259async fn serve_font_regular() -> Response {
260    (
261        StatusCode::OK,
262        [(header::CONTENT_TYPE, "font/woff2")],
263        EMBEDDED_FONT_REGULAR,
264    )
265        .into_response()
266}
267
268async fn serve_font_bold() -> Response {
269    (
270        StatusCode::OK,
271        [(header::CONTENT_TYPE, "font/woff2")],
272        EMBEDDED_FONT_BOLD,
273    )
274        .into_response()
275}
276
277async fn serve_font_italic() -> Response {
278    (
279        StatusCode::OK,
280        [(header::CONTENT_TYPE, "font/woff2")],
281        EMBEDDED_FONT_ITALIC,
282    )
283        .into_response()
284}