1pub 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
42const 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#[derive(Clone)]
52pub struct AppState {
53 pub bridge: BridgeClient,
54}
55
56pub struct ChatEcho {
58 config: Config,
59 started: bool,
60 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
61}
62
63impl ChatEcho {
64 pub fn new(config: Config) -> Self {
66 Self {
67 config,
68 started: false,
69 shutdown_tx: None,
70 }
71 }
72
73 fn health_check(&self) -> HealthStatus {
75 if self.started {
76 HealthStatus::Healthy
77 } else {
78 HealthStatus::Down("not started".into())
79 }
80 }
81
82 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 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 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
133pub 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 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}