From 8df639ac4b2caaa11253a7da4a5b4a6772595376 Mon Sep 17 00:00:00 2001 From: akn Date: Tue, 29 Sep 2026 21:39:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20LLM=20=E6=9C=8D=E5=8A=A1=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E6=8C=87=E7=A4=BA=E7=81=AF=20(/llm/status=20?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=8E=A2=E6=B5=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.html | 30 +++++++++++++++++ src/main.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/index.html b/src/index.html index 123b3d3..f077f15 100644 --- a/src/index.html +++ b/src/index.html @@ -144,6 +144,9 @@ h1 { } .status-dot.connected { background: #a6e3a1; } +.status-dot.online { background: #a6e3a1; box-shadow: 0 0 6px #a6e3a1; } +.status-dot.offline { background: #f38ba8; } +.status-sep { color: #45475a; } .legend { display: flex; @@ -175,6 +178,9 @@ h1 {
connecting... + · + + LLM ...
@@ -295,8 +301,32 @@ function connectSSE() { }; } +const llmDot = document.getElementById('llmDot'); +const llmText = document.getElementById('llmText'); + +async function pollLlm() { + try { + const res = await fetch('/llm/status'); + const d = await res.json(); + if (d.online) { + llmDot.className = 'status-dot online'; + llmText.textContent = d.latency_ms != null + ? `LLM online · ${d.latency_ms}ms` + : 'LLM online'; + } else { + llmDot.className = 'status-dot offline'; + llmText.textContent = 'LLM offline'; + } + } catch { + llmDot.className = 'status-dot offline'; + llmText.textContent = 'LLM unknown'; + } +} + loadHistory(); connectSSE(); +pollLlm(); +setInterval(pollLlm, 15000); diff --git a/src/main.rs b/src/main.rs index 8dad9ea..17115e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,9 @@ use sqlx::sqlite::SqlitePoolOptions; use sqlx::SqlitePool; use std::convert::Infallible; use std::sync::Arc; +use std::sync::RwLock; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::broadcast; use tokio_stream::StreamExt; use tokio_stream::wrappers::BroadcastStream; @@ -33,10 +36,80 @@ struct Notification { created_at: String, } +#[derive(Clone, Serialize, Default)] +struct LlmStatus { + online: bool, + checked_at: Option, + latency_ms: Option, +} + #[derive(Clone)] struct AppState { pool: SqlitePool, tx: broadcast::Sender, + llm: Arc>, +} + +fn parse_http_url(url: &str) -> Option<(String, u16, String)> { + let rest = url.strip_prefix("http://")?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let (host, port) = match authority.rsplit_once(':') { + Some((h, p)) => (h.to_string(), p.parse().ok()?), + None => (authority.to_string(), 80u16), + }; + Some((host, port, path.to_string())) +} + +/// 裸 HTTP GET,仅检查状态行是否为 200。 +/// 避免引入 HTTP client 依赖;目标为受信内网地址。 +async fn probe_llm(host: &str, port: u16, path: &str) -> bool { + let addr = format!("{host}:{port}"); + let req = format!( + "GET {path} HTTP/1.1\r\nHost: {addr}\r\nUser-Agent: newsboard-health\r\nConnection: close\r\n\r\n" + ); + let attempt = async { + let mut stream = tokio::net::TcpStream::connect(&addr).await?; + stream.write_all(req.as_bytes()).await?; + let mut buf = [0u8; 64]; + let n = stream.read(&mut buf).await?; + let head = String::from_utf8_lossy(&buf[..n]); + Ok::( + head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200"), + ) + }; + matches!( + tokio::time::timeout(Duration::from_secs(5), attempt).await, + Ok(Ok(true)) + ) +} + +fn spawn_llm_probe(status: Arc>, url: String) { + tokio::spawn(async move { + let (host, port, path) = match parse_http_url(&url) { + Some(v) => v, + None => { + error!("Invalid LLM health URL: {url}"); + return; + } + }; + let mut ticker = tokio::time::interval(Duration::from_secs(15)); + loop { + ticker.tick().await; + let start = Instant::now(); + let online = probe_llm(&host, port, &path).await; + let snapshot = LlmStatus { + online, + checked_at: Some(Utc::now().to_rfc3339()), + latency_ms: Some(start.elapsed().as_millis()), + }; + if let Ok(mut s) = status.write() { + *s = snapshot; + } + } + }); } async fn init_db(pool: &SqlitePool) -> sqlx::Result<()> { @@ -171,6 +244,11 @@ async fn handle_history(State(state): State>) -> impl IntoResponse axum::Json(rows) } +async fn handle_llm_status(State(state): State>) -> impl IntoResponse { + let status = state.llm.read().map(|s| s.clone()).unwrap_or_default(); + axum::Json(status) +} + async fn handle_events( State(state): State>, ) -> Sse>> { @@ -215,12 +293,23 @@ async fn main() { run_mqtt(pool.clone(), tx.clone()).await; - let state = Arc::new(AppState { pool, tx }); + let llm_status = Arc::new(RwLock::new(LlmStatus::default())); + let llm_health_url = std::env::var("NEWSBOARD_LLM_HEALTH_URL") + .unwrap_or_else(|_| "http://192.168.0.4:8090/health".into()); + info!("LLM health probe target: {llm_health_url}"); + spawn_llm_probe(llm_status.clone(), llm_health_url); + + let state = Arc::new(AppState { + pool, + tx, + llm: llm_status, + }); let app = Router::new() .route("/", get(handle_root)) .route("/events", get(handle_events)) .route("/history", get(handle_history)) + .route("/llm/status", get(handle_llm_status)) .with_state(state); let port: u16 = std::env::var("NEWSBOARD_PORT")