diff --git a/.config/supply-chain/audits.toml b/.config/supply-chain/audits.toml index 1b1542f7..17675761 100644 --- a/.config/supply-chain/audits.toml +++ b/.config/supply-chain/audits.toml @@ -1,6 +1,11 @@ # cargo-vet audits file +[[audits.axum-core]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +version = "0.5.6" + [[audits.cfb]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -71,6 +76,21 @@ who = "Jean Mertz " criteria = "safe-to-deploy" version = "0.36.0+unofficial" +[[audits.matchit]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +version = "0.8.4" + +[[audits.maud]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +version = "0.27.0" + +[[audits.maud_macros]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +version = "0.27.0" + [[audits.nix]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -97,6 +117,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" version = "0.2.1" +[[audits.proc-macro2-diagnostics]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +version = "0.10.1" + [[audits.process-wrap]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -311,6 +336,12 @@ user-id = 3618 # David Tolnay (dtolnay) start = "2019-07-23" end = "2027-02-13" +[[trusted.axum]] +criteria = "safe-to-deploy" +user-id = 6741 # Alice Ryhl (Darksonn) +start = "2026-04-14" +end = "2027-04-14" + [[trusted.backtrace]] criteria = "safe-to-deploy" user-id = 55123 # rust-lang-owner diff --git a/.config/supply-chain/imports.lock b/.config/supply-chain/imports.lock index 73e01810..693beab9 100644 --- a/.config/supply-chain/imports.lock +++ b/.config/supply-chain/imports.lock @@ -50,6 +50,13 @@ user-id = 3618 user-login = "dtolnay" user-name = "David Tolnay" +[[publisher.axum]] +version = "0.8.9" +when = "2026-04-14" +user-id = 6741 +user-login = "Darksonn" +user-name = "Alice Ryhl" + [[publisher.backtrace]] version = "0.3.75" when = "2025-05-06" diff --git a/Cargo.lock b/Cargo.lock index c275958b..e2c0fb70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,6 +246,52 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "backon" version = "1.5.1" @@ -1918,6 +1964,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jp-serve-web" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "comrak", + "jp_plugin", + "maud", + "pretty_assertions", + "serde_json", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "jp_attachment" version = "0.1.0" @@ -2592,6 +2655,36 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "axum-core", + "http", + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "memchr" version = "2.7.5" @@ -3035,6 +3128,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", +] + [[package]] name = "process-wrap" version = "9.0.0" diff --git a/Cargo.toml b/Cargo.toml index 8a8057d1..e2e184a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ assert_matches = { version = "1", default-features = false } async-anthropic = { git = "https://github.com/JeanMertz/async-anthropic", default-features = false } async-stream = { version = "0.3", default-features = false } async-trait = { version = "0.1", default-features = false } +axum = { version = "0.8", default-features = false } backon = { git = "https://github.com/JeanMertz/backon", default-features = false } # base64 = { version = "0.22", default-features = false } camino = { version = "1", default-features = false } @@ -80,6 +81,7 @@ inquire = { git = "https://github.com/JeanMertz/inquire", branch = "merged", def insta = { version = "1", default-features = false } libc = { version = "0.2", default-features = false } linkme = { version = "0.3", default-features = false } +maud = { version = "0.27", default-features = false } minijinja = { version = "2", default-features = false } ollama-rs = { version = "0.3", default-features = false } open-editor = { version = "1", default-features = false } diff --git a/crates/plugins/command/serve-web/Cargo.toml b/crates/plugins/command/serve-web/Cargo.toml new file mode 100644 index 00000000..bc959e27 --- /dev/null +++ b/crates/plugins/command/serve-web/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "jp-serve-web" + +authors.workspace = true +description = "Read-only web UI for browsing JP conversations." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +publish.workspace = true +readme.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +jp_plugin = { workspace = true } + +axum = { workspace = true, features = ["http1", "tokio"] } +chrono = { workspace = true } +comrak = { workspace = true } +maud = { workspace = true, features = ["axum"] } +serde_json = { workspace = true, features = ["std"] } +sha2 = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["ansi", "env-filter", "fmt", "std"] } + +[dev-dependencies] +pretty_assertions = { workspace = true, features = ["std"] } +tracing = { workspace = true, features = ["std"] } + +[lints] +workspace = true + +[package.metadata.jp-registry] +id = "serve-web" +command = ["serve", "web"] +description = "Read-only web UI for browsing conversations" +official = true +requires = ["serve"] +repository = "https://github.com/dcdpr/jp" + +[[bin]] +name = "jp-serve-web" +path = "src/main.rs" diff --git a/crates/plugins/command/serve-web/src/client.rs b/crates/plugins/command/serve-web/src/client.rs new file mode 100644 index 00000000..c7fe9133 --- /dev/null +++ b/crates/plugins/command/serve-web/src/client.rs @@ -0,0 +1,242 @@ +//! Protocol client for communicating with the JP host. +//! +//! Manages the stdin reader loop and provides async methods for sending +//! requests and awaiting responses. Thread-safe and shareable across axum +//! handlers via `Arc`. + +use std::{ + collections::HashMap, + io::{BufRead, Write}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + thread, +}; + +use jp_plugin::message::{ + ConversationSummary, EventsResponse, ExitMessage, HostToPlugin, OptionalId, PluginToHost, + ReadEventsRequest, +}; +use tokio::sync::{oneshot, watch}; +use tracing::{debug, error, trace, warn}; + +/// Shared writer for stdout, used by both the protocol client and the +/// tracing log layer. +pub type SharedWriter = Arc>>; + +/// A protocol client that talks to the JP host over stdin/stdout. +/// +/// Cloneable via `Arc` internally — pass it into axum state directly. +#[derive(Clone)] +pub struct PluginClient { + inner: Arc, +} + +struct Inner { + writer: SharedWriter, + pending: Mutex>>, + next_id: AtomicU64, +} + +impl PluginClient { + /// Start the protocol client. + /// + /// Spawns a background thread that reads from `stdin` and dispatches + /// responses to pending requests. Returns the client and a watch channel + /// that signals when a shutdown message is received from the host. + pub fn start( + stdin: impl BufRead + Send + 'static, + writer: SharedWriter, + ) -> (Self, watch::Receiver) { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + let inner = Arc::new(Inner { + writer, + pending: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + }); + + let reader_inner = inner.clone(); + thread::Builder::new() + .name("stdin-reader".into()) + .spawn(move || reader_loop(stdin, &reader_inner, &shutdown_tx)) + .expect("failed to spawn stdin reader thread"); + + (Self { inner }, shutdown_rx) + } + + /// Request the list of conversations from the host. + pub async fn list_conversations(&self) -> Result, ClientError> { + let id = self.next_id(); + let rx = self.register(&id); + + self.send(&PluginToHost::ListConversations(OptionalId { + id: Some(id.clone()), + }))?; + + match rx.await.map_err(|_| ClientError::ChannelClosed)? { + HostToPlugin::Conversations(resp) => Ok(resp.data), + HostToPlugin::Error(e) => Err(ClientError::Host(e.message)), + other => Err(ClientError::Unexpected(format!("{other:?}"))), + } + } + + /// Request events for a specific conversation. + pub async fn read_events(&self, conversation: &str) -> Result { + let id = self.next_id(); + let rx = self.register(&id); + + self.send(&PluginToHost::ReadEvents(ReadEventsRequest { + id: Some(id.clone()), + conversation: conversation.to_owned(), + }))?; + + match rx.await.map_err(|_| ClientError::ChannelClosed)? { + HostToPlugin::Events(resp) => Ok(resp), + HostToPlugin::Error(e) => Err(ClientError::Host(e.message)), + other => Err(ClientError::Unexpected(format!("{other:?}"))), + } + } + + /// Send an exit message to the host. + pub fn send_exit(&self, code: u8) { + drop(self.send(&PluginToHost::Exit(ExitMessage { code, reason: None }))); + } + + fn next_id(&self) -> String { + self.inner + .next_id + .fetch_add(1, Ordering::Relaxed) + .to_string() + } + + fn register(&self, id: &str) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.inner + .pending + .lock() + .expect("pending lock poisoned") + .insert(id.to_owned(), tx); + rx + } + + fn send(&self, msg: &PluginToHost) -> Result<(), ClientError> { + let json = serde_json::to_string(msg).map_err(|e| ClientError::Protocol(e.to_string()))?; + let mut writer = self.inner.writer.lock().expect("writer lock poisoned"); + writeln!(writer, "{json}").map_err(|e| ClientError::Protocol(e.to_string()))?; + writer + .flush() + .map_err(|e| ClientError::Protocol(e.to_string())) + } +} + +/// Errors from the plugin client. +#[derive(Debug)] +pub enum ClientError { + /// The host returned an error response. + Host(String), + /// Unexpected response type. + Unexpected(String), + /// The response channel was closed (reader thread died). + ChannelClosed, + /// Protocol-level I/O or serialization error. + Protocol(String), +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Host(msg) => write!(f, "host error: {msg}"), + Self::Unexpected(msg) => write!(f, "unexpected response: {msg}"), + Self::ChannelClosed => write!(f, "protocol channel closed"), + Self::Protocol(msg) => write!(f, "protocol error: {msg}"), + } + } +} + +/// Background loop that reads stdin and dispatches messages. +fn reader_loop(reader: impl BufRead, inner: &Inner, shutdown_tx: &watch::Sender) { + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + error!("stdin read error: {e}"); + break; + } + }; + + if line.trim().is_empty() { + continue; + } + + let msg: HostToPlugin = match serde_json::from_str(&line) { + Ok(m) => m, + Err(e) => { + warn!("invalid host message: {e}: {line}"); + continue; + } + }; + + trace!(?msg, "Received host message"); + + // Extract the request ID (if any) before moving `msg` into dispatch. + let req_id = match &msg { + HostToPlugin::Conversations(r) => r.id.clone(), + HostToPlugin::Events(r) => r.id.clone(), + HostToPlugin::Config(r) => r.id.clone(), + HostToPlugin::Error(r) => r.id.clone(), + _ => None, + }; + + match msg { + HostToPlugin::Shutdown => { + debug!("Received shutdown from host"); + let _ = shutdown_tx.send(true); + } + + HostToPlugin::Init(_) | HostToPlugin::Describe => { + warn!("Unexpected message after startup"); + } + + // Response messages — dispatch to the pending request. + msg @ (HostToPlugin::Conversations(_) + | HostToPlugin::Events(_) + | HostToPlugin::Config(_) + | HostToPlugin::Error(_)) => { + dispatch(&inner.pending, req_id.as_deref(), msg); + } + } + } + + // stdin closed — host process is gone. + debug!("stdin reader loop exited"); + let _ = shutdown_tx.send(true); +} + +/// Dispatch a response to the pending request with the given ID. +fn dispatch( + pending: &Mutex>>, + id: Option<&str>, + msg: HostToPlugin, +) { + let Some(id) = id else { + warn!("Response without ID, cannot dispatch: {msg:?}"); + return; + }; + + let tx = pending.lock().expect("pending lock poisoned").remove(id); + + match tx { + Some(tx) => { + drop(tx.send(msg)); + } + None => { + warn!("No pending request for ID {id}"); + } + } +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/crates/plugins/command/serve-web/src/client_tests.rs b/crates/plugins/command/serve-web/src/client_tests.rs new file mode 100644 index 00000000..7402095e --- /dev/null +++ b/crates/plugins/command/serve-web/src/client_tests.rs @@ -0,0 +1,83 @@ +use std::io::{BufReader, Cursor}; + +use jp_plugin::message::*; +use serde_json::json; + +use super::*; + +/// Helper to build a host response line. +fn host_line(msg: &HostToPlugin) -> String { + serde_json::to_string(msg).unwrap() +} + +fn shared_writer() -> SharedWriter { + Arc::new(Mutex::new(Box::new(Vec::::new()))) +} + +#[tokio::test] +async fn list_conversations_roundtrip() { + let response = HostToPlugin::Conversations(ConversationsResponse { + id: Some("1".to_owned()), + data: vec![ConversationSummary { + id: "123".to_owned(), + title: Some("Test".to_owned()), + last_activated_at: chrono::Utc::now(), + events_count: 5, + }], + }); + + let stdin_data = format!("{}\n", host_line(&response)); + let stdin = BufReader::new(Cursor::new(stdin_data)); + let (client, _shutdown) = PluginClient::start(stdin, shared_writer()); + let result = client.list_conversations().await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, "123"); + assert_eq!(result[0].title.as_deref(), Some("Test")); +} + +#[tokio::test] +async fn read_events_roundtrip() { + let response = HostToPlugin::Events(EventsResponse { + id: Some("1".to_owned()), + conversation: "456".to_owned(), + data: vec![json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"})], + }); + + let stdin_data = format!("{}\n", host_line(&response)); + let stdin = BufReader::new(Cursor::new(stdin_data)); + let (client, _shutdown) = PluginClient::start(stdin, shared_writer()); + let result = client.read_events("456").await.unwrap(); + + assert_eq!(result.conversation, "456"); + assert_eq!(result.data.len(), 1); +} + +#[tokio::test] +async fn host_error_propagated() { + let response = HostToPlugin::Error(ErrorResponse { + id: Some("1".to_owned()), + request: Some("list_conversations".to_owned()), + message: "something went wrong".to_owned(), + }); + + let stdin_data = format!("{}\n", host_line(&response)); + let stdin = BufReader::new(Cursor::new(stdin_data)); + let (client, _shutdown) = PluginClient::start(stdin, shared_writer()); + let err = client.list_conversations().await.unwrap_err(); + + assert!(matches!(err, ClientError::Host(msg) if msg.contains("something went wrong"))); +} + +#[test] +fn shutdown_signals_watch() { + let shutdown_msg = HostToPlugin::Shutdown; + let stdin_data = format!("{}\n", host_line(&shutdown_msg)); + let stdin = BufReader::new(Cursor::new(stdin_data)); + let (_client, shutdown_rx) = PluginClient::start(stdin, shared_writer()); + + // Give the reader thread a moment to process. + std::thread::sleep(std::time::Duration::from_millis(50)); + + assert!(*shutdown_rx.borrow()); +} diff --git a/crates/plugins/command/serve-web/src/log_layer.rs b/crates/plugins/command/serve-web/src/log_layer.rs new file mode 100644 index 00000000..73b3922e --- /dev/null +++ b/crates/plugins/command/serve-web/src/log_layer.rs @@ -0,0 +1,173 @@ +//! Custom tracing layer that sends log events through the plugin protocol. +//! +//! Events are buffered in memory until the protocol connection is ready, +//! then flushed and all subsequent events are written as `PluginToHost::Log` +//! JSON-lines on stdout. + +use std::{io::Write, sync::Mutex}; + +use jp_plugin::message::{LogMessage, PluginToHost}; +use tracing::{ + Event, Level, Subscriber, + field::{Field, Visit}, +}; +use tracing_subscriber::{Layer, layer::Context}; + +use crate::client::SharedWriter; + +struct BufferedEvent { + level: Level, + target: String, + message: String, +} + +enum Sink { + /// Events are held in memory until the protocol is ready. + Buffering(Vec), + /// Events are written to stdout as protocol messages. + Active { + writer: SharedWriter, + min_level: Level, + }, +} + +/// A tracing [`Layer`] that routes events through the plugin protocol. +pub struct ProtocolLogLayer { + sink: &'static Mutex, +} + +/// Handle used to activate the layer once the protocol client is ready. +pub struct ProtocolLogHandle { + sink: &'static Mutex, +} + +impl ProtocolLogLayer { + /// Create a new layer and its activation handle. + /// + /// The layer buffers events until [`ProtocolLogHandle::activate`] is + /// called. Both the layer and handle share a `'static` reference to the + /// sink (leaked via `Box::leak`). This is intentional: the layer is + /// installed as the global tracing subscriber and lives for the entire + /// process. + pub fn new() -> (Self, ProtocolLogHandle) { + let sink: &'static Mutex = + Box::leak(Box::new(Mutex::new(Sink::Buffering(Vec::new())))); + + (Self { sink }, ProtocolLogHandle { sink }) + } +} + +impl ProtocolLogHandle { + /// Switch from buffering to protocol mode. + /// + /// Flushes any buffered events that meet `min_level` through `writer`, + /// then all future events are sent directly. + pub fn activate(&self, writer: &SharedWriter, min_level: Level) { + let buffer = { + let mut sink = self.sink.lock().expect("sink lock"); + match std::mem::replace(&mut *sink, Sink::Active { + writer: writer.clone(), + min_level, + }) { + Sink::Buffering(buf) => buf, + Sink::Active { .. } => return, + } + }; + + // Flush buffered events outside the sink lock. + if let Ok(mut w) = writer.lock() { + for event in buffer { + if event.level <= min_level { + write_log(&mut *w, event.level, &event.target, &event.message); + } + } + } + } +} + +impl Layer for ProtocolLogLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let meta = event.metadata(); + + // Only capture events from our own crate. + if !meta.target().starts_with("jp_serve_web") { + return; + } + + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + + let mut sink = self.sink.lock().expect("sink lock"); + match &mut *sink { + Sink::Buffering(buf) => { + buf.push(BufferedEvent { + level: *meta.level(), + target: meta.target().to_owned(), + message: visitor.message, + }); + } + Sink::Active { writer, min_level } => { + if meta.level() > min_level { + return; + } + // Use try_lock to avoid deadlocking when a tracing event + // fires while the writer is already held (e.g. during a + // protocol write in the same thread). + if let Ok(mut w) = writer.try_lock() { + write_log(&mut *w, *meta.level(), meta.target(), &visitor.message); + } + } + } + } +} + +fn write_log(w: &mut dyn Write, level: Level, target: &str, message: &str) { + let level_str = match level { + Level::TRACE => "trace", + Level::DEBUG => "debug", + Level::INFO => "info", + Level::WARN => "warn", + Level::ERROR => "error", + }; + + let mut fields = serde_json::Map::new(); + fields.insert( + "target".to_owned(), + serde_json::Value::String(target.to_owned()), + ); + + let msg = PluginToHost::Log(LogMessage { + level: level_str.to_owned(), + message: message.to_owned(), + fields, + }); + + if let Ok(json) = serde_json::to_string(&msg) { + drop(writeln!(w, "{json}")); + drop(w.flush()); + } +} + +/// Visitor that extracts the `message` field from a tracing event. +#[derive(Default)] +struct MessageVisitor { + message: String, +} + +impl Visit for MessageVisitor { + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_owned(); + } + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = format!("{value:?}"); + } + } +} + +#[cfg(test)] +#[path = "log_layer_tests.rs"] +mod tests; diff --git a/crates/plugins/command/serve-web/src/log_layer_tests.rs b/crates/plugins/command/serve-web/src/log_layer_tests.rs new file mode 100644 index 00000000..d8ffbf2d --- /dev/null +++ b/crates/plugins/command/serve-web/src/log_layer_tests.rs @@ -0,0 +1,109 @@ +use std::sync::{Arc, Mutex}; + +use tracing::Level; +use tracing_subscriber::prelude::*; + +use super::*; + +/// A writer that captures bytes for inspection. +#[derive(Default, Clone)] +struct TestWriter { + buf: Arc>>, +} + +impl std::io::Write for TestWriter { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.buf.lock().unwrap().extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl TestWriter { + fn lines(&self) -> Vec { + let buf = self.buf.lock().unwrap(); + let text = String::from_utf8_lossy(&buf); + text.lines().map(String::from).collect() + } +} + +fn make_shared(tw: &TestWriter) -> SharedWriter { + Arc::new(Mutex::new(Box::new(tw.clone()))) +} + +#[test] +fn buffered_events_are_flushed_on_activate() { + let (layer, handle) = ProtocolLogLayer::new(); + + let _guard = tracing::subscriber::set_default(tracing_subscriber::registry().with(layer)); + + tracing::debug!(target: "jp_serve_web::routes", "request received"); + tracing::info!(target: "jp_serve_web::routes", "rendered page"); + + let tw = TestWriter::default(); + handle.activate(&make_shared(&tw), Level::DEBUG); + + let lines = tw.lines(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("request received")); + assert!(lines[1].contains("rendered page")); +} + +#[test] +fn events_below_min_level_are_dropped() { + let (layer, handle) = ProtocolLogLayer::new(); + + let _guard = tracing::subscriber::set_default(tracing_subscriber::registry().with(layer)); + + tracing::trace!(target: "jp_serve_web::routes", "very verbose"); + tracing::debug!(target: "jp_serve_web::routes", "somewhat verbose"); + tracing::info!(target: "jp_serve_web::routes", "normal"); + + // Activate at INFO — trace and debug should be dropped. + let tw = TestWriter::default(); + handle.activate(&make_shared(&tw), Level::INFO); + + let lines = tw.lines(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("normal")); +} + +#[test] +fn non_jp_serve_web_events_are_ignored() { + let (layer, handle) = ProtocolLogLayer::new(); + + let _guard = tracing::subscriber::set_default(tracing_subscriber::registry().with(layer)); + + tracing::info!(target: "tokio::runtime", "tokio noise"); + tracing::info!(target: "jp_serve_web::routes", "our event"); + + let tw = TestWriter::default(); + handle.activate(&make_shared(&tw), Level::TRACE); + + let lines = tw.lines(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("our event")); +} + +#[test] +fn active_events_sent_directly() { + let (layer, handle) = ProtocolLogLayer::new(); + + let _guard = tracing::subscriber::set_default(tracing_subscriber::registry().with(layer)); + + let tw = TestWriter::default(); + handle.activate(&make_shared(&tw), Level::DEBUG); + + tracing::debug!(target: "jp_serve_web::routes", "after activate"); + + let lines = tw.lines(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("after activate")); + + // Verify it's valid protocol JSON. + let msg: PluginToHost = serde_json::from_str(&lines[0]).unwrap(); + assert!(matches!(msg, PluginToHost::Log(_))); +} diff --git a/crates/plugins/command/serve-web/src/main.rs b/crates/plugins/command/serve-web/src/main.rs new file mode 100644 index 00000000..a767e4df --- /dev/null +++ b/crates/plugins/command/serve-web/src/main.rs @@ -0,0 +1,241 @@ +//! `jp-serve-web`: read-only web UI plugin for JP. +//! +//! Communicates with the `jp` host over the JSON-lines plugin protocol +//! (stdin/stdout) and serves a read-only conversation browser over HTTP. +//! +//! See: `docs/rfd/D17-command-plugin-system.md` + +mod client; +mod log_layer; +mod render; +mod routes; +mod style; +mod views; + +use std::{ + io::{self, BufRead, BufReader, IsTerminal as _, Write}, + sync::{Arc, Mutex}, +}; + +use jp_plugin::message::{DescribeResponse, HostToPlugin, InitMessage, PluginToHost, PrintMessage}; +use tracing::{Level, info}; + +use crate::{ + client::SharedWriter, + log_layer::{ProtocolLogHandle, ProtocolLogLayer}, +}; + +const HELP_TEXT: &str = "\ +Start the read-only web interface for browsing JP conversations. + +Usage: jp serve web [OPTIONS] + +Options: + --bind Address to bind to [default: 127.0.0.1] + --port Port to listen on [default: 3000] + +Configuration (in .jp/config.toml): + [plugins.command.serve.options] + bind = \"0.0.0.0\" + port = 8080"; + +fn main() { + let log_handle = init_tracing(); + + // If stdin is a TTY, the binary was invoked directly (not via the plugin + // protocol). Print help and exit. + if io::stdin().is_terminal() { + let mut err = io::stderr().lock(); + drop(writeln!(err, "{HELP_TEXT}")); + drop(writeln!(err)); + drop(writeln!( + err, + "Note: this binary is a JP plugin. Run it via `jp serve web`." + )); + std::process::exit(0); + } + + let stdin = BufReader::new(io::stdin()); + let stdout = io::stdout(); + + let code = match run(stdin, stdout, &log_handle) { + Ok(()) => 0, + Err(e) => { + let mut err = io::stderr().lock(); + drop(writeln!(err, "Fatal: {e}")); + 1 + } + }; + + std::process::exit(code); +} + +fn run( + mut stdin: impl BufRead + Send + 'static, + mut stdout: impl Write + Send + 'static, + log_handle: &ProtocolLogHandle, +) -> Result<(), String> { + let first_msg = read_message(&mut stdin)?; + + match first_msg { + HostToPlugin::Describe => { + send_describe(&mut stdout)?; + Ok(()) + } + HostToPlugin::Init(ref init) => run_server(init, stdin, stdout, log_handle), + other => Err(format!("expected init or describe, got: {other:?}")), + } +} + +fn run_server( + init: &InitMessage, + stdin: impl BufRead + Send + 'static, + mut stdout: impl Write + Send + 'static, + log_handle: &ProtocolLogHandle, +) -> Result<(), String> { + let args = parse_args(init); + + let bind = args + .bind + .or_else(|| { + init.options + .get("bind") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| "127.0.0.1".into()); + let port = args + .port + .or_else(|| { + init.options + .get("port") + .and_then(serde_json::Value::as_u64) + .and_then(|v| u16::try_from(v).ok()) + }) + .unwrap_or(3000); + + // Send early protocol messages before sharing stdout. + send(&mut stdout, &PluginToHost::Ready)?; + send( + &mut stdout, + &PluginToHost::Print(PrintMessage { + text: format!("Serving at http://{bind}:{port}\n"), + channel: "content".into(), + format: "plain".into(), + language: None, + }), + )?; + + // Wrap stdout for shared access between the protocol client and log layer. + let writer: SharedWriter = Arc::new(Mutex::new(Box::new(stdout))); + + // Activate the log layer now that we have the writer and know the level. + let min_level = match init.log_level { + 0 => Level::ERROR, + 1 => Level::WARN, + 2 => Level::INFO, + 3 => Level::DEBUG, + _ => Level::TRACE, + }; + log_handle.activate(&writer, min_level); + + let (client, shutdown_rx) = client::PluginClient::start(stdin, writer); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("jp-serve-web") + .build() + .map_err(|e| format!("failed to build tokio runtime: {e}"))?; + + let exit_client = client.clone(); + + let result = rt.block_on(async { + let addr = format!("{bind}:{port}"); + info!(%addr, "Starting web server"); + + let mut shutdown = shutdown_rx; + let shutdown_signal = async move { + let _ = shutdown.changed().await; + }; + + routes::serve(client, &addr, shutdown_signal) + .await + .map_err(|e| format!("server error: {e}")) + }); + + let code = u8::from(result.is_err()); + exit_client.send_exit(code); + + result +} + +fn send_describe(stdout: &mut impl Write) -> Result<(), String> { + send( + stdout, + &PluginToHost::Describe(DescribeResponse { + name: "serve-web".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + description: "Read-only web UI for browsing conversations".to_owned(), + command: vec!["serve".to_owned(), "web".to_owned()], + author: Some("Jean Mertz ".to_owned()), + help: Some(HELP_TEXT.to_owned()), + repository: Some("https://github.com/dcdpr/jp".to_owned()), + }), + ) +} + +fn read_message(stdin: &mut impl BufRead) -> Result { + let mut line = String::new(); + stdin + .read_line(&mut line) + .map_err(|e| format!("failed to read from host: {e}"))?; + + serde_json::from_str(line.trim()).map_err(|e| format!("invalid host message: {e}")) +} + +fn send(stdout: &mut impl Write, msg: &PluginToHost) -> Result<(), String> { + let json = serde_json::to_string(msg).map_err(|e| format!("serialize error: {e}"))?; + writeln!(stdout, "{json}").map_err(|e| format!("write error: {e}"))?; + stdout.flush().map_err(|e| format!("flush error: {e}")) +} + +struct Args { + bind: Option, + port: Option, +} + +fn parse_args(init: &InitMessage) -> Args { + let mut bind = None; + let mut port = None; + let mut iter = init.args.iter(); + + while let Some(arg) = iter.next() { + match arg.as_str() { + "--bind" => bind = iter.next().map(String::from), + "--port" => { + port = iter.next().and_then(|v| v.parse().ok()); + } + s if s.starts_with("--bind=") => bind = s.strip_prefix("--bind=").map(String::from), + s if s.starts_with("--port=") => { + port = s.strip_prefix("--port=").and_then(|v| v.parse().ok()); + } + _ => {} + } + } + + Args { bind, port } +} + +/// Install the tracing subscriber with the protocol log layer. +/// +/// Events are buffered until the protocol writer is available. Returns a +/// handle that must be activated once the writer and log level are known. +fn init_tracing() -> ProtocolLogHandle { + use tracing_subscriber::prelude::*; + + let (layer, handle) = ProtocolLogLayer::new(); + + tracing_subscriber::registry().with(layer).init(); + + handle +} diff --git a/crates/plugins/command/serve-web/src/render.rs b/crates/plugins/command/serve-web/src/render.rs new file mode 100644 index 00000000..16173309 --- /dev/null +++ b/crates/plugins/command/serve-web/src/render.rs @@ -0,0 +1,148 @@ +//! Rendering pipeline: raw JSON events to HTML-ready types. +//! +//! Works directly with `serde_json::Value` events received from the JP host +//! protocol, without depending on `jp_conversation` types. The host decodes +//! base64-encoded storage fields before sending, so values arrive as plain +//! text. + +use serde_json::Value; + +/// A pre-rendered event ready for the detail view template. +pub(crate) enum RenderedEvent { + TurnSeparator, + UserMessage { + html: String, + }, + AssistantMessage { + html: String, + }, + Reasoning { + html: String, + }, + Structured { + json: String, + }, + ToolCall { + name: String, + arguments: String, + result: Option, + }, +} + +/// Render raw JSON events into [`RenderedEvent`]s for the detail view. +/// +/// Events come from the host's `read_events` response with base64 fields +/// already decoded to plain text. +pub(crate) fn render_events(events: &[Value]) -> Vec { + let mut out = Vec::new(); + let mut is_first_turn = true; + + for event in events { + let Some(event_type) = event.get("type").and_then(Value::as_str) else { + continue; + }; + + match event_type { + "turn_start" => { + if !is_first_turn { + out.push(RenderedEvent::TurnSeparator); + } + is_first_turn = false; + } + + "chat_request" => { + if let Some(content) = event.get("content").and_then(Value::as_str) { + out.push(RenderedEvent::UserMessage { + html: markdown_to_html(content), + }); + } + } + + "chat_response" => render_chat_response(event, &mut out), + + "tool_call_request" => { + let name = event + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + + let arguments = pretty_print_args(event.get("arguments")); + let id = event.get("id").and_then(Value::as_str).unwrap_or(""); + let result = find_tool_response(events, id); + + out.push(RenderedEvent::ToolCall { + name, + arguments, + result, + }); + } + + // tool_call_response: folded into the ToolCall above. + // config_delta, inquiry_*: skipped. + _ => {} + } + } + + out +} + +/// Handle the untagged `ChatResponse` variants by checking which key is +/// present. +fn render_chat_response(event: &Value, out: &mut Vec) { + if let Some(msg) = event.get("message").and_then(Value::as_str) { + out.push(RenderedEvent::AssistantMessage { + html: markdown_to_html(msg), + }); + } else if let Some(reasoning) = event.get("reasoning").and_then(Value::as_str) { + out.push(RenderedEvent::Reasoning { + html: markdown_to_html(reasoning), + }); + } else if let Some(data) = event.get("data") { + let json = serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string()); + out.push(RenderedEvent::Structured { json }); + } +} + +/// Find the `tool_call_response` matching a given request ID. +fn find_tool_response(events: &[Value], id: &str) -> Option { + events + .iter() + .filter(|e| e.get("type").and_then(Value::as_str) == Some("tool_call_response")) + .find(|e| e.get("id").and_then(Value::as_str) == Some(id)) + .and_then(|e| e.get("content").and_then(Value::as_str)) + .map(|s| truncate(s, 10_000)) +} + +/// Pretty-print tool call arguments. +fn pretty_print_args(value: Option<&Value>) -> String { + let Some(val) = value else { + return String::new(); + }; + serde_json::to_string_pretty(val).unwrap_or_else(|_| val.to_string()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_owned() + } else { + let truncated: String = s.chars().take(max).collect(); + format!("{truncated}\n\n... (truncated)") + } +} + +/// Convert markdown to HTML using comrak. +pub(crate) fn markdown_to_html(md: &str) -> String { + let mut options = comrak::Options::default(); + options.render.r#unsafe = true; + options.extension.strikethrough = true; + options.extension.table = true; + options.extension.autolink = true; + options.extension.tasklist = true; + + comrak::markdown_to_html(md, &options) +} + +#[cfg(test)] +#[path = "render_tests.rs"] +mod tests; diff --git a/crates/plugins/command/serve-web/src/render_tests.rs b/crates/plugins/command/serve-web/src/render_tests.rs new file mode 100644 index 00000000..17c2c39f --- /dev/null +++ b/crates/plugins/command/serve-web/src/render_tests.rs @@ -0,0 +1,197 @@ +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +#[test] +fn render_empty_events() { + let events = render_events(&[]); + assert!(events.is_empty()); +} + +#[test] +fn render_chat_request_and_response() { + let events = vec![ + json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:00:01Z", "content": "Hello"}), + json!({"type": "chat_response", "timestamp": "2025-01-01T00:00:02Z", "message": "Hi there"}), + ]; + + let rendered = render_events(&events); + assert_eq!(rendered.len(), 2); // no separator for first turn + + assert!(matches!(&rendered[0], RenderedEvent::UserMessage { html } if html.contains("Hello"))); + assert!( + matches!(&rendered[1], RenderedEvent::AssistantMessage { html } if html.contains("Hi there")) + ); +} + +#[test] +fn render_turn_separator() { + let events = vec![ + json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:00:01Z", "content": "First"}), + json!({"type": "turn_start", "timestamp": "2025-01-01T00:01:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:01:01Z", "content": "Second"}), + ]; + + let rendered = render_events(&events); + assert_eq!(rendered.len(), 3); + assert!(matches!(&rendered[0], RenderedEvent::UserMessage { .. })); + assert!(matches!(&rendered[1], RenderedEvent::TurnSeparator)); + assert!(matches!(&rendered[2], RenderedEvent::UserMessage { .. })); +} + +#[test] +fn render_reasoning() { + let events = vec![ + json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:00:01Z", "content": "think"}), + json!({"type": "chat_response", "timestamp": "2025-01-01T00:00:02Z", "reasoning": "Let me think..."}), + json!({"type": "chat_response", "timestamp": "2025-01-01T00:00:03Z", "message": "Done."}), + ]; + + let rendered = render_events(&events); + assert_eq!(rendered.len(), 3); + assert!( + matches!(&rendered[1], RenderedEvent::Reasoning { html } if html.contains("Let me think")) + ); +} + +#[test] +fn render_structured_response() { + let events = vec![ + json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:00:01Z", "content": "data"}), + json!({"type": "chat_response", "timestamp": "2025-01-01T00:00:02Z", "data": {"key": "value"}}), + ]; + + let rendered = render_events(&events); + assert_eq!(rendered.len(), 2); + assert!(matches!(&rendered[1], RenderedEvent::Structured { json } if json.contains("key"))); +} + +#[test] +fn render_tool_call_with_response() { + let events = vec![ + json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:00:01Z", "content": "go"}), + json!({ + "type": "tool_call_request", + "timestamp": "2025-01-01T00:00:02Z", + "id": "tc_1", + "name": "read_file", + "arguments": {"path": "test.rs"} + }), + json!({ + "type": "tool_call_response", + "timestamp": "2025-01-01T00:00:03Z", + "id": "tc_1", + "content": "file contents here", + "is_error": false + }), + ]; + + let rendered = render_events(&events); + assert_eq!(rendered.len(), 2); // chat_request + tool_call + + match &rendered[1] { + RenderedEvent::ToolCall { + name, + arguments, + result, + } => { + assert_eq!(name, "read_file"); + assert!(arguments.contains("test.rs")); + assert_eq!(result.as_deref(), Some("file contents here")); + } + other => panic!("expected ToolCall, got {other:?}"), + } +} + +#[test] +fn render_tool_call_without_response() { + let events = vec![ + json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:00:01Z", "content": "go"}), + json!({ + "type": "tool_call_request", + "timestamp": "2025-01-01T00:00:02Z", + "id": "tc_orphan", + "name": "some_tool", + "arguments": {} + }), + ]; + + let rendered = render_events(&events); + assert_eq!(rendered.len(), 2); + + match &rendered[1] { + RenderedEvent::ToolCall { result, .. } => { + assert!(result.is_none()); + } + other => panic!("expected ToolCall, got {other:?}"), + } +} + +#[test] +fn config_delta_events_are_skipped() { + let events = vec![ + json!({"type": "config_delta", "timestamp": "2025-01-01T00:00:00Z", "delta": {}}), + json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:00:01Z", "content": "hi"}), + ]; + + let rendered = render_events(&events); + assert_eq!(rendered.len(), 1); + assert!(matches!(&rendered[0], RenderedEvent::UserMessage { .. })); +} + +#[test] +fn plain_text_tool_response() { + // The host decodes base64 before sending events to the plugin, + // so content arrives as plain text. + let events = vec![ + json!({"type": "turn_start", "timestamp": "2025-01-01T00:00:00Z"}), + json!({"type": "chat_request", "timestamp": "2025-01-01T00:00:01Z", "content": "go"}), + json!({ + "type": "tool_call_request", + "timestamp": "2025-01-01T00:00:02Z", + "id": "tc_1", + "name": "test", + "arguments": {"key": "value"} + }), + json!({ + "type": "tool_call_response", + "timestamp": "2025-01-01T00:00:03Z", + "id": "tc_1", + "content": "plain text result", + "is_error": false + }), + ]; + + let rendered = render_events(&events); + match &rendered[1] { + RenderedEvent::ToolCall { + result, arguments, .. + } => { + assert_eq!(result.as_deref(), Some("plain text result")); + assert!(arguments.contains("value")); + } + other => panic!("expected ToolCall, got {other:?}"), + } +} + +// RenderedEvent doesn't derive Debug, add a basic impl for panic messages. +impl std::fmt::Debug for RenderedEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TurnSeparator => write!(f, "TurnSeparator"), + Self::UserMessage { .. } => write!(f, "UserMessage"), + Self::AssistantMessage { .. } => write!(f, "AssistantMessage"), + Self::Reasoning { .. } => write!(f, "Reasoning"), + Self::Structured { .. } => write!(f, "Structured"), + Self::ToolCall { name, .. } => write!(f, "ToolCall({name})"), + } + } +} diff --git a/crates/plugins/command/serve-web/src/routes.rs b/crates/plugins/command/serve-web/src/routes.rs new file mode 100644 index 00000000..25d0de2a --- /dev/null +++ b/crates/plugins/command/serve-web/src/routes.rs @@ -0,0 +1,143 @@ +//! Axum router and HTTP handlers. + +use std::{future::Future, net::SocketAddr}; + +use axum::{ + Router, + extract::{Path, State}, + http::{StatusCode, header}, + response::{IntoResponse, Redirect, Response}, +}; +use maud::Markup; +use tokio::net::TcpListener; +use tracing::{debug, info}; + +use crate::{client::PluginClient, render, style, views}; + +/// Shared state for axum handlers. +#[derive(Clone)] +struct AppState { + client: PluginClient, +} + +/// Start the HTTP server and block until `shutdown` resolves. +pub(crate) async fn serve( + client: PluginClient, + addr: &str, + shutdown: impl Future + Send + 'static, +) -> Result<(), String> { + let state = AppState { client }; + + let app = Router::new() + .route("/", axum::routing::get(index)) + .route("/conversations", axum::routing::get(conversation_list)) + .route( + "/conversations/{id}", + axum::routing::get(conversation_detail), + ) + .route("/assets/style.css", axum::routing::get(serve_css)) + .with_state(state); + + let socket_addr: SocketAddr = addr + .parse() + .map_err(|e| format!("invalid address `{addr}`: {e}"))?; + + let listener = TcpListener::bind(socket_addr) + .await + .map_err(|e| format!("failed to bind {addr}: {e}"))?; + + info!(%socket_addr, "Web server listening"); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await + .map_err(|e| format!("server error: {e}")) +} + +async fn index() -> Redirect { + debug!("GET / -> redirect to /conversations"); + Redirect::permanent("/conversations") +} + +async fn conversation_list(State(state): State) -> Result { + debug!("GET /conversations"); + + let conversations = state + .client + .list_conversations() + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + debug!(count = conversations.len(), "Rendered conversation list"); + Ok(views::list::render(&conversations)) +} + +async fn conversation_detail( + State(state): State, + Path(id): Path, +) -> Result { + debug!(%id, "GET /conversations/{{id}}"); + + let resp = state + .client + .read_events(&id) + .await + .map_err(|e| AppError::NotFound(e.to_string()))?; + + // Find the title from the conversation list (protocol doesn't include it + // in the events response). Fall back to "Untitled". + let title = match state.client.list_conversations().await { + Ok(convos) => convos + .iter() + .find(|c| c.id == id) + .and_then(|c| c.title.clone()) + .unwrap_or_else(|| "Untitled".into()), + Err(_) => "Untitled".into(), + }; + + let rendered = render::render_events(&resp.data); + debug!(%id, events = rendered.len(), "Rendered conversation detail"); + Ok(views::detail::render(&title, &rendered)) +} + +async fn serve_css() -> impl IntoResponse { + use axum::http::HeaderValue; + + debug!("GET /assets/style.css"); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/css; charset=utf-8"), + ); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + if let Ok(val) = HeaderValue::from_str(&style::css_etag()) { + headers.insert(header::ETAG, val); + } + + (StatusCode::OK, headers, style::CSS) +} + +enum AppError { + NotFound(String), + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + match self { + Self::NotFound(msg) => { + let body = views::layout::error_page("Not Found", &msg); + (StatusCode::NOT_FOUND, body).into_response() + } + Self::Internal(msg) => { + tracing::error!(%msg, "internal server error"); + let body = views::layout::error_page("Server Error", "Something went wrong."); + (StatusCode::INTERNAL_SERVER_ERROR, body).into_response() + } + } + } +} diff --git a/crates/plugins/command/serve-web/src/style.css b/crates/plugins/command/serve-web/src/style.css new file mode 100644 index 00000000..a12cec86 --- /dev/null +++ b/crates/plugins/command/serve-web/src/style.css @@ -0,0 +1,336 @@ +/* JP Web UI - Mobile-first, dark mode support, no JavaScript */ + +:root { + --bg: #ffffff; + --bg-alt: #f5f5f5; + --fg: #1a1a1a; + --fg-muted: #666666; + --border: #e0e0e0; + --accent: #2563eb; + --user-bg: #e8f0fe; + --assistant-bg: #f8f8f8; + --reasoning-bg: #fffbeb; + --tool-bg: #f0fdf4; + --code-bg: #f4f4f5; + --radius: 8px; + --max-width: 800px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --bg-alt: #161b22; + --fg: #e6edf3; + --fg-muted: #8b949e; + --border: #30363d; + --accent: #58a6ff; + --user-bg: #1c2d41; + --assistant-bg: #161b22; + --reasoning-bg: #2d2305; + --tool-bg: #0d2818; + --code-bg: #1e2228; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.6; + color: var(--fg); + background: var(--bg); +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Page header */ +.page-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--bg); + border-bottom: 1px solid var(--border); + padding: 12px 16px; +} + +.page-header h1 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.page-header .back { + display: inline-block; + margin-bottom: 4px; + font-size: 0.875rem; +} + +/* Conversation list */ +.conversation-list { + padding: 0 16px 16px; + max-width: var(--max-width); + margin: 0 auto; +} + +.conversation-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.conversation-list li { + border-bottom: 1px solid var(--border); +} + +.conversation-list li a { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 12px 4px; + color: var(--fg); +} + +.conversation-list li a:hover { + background: var(--bg-alt); + text-decoration: none; +} + +.conversation-list .title { + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-list .timestamp { + color: var(--fg-muted); + font-size: 0.8125rem; + flex-shrink: 0; +} + +.conversation-list .empty { + padding: 48px 0; + text-align: center; + color: var(--fg-muted); +} + +/* Conversation detail */ +.conversation-detail { + padding: 16px; + max-width: var(--max-width); + margin: 0 auto; +} + +/* Turn separator */ +.turn-separator { + border: none; + border-top: 1px dashed var(--border); + margin: 24px 0; +} + +/* Messages */ +.message { + margin-bottom: 16px; + padding: 12px 16px; + border-radius: var(--radius); +} + +.message .role { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-muted); + margin-bottom: 4px; +} + +.message.user { + background: var(--user-bg); +} + +.message.assistant { + background: var(--assistant-bg); +} + +.message .content { + overflow-wrap: break-word; +} + +.message .content > :first-child { + margin-top: 0; +} + +.message .content > :last-child { + margin-bottom: 0; +} + +/* Reasoning blocks */ +.reasoning { + margin-bottom: 16px; + background: var(--reasoning-bg); + border-radius: var(--radius); + padding: 0; +} + +.reasoning summary { + padding: 8px 16px; + font-size: 0.8125rem; + font-weight: 600; + color: var(--fg-muted); + cursor: pointer; + user-select: none; +} + +.reasoning .content { + padding: 0 16px 12px; +} + +/* Tool calls */ +.tool-call { + margin-bottom: 16px; + background: var(--tool-bg); + border-radius: var(--radius); +} + +.tool-call summary { + padding: 8px 16px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + user-select: none; +} + +.tool-call h4 { + margin: 8px 0 4px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fg-muted); +} + +.tool-args, +.tool-result { + padding: 0 16px 12px; +} + +/* Structured output */ +.message.structured pre { + margin: 0; +} + +/* Code blocks */ +pre { + background: var(--code-bg); + border-radius: 4px; + padding: 12px; + overflow-x: auto; + font-size: 0.875rem; + line-height: 1.5; +} + +code { + font-family: "SF Mono", "Cascadia Code", "Fira Code", Menlo, Monaco, Consolas, monospace; + font-size: 0.875em; +} + +/* Inline code */ +:not(pre) > code { + background: var(--code-bg); + padding: 2px 6px; + border-radius: 3px; +} + +/* Tables */ +table { + border-collapse: collapse; + width: 100%; + margin: 16px 0; + font-size: 0.875rem; +} + +th, td { + border: 1px solid var(--border); + padding: 8px 12px; + text-align: left; +} + +th { + background: var(--bg-alt); + font-weight: 600; +} + +/* Error page */ +.error-page { + padding: 48px 16px; + text-align: center; + max-width: var(--max-width); + margin: 0 auto; +} + +.error-page h1 { + margin-bottom: 8px; +} + +.error-page p { + color: var(--fg-muted); + margin-bottom: 24px; +} + +/* Responsive: wider viewports */ +@media (min-width: 768px) { + .page-header { + padding: 16px 24px; + } + + .conversation-list, + .conversation-detail { + padding: 16px 24px; + } + + .page-header h1 { + font-size: 1.5rem; + } +} + +/* Images */ +.message .content img { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +/* Blockquotes */ +blockquote { + border-left: 3px solid var(--border); + margin: 16px 0; + padding: 4px 16px; + color: var(--fg-muted); +} + +blockquote > :first-child { margin-top: 0; } +blockquote > :last-child { margin-bottom: 0; } + +/* Lists inside messages */ +.message .content ul, +.message .content ol { + padding-left: 24px; +} diff --git a/crates/plugins/command/serve-web/src/style.rs b/crates/plugins/command/serve-web/src/style.rs new file mode 100644 index 00000000..10ba0099 --- /dev/null +++ b/crates/plugins/command/serve-web/src/style.rs @@ -0,0 +1,23 @@ +//! Embedded CSS for the web UI. + +use std::sync::OnceLock; + +use sha2::{Digest as _, Sha256}; + +/// The CSS content, embedded at compile time. +pub(crate) const CSS: &str = include_str!("style.css"); + +/// Compute a stable `ETag` from the CSS content hash. +pub(crate) fn css_etag() -> String { + static ETAG: OnceLock = OnceLock::new(); + ETAG.get_or_init(|| { + let hash = Sha256::digest(CSS.as_bytes()); + let hex: String = hash[..8].iter().fold(String::new(), |mut acc, b| { + use std::fmt::Write as _; + let _ = write!(acc, "{b:02x}"); + acc + }); + format!("\"{hex}\"") + }) + .clone() +} diff --git a/crates/plugins/command/serve-web/src/views/detail.rs b/crates/plugins/command/serve-web/src/views/detail.rs new file mode 100644 index 00000000..032f4d30 --- /dev/null +++ b/crates/plugins/command/serve-web/src/views/detail.rs @@ -0,0 +1,65 @@ +//! Conversation detail page: renders a single conversation's chat history. + +use maud::{Markup, PreEscaped, html}; + +use crate::{render::RenderedEvent, views::layout}; + +/// Render the conversation detail page. +pub(crate) fn render(title: &str, events: &[RenderedEvent]) -> Markup { + layout::page(title, html! { + header class="page-header" { + a href="/conversations" class="back" { "← Conversations" } + h1 { (title) } + } + main class="conversation-detail" { + @for event in events { + @match event { + RenderedEvent::TurnSeparator => { + hr class="turn-separator"; + } + RenderedEvent::UserMessage { html } => { + div class="message user" { + div class="role" { "You" } + div class="content" { (PreEscaped(html)) } + } + } + RenderedEvent::AssistantMessage { html } => { + div class="message assistant" { + div class="role" { "Assistant" } + div class="content" { (PreEscaped(html)) } + } + } + RenderedEvent::Reasoning { html } => { + details class="reasoning" { + summary { "Reasoning" } + div class="content" { (PreEscaped(html)) } + } + } + RenderedEvent::Structured { json } => { + div class="message assistant structured" { + div class="role" { "Assistant (structured)" } + pre class="content" { code { (json) } } + } + } + RenderedEvent::ToolCall { name, arguments, result } => { + details class="tool-call" { + summary { "Tool: " (name) } + @if !arguments.is_empty() { + div class="tool-args" { + h4 { "Arguments" } + pre { code { (arguments) } } + } + } + @if let Some(result) = result { + div class="tool-result" { + h4 { "Result" } + pre { code { (result) } } + } + } + } + } + } + } + } + }) +} diff --git a/crates/plugins/command/serve-web/src/views/layout.rs b/crates/plugins/command/serve-web/src/views/layout.rs new file mode 100644 index 00000000..fd8a9301 --- /dev/null +++ b/crates/plugins/command/serve-web/src/views/layout.rs @@ -0,0 +1,36 @@ +//! Base HTML shell shared by all pages. + +use maud::{DOCTYPE, Markup, html}; + +/// Wrap page content in the common HTML shell. +#[expect( + clippy::needless_pass_by_value, + reason = "maud templates consume Markup" +)] +pub(crate) fn page(title: &str, body: Markup) -> Markup { + html! { + (DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { (title) " - JP" } + link rel="stylesheet" href="/assets/style.css"; + } + body { + (body) + } + } + } +} + +/// Render an error page. +pub(crate) fn error_page(title: &str, message: &str) -> Markup { + page(title, html! { + main class="error-page" { + h1 { (title) } + p { (message) } + a href="/conversations" { "← Back to conversations" } + } + }) +} diff --git a/crates/plugins/command/serve-web/src/views/list.rs b/crates/plugins/command/serve-web/src/views/list.rs new file mode 100644 index 00000000..af2a6be7 --- /dev/null +++ b/crates/plugins/command/serve-web/src/views/list.rs @@ -0,0 +1,72 @@ +//! Conversation list page. + +use chrono::{DateTime, Utc}; +use jp_plugin::message::ConversationSummary; +use maud::{Markup, html}; + +use crate::views::layout; + +/// Render the conversation list page. +/// +/// Takes the summaries directly from the protocol response. +pub(crate) fn render(conversations: &[ConversationSummary]) -> Markup { + // Sort by last activity (most recent first). The protocol doesn't + // guarantee order, so we sort here. + let mut sorted: Vec<&ConversationSummary> = conversations.iter().collect(); + sorted.sort_by_key(|c| std::cmp::Reverse(c.last_activated_at)); + + layout::page("Conversations", html! { + header class="page-header" { + h1 { "Conversations" } + } + main class="conversation-list" { + @if sorted.is_empty() { + p class="empty" { "No conversations yet." } + } @else { + ul { + @for entry in &sorted { + li { + a href=(format!("/conversations/{}", entry.id)) { + span class="title" { + (entry.title.as_deref().unwrap_or("Untitled")) + } + time class="timestamp" + datetime=(entry.last_activated_at.to_rfc3339()) { + (format_relative_time(entry.last_activated_at)) + } + } + } + } + } + } + } + }) +} + +/// Format a timestamp as a human-readable relative string. +fn format_relative_time(dt: DateTime) -> String { + let now = Utc::now(); + let duration = now.signed_duration_since(dt); + + let secs = duration.num_seconds(); + if secs < 60 { + return "just now".to_owned(); + } + + let mins = duration.num_minutes(); + if mins < 60 { + return format!("{mins}m ago"); + } + + let hours = duration.num_hours(); + if hours < 24 { + return format!("{hours}h ago"); + } + + let days = duration.num_days(); + if days < 30 { + return format!("{days}d ago"); + } + + dt.format("%Y-%m-%d").to_string() +} diff --git a/crates/plugins/command/serve-web/src/views/mod.rs b/crates/plugins/command/serve-web/src/views/mod.rs new file mode 100644 index 00000000..cb4ddf59 --- /dev/null +++ b/crates/plugins/command/serve-web/src/views/mod.rs @@ -0,0 +1,5 @@ +//! View modules for rendering HTML pages. + +pub(crate) mod detail; +pub(crate) mod layout; +pub(crate) mod list;