diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..537aacd6 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# whisper-rs-sys builds whisper.cpp with CUDA and the static CRT (/MT); +# Rust links with the dynamic CRT (/MD). The linker resolves this fine +# but warns about the conflicting default lib. Suppress it. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "link-args=/NODEFAULTLIB:LIBCMT"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5f2cd38..e669b594 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,26 +26,50 @@ jobs: run: cargo fmt --all --check - name: Clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-targets --all-features -- -D warnings - name: Test - run: cargo test --locked --workspace --all-features + run: cargo test --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features - name: Doctests - run: cargo test --workspace --doc + run: cargo test --workspace --exclude promptforge-wb --exclude promptforge-wb-server --doc - name: Docs env: RUSTDOCFLAGS: -D warnings - run: cargo doc --no-deps --all-features + run: cargo doc --workspace --no-deps --all-features --exclude promptforge-wb --exclude promptforge-wb-server + + check-workbench: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install UI dependencies + working-directory: crates/promptforge-wb-server/ui + run: npm ci + + - name: Clippy (workbench) + run: cargo clippy -p promptforge-wb -p promptforge-wb-server --all-targets -- -D warnings + + - name: Test (workbench) + run: cargo test --locked -p promptforge-wb -p promptforge-wb-server msrv: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # Verify the declared MSRV (workspace.package.rust-version) instead of - # trusting it: build and test on exactly that toolchain. - uses: dtolnay/rust-toolchain@1.89.0 - name: Cache cargo @@ -53,8 +77,8 @@ jobs: - name: Build and test on MSRV run: | - cargo build --locked --workspace --all-features - cargo test --locked --workspace --all-features + cargo build --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features + cargo test --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features supply-chain: runs-on: ubuntu-latest @@ -66,13 +90,8 @@ jobs: - name: Install cargo-deny and cargo-audit run: cargo install cargo-deny cargo-audit --locked - # Advisories, licenses, duplicate versions, and source policy (deny.toml). - name: cargo deny run: cargo deny check - # RUSTSEC vulnerability scan of the committed lockfile. - name: cargo audit run: cargo audit - - # Unused-dependency hygiene: run `cargo machete` locally - # (`cargo install cargo-machete`) before adding or removing dependencies. diff --git a/.gitignore b/.gitignore index 11e15efe..99d94189 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ *.env # Voice test fixtures, downloaded out of band (see design-promptforge-wb-1.md). /crates/promptforge-wb-server/tests/fixtures/ +# UI build pipeline: npm install target and esbuild output (rebuilt by build.rs). +/crates/promptforge-wb-server/ui/node_modules/ +/crates/promptforge-wb-server/ui/dist/ +# Workbench tape, written to the cwd when the server runs from the repo root. +/tape.jsonl diff --git a/Cargo.lock b/Cargo.lock index 6fb4989a..1f8c6d97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4047,8 +4047,10 @@ dependencies = [ "hound", "open", "reqwest 0.12.28", + "rust-embed", "serde", "serde_json", + "socket2", "tempfile", "thiserror 2.0.19", "time", @@ -4575,6 +4577,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/Cargo.toml b/Cargo.toml index 639cc7be..ba0133ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ rmcp = { version = "3.1.0", features = [ ] } tower = { version = "0.5", features = ["util"] } arc-swap = "1" +# Serves the workbench UI: reads ui/dist from disk in debug builds, embeds it +# into the binary in release builds. +rust-embed = "8" glob = "0.3" humantime = "2" humantime-serde = "1" @@ -78,10 +81,12 @@ tar = { version = "0.4.46", default-features = false } zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2"] } indicatif = "0.18" # Safe bindings to whisper.cpp; the C++ core is built from source by -# whisper-rs-sys (cmake + MSVC required). +# whisper-rs-sys (cmake + MSVC required). Enable the wb-server's "cuda" +# feature to build with GPU acceleration (needs the CUDA Toolkit). whisper-rs = "0.16" # WAV parsing for the voice test fixtures; dev-dependency of the wb-server. hound = "3.5" +socket2 = { version = "0.6", features = ["all"] } # wry 0.56 pairs with tao 0.36 (wry's own dev-dependency constraint); tao 0.37 # is newer but untested against this wry. wry = "0.56" diff --git a/crates/promptforge-dev/src/dump/fs_safe.rs b/crates/promptforge-dev/src/dump/fs_safe.rs index e2868105..baadd550 100644 --- a/crates/promptforge-dev/src/dump/fs_safe.rs +++ b/crates/promptforge-dev/src/dump/fs_safe.rs @@ -177,8 +177,7 @@ fn create_dir_restricted(dir: &Path) -> io::Result<()> { } /// Creates (truncating) a file owner-only. On Unix the mode is set at creation; -/// on Windows the ACL is applied separately once the file is closed -/// ([`restrict_to_owner`]). +/// on Windows the ACL is applied separately once the file is closed. fn create_restricted(path: &Path) -> io::Result { let mut options = fs::OpenOptions::new(); options.write(true).create(true).truncate(true); diff --git a/crates/promptforge-gateway/src/cache.rs b/crates/promptforge-gateway/src/cache.rs new file mode 100644 index 00000000..9bece38a --- /dev/null +++ b/crates/promptforge-gateway/src/cache.rs @@ -0,0 +1,268 @@ +//! The `/v1/cache` routes: bearer-authenticated on-demand blob downloads into +//! the operator cache, with sidecar-based listing and removal. +//! +//! The store is blocking filesystem plus a reqwest-blocking client, so every +//! store operation runs inside `tokio::task::spawn_blocking` and never blocks +//! the executor (Amendment D). A download reports progress over a bounded +//! channel that the SSE response drains; intermediate samples drop under +//! backpressure, while the terminal ready/error event is produced from the +//! download task's join result and is therefore never lost. + +use std::convert::Infallible; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use axum::Json; +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use axum::http::{HeaderMap, HeaderValue}; +use axum::response::{IntoResponse, Response}; +use futures_util::StreamExt as _; +use serde::Deserialize; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::error::GatewayError; +use crate::local::artifacts::{DownloadProgress, filename_from_url, parse_expected_digest}; +use crate::local::cache::{BlobCache, CacheEntry, CachedBlob}; +use crate::local::{LocalError, resolve_cache_root}; +use crate::{AppState, check_auth}; + +/// Opens the blob cache at the active profile's resolved cache root. +fn open_cache(cache_dir: Option<&str>) -> Result { + BlobCache::new(resolve_cache_root(cache_dir)?) +} + +/// The cache dir configured on the live profile (`[local].cache_dir`). +async fn live_cache_dir(state: &AppState) -> Option { + state.cache_dir().await +} + +/// `GET /v1/cache`: the sidecar-backed listing of cached blobs. +/// +/// Reads `.meta.json` sidecars only, so listing never re-hashes a blob +/// (Amendment C); blobs without sidecars are not cache entries and do not +/// appear. +pub(crate) async fn list_cache( + State(state): State, + headers: HeaderMap, +) -> Result>, GatewayError> { + check_auth(&state, &headers).await?; + let cache_dir = live_cache_dir(&state).await; + let entries = tokio::task::spawn_blocking(move || open_cache(cache_dir.as_deref())?.list()) + .await + .map_err(GatewayError::cache)? + .map_err(GatewayError::cache)?; + Ok(Json(entries)) +} + +/// The `POST /v1/cache` request body. +#[derive(Debug, Deserialize)] +pub(crate) struct CacheRequest { + /// The http(s) URL to download. + source: String, + /// Optional SHA-256 pin, verified against the downloaded bytes. + sha256: Option, +} + +/// Validates the network-facing `source`: an http(s) URL with a host and a +/// usable filename segment. Anything else is a 400, never a download attempt. +fn validate_source(source: &str) -> Result<(), GatewayError> { + let parsed = url::Url::parse(source).map_err(|_| { + GatewayError::MalformedRequest(format!("cache source `{source}` is not a valid URL")) + })?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(GatewayError::MalformedRequest(format!( + "cache source `{source}` must be an http or https URL with a host" + ))); + } + filename_from_url(source).map_err(|error| GatewayError::MalformedRequest(error.to_string()))?; + Ok(()) +} + +/// `POST /v1/cache`: ensure the blob for `source` is cached. +/// +/// A cache hit (blob + sidecar present, pin matching when named) answers +/// immediately with JSON `{"path", "status": "ready"}`. A miss answers with +/// `text/event-stream`: `{"status": "downloading", "bytes", "total"}` +/// progress events (`total` is null when the server sent no Content-Length), +/// terminated by `{"status": "ready", "path"}` or, on failure, +/// `{"status": "error", "message"}`. +pub(crate) async fn post_cache( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + check_auth(&state, &headers).await?; + validate_source(&request.source)?; + let expected = request + .sha256 + .as_deref() + .map(parse_expected_digest) + .transpose() + .map_err(|error| GatewayError::MalformedRequest(error.to_string()))?; + let cache_dir = live_cache_dir(&state).await; + let source = request.source; + + // The hit check runs on the blocking pool; on a miss the opened store is + // handed to the download task so the root is enforced exactly once. + let lookup_source = source.clone(); + let lookup_pin = expected.clone(); + let (cache, hit) = tokio::task::spawn_blocking(move || { + let cache = open_cache(cache_dir.as_deref())?; + let hit = cache.lookup(&lookup_source, lookup_pin.as_deref())?; + Ok::<_, LocalError>((cache, hit)) + }) + .await + .map_err(GatewayError::cache)? + .map_err(GatewayError::cache)?; + + if let Some(blob) = hit { + return Ok(Json(serde_json::json!({ + "path": blob.path, + "status": "ready", + })) + .into_response()); + } + + let (tx, rx) = mpsc::channel::(64); + let join = tokio::task::spawn_blocking(move || { + let progress = ChannelProgress::new(tx); + cache.download_to_cache(&source, expected.as_deref(), &progress) + }); + Ok(sse_response(rx, join)) +} + +/// `DELETE /v1/cache/{sha256}`: removes the blob and sidecar for a digest. +/// +/// Answers 200 with `{"status": "deleted", "sha256"}` when an entry was +/// removed, 404 `cache_entry_not_found` when no sidecar records the digest, +/// and 400 when the path parameter is not a 64-character hex digest. +pub(crate) async fn delete_cache( + State(state): State, + headers: HeaderMap, + Path(sha256): Path, +) -> Result, GatewayError> { + check_auth(&state, &headers).await?; + let wanted = parse_expected_digest(&sha256) + .map_err(|error| GatewayError::MalformedRequest(error.to_string()))?; + let cache_dir = live_cache_dir(&state).await; + let lookup = wanted.clone(); + let removed = + tokio::task::spawn_blocking(move || open_cache(cache_dir.as_deref())?.remove(&lookup)) + .await + .map_err(GatewayError::cache)? + .map_err(GatewayError::cache)?; + if !removed { + return Err(GatewayError::CacheEntryNotFound(wanted)); + } + Ok(Json(serde_json::json!({ + "status": "deleted", + "sha256": wanted, + }))) +} + +/// One progress sample from a running download. +#[derive(Debug, Clone, Copy)] +struct CacheProgress { + bytes: u64, + total: Option, +} + +/// [`DownloadProgress`] over a bounded channel toward the SSE response. +/// +/// Intermediate samples are sent with `try_send` and dropped when the client +/// is not keeping up - progress is lossy by nature. The terminal event is not +/// a sample: it comes from the download task's join result, so backpressure +/// can never drop the ready/error outcome. +struct ChannelProgress { + tx: mpsc::Sender, + downloaded: AtomicU64, + total: Mutex>, +} + +impl ChannelProgress { + fn new(tx: mpsc::Sender) -> Self { + Self { + tx, + downloaded: AtomicU64::new(0), + total: Mutex::new(None), + } + } + + fn total(&self) -> Option { + // The guarded value is plain data with no panic path; a poisoned lock + // (only possible if a panic landed mid-store) recovers the value. + *self.total.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn publish(&self, bytes: u64) { + let _ = self.tx.try_send(CacheProgress { + bytes, + total: self.total(), + }); + } +} + +impl DownloadProgress for ChannelProgress { + fn set_len(&self, total: Option) { + *self.total.lock().unwrap_or_else(PoisonError::into_inner) = total; + self.publish(self.downloaded.load(Ordering::Relaxed)); + } + + fn inc(&self, n: u64) { + let downloaded = self.downloaded.fetch_add(n, Ordering::Relaxed) + n; + self.publish(downloaded); + } + + fn finish(&self) {} + + fn abandon(&self) {} +} + +/// Builds the SSE response draining the progress channel, then appending the +/// terminal event from the download task's join result. +/// +/// The channel closes when the download task drops its `ChannelProgress`, so +/// the progress stream ends before the terminal event is awaited. A client +/// disconnect drops the response body and the receiver; the blocking download +/// itself runs to completion (its staging cleanup still applies) and a later +/// POST for the same source then hits the cache. +fn sse_response( + mut rx: mpsc::Receiver, + join: JoinHandle>, +) -> Response { + let progress = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)).map(|sample| { + Ok::<_, Infallible>(format!( + "data: {}\n\n", + serde_json::json!({ + "status": "downloading", + "bytes": sample.bytes, + "total": sample.total, + }) + )) + }); + let terminal = futures_util::stream::once(async move { + let payload = match join.await { + Ok(Ok(blob)) => serde_json::json!({ + "status": "ready", + "path": blob.path, + }), + Ok(Err(error)) => serde_json::json!({ + "status": "error", + "message": error.to_string(), + }), + Err(join_error) => serde_json::json!({ + "status": "error", + "message": format!("download task failed: {join_error}"), + }), + }; + Ok::<_, Infallible>(format!("data: {payload}\n\n")) + }); + let mut response = Response::new(Body::from_stream(progress.chain(terminal))); + let headers = response.headers_mut(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream")); + headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + response +} diff --git a/crates/promptforge-gateway/src/error.rs b/crates/promptforge-gateway/src/error.rs index c259d8f9..63450e75 100644 --- a/crates/promptforge-gateway/src/error.rs +++ b/crates/promptforge-gateway/src/error.rs @@ -119,6 +119,18 @@ pub(crate) enum GatewayError { /// Admin profile routes were reached without a configured profiles directory. #[error("profiles directory not configured")] ProfilesUnavailable, + + /// A `/v1/cache` route failed at the storage or transport layer before its + /// response was committed (mid-stream failures are SSE error events, not + /// this variant). + #[non_exhaustive] + #[error("cache operation failed")] + Cache(#[source] Box), + + /// `DELETE /v1/cache/{sha256}` named a digest no cache entry carries. + #[non_exhaustive] + #[error("cache entry not found: {0}")] + CacheEntryNotFound(String), } impl From for GatewayError { @@ -173,6 +185,12 @@ impl GatewayError { } } + /// Wrap a cache-operation failure, preserving the cause. + #[must_use] + pub(crate) fn cache(source: impl std::error::Error + Send + Sync + 'static) -> GatewayError { + GatewayError::Cache(Box::new(source)) + } + /// The `(status, type, code)` triple for the OpenAI error envelope. fn classify(&self) -> (StatusCode, &'static str, &'static str) { match self { @@ -248,6 +266,16 @@ impl GatewayError { "invalid_request_error", "profiles_unavailable", ), + GatewayError::Cache(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "cache_error", + ), + GatewayError::CacheEntryNotFound(_) => ( + StatusCode::NOT_FOUND, + "invalid_request_error", + "cache_entry_not_found", + ), } } } diff --git a/crates/promptforge-gateway/src/lib.rs b/crates/promptforge-gateway/src/lib.rs index f459a982..859392d9 100644 --- a/crates/promptforge-gateway/src/lib.rs +++ b/crates/promptforge-gateway/src/lib.rs @@ -16,10 +16,14 @@ //! subprocess (`[[local_model]]`), named profiles with recursive `include` //! and immediate `POST /admin/switch-profile`, a bearer-authed //! `GET /v1/models` catalog, a Brave-backed `POST /v1/tools/web_search` -//! configured by `[tools.web_search]`, and `GET /health`. In-process +//! configured by `[tools.web_search]`, an on-demand blob cache +//! (`POST /v1/cache` with SSE download progress, `GET /v1/cache`, +//! `DELETE /v1/cache/{sha256}`) backed by the local artifact store, and +//! `GET /health`. In-process //! llama.cpp FFI and endpoint pinning are deferred. mod api_error; +mod cache; mod dialect; mod error; mod http_util; @@ -27,6 +31,8 @@ mod local; mod queue; mod routing; mod runner; +#[cfg(test)] +mod testsupport; mod tools; mod upstream; mod web_search_process; @@ -47,7 +53,7 @@ use axum::extract::State; use axum::http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE}; use axum::http::{HeaderMap, HeaderValue}; use axum::response::Response; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::{Router, response::IntoResponse}; use serde::Deserialize; use tokio::sync::RwLock; @@ -137,6 +143,11 @@ impl AppState { pub(crate) async fn web_search(&self) -> Option> { self.live.read().await.web_search.clone() } + + /// The active profile's `[local].cache_dir` setting, for the cache routes. + pub(crate) async fn cache_dir(&self) -> Option { + self.live.read().await.local.cache_dir().map(str::to_owned) + } } /// Build the gateway's axum router. @@ -147,6 +158,8 @@ pub(crate) fn build_router(state: AppState) -> Router { .route("/v1/rerank", post(rerank)) .route("/v1/models", get(list_models)) .route("/v1/tools/web_search", post(tools::web_search)) + .route("/v1/cache", get(cache::list_cache).post(cache::post_cache)) + .route("/v1/cache/{sha256}", delete(cache::delete_cache)) .route("/health", get(health)) .route("/admin/profiles", get(admin_list_profiles)) .route("/admin/status", get(admin_status)) diff --git a/crates/promptforge-gateway/src/local/artifacts.rs b/crates/promptforge-gateway/src/local/artifacts.rs index 2def98c0..0b3c1f4e 100644 --- a/crates/promptforge-gateway/src/local/artifacts.rs +++ b/crates/promptforge-gateway/src/local/artifacts.rs @@ -28,15 +28,19 @@ use crate::local::error::LocalError; use archive::{extract_archive, find_executable, require_executable}; use assets::{ArchiveKind, FileAsset, LLAMA_RELEASE, ServerAsset, server_asset}; -use confine::{ +use confine::validate_tree_path; +use digest::{file_digest, tree_digest}; + +// Re-exports consumed elsewhere in the crate (`local/mod.rs`, `local/cache.rs`, +// `testsupport.rs`). Test-only helpers are imported directly from their +// submodules by `tests.rs`. +pub(crate) use confine::{ enforce_private_cache_root, ensure_cache_directory, part_path, remove_cache_entry, - rename_confined, safe_relative_path, validate_cache_path, validate_tree_path, write_synced, + rename_confined, safe_relative_path, validate_cache_path, write_synced, }; -use digest::{file_digest, hex_digest, parse_expected_digest, tree_digest}; - -// Re-export consumed elsewhere in the crate (`local/mod.rs`). Test-only helpers -// are imported directly from their submodules by `tests.rs`. -pub(crate) use download::hub_bearer_token_from_env; +pub(crate) use digest::{hex_digest, parse_expected_digest}; +pub(crate) use download::{download_with_progress, hub_bearer_token_from_env}; +pub(crate) use progress::DownloadProgress; const INSTALL_MARKER: &str = ".promptforge-install"; /// Connect timeout for artifact downloads (bounds a stalled connect). @@ -70,13 +74,10 @@ impl ArtifactStore { // Enforce the private-cache precondition the confinement design relies on // (owner-only root) before trusting the tree (ART-006). enforce_private_cache_root(&cache)?; - let client = Client::builder() - .user_agent(concat!("promptforge-gateway/", env!("CARGO_PKG_VERSION"))) - .connect_timeout(DOWNLOAD_CONNECT_TIMEOUT) - .timeout(DOWNLOAD_REQUEST_TIMEOUT) - .build() - .map_err(LocalError::HttpClient)?; - Ok(Self { cache, client }) + Ok(Self { + cache, + client: download_client()?, + }) } /// Ensures the pinned GPU-capable `llama-server` for this host is installed. @@ -293,38 +294,64 @@ impl ArtifactStore { } fn lock_artifact(&self, artifact: &Path) -> Result { - validate_cache_path(&self.cache, artifact)?; - let relative = - artifact - .strip_prefix(&self.cache) - .map_err(|_| LocalError::UnsafeCachePath { - path: artifact.to_owned(), - })?; - let mut hasher = Sha256::new(); - hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); - let lock_directory = self.cache.join(".locks"); - ensure_cache_directory(&self.cache, &lock_directory)?; - let lock_path = lock_directory.join(format!("{}.lock", hex_digest(hasher))); - validate_cache_path(&self.cache, &lock_path)?; - let lock = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&lock_path) - .map_err(|source| LocalError::Io { - operation: "open artifact lock", - path: lock_path.clone(), - source, - })?; - lock.lock().map_err(|source| LocalError::Io { - operation: "lock artifact", - path: lock_path, + lock_artifact(&self.cache, artifact) + } +} + +/// The blocking HTTP client shared by artifact provisioning and the blob +/// cache: gateway user agent, bounded connect, and a generous whole-request +/// ceiling (ART-003) in place of a per-read idle timeout. +/// +/// # Errors +/// Returns [`LocalError::HttpClient`] when the client cannot be built. +pub(crate) fn download_client() -> Result { + Client::builder() + .user_agent(concat!("promptforge-gateway/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(DOWNLOAD_CONNECT_TIMEOUT) + .timeout(DOWNLOAD_REQUEST_TIMEOUT) + .build() + .map_err(LocalError::HttpClient) +} + +/// Takes the advisory OS lock serializing publishers of `artifact` under +/// `cache`, keyed by the artifact's cache-relative path. +/// +/// The returned handle owns the lock; dropping it releases. Both the artifact +/// and the lock file are confinement-checked before use (ART-006/007). +/// +/// # Errors +/// Returns [`LocalError`] when a path is unsafe or the lock cannot be taken. +pub(crate) fn lock_artifact(cache: &Path, artifact: &Path) -> Result { + validate_cache_path(cache, artifact)?; + let relative = artifact + .strip_prefix(cache) + .map_err(|_| LocalError::UnsafeCachePath { + path: artifact.to_owned(), + })?; + let mut hasher = Sha256::new(); + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + let lock_directory = cache.join(".locks"); + ensure_cache_directory(cache, &lock_directory)?; + let lock_path = lock_directory.join(format!("{}.lock", hex_digest(hasher))); + validate_cache_path(cache, &lock_path)?; + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|source| LocalError::Io { + operation: "open artifact lock", + path: lock_path.clone(), source, })?; - validate_cache_path(&self.cache, artifact)?; - Ok(lock) - } + lock.lock().map_err(|source| LocalError::Io { + operation: "lock artifact", + path: lock_path, + source, + })?; + validate_cache_path(cache, artifact)?; + Ok(lock) } fn looks_like_url(source: &str) -> bool { @@ -335,13 +362,18 @@ fn looks_like_url(source: &str) -> bool { /// /// Two different URLs that share a filename map to different slots (ART-004), /// while the same URL always maps to the same slot so a cache hit is stable. -fn source_cache_key(source: &str) -> String { +pub(crate) fn source_cache_key(source: &str) -> String { let mut hasher = Sha256::new(); hasher.update(source.as_bytes()); hex_digest(hasher).chars().take(16).collect() } -fn filename_from_url(url: &str) -> Result { +/// The URL's final path segment, validated as a safe relative filename. +/// +/// # Errors +/// Returns [`LocalError::InvalidSource`] when the URL has no filename segment +/// or the segment is not a safe relative path. +pub(crate) fn filename_from_url(url: &str) -> Result { let without_query = url.split('?').next().unwrap_or(url); let name = without_query .rsplit('/') diff --git a/crates/promptforge-gateway/src/local/artifacts/confine.rs b/crates/promptforge-gateway/src/local/artifacts/confine.rs index b62222a7..81edb695 100644 --- a/crates/promptforge-gateway/src/local/artifacts/confine.rs +++ b/crates/promptforge-gateway/src/local/artifacts/confine.rs @@ -39,7 +39,7 @@ use crate::local::error::LocalError; /// /// Returns [`LocalError::CacheNotPrivate`] when the root cannot be made private. #[cfg(unix)] -pub(super) fn enforce_private_cache_root(root: &Path) -> Result<()> { +pub(crate) fn enforce_private_cache_root(root: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt as _; fs::set_permissions(root, fs::Permissions::from_mode(0o700)).map_err(|source| { @@ -86,7 +86,7 @@ const BROAD_WINDOWS_PRINCIPALS: [&str; 5] = [ ]; #[cfg(windows)] -pub(super) fn enforce_private_cache_root(root: &Path) -> Result<()> { +pub(crate) fn enforce_private_cache_root(root: &Path) -> Result<()> { let account = current_windows_account(root)?; set_owner_only_windows_dacl(root, &account)?; verify_private_windows_dacl(root) @@ -174,7 +174,7 @@ fn verify_private_windows_dacl(root: &Path) -> Result<()> { } /// Whether `path` is a non-empty relative path of only normal components. -pub(super) fn safe_relative_path(path: &Path) -> bool { +pub(crate) fn safe_relative_path(path: &Path) -> bool { !path.as_os_str().is_empty() && path .components() @@ -182,7 +182,7 @@ pub(super) fn safe_relative_path(path: &Path) -> bool { } /// The sibling `.part` staging name for an atomic publish. -pub(super) fn part_path(path: &Path) -> PathBuf { +pub(crate) fn part_path(path: &Path) -> PathBuf { let mut name = path.as_os_str().to_owned(); name.push(".part"); PathBuf::from(name) @@ -192,7 +192,7 @@ pub(super) fn part_path(path: &Path) -> PathBuf { /// /// # Errors /// Returns [`LocalError`] when the path escapes `root` or a component is unsafe. -pub(super) fn ensure_cache_directory(root: &Path, directory: &Path) -> Result<()> { +pub(crate) fn ensure_cache_directory(root: &Path, directory: &Path) -> Result<()> { if directory == root { fs::create_dir_all(root).map_err(|source| LocalError::Io { operation: "create cache directory", @@ -240,7 +240,7 @@ pub(super) fn ensure_cache_directory(root: &Path, directory: &Path) -> Result<() /// /// # Errors /// Returns [`LocalError`] when the path is unsafe or removal fails. -pub(super) fn remove_cache_entry(root: &Path, path: &Path) -> Result<()> { +pub(crate) fn remove_cache_entry(root: &Path, path: &Path) -> Result<()> { validate_tree_path(root, path)?; let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, @@ -274,7 +274,7 @@ pub(super) fn remove_cache_entry(root: &Path, path: &Path) -> Result<()> { /// /// # Errors /// Returns [`LocalError`] when either path is unsafe or the rename fails. -pub(super) fn rename_confined(root: &Path, source: &Path, destination: &Path) -> Result<()> { +pub(crate) fn rename_confined(root: &Path, source: &Path, destination: &Path) -> Result<()> { validate_tree_path(root, source)?; validate_tree_path(root, destination)?; fs::rename(source, destination).map_err(|error| LocalError::Io { @@ -288,7 +288,7 @@ pub(super) fn rename_confined(root: &Path, source: &Path, destination: &Path) -> /// /// # Errors /// Returns [`LocalError::UnsafeCachePath`] when `path` is not confined. -pub(super) fn validate_cache_path(root: &Path, path: &Path) -> Result<()> { +pub(crate) fn validate_cache_path(root: &Path, path: &Path) -> Result<()> { validate_tree_path(root, path) } @@ -365,7 +365,7 @@ fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { /// /// # Errors /// Returns [`LocalError::Io`] when creating, writing, or syncing fails. -pub(super) fn write_synced(path: &Path, contents: &[u8]) -> Result<()> { +pub(crate) fn write_synced(path: &Path, contents: &[u8]) -> Result<()> { let mut file = File::create(path).map_err(|source| LocalError::Io { operation: "create install marker", path: path.to_owned(), diff --git a/crates/promptforge-gateway/src/local/artifacts/digest.rs b/crates/promptforge-gateway/src/local/artifacts/digest.rs index 062c73a2..2fa219f2 100644 --- a/crates/promptforge-gateway/src/local/artifacts/digest.rs +++ b/crates/promptforge-gateway/src/local/artifacts/digest.rs @@ -17,7 +17,7 @@ use crate::local::error::LocalError; /// /// # Errors /// Returns [`LocalError::InvalidDigest`] when the pin is not 64 hex characters. -pub(super) fn parse_expected_digest(raw: &str) -> Result { +pub(crate) fn parse_expected_digest(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.len() != 64 { return Err(LocalError::InvalidDigest { @@ -35,7 +35,7 @@ pub(super) fn parse_expected_digest(raw: &str) -> Result { } /// Lowercase hex encoding of a finalized SHA-256 hasher. -pub(super) fn hex_digest(hasher: Sha256) -> String { +pub(crate) fn hex_digest(hasher: Sha256) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let bytes = hasher.finalize(); let mut output = String::with_capacity(bytes.len() * 2); diff --git a/crates/promptforge-gateway/src/local/artifacts/download.rs b/crates/promptforge-gateway/src/local/artifacts/download.rs index 1874bf8b..fb1f9557 100644 --- a/crates/promptforge-gateway/src/local/artifacts/download.rs +++ b/crates/promptforge-gateway/src/local/artifacts/download.rs @@ -83,7 +83,7 @@ pub(super) fn download(client: &Client, url: &str, destination: &Path) -> Result /// /// # Errors /// Returns [`LocalError`] on transport, size-cap, or filesystem failure. -pub(super) fn download_with_progress( +pub(crate) fn download_with_progress( client: &Client, url: &str, destination: &Path, diff --git a/crates/promptforge-gateway/src/local/artifacts/progress.rs b/crates/promptforge-gateway/src/local/artifacts/progress.rs index 55a44982..f555ce44 100644 --- a/crates/promptforge-gateway/src/local/artifacts/progress.rs +++ b/crates/promptforge-gateway/src/local/artifacts/progress.rs @@ -8,7 +8,7 @@ use indicatif::{ProgressBar, ProgressStyle}; const LOG_PROGRESS_BYTES: u64 = 64 * 1024 * 1024; /// Progress updates for a single HTTP blob download. -pub(super) trait DownloadProgress: Send { +pub(crate) trait DownloadProgress: Send { fn set_len(&self, total: Option); fn inc(&self, n: u64); fn finish(&self); diff --git a/crates/promptforge-gateway/src/local/artifacts/tests.rs b/crates/promptforge-gateway/src/local/artifacts/tests.rs index a8a28ba5..0aec5f1f 100644 --- a/crates/promptforge-gateway/src/local/artifacts/tests.rs +++ b/crates/promptforge-gateway/src/local/artifacts/tests.rs @@ -1,17 +1,17 @@ use std::io::{self, Read as _, Write as _}; use std::net::{TcpListener, TcpStream}; -use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use std::thread::{self, JoinHandle}; +use std::thread; use std::time::Duration; -use sha2::{Digest, Sha256}; use tempfile::TempDir; use super::archive::safe_archive_path; use super::download::{hub_bearer_token, is_huggingface_https}; use super::progress::{DownloadProgress, download_label, progress_for_download}; use super::*; +use crate::testsupport::{FakeServer, hex_sha256}; #[test] fn parse_expected_digest_normalizes_and_validates() { @@ -507,106 +507,6 @@ impl DownloadProgress for RecordingProgress { } } -struct FakeServer { - address: String, - requests: Arc, - shutdown: Arc, - thread: Option>>, -} - -impl FakeServer { - fn new(body: &[u8]) -> Self { - // A blocking listener: the socket is bound before the accept thread - // starts, so the kernel backlog holds any early client connection until - // `accept` runs. There is no startup race and thus no startup sleep, and - // blocking `accept` needs no WouldBlock poll loop. `Drop` wakes the - // final blocking `accept` with a self-connect after setting `shutdown`. - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake server"); - let address = listener.local_addr().expect("local addr").to_string(); - let requests = Arc::new(AtomicUsize::new(0)); - let shutdown = Arc::new(AtomicBool::new(false)); - let thread_requests = Arc::clone(&requests); - let thread_shutdown = Arc::clone(&shutdown); - let body = body.to_owned(); - // The thread returns an `io::Result`: a genuine write/flush failure while - // serving a real client is surfaced on join (HYGIENE-RESULT-001) instead - // of being swallowed, so a broken fixture cannot masquerade as success. - // The shutdown self-connect is skipped by the top-of-loop `shutdown` - // check, so it never counts as a serve error. - let thread = thread::spawn(move || -> io::Result<()> { - for stream in listener.incoming() { - if thread_shutdown.load(Ordering::Acquire) { - break; - } - let Ok(mut stream) = stream else { - break; - }; - let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); - let _ = stream.set_write_timeout(Some(Duration::from_secs(2))); - let mut request = Vec::new(); - let mut buf = [0_u8; 1024]; - loop { - match stream.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => { - request.extend_from_slice(&buf[..n]); - if request.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - } - } - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - stream.write_all(response.as_bytes())?; - stream.write_all(&body)?; - stream.flush()?; - thread_requests.fetch_add(1, Ordering::AcqRel); - } - Ok(()) - }); - Self { - address, - requests, - shutdown, - thread: Some(thread), - } - } - - fn url(&self, name: &str) -> String { - format!("http://{}/{name}", self.address) - } - - fn requests(&self) -> usize { - self.requests.load(Ordering::Acquire) - } -} - -impl Drop for FakeServer { - fn drop(&mut self) { - self.shutdown.store(true, Ordering::Release); - let _ = TcpStream::connect(&self.address); - if let Some(thread) = self.thread.take() { - let joined = thread.join(); - // Don't mask an in-flight test panic, but otherwise a serve-side - // transport failure must surface rather than be silently dropped. - if !std::thread::panicking() { - joined - .expect("fake server thread panicked") - .expect("fake server encountered a socket write/flush error"); - } - } - } -} - -fn hex_sha256(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - hex_digest(hasher) -} - #[test] fn download_label_uses_url_basename() { assert_eq!( diff --git a/crates/promptforge-gateway/src/local/cache.rs b/crates/promptforge-gateway/src/local/cache.rs new file mode 100644 index 00000000..0fc6f0fb --- /dev/null +++ b/crates/promptforge-gateway/src/local/cache.rs @@ -0,0 +1,691 @@ +//! On-demand blob cache behind the `/v1/cache` routes. +//! +//! A blob is downloaded once into the same `models//` +//! slot layout local provisioning uses (so a cache-API download is a +//! provisioning cache hit for the same URL, and vice versa), staged through a +//! `.part` sibling and renamed into place only after its digest verifies +//! (Amendment E). Each published blob gets a `.meta.json` sidecar holding +//! its source URL, SHA-256, and size, so listing and lookup never re-hash a +//! multi-gigabyte blob (Amendment C). Blobs without sidecars - pre-existing +//! local model files - are not cache entries: they are neither listed nor +//! treated as hits. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::local::artifacts::{ + DownloadProgress, download_client, download_with_progress, enforce_private_cache_root, + ensure_cache_directory, filename_from_url, lock_artifact, parse_expected_digest, part_path, + remove_cache_entry, rename_confined, safe_relative_path, source_cache_key, validate_cache_path, + write_synced, +}; +use crate::local::error::LocalError; + +/// The sidecar suffix marking a blob as a cache-API entry. +const META_SUFFIX: &str = ".meta.json"; + +/// A blob present in the cache: its path, content digest, and size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CachedBlob { + /// Absolute path of the blob under the cache root. + pub(crate) path: PathBuf, + /// Lowercase hex SHA-256 of the blob's bytes. + pub(crate) sha256: String, + /// Blob length in bytes. + pub(crate) size_bytes: u64, +} + +/// The `.meta.json` sidecar written when a cache download completes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct BlobMeta { + source: String, + sha256: String, + size_bytes: u64, +} + +/// One entry of the cache listing: a blob plus the source it was fetched from. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct CacheEntry { + /// The URL the blob was downloaded from. + pub(crate) source: String, + /// Absolute path of the blob under the cache root. + pub(crate) path: PathBuf, + /// Lowercase hex SHA-256 of the blob's bytes. + pub(crate) sha256: String, + /// Blob length in bytes. + pub(crate) size_bytes: u64, +} + +/// The sidecar path for a cached blob: `.meta.json`. +fn meta_path(blob: &Path) -> PathBuf { + let mut name = blob.as_os_str().to_owned(); + name.push(META_SUFFIX); + PathBuf::from(name) +} + +/// Reads the sidecar beside `blob`, returning `None` when it is absent. +/// +/// A corrupt sidecar is logged and treated as absent, so the blob falls back +/// to a re-download rather than failing the request. +/// +/// # Errors +/// Returns [`LocalError::Io`] when an existing sidecar cannot be read. +fn read_meta(blob: &Path) -> Result, LocalError> { + let path = meta_path(blob); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(LocalError::Io { + operation: "read cache sidecar", + path, + source, + }); + } + }; + match serde_json::from_str(&text) { + Ok(meta) => Ok(Some(meta)), + Err(error) => { + tracing::warn!( + path = %path.display(), + error = %error, + "ignoring corrupt cache sidecar" + ); + Ok(None) + } + } +} + +/// The cache-hit test: blob and sidecar present, and the sidecar's digest +/// matching `expected` when a pin is named (Amendment E). The blob's bytes are +/// never re-hashed; the sidecar written at download completion is the record +/// of truth (Amendment C). +fn cached(destination: &Path, expected: Option<&str>) -> Result, LocalError> { + if !destination.is_file() { + return Ok(None); + } + let Some(meta) = read_meta(destination)? else { + return Ok(None); + }; + if let Some(expected) = expected + && meta.sha256 != expected + { + return Ok(None); + } + Ok(Some(CachedBlob { + path: destination.to_owned(), + sha256: meta.sha256, + size_bytes: meta.size_bytes, + })) +} + +/// The cache root plus the shared blocking HTTP client. +/// +/// Construction enforces the same owner-private-root precondition as artifact +/// provisioning (ART-006), since the cache writes into the same tree. +#[derive(Debug)] +pub(crate) struct BlobCache { + root: PathBuf, + client: reqwest::blocking::Client, +} + +impl BlobCache { + /// Opens the cache at `root`, creating and owner-restricting it if needed. + /// + /// # Errors + /// Returns [`LocalError::Io`], [`LocalError::CacheNotPrivate`], or + /// [`LocalError::HttpClient`] on setup failure. + pub(crate) fn new(root: impl Into) -> Result { + let root = root.into(); + ensure_cache_directory(&root, &root)?; + enforce_private_cache_root(&root)?; + Ok(Self { + root, + client: download_client()?, + }) + } + + /// The cache-slot destination for `source`: `models//`. + fn destination(&self, source: &str) -> Result { + let name = filename_from_url(source)?; + let key = source_cache_key(source); + let relative = Path::new("models").join(&key).join(&name); + if !safe_relative_path(&relative) { + return Err(LocalError::UnsafeCachePath { + path: self.root.join(relative), + }); + } + let path = self.root.join(relative); + validate_cache_path(&self.root, &path)?; + Ok(path) + } + + /// Returns the cached blob for `source` when the cache-hit test passes. + /// + /// # Errors + /// Returns [`LocalError::InvalidDigest`] for a malformed pin, or + /// [`LocalError`] on filesystem failure. + pub(crate) fn lookup( + &self, + source: &str, + expected_sha256: Option<&str>, + ) -> Result, LocalError> { + let expected = expected_sha256.map(parse_expected_digest).transpose()?; + let destination = self.destination(source)?; + cached(&destination, expected.as_deref()) + } + + /// Ensures `source` is cached, downloading it when the cache-hit test + /// fails, and returns the published blob. + /// + /// The download is staged to `.part` and renamed into place only + /// after the digest verifies against `expected_sha256` (when named); any + /// failure removes the staging file and leaves no sidecar. Concurrent + /// publishers of the same source serialize on the artifact lock, and the + /// hit test is repeated under the lock so exactly one of them downloads. + /// + /// # Errors + /// Returns [`LocalError`] on transport, digest, confinement, or filesystem + /// failure. + pub(crate) fn download_to_cache( + &self, + source: &str, + expected_sha256: Option<&str>, + progress: &dyn DownloadProgress, + ) -> Result { + let expected = expected_sha256.map(parse_expected_digest).transpose()?; + let destination = self.destination(source)?; + let _lock = lock_artifact(&self.root, &destination)?; + if let Some(blob) = cached(&destination, expected.as_deref())? { + return Ok(blob); + } + let staging = part_path(&destination); + remove_cache_entry(&self.root, &staging)?; + let Some(parent) = destination.parent() else { + return Err(LocalError::InvalidPath { + path: destination.clone(), + }); + }; + ensure_cache_directory(&self.root, parent)?; + validate_cache_path(&self.root, &staging)?; + let actual = match download_with_progress(&self.client, source, &staging, progress) { + Ok(actual) => actual, + Err(error) => { + progress.abandon(); + let _ignored = fs::remove_file(&staging); + return Err(error); + } + }; + if let Some(expected) = expected.as_deref() + && actual != expected + { + progress.abandon(); + remove_cache_entry(&self.root, &staging)?; + return Err(LocalError::DigestMismatch { + name: filename_from_url(source)?, + expected: expected.to_owned(), + actual, + }); + } + // A stale or sidecar-less blob at the destination is replaced only + // after the new content is verified (Windows rename refuses an + // existing target). + remove_cache_entry(&self.root, &destination)?; + rename_confined(&self.root, &staging, &destination)?; + let size_bytes = fs::metadata(&destination) + .map_err(|source_err| LocalError::Io { + operation: "stat cached blob", + path: destination.clone(), + source: source_err, + })? + .len(); + let meta = BlobMeta { + source: source.to_owned(), + sha256: actual.clone(), + size_bytes, + }; + let meta_json = serde_json::to_vec(&meta).map_err(|source_err| LocalError::Io { + operation: "encode cache sidecar", + path: meta_path(&destination), + source: io::Error::other(source_err), + })?; + write_synced(&meta_path(&destination), &meta_json)?; + progress.finish(); + Ok(CachedBlob { + path: destination, + sha256: actual, + size_bytes, + }) + } + + /// Lists every cache entry: blobs under `models/` that carry a sidecar. + /// + /// Reads sidecars only - blob bytes are never hashed (Amendment C), so + /// listing stays cheap with multi-gigabyte entries. Blobs without + /// sidecars (pre-existing local model files) and sidecars whose blob is + /// gone are not listed. Entries sort by source for a stable response. + /// + /// # Errors + /// Returns [`LocalError::Io`] when the cache tree cannot be walked. + pub(crate) fn list(&self) -> Result, LocalError> { + let models = self.root.join("models"); + let key_dirs = match fs::read_dir(&models) { + Ok(key_dirs) => key_dirs, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => { + return Err(LocalError::Io { + operation: "read cache models directory", + path: models, + source, + }); + } + }; + let mut entries = Vec::new(); + for key_dir in key_dirs { + let key_dir = key_dir.map_err(|source| LocalError::Io { + operation: "read cache models entry", + path: models.clone(), + source, + })?; + // `file_type` does not follow links, so a planted symlinked key + // directory or blob is skipped rather than read through. + let Ok(file_type) = key_dir.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let slot = key_dir.path(); + let slot_entries = fs::read_dir(&slot).map_err(|source| LocalError::Io { + operation: "read cache slot directory", + path: slot.clone(), + source, + })?; + for slot_entry in slot_entries { + let slot_entry = slot_entry.map_err(|source| LocalError::Io { + operation: "read cache slot entry", + path: slot.clone(), + source, + })?; + let Ok(file_type) = slot_entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + let sidecar = slot_entry.path(); + let Some(name) = sidecar.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(blob_name) = name.strip_suffix(META_SUFFIX) else { + continue; + }; + let blob = sidecar.with_file_name(blob_name); + if !blob.is_file() { + continue; + } + let Some(meta) = read_meta(&blob)? else { + continue; + }; + entries.push(CacheEntry { + source: meta.source, + path: blob, + sha256: meta.sha256, + size_bytes: meta.size_bytes, + }); + } + } + entries.sort_by(|left, right| left.source.cmp(&right.source)); + Ok(entries) + } + + /// Removes the cache entry whose sidecar records `sha256`, returning + /// whether one was found. + /// + /// Matches on the sidecar digest (never a re-hash, Amendment C) and + /// removes the blob and its sidecar through the confinement-checked + /// removal path. + /// + /// # Errors + /// Returns [`LocalError::InvalidDigest`] for a malformed digest, or + /// [`LocalError`] on filesystem failure. + pub(crate) fn remove(&self, sha256: &str) -> Result { + let wanted = parse_expected_digest(sha256)?; + for entry in self.list()? { + if entry.sha256 == wanted { + remove_cache_entry(&self.root, &entry.path)?; + remove_cache_entry(&self.root, &meta_path(&entry.path))?; + return Ok(true); + } + } + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + use std::sync::atomic::{AtomicU64, Ordering}; + + use tempfile::TempDir; + + use super::*; + use crate::testsupport::{FakeServer, hex_sha256}; + + /// Test double recording the progress callbacks a download drives. + struct RecordingProgress { + total: Mutex>, + bytes: AtomicU64, + finished: AtomicU64, + abandoned: AtomicU64, + } + + impl RecordingProgress { + fn new() -> Self { + Self { + total: Mutex::new(None), + bytes: AtomicU64::new(0), + finished: AtomicU64::new(0), + abandoned: AtomicU64::new(0), + } + } + } + + impl DownloadProgress for RecordingProgress { + fn set_len(&self, total: Option) { + *self.total.lock().expect("progress total lock") = total; + } + + fn inc(&self, n: u64) { + self.bytes.fetch_add(n, Ordering::Relaxed); + } + + fn finish(&self) { + self.finished.fetch_add(1, Ordering::Relaxed); + } + + fn abandon(&self) { + self.abandoned.fetch_add(1, Ordering::Relaxed); + } + } + + #[test] + fn download_to_cache_downloads_verifies_and_writes_sidecar() { + let body = b"cache-api-fixture-bytes"; + let digest = hex_sha256(body); + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("model.gguf"); + let progress = RecordingProgress::new(); + + let blob = cache + .download_to_cache(&url, Some(&digest), &progress) + .expect("download"); + assert_eq!(blob.sha256, digest); + assert_eq!(blob.size_bytes, body.len() as u64); + assert_eq!(fs::read(&blob.path).expect("read blob"), body); + assert_eq!(server.requests(), 1); + assert_eq!(progress.finished.load(Ordering::Relaxed), 1); + assert_eq!(progress.bytes.load(Ordering::Relaxed), body.len() as u64); + assert_eq!( + *progress.total.lock().expect("total"), + Some(body.len() as u64) + ); + + // The sidecar records source, digest, and size; the staging file is gone. + let meta_text = fs::read_to_string(meta_path(&blob.path)).expect("read sidecar"); + let meta: BlobMeta = serde_json::from_str(&meta_text).expect("parse sidecar"); + assert_eq!(meta.source, url); + assert_eq!(meta.sha256, digest); + assert_eq!(meta.size_bytes, body.len() as u64); + assert!(!part_path(&blob.path).exists(), "stale .part left behind"); + } + + #[test] + fn download_to_cache_rejects_digest_mismatch_and_cleans_up() { + let body = b"wrong-bytes-for-the-pin"; + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("pinned.gguf"); + let progress = RecordingProgress::new(); + + let error = cache + .download_to_cache(&url, Some(&"0".repeat(64)), &progress) + .expect_err("digest mismatch"); + assert!(matches!(error, LocalError::DigestMismatch { .. })); + assert_eq!(progress.abandoned.load(Ordering::Relaxed), 1); + + let destination = cache.destination(&url).expect("destination"); + assert!(!destination.exists(), "mismatched blob must not publish"); + assert!(!part_path(&destination).exists(), "stale .part left behind"); + assert!( + !meta_path(&destination).exists(), + "sidecar must not be written" + ); + } + + #[test] + fn download_to_cache_hit_skips_the_download() { + let body = b"cached-once-fixture"; + let digest = hex_sha256(body); + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("hit.gguf"); + + let first = cache + .download_to_cache(&url, Some(&digest), &RecordingProgress::new()) + .expect("first download"); + let second = cache + .download_to_cache(&url, Some(&digest), &RecordingProgress::new()) + .expect("cache hit"); + assert_eq!(first, second); + assert_eq!(server.requests(), 1, "a hit must not re-download"); + + // The same hit is visible through the read-only lookup path. + let looked_up = cache.lookup(&url, Some(&digest)).expect("lookup"); + assert_eq!(looked_up, Some(first)); + } + + #[test] + fn download_to_cache_without_pin_caches_by_source() { + let body = b"unpinned-cache-fixture"; + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("free.gguf"); + + let blob = cache + .download_to_cache(&url, None, &RecordingProgress::new()) + .expect("download"); + assert_eq!(blob.sha256, hex_sha256(body)); + let hit = cache + .download_to_cache(&url, None, &RecordingProgress::new()) + .expect("cache hit"); + assert_eq!(hit, blob); + assert_eq!(server.requests(), 1); + } + + #[test] + fn sidecar_less_blob_is_not_a_hit_and_is_replaced() { + // Amendment E: a blob without a sidecar (a pre-existing local model + // file) is not a cache entry, so the source is re-downloaded and the + // verified replacement is published with a sidecar. + let body = b"fresh-download-over-legacy-blob"; + let digest = hex_sha256(body); + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("legacy.gguf"); + let destination = cache.destination(&url).expect("destination"); + fs::create_dir_all(destination.parent().expect("parent")).expect("mkdir"); + fs::write(&destination, b"legacy-untracked-bytes").expect("seed bare blob"); + + assert!( + cache.lookup(&url, None).expect("lookup").is_none(), + "a blob without a sidecar is not a cache hit" + ); + let blob = cache + .download_to_cache(&url, Some(&digest), &RecordingProgress::new()) + .expect("re-download over bare blob"); + assert_eq!(fs::read(&blob.path).expect("read blob"), body); + assert!(meta_path(&blob.path).is_file(), "sidecar written"); + assert_eq!(server.requests(), 1); + } + + #[test] + fn list_returns_sidecar_bearing_blobs_with_metadata() { + let body_a = b"listing-fixture-a"; + let body_b = b"listing-fixture-bb"; + let server_a = FakeServer::new(body_a); + let server_b = FakeServer::new(body_b); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url_a = server_a.url("a.gguf"); + let url_b = server_b.url("b.gguf"); + let blob_a = cache + .download_to_cache(&url_a, None, &RecordingProgress::new()) + .expect("download a"); + let blob_b = cache + .download_to_cache(&url_b, None, &RecordingProgress::new()) + .expect("download b"); + + // A bare blob without a sidecar (a pre-existing local model file) is + // not listed; nor is a stale sidecar whose blob is gone. + let bare_dir = temp.path().join("models").join("0123456789abcdef"); + fs::create_dir_all(&bare_dir).expect("mkdir bare slot"); + fs::write(bare_dir.join("bare.gguf"), b"bare").expect("write bare blob"); + let stale_dir = temp.path().join("models").join("fedcba9876543210"); + fs::create_dir_all(&stale_dir).expect("mkdir stale slot"); + fs::write( + stale_dir.join("gone.gguf.meta.json"), + r#"{"source":"http://x/gone.gguf","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":4}"#, + ) + .expect("write stale sidecar"); + + let entries = cache.list().expect("list"); + // Sorted by source for a stable response. The fake servers bind + // ephemeral ports, so which of the two sources sorts first is not + // fixed; build the expectation and sort it the same way. + let mut expected = vec![ + CacheEntry { + source: url_a, + path: blob_a.path, + sha256: hex_sha256(body_a), + size_bytes: body_a.len() as u64, + }, + CacheEntry { + source: url_b, + path: blob_b.path, + sha256: hex_sha256(body_b), + size_bytes: body_b.len() as u64, + }, + ]; + expected.sort_by(|left, right| left.source.cmp(&right.source)); + assert_eq!(entries, expected); + } + + #[test] + fn remove_deletes_blob_and_sidecar_by_digest() { + let body = b"delete-me-fixture"; + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("gone.gguf"); + let blob = cache + .download_to_cache(&url, None, &RecordingProgress::new()) + .expect("download"); + let sidecar = meta_path(&blob.path); + assert!(blob.path.is_file() && sidecar.is_file()); + + assert!(cache.remove(&blob.sha256).expect("remove")); + assert!(!blob.path.exists(), "blob removed"); + assert!(!sidecar.exists(), "sidecar removed"); + assert!(cache.list().expect("list").is_empty()); + + // A second removal of the same digest reports not-found. + assert!(!cache.remove(&blob.sha256).expect("remove again")); + // A malformed digest is rejected at the boundary. + assert!(matches!( + cache.remove("not-hex"), + Err(LocalError::InvalidDigest { .. }) + )); + } + + #[test] + fn concurrent_publishers_converge_on_one_download() { + // Two racing publishers of one source serialize on the artifact lock, + // and the hit test repeated under the lock means exactly one of them + // downloads (design entry 54). + let body = b"racing-publishers-fixture"; + let digest = hex_sha256(body); + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("raced.gguf"); + + let (first, second) = std::thread::scope(|scope| { + let first = scope + .spawn(|| cache.download_to_cache(&url, Some(&digest), &RecordingProgress::new())); + let second = scope + .spawn(|| cache.download_to_cache(&url, Some(&digest), &RecordingProgress::new())); + ( + first.join().expect("first publisher panicked"), + second.join().expect("second publisher panicked"), + ) + }); + let first = first.expect("first download"); + let second = second.expect("second download"); + assert_eq!(first, second); + assert_eq!(server.requests(), 1, "exactly one publisher downloads"); + } + + #[test] + fn corrupt_sidecar_is_skipped_and_not_a_hit() { + // A sidecar that does not parse is treated as absent (design entry + // 55): the blob is neither a hit nor listed, and neither read fails. + let body = b"corrupt-sidecar-fixture"; + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("corrupt.gguf"); + let blob = cache + .download_to_cache(&url, None, &RecordingProgress::new()) + .expect("download"); + fs::write(meta_path(&blob.path), b"not json").expect("corrupt sidecar"); + + assert!( + cache.lookup(&url, None).expect("lookup").is_none(), + "a corrupt sidecar is not a cache hit" + ); + assert!( + cache.list().expect("list").is_empty(), + "a corrupt sidecar is skipped, not listed" + ); + } + + #[test] + fn mismatched_pin_against_sidecar_forces_redownload() { + // A request naming a pin that differs from the sidecar's digest is not + // a hit; the re-downloaded content still fails verification. + let body = b"real-content-bytes"; + let server = FakeServer::new(body); + let temp = TempDir::new().expect("tempdir"); + let cache = BlobCache::new(temp.path()).expect("cache"); + let url = server.url("repin.gguf"); + cache + .download_to_cache(&url, None, &RecordingProgress::new()) + .expect("initial download"); + + let error = cache + .download_to_cache(&url, Some(&"f".repeat(64)), &RecordingProgress::new()) + .expect_err("pin mismatch"); + assert!(matches!(error, LocalError::DigestMismatch { .. })); + assert_eq!(server.requests(), 2, "the miss re-downloads"); + } +} diff --git a/crates/promptforge-gateway/src/local/mod.rs b/crates/promptforge-gateway/src/local/mod.rs index a450fc7f..524ed536 100644 --- a/crates/promptforge-gateway/src/local/mod.rs +++ b/crates/promptforge-gateway/src/local/mod.rs @@ -7,6 +7,7 @@ //! [`LocalRuntime`] kills the children. pub(crate) mod artifacts; +pub(crate) mod cache; mod dialect; mod error; mod server; @@ -38,6 +39,9 @@ use upstream::LocalUpstream; #[derive(Debug)] pub(crate) struct LocalRuntime { models: Vec>, + /// The profile's `[local].cache_dir`, retained so the `/v1/cache` routes + /// resolve the same root provisioning does, even with no local models. + cache_dir: Option, } impl LocalRuntime { @@ -45,7 +49,10 @@ impl LocalRuntime { /// and as the placeholder before the first profile switch. #[must_use] pub(crate) fn empty() -> LocalRuntime { - LocalRuntime { models: Vec::new() } + LocalRuntime { + models: Vec::new(), + cache_dir: None, + } } /// Provisions binaries/models and starts one `llama-server` per local model. @@ -56,8 +63,12 @@ impl LocalRuntime { /// # Errors /// Returns [`LocalError`] when download, verification, spawn, or readiness fails. pub(crate) fn start(config: &Config) -> Result { + let cache_dir = config.local().cache_dir().map(str::to_owned); if config.local_models().is_empty() { - return Ok(LocalRuntime::empty()); + return Ok(LocalRuntime { + models: Vec::new(), + cache_dir, + }); } let cache_root = resolve_cache_root(config.local().cache_dir())?; @@ -124,7 +135,7 @@ impl LocalRuntime { ); } - Ok(LocalRuntime { models }) + Ok(LocalRuntime { models, cache_dir }) } /// Models registered for local inference, in `[[local_model]]` order. @@ -133,6 +144,12 @@ impl LocalRuntime { &self.models } + /// The profile's configured `[local].cache_dir`, when set. + #[must_use] + pub(crate) fn cache_dir(&self) -> Option<&str> { + self.cache_dir.as_deref() + } + /// Number of local model endpoints (each owns one `llama-server` child). #[must_use] pub(crate) fn child_count(&self) -> usize { @@ -166,7 +183,13 @@ impl LocalRuntime { } } -fn resolve_cache_root(configured: Option<&str>) -> Result { +/// Resolves the operator cache root from the configured `[local].cache_dir`, +/// defaulting to `~/.promptforge` (ART-009). +/// +/// # Errors +/// Returns [`LocalError::MissingHome`] when no cache dir is configured and the +/// home variable is unset or empty. +pub(crate) fn resolve_cache_root(configured: Option<&str>) -> Result { match configured { Some(path) if !path.is_empty() => expand_configured_path(path), // An unset cache_dir defaults to `~/.promptforge`; a missing home is a diff --git a/crates/promptforge-gateway/src/testsupport.rs b/crates/promptforge-gateway/src/testsupport.rs new file mode 100644 index 00000000..b3bc7dc9 --- /dev/null +++ b/crates/promptforge-gateway/src/testsupport.rs @@ -0,0 +1,116 @@ +//! Test doubles shared by the crate's unit tests: a blocking fake HTTP server +//! and a SHA-256 hex helper. + +use std::io::{self, Read as _, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; + +use crate::local::artifacts::hex_digest; + +/// A blocking one-response HTTP server on an ephemeral loopback port. +/// +/// The socket is bound before the accept thread starts, so the kernel backlog +/// holds any early client connection until `accept` runs. There is no startup +/// race and thus no startup sleep, and blocking `accept` needs no WouldBlock +/// poll loop. `Drop` wakes the final blocking `accept` with a self-connect +/// after setting `shutdown`. +pub(crate) struct FakeServer { + address: String, + requests: Arc, + shutdown: Arc, + thread: Option>>, +} + +impl FakeServer { + pub(crate) fn new(body: &[u8]) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake server"); + let address = listener.local_addr().expect("local addr").to_string(); + let requests = Arc::new(AtomicUsize::new(0)); + let shutdown = Arc::new(AtomicBool::new(false)); + let thread_requests = Arc::clone(&requests); + let thread_shutdown = Arc::clone(&shutdown); + let body = body.to_owned(); + // The thread returns an `io::Result`: a genuine write/flush failure while + // serving a real client is surfaced on join (HYGIENE-RESULT-001) instead + // of being swallowed, so a broken fixture cannot masquerade as success. + // The shutdown self-connect is skipped by the top-of-loop `shutdown` + // check, so it never counts as a serve error. + let thread = thread::spawn(move || -> io::Result<()> { + for stream in listener.incoming() { + if thread_shutdown.load(Ordering::Acquire) { + break; + } + let Ok(mut stream) = stream else { + break; + }; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(2))); + let mut request = Vec::new(); + let mut buf = [0_u8; 1024]; + loop { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + request.extend_from_slice(&buf[..n]); + if request.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes())?; + stream.write_all(&body)?; + stream.flush()?; + thread_requests.fetch_add(1, Ordering::AcqRel); + } + Ok(()) + }); + Self { + address, + requests, + shutdown, + thread: Some(thread), + } + } + + pub(crate) fn url(&self, name: &str) -> String { + format!("http://{}/{name}", self.address) + } + + pub(crate) fn requests(&self) -> usize { + self.requests.load(Ordering::Acquire) + } +} + +impl Drop for FakeServer { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Release); + let _ = TcpStream::connect(&self.address); + if let Some(thread) = self.thread.take() { + let joined = thread.join(); + // Don't mask an in-flight test panic, but otherwise a serve-side + // transport failure must surface rather than be silently dropped. + if !std::thread::panicking() { + joined + .expect("fake server thread panicked") + .expect("fake server encountered a socket write/flush error"); + } + } + } +} + +/// Lowercase hex SHA-256 of `bytes`, for test fixtures. +pub(crate) fn hex_sha256(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex_digest(hasher) +} diff --git a/crates/promptforge-gateway/tests/it/cache.rs b/crates/promptforge-gateway/tests/it/cache.rs new file mode 100644 index 00000000..1e7a59aa --- /dev/null +++ b/crates/promptforge-gateway/tests/it/cache.rs @@ -0,0 +1,466 @@ +//! The `/v1/cache` routes through the real gateway: listing, download with +//! SSE progress, and removal, against a tempdir cache root. + +use std::fmt::Write as _; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::Router; +use axum::body::Body; +use axum::extract::State; +use axum::http::StatusCode; +use axum::http::header::CONTENT_TYPE; +use axum::response::Response; +use axum::routing::get; +use promptforge_gateway::{Config, Gateway, ProfilesContext}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; + +use crate::support::{PHASE_TIMEOUT, TestServer, json_within, send_within, spawn_backend}; + +/// Starts the gateway with `[local].cache_dir` rooted at `cache_dir`. +/// +/// The TOML literal string (single quotes) keeps Windows backslashes verbatim. +async fn cache_gateway(cache_dir: &Path) -> TestServer { + let toml = format!( + r#" +[server] +bind = "127.0.0.1:0" +api_key = "test-token" + +[local] +cache_dir = '{cache_dir}' + +[[endpoint]] +id = "fake" +protocol = "openai" +base_url = "http://127.0.0.1:9" +api_key = "" + +[[model]] +name = "test-model" +description = "a test model for integration" +context = 8192 +upstream = "backend-model" +endpoints = ["fake"] +"#, + cache_dir = cache_dir.display() + ); + let config = Config::from_toml_str(&toml).unwrap(); + let gateway = Gateway::from_config(&config, ProfilesContext::default()).unwrap(); + TestServer::start(gateway).await +} + +/// Lowercase hex SHA-256 of `bytes`. +fn hex_sha256(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail"); + } + output +} + +/// Seeds a cache slot: the blob plus its `.meta.json` sidecar. +fn seed_blob(cache_dir: &Path, key: &str, name: &str, source: &str, body: &[u8]) { + let slot = cache_dir.join("models").join(key); + std::fs::create_dir_all(&slot).unwrap(); + std::fs::write(slot.join(name), body).unwrap(); + let sidecar = serde_json::json!({ + "source": source, + "sha256": hex_sha256(body), + "size_bytes": body.len(), + }); + std::fs::write(slot.join(format!("{name}.meta.json")), sidecar.to_string()).unwrap(); +} + +#[tokio::test] +async fn get_cache_lists_sidecar_bearing_blobs_only() { + let temp = TempDir::new().unwrap(); + let body_a = b"route-list-fixture-a"; + let body_b = b"route-list-fixture-bb"; + seed_blob( + temp.path(), + "aaaaaaaaaaaaaaaa", + "a.bin", + "http://seeded.example/a.bin", + body_a, + ); + seed_blob( + temp.path(), + "bbbbbbbbbbbbbbbb", + "b.bin", + "http://seeded.example/b.bin", + body_b, + ); + // A bare blob without a sidecar is not a cache entry and is not listed. + let bare = temp.path().join("models").join("cccccccccccccccc"); + std::fs::create_dir_all(&bare).unwrap(); + std::fs::write(bare.join("bare.bin"), b"bare").unwrap(); + + let gateway = cache_gateway(temp.path()).await; + let http = reqwest::Client::new(); + let response = send_within( + http.get(format!("http://{}/v1/cache", gateway.addr)) + .bearer_auth("test-token"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let listing = json_within(response).await; + let entries = listing.as_array().expect("listing is an array"); + assert_eq!(entries.len(), 2, "listing: {listing}"); + + // The store sorts by source for a stable response. + let expected_a = temp + .path() + .join("models") + .join("aaaaaaaaaaaaaaaa") + .join("a.bin"); + assert_eq!(entries[0]["source"], "http://seeded.example/a.bin"); + assert_eq!(entries[0]["path"], serde_json::json!(expected_a)); + assert_eq!(entries[0]["sha256"], hex_sha256(body_a)); + assert_eq!(entries[0]["size_bytes"], body_a.len() as u64); + let expected_b = temp + .path() + .join("models") + .join("bbbbbbbbbbbbbbbb") + .join("b.bin"); + assert_eq!(entries[1]["source"], "http://seeded.example/b.bin"); + assert_eq!(entries[1]["path"], serde_json::json!(expected_b)); + assert_eq!(entries[1]["sha256"], hex_sha256(body_b)); + assert_eq!(entries[1]["size_bytes"], body_b.len() as u64); + gateway.shutdown().await; +} + +#[tokio::test] +async fn get_cache_requires_auth() { + let temp = TempDir::new().unwrap(); + let gateway = cache_gateway(temp.path()).await; + let response = + send_within(reqwest::Client::new().get(format!("http://{}/v1/cache", gateway.addr))).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + gateway.shutdown().await; +} + +/// Shared state for the fake file server: the body to serve and a hit count. +struct FileServerState { + body: Vec, + requests: AtomicUsize, +} + +/// Serves `body` at `/model.bin` with an accurate Content-Length. +async fn fake_file_server(body: &[u8]) -> (SocketAddr, Arc) { + async fn file(State(state): State>) -> Response { + state.requests.fetch_add(1, Ordering::AcqRel); + Response::new(Body::from(state.body.clone())) + } + let state = Arc::new(FileServerState { + body: body.to_owned(), + requests: AtomicUsize::new(0), + }); + let router = Router::new() + .route("/model.bin", get(file)) + .with_state(Arc::clone(&state)); + (spawn_backend(router).await, state) +} + +/// Parses an SSE body into its `data:` JSON payloads. +fn parse_sse(body: &str) -> Vec { + body.split("\n\n") + .filter(|chunk| !chunk.trim().is_empty()) + .map(|chunk| { + let data = chunk.trim().strip_prefix("data: ").expect("data prefix"); + serde_json::from_str(data).expect("json event") + }) + .collect() +} + +/// Every regular file under `root`, recursively. +fn all_files(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).expect("read dir") { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + stack.push(path); + } else { + files.push(path); + } + } + } + files +} + +#[tokio::test] +async fn post_cache_streams_progress_then_ready_and_caches_the_blob() { + let body = b"sse-cache-fixture-bytes"; + let digest = hex_sha256(body); + let (file_addr, file_server) = fake_file_server(body).await; + let temp = TempDir::new().unwrap(); + let gateway = cache_gateway(temp.path()).await; + let http = reqwest::Client::new(); + let source = format!("http://{file_addr}/model.bin"); + + let response = send_within( + http.post(format!("http://{}/v1/cache", gateway.addr)) + .bearer_auth("test-token") + .json(&serde_json::json!({ "source": source, "sha256": digest })), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "text/event-stream" + ); + let text = tokio::time::timeout(PHASE_TIMEOUT, response.text()) + .await + .expect("SSE body exceeded the phase timeout") + .expect("SSE body read failed"); + let events = parse_sse(&text); + assert!( + events.len() >= 2, + "expected progress plus ready: {events:?}" + ); + + let (terminal, progress) = events.split_last().unwrap(); + let mut last_bytes = 0; + for event in progress { + assert_eq!(event["status"], "downloading"); + let bytes = event["bytes"].as_u64().expect("bytes"); + assert!(bytes >= last_bytes, "bytes must not regress: {events:?}"); + last_bytes = bytes; + assert_eq!(event["total"], body.len() as u64); + } + assert_eq!(last_bytes, body.len() as u64); + + assert_eq!(terminal["status"], "ready"); + let path = PathBuf::from(terminal["path"].as_str().expect("path")); + assert_eq!(std::fs::read(&path).expect("read blob"), body); + let sidecar: Value = serde_json::from_str( + &std::fs::read_to_string(format!("{}.meta.json", path.display())).expect("sidecar"), + ) + .expect("parse sidecar"); + assert_eq!(sidecar["source"], source); + assert_eq!(sidecar["sha256"], digest); + assert_eq!(sidecar["size_bytes"], body.len() as u64); + assert_eq!( + file_server.requests.load(Ordering::Acquire), + 1, + "exactly one download" + ); + + // A second POST for the same source is an immediate JSON cache hit. + let response = send_within( + http.post(format!("http://{}/v1/cache", gateway.addr)) + .bearer_auth("test-token") + .json(&serde_json::json!({ "source": source, "sha256": digest })), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/json" + ); + let hit = json_within(response).await; + assert_eq!(hit["status"], "ready"); + assert_eq!(hit["path"], serde_json::json!(path)); + assert_eq!( + file_server.requests.load(Ordering::Acquire), + 1, + "a cache hit must not re-download" + ); + gateway.shutdown().await; +} + +#[tokio::test] +async fn post_cache_digest_mismatch_streams_an_error_event() { + let body = b"real-bytes-wrong-pin"; + let (file_addr, _state) = fake_file_server(body).await; + let temp = TempDir::new().unwrap(); + let gateway = cache_gateway(temp.path()).await; + let source = format!("http://{file_addr}/model.bin"); + + let response = send_within( + reqwest::Client::new() + .post(format!("http://{}/v1/cache", gateway.addr)) + .bearer_auth("test-token") + .json(&serde_json::json!({ "source": source, "sha256": "0".repeat(64) })), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "text/event-stream" + ); + let text = tokio::time::timeout(PHASE_TIMEOUT, response.text()) + .await + .expect("SSE body exceeded the phase timeout") + .expect("SSE body read failed"); + let events = parse_sse(&text); + let terminal = events.last().expect("a terminal event"); + assert_eq!(terminal["status"], "error"); + assert!( + terminal["message"] + .as_str() + .expect("message") + .contains("mismatch"), + "terminal event: {terminal}" + ); + + // No blob, sidecar, or staging file survives a failed publication; only + // the artifact lock file remains under the cache root. + let left = all_files(temp.path()); + assert!( + left.iter() + .all(|path| path.parent().is_some_and(|dir| dir.ends_with(".locks"))), + "only lock files may remain: {left:?}" + ); + gateway.shutdown().await; +} + +#[tokio::test] +async fn post_cache_validates_source_and_pin_before_downloading() { + let body = b"never-served"; + let (file_addr, file_server) = fake_file_server(body).await; + let temp = TempDir::new().unwrap(); + let gateway = cache_gateway(temp.path()).await; + let http = reqwest::Client::new(); + let url = format!("http://{}/v1/cache", gateway.addr); + + for source in [ + "not a url", + "ftp://example.com/f.bin", + "https://example.com/", + ] { + let response = send_within( + http.post(&url) + .bearer_auth("test-token") + .json(&serde_json::json!({ "source": source })), + ) + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "source {source}" + ); + let envelope = json_within(response).await; + assert_eq!(envelope["error"]["code"], "malformed_request"); + } + + let response = send_within(http.post(&url).bearer_auth("test-token").json( + &serde_json::json!({ "source": format!("http://{file_addr}/model.bin"), "sha256": "abc" }), + )) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let envelope = json_within(response).await; + assert_eq!(envelope["error"]["code"], "malformed_request"); + + assert_eq!( + file_server.requests.load(Ordering::Acquire), + 0, + "validation failures must not reach the network" + ); + gateway.shutdown().await; +} + +#[tokio::test] +async fn post_cache_requires_auth() { + let temp = TempDir::new().unwrap(); + let gateway = cache_gateway(temp.path()).await; + let response = send_within( + reqwest::Client::new() + .post(format!("http://{}/v1/cache", gateway.addr)) + .json(&serde_json::json!({ "source": "http://127.0.0.1:9/model.bin" })), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + gateway.shutdown().await; +} + +#[tokio::test] +async fn delete_cache_removes_the_entry_and_then_404s() { + let temp = TempDir::new().unwrap(); + let body = b"delete-route-fixture"; + let digest = hex_sha256(body); + seed_blob( + temp.path(), + "dddddddddddddddd", + "d.bin", + "http://seeded.example/d.bin", + body, + ); + let gateway = cache_gateway(temp.path()).await; + let http = reqwest::Client::new(); + + let response = send_within( + http.delete(format!("http://{}/v1/cache/{digest}", gateway.addr)) + .bearer_auth("test-token"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let deleted = json_within(response).await; + assert_eq!(deleted["status"], "deleted"); + assert_eq!(deleted["sha256"], digest); + + // Blob and sidecar are gone from disk and from the listing. + let blob = temp + .path() + .join("models") + .join("dddddddddddddddd") + .join("d.bin"); + assert!(!blob.exists(), "blob removed"); + assert!( + !blob.with_file_name("d.bin.meta.json").exists(), + "sidecar removed" + ); + let listing = json_within( + send_within( + http.get(format!("http://{}/v1/cache", gateway.addr)) + .bearer_auth("test-token"), + ) + .await, + ) + .await; + assert_eq!(listing.as_array().expect("array").len(), 0); + + // A second delete of the same digest is a 404. + let response = send_within( + http.delete(format!("http://{}/v1/cache/{digest}", gateway.addr)) + .bearer_auth("test-token"), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let envelope = json_within(response).await; + assert_eq!(envelope["error"]["code"], "cache_entry_not_found"); + + // A malformed digest parameter is a 400. + let response = send_within( + http.delete(format!("http://{}/v1/cache/not-hex", gateway.addr)) + .bearer_auth("test-token"), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let envelope = json_within(response).await; + assert_eq!(envelope["error"]["code"], "malformed_request"); + gateway.shutdown().await; +} + +#[tokio::test] +async fn delete_cache_requires_auth() { + let temp = TempDir::new().unwrap(); + let gateway = cache_gateway(temp.path()).await; + let response = send_within(reqwest::Client::new().delete(format!( + "http://{}/v1/cache/{}", + gateway.addr, + "a".repeat(64) + ))) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + gateway.shutdown().await; +} diff --git a/crates/promptforge-gateway/tests/it/main.rs b/crates/promptforge-gateway/tests/it/main.rs index 88763836..fbf565f7 100644 --- a/crates/promptforge-gateway/tests/it/main.rs +++ b/crates/promptforge-gateway/tests/it/main.rs @@ -19,6 +19,7 @@ mod support; +mod cache; mod chat; mod embeddings; mod local; diff --git a/crates/promptforge-gateway/user-guide-promptforge-gateway.md b/crates/promptforge-gateway/user-guide-promptforge-gateway.md index d768dd15..8a13723e 100644 --- a/crates/promptforge-gateway/user-guide-promptforge-gateway.md +++ b/crates/promptforge-gateway/user-guide-promptforge-gateway.md @@ -236,6 +236,8 @@ All errors use the OpenAI error envelope: | Backend 5xx | 502 | `server_error` | `upstream_error` | | Queue full | 503 | `server_error` | `queue_full` | | Rejected at capacity (`policy = "reject"`) | 429 | `rate_limit_error` | `queue_rejected` | +| Cache entry absent on `DELETE /v1/cache/{sha256}` | 404 | `invalid_request_error` | `cache_entry_not_found` | +| Cache storage/transport failure before the response commits | 500 | `server_error` | `cache_error` | An unmodified OpenAI SDK surfaces these as its own error types rather than unparseable blobs. @@ -564,6 +566,16 @@ First-time downloads show an indicatif progress bar on interactive TTY stderr - When `sha256` is set, the downloaded file is verified against the digest. +### The blob cache API + +Three bearer-authenticated routes let a client (the workbench, for example) download arbitrary blobs into the same cache on demand: + +- `POST /v1/cache` with `{"source": "", "sha256": ""}` ensures the blob is cached. A cache hit answers immediately with `200` JSON `{"path": "...", "status": "ready"}`. A miss answers with `200` `text/event-stream`: `data: {"status":"downloading","bytes":N,"total":N}` progress events (`total` is `null` when the server sent no Content-Length), terminated by `data: {"status":"ready","path":"..."}` or, on failure, `data: {"status":"error","message":"..."}`. A mid-stream failure is an SSE event, not an HTTP error, because the response is already committed. +- `GET /v1/cache` returns `200` JSON `[{"source", "path", "sha256", "size_bytes"}, ...]` sorted by source. +- `DELETE /v1/cache/{sha256}` removes the blob and its metadata, answering `200` `{"status": "deleted", "sha256": "..."}` or 404 `cache_entry_not_found` when no entry carries that digest. + +Blobs land in `/models//`, the same slot local-model provisioning computes for the URL, so a blob fetched through the API is a provisioning cache hit for the same `source` and vice versa. Each API download writes a `.meta.json` sidecar holding its source, digest, and size; the listing and delete routes read sidecars only and never re-hash a blob. Only blobs with sidecars are cache entries: a model file that provisioning downloaded on its own is not listed and cannot be deleted through the API, and a `POST` for its URL re-downloads it once to earn the sidecar. Downloads stage to a `.part` sibling and rename into place only after the digest verifies, so a failed or interrupted download never leaves a partial blob at a final path. + ### Tool-calling dialect detection After a local child reports ready, the gateway queries its `/props` endpoint and resolves a tool-calling dialect from that evidence, hard-failing on ambiguous or absent evidence so a local model never silently defaults to an incorrect dialect. A sidecar `.md` file beside the GGUF (with frontmatter and a Jinja chat template) provides fallback evidence when `/props` omits `chat_template`; live props always win. The probe runs for `kind = "chat"` children only: a non-chat child has no chat template to evidence a dialect, so it carries the default `openai` dialect, same as a remote model. The resolved dialect is gateway-internal and selects how tool calls are emulated; it is not advertised in the catalog. diff --git a/crates/promptforge-mcp-server/src/watch/reload.rs b/crates/promptforge-mcp-server/src/watch/reload.rs index 539ab25b..b504d3b3 100644 --- a/crates/promptforge-mcp-server/src/watch/reload.rs +++ b/crates/promptforge-mcp-server/src/watch/reload.rs @@ -85,7 +85,7 @@ enum ReloadErrorRepr { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr( not(test), - expect( + allow( dead_code, reason = "the watcher acts on a reload failure through Display and source; the classifier exists for the tests and for a future caller that must branch on the class" ) diff --git a/crates/promptforge-tool-picker/build.rs b/crates/promptforge-tool-picker/build.rs index 9c3b5d42..c14bb1a7 100644 --- a/crates/promptforge-tool-picker/build.rs +++ b/crates/promptforge-tool-picker/build.rs @@ -259,7 +259,7 @@ fn convert(name: &str, view: &TensorView<'_>) -> Result { } let source = view.data(); - if source.len() % 4 != 0 { + if !source.len().is_multiple_of(4) { bail!( "tensor {name} is F32 but its {} bytes are not a whole number of f32 values", source.len() diff --git a/crates/promptforge-wb-server/Cargo.toml b/crates/promptforge-wb-server/Cargo.toml index 3438529f..801d50f1 100644 --- a/crates/promptforge-wb-server/Cargo.toml +++ b/crates/promptforge-wb-server/Cargo.toml @@ -19,8 +19,10 @@ axum.workspace = true futures-util.workspace = true open.workspace = true reqwest.workspace = true +rust-embed.workspace = true serde.workspace = true serde_json.workspace = true +socket2.workspace = true thiserror.workspace = true time.workspace = true tokio.workspace = true @@ -29,6 +31,10 @@ tracing.workspace = true tracing-subscriber.workspace = true whisper-rs.workspace = true +[features] +default = [] +cuda = ["whisper-rs/cuda"] + [dev-dependencies] hound.workspace = true tempfile.workspace = true diff --git a/crates/promptforge-wb-server/README.md b/crates/promptforge-wb-server/README.md index c38ee8f5..847a7065 100644 --- a/crates/promptforge-wb-server/README.md +++ b/crates/promptforge-wb-server/README.md @@ -2,7 +2,7 @@ [![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) -The PromptForge Workbench HTTP server. It serves a local chat UI and API on loopback: an OpenAI-shaped model catalog and chat relay in front of a PromptForge gateway (with streaming over SSE), a JSONL session tape recording every exchange, and a WebSocket voice endpoint that transcribes push-to-talk microphone audio on-device with whisper.cpp. The desktop shell (`promptforge-wb`) embeds it in-process; run standalone it is the browser-tab frame of the same workbench. +The PromptForge Workbench HTTP server. It serves a local chat UI and API on loopback: an OpenAI-shaped model catalog and chat relay in front of a PromptForge gateway (with streaming over a WebSocket), a JSONL session tape recording every exchange, and a WebSocket voice endpoint that transcribes push-to-talk microphone audio on-device with whisper.cpp. The desktop shell (`promptforge-wb`) embeds it in-process; run standalone it is the browser-tab frame of the same workbench. ## Quick start @@ -14,15 +14,6 @@ base_url = "http://127.0.0.1:8081" api_key = "${PROMPTFORGE_GATEWAY_API_KEY}" ``` -Or skip the TOML entirely and set environment variables: - -```bash -export PROMPTFORGE_GATEWAY_API_KEY="your-key" -cargo run -p promptforge-wb-server -``` - -When no `workbench.toml` is found the server builds its config from environment variables (see table below). The only required variable is `PROMPTFORGE_GATEWAY_API_KEY`; all others have sensible defaults. - Then run: ```bash @@ -31,7 +22,9 @@ cargo run -p promptforge-wb-server The server binds `127.0.0.1:7910` by default and serves the chat UI at `http://127.0.0.1:7910/`. Set `server.open_browser = true` to have it open your system browser once it is serving. -String values support `${VAR}` environment interpolation; `$$` is a literal `$`, and an unset variable is a startup error. +The desktop shell (`promptforge-wb`) is the zero-config path: it searches beside its executable, then the current directory, then `~/.promptforge/`, and on first run writes a default `workbench.toml` into `~/.promptforge/` and loads that. The server binary does not generate one - it reads `workbench.toml` from the current directory only. + +String values support `${VAR}` environment interpolation; `$$` is a literal `$`, and an unset variable interpolates to the empty string. ## Configuration @@ -39,45 +32,107 @@ Every field of `workbench.toml`: | Field | Default | Description | | --- | --- | --- | -| `gateway.base_url` | (required) | Base URL of the PromptForge gateway, for example `http://127.0.0.1:8081` | -| `gateway.api_key` | (required) | Bearer key for the gateway API; supports `${VAR}` interpolation | +| `gateway.base_url` | `http://127.0.0.1:8081` when empty | Base URL of the PromptForge gateway; an empty value (for example an unset `${PROMPTFORGE_GATEWAY_URL}`) falls back to the default | +| `gateway.api_key` | (empty) | Bearer key for the gateway API; supports `${VAR}` interpolation; empty sends no `Authorization` header | | `tape.path` | `tape.jsonl` | Path of the JSONL session tape; one event per chat exchange | | `server.bind` | `127.0.0.1:7910` | Address the workbench server binds to | | `server.open_browser` | `false` | When true, the server binary opens the system browser at its address once serving; the desktop shell ignores it | -| `voice.interim_model` | (empty) | Path to the GGML whisper model for streaming interim transcription; empty disables transcription | -| `voice.final_model` | (empty) | Path to the whisper model for the pipelined final pass; empty falls back to the interim model | +| `voice.interim_model` | (empty) | Path to the GGML whisper model for streaming interim transcription; empty disables transcription until a source provides the model. A missing file with `interim_source` set is fetched through the gateway cache; a missing file with no source degrades to voice disabled (with a status-bar note), never a startup failure | +| `voice.final_model` | (empty) | Path to the whisper model for the pipelined final pass; empty falls back to the interim model. A missing file with no `final_source` drops just the final pass | +| `voice.interim_source` | (empty) | URL the interim model is downloaded from through the gateway cache when no local file exists | +| `voice.final_source` | (empty) | URL the final-pass model is downloaded from through the gateway cache when no local file exists | | `voice.window_seconds` | `5` | Seconds of trailing audio each interim pass transcribes | | `voice.interval_ms` | `800` | Milliseconds between interim passes while a take is recording | -### Environment-variable-only mode - -When no `workbench.toml` is found, config is built entirely from environment variables: - -| Variable | Maps to | Default | -| --- | --- | --- | -| `PROMPTFORGE_GATEWAY_BASE_URL` | `gateway.base_url` | `http://127.0.0.1:8081` | -| `PROMPTFORGE_GATEWAY_API_KEY` | `gateway.api_key` | **(required)** | -| `PROMPTFORGE_TAPE_PATH` | `tape.path` | `tape.jsonl` | -| `PROMPTFORGE_SERVER_BIND` | `server.bind` | `127.0.0.1:7910` | -| `PROMPTFORGE_SERVER_OPEN_BROWSER` | `server.open_browser` | `false` (accepts `true` or `1`) | -| `PROMPTFORGE_VOICE_INTERIM_MODEL` | `voice.interim_model` | empty (disabled) | -| `PROMPTFORGE_VOICE_FINAL_MODEL` | `voice.final_model` | empty | -| `PROMPTFORGE_VOICE_WINDOW_SECONDS` | `voice.window_seconds` | `5` | -| `PROMPTFORGE_VOICE_INTERVAL_MS` | `voice.interval_ms` | `800` | - ## Routes | Route | Description | | --- | --- | | `GET /health` | Health probe; answers `{"status":"serving"}` | -| `GET /` | The chat UI (also `/app.js`, `/style.css`, `/markdown-it.min.js`, `/pcm-worklet.js`, all embedded in the binary) | -| `GET /v1/models` | Proxies the gateway's model catalog verbatim | -| `POST /chat` | Chat relay: `{"model", "messages"}` in, gateway response out; `"stream": true` switches to SSE | +| `GET /` | The chat UI (also `/app.js`, `/app.css`, `/style.css`, `/pcm-worklet.js`, served from `ui/dist/`: read from disk in debug builds, embedded in the binary in release builds) | +| `GET /v1/models` | Proxies the gateway's model catalog verbatim; while the gateway is known down, answers 502 `gateway_unreachable` without attempting it | +| `POST /chat` | Buffered chat relay: `{"model", "messages"}` in, gateway response out; `"stream": true` is rejected with 400 - streaming lives on `/ws`; while the gateway is known down, answers 502 `gateway_unreachable` without attempting it | +| `GET /ws` | WebSocket upgrade, one persistent socket for all downstream JSON: `{"type":"chat","id","model","messages"}` frames in (the optional `id` is echoed on the reply), `{"type":"delta","content"}` / `{"type":"done"}` / `{"type":"error","message"}` frames out, plus unsolicited `{"type":"status","label","description","severity","activity","progress"}` observer updates and `{"type":"models","models":[...]}` catalog pushes when the gateway comes back after an outage | | `GET /voice` | WebSocket upgrade: binary f32 PCM at 16 kHz mono in, `start`/`stop` control words, interim and final transcripts out | +## Gateway resilience + +A background heartbeat polls the gateway's `GET /health` every five seconds and reports transitions on the status bus: "Gateway unreachable" when the gateway stops answering, "Connected to gateway" when it comes back. While the gateway is known down, chat over `/ws` is answered immediately with a `{"type":"error","message":"Gateway unreachable"}` frame (no upstream attempt, nothing taped), and `GET /v1/models` and `POST /chat` answer 502 `gateway_unreachable` instead of waiting on a dead connection. A reconnect re-fetches the model catalog and pushes it to every `/ws` session as a `{"type":"models",...}` frame, so a UI that booted during the outage refreshes its model picker by itself. The server boots and serves the UI whether or not the gateway has ever answered; voice works whenever its models are local. + +When voice model sources are configured but the model files are not on disk, a provisioning task waits on the heartbeat and, once the gateway answers, calls the gateway's `POST /v1/cache` for each source - streaming download progress to the status bar - then loads the voice engine from the cached paths and reports "Voice ready". A cache hit answers immediately, so a reconnect re-run is cheap; a loaded engine is never re-provisioned. On any failure voice stays disabled, the status bar says why, the app runs on, and the next gateway reconnect retries. + +## UI development + +The chat UI is TypeScript under `ui/src/`, bundled by esbuild into `ui/dist/app.js`. Node.js is required: run `npm install` in `ui/` once per checkout. After that, `cargo build` runs the UI build itself (the crate's `build.rs` prefers `ui/node_modules/.bin/esbuild` and falls back to `npx esbuild`, which may download esbuild on first use). `ui/node_modules/` and `ui/dist/` are gitignored. + +Two workflows: + +1. **Just cargo:** edit the TypeScript, then `cargo build` (or `cargo run -p promptforge-wb-server`). The build script re-bundles whenever `ui/src/` or the static UI files change, and debug builds read `ui/dist/` from disk on every request. +2. **esbuild watch:** run `npm run watch` in `ui/` in one terminal and `cargo run` in another. Edit, save, refresh the browser - no Rust recompile for UI changes. + +`npm run typecheck` runs `tsc --noEmit`; esbuild strips types without checking them, so the typecheck is advisory. `npm test` runs a jsdom smoke test that imports the built `dist/app.js` and asserts the chat UI mounts (run `npm run build` first). + +The chat UI itself is [murm-ui](https://github.com/levmv/murm-ui) 0.2.0, vendored in `ui/src/chat/` (MIT, see its `PROVENANCE.md`), driven by a WebSocket provider against `GET /ws` (one persistent socket, opened on load; chat frames carry an `id` the server echoes, and unsolicited status frames ride the same connection). Its styles are bundled by esbuild into `dist/app.css`; `ui/style.css` carries the workbench shell (sidebar, picker, voice UI, status bar) and overrides. + +The status bar at the bottom of the window renders the observer's `{"type":"status",...}` frames (`ui/src/status-bar.ts`): the label as the bar text, the description as the tooltip, error frames in a distinct color. Debug-severity frames are internal instrumentation and never touch the text. The right slot holds a `` bar while a frame carries progress, and an activity LED otherwise: a small circle that pulses green on gateway traffic and amber on voice activity (green wins when both coincide), lit for one pulse window per frame and faded by a CSS transition. The bar's colors, glow radii, and pulse window are CSS custom properties (`--led-green`, `--led-amber`, `--led-off`, `--led-glow-radius`, `--led-pulse-ms`, `--progress-fill`, `--progress-glow`, ...) at the top of `ui/style.css`. + +## Skinning + +The whole UI skins from the `:root` block at the top of `ui/style.css` - every color, spacing step, radius, font, scrollbar metric, and the status bar's LED and progress effect is a CSS custom property there. The vendored murm-ui chat panel skins from the same block through a bridge in `ui/style.css` (the `.mur-app[data-theme="dark"]` rule, with a comment mapping each `--mur-*` variable to the workbench variable it follows). + +Two ways to reskin: + +1. **Edit the block.** Change values in the `:root` block of `ui/style.css` and rebuild (`cargo build`; debug builds serve `ui/dist/` from disk). This is the path for changes you keep. +2. **Override from an additional stylesheet.** Add a `` after `/style.css` in `ui/index.html` and redeclare any variable on `:root`. Later declarations win the cascade, and because the murm-ui bridge dereferences the workbench variables at computed-value time, overriding e.g. `--bg` re-skins the chat panel too. To retune murm-ui-only knobs (`--mur-chat-form-width`, the shadows), target `.mur-app[data-theme="dark"]` in the same stylesheet. + +The variables: + +| Variable | Default | What it paints | +| --- | --- | --- | +| `--bg` | `#0d0e12` | Window and chat background | +| `--bg-raised` | `#14161c` | Raised surfaces (cards, code blocks) | +| `--bg-hover` | `#1a1d25` | Hover washes, user message bubble | +| `--bg-sidebar` | `--bg-raised` | Sidebar background | +| `--bg-composer` | `--bg` | Chat composer form | +| `--text` | `#d6d9e0` | Body text (13:1 on `--bg`) | +| `--text-muted` | `#8b90a0` | Dimmed text (6:1 on `--bg`; do not go dimmer, 4.5:1 is the floor) | +| `--border` | `#262a33` | Hairline borders | +| `--accent` | `#7c7fd4` | Primary action (send button) | +| `--accent-dim` | `#5658a0` | Focus border | +| `--danger` | `#b0606a` | Recording background, danger accents (non-text) | +| `--danger-text` | `#cf7f88` | Danger as text on dark surfaces | +| `--on-danger` | `#ffffff` | Icon or text on a `--danger` fill | +| `--font-prose` | system stack | UI font | +| `--code-font` | ui-monospace stack | Code blocks, code chrome | +| `--space-xs`..`--space-xl` | `4/6/8/12/16px` | Shell spacing scale | +| `--radius` | `6px` | Control corner radius | +| `--sidebar-width` | `220px` | Sidebar width | +| `--status-bar-height` | `24px` | Status bar height | +| `--status-bar-bg` | `--bg-raised` | Status bar background | +| `--status-bar-text` | `--text-muted` | Status bar text | +| `--status-bar-text-error` | `--danger-text` | Status bar error text | +| `--status-bar-padding-inline` | `--space-lg` | Status bar horizontal padding | +| `--status-bar-gap` | `--space-lg` | Status bar item gap | +| `--progress-width` | `96px` | Progress bar width (also the slot's minimum) | +| `--progress-height` | `6px` | Progress bar height (drives its rounding) | +| `--progress-fill` | `#4caf7d` | Progress fill | +| `--progress-track` | `rgba(255,255,255,0.08)` | Progress track | +| `--progress-glow` | `4px` | Blur radius of the fill's glow | +| `--led-size` | `10px` | Activity LED diameter | +| `--led-green` / `--led-amber` | `#4caf7d` / `#d9a03f` | Gateway / voice activity colors | +| `--led-off` | `rgba(255,255,255,0.08)` | The unlit LED lens | +| `--led-core` | `#ffffff` | Hot center of the lit gradient | +| `--led-glow-radius` | `6px` | Base blur of the layered bloom | +| `--led-pulse-ms` | `250ms` | Pulse hold window and fade-out (also read by the status bar's JS) | +| `--led-fade-in-ms` | `60ms` | Fade-in when a pulse lights the LED | +| `--led-lens-highlight` / `--led-lens-shadow` | white/black alphas | Idle lens inset shading | +| `--scrollbar-width` | `8px` | Scrollbar thickness (drives thumb rounding) | +| `--scrollbar-thumb` | `rgba(255,255,255,0.16)` | Scrollbar thumb | +| `--scrollbar-thumb-hover` | `rgba(255,255,255,0.28)` | Scrollbar thumb on hover | + ## Whisper models -Whisper models are not downloaded by the build; fetch them out of band from the whisper.cpp GGML model collection on Hugging Face: . Production configs typically pair `ggml-large-v3-turbo.bin` (interim) with `ggml-large-v3.bin` (final). The test suite uses the tiny English model (`ggml-tiny.en.bin`) plus the `jfk.wav` speech fixture, placed in `tests/fixtures/` (gitignored); a missing fixture fails the test with the download URL in the message. +With `voice.interim_source` / `voice.final_source` set (the generated config sets both), the workbench downloads the models through the gateway's cache API once the gateway connects and loads them from the cached paths - no manual step. Models can also be placed on disk directly and named with `voice.interim_model` / `voice.final_model`; a local file always wins over a source. The models come from the whisper.cpp GGML model collection on Hugging Face: . Production configs typically pair `ggml-large-v3-turbo.bin` (interim) with `ggml-large-v3.bin` (final). The test suite uses the tiny English model (`ggml-tiny.en.bin`) plus the `jfk.wav` speech fixture, placed in `tests/fixtures/` (gitignored); a missing fixture fails the test with the download URL in the message. ## Minimum Rust Version diff --git a/crates/promptforge-wb-server/build.rs b/crates/promptforge-wb-server/build.rs new file mode 100644 index 00000000..84a43a2e --- /dev/null +++ b/crates/promptforge-wb-server/build.rs @@ -0,0 +1,145 @@ +//! Builds the workbench UI bundle before the Rust compile. +//! +//! Runs esbuild on `ui/src/main.ts` into `ui/dist/app.js` and copies the +//! static assets (`ui/index.html`, `ui/style.css`, ...) into `ui/dist/`, +//! which `rust-embed` then serves from disk (debug) or embeds (release). +//! +//! Requires Node.js on `PATH` and one `npm install` in `ui/` per checkout +//! (see the crate README). The local `ui/node_modules/.bin/esbuild` is +//! preferred; without it the build falls back to `npx esbuild`, which may +//! download esbuild on first use. + +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +/// Static UI files copied verbatim into `ui/dist/`. Mirrored in +/// `ui/build.mjs`. +const STATIC_FILES: &[&str] = &["index.html", "style.css", "pcm-worklet.js"]; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let manifest_dir = PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR") + .ok_or("CARGO_MANIFEST_DIR is not set; run through cargo")?, + ); + let ui_dir = manifest_dir.join("ui"); + let dist_dir = ui_dir.join("dist"); + + println!("cargo::rerun-if-changed={}", ui_dir.join("src").display()); + for file in STATIC_FILES { + println!("cargo::rerun-if-changed={}", ui_dir.join(file).display()); + } + println!( + "cargo::rerun-if-changed={}", + ui_dir.join("build.mjs").display() + ); + println!( + "cargo::rerun-if-changed={}", + ui_dir.join("package.json").display() + ); + // esbuild reads tsconfig.json from its working directory, and the + // lockfile pins the dependency code that lands in the bundle; both can + // change dist/ output without touching ui/src. + for file in ["tsconfig.json", "package-lock.json"] { + println!("cargo::rerun-if-changed={}", ui_dir.join(file).display()); + } + + // dist/ is rebuilt from scratch so removed assets never linger into the + // release embed. + if dist_dir.exists() { + std::fs::remove_dir_all(&dist_dir).map_err(|error| format!("clear ui/dist: {error}"))?; + } + bundle(&ui_dir)?; + copy_static(&ui_dir, &dist_dir)?; + Ok(()) +} + +/// Runs the esbuild bundle step, preferring the local install in +/// `ui/node_modules` and falling back to `npx esbuild`. +fn bundle(ui_dir: &Path) -> Result<(), String> { + let mut command = esbuild_command(ui_dir); + command.current_dir(ui_dir).args([ + "src/main.ts", + "--bundle", + "--format=esm", + "--target=es2022", + "--outfile=dist/app.js", + ]); + // Release builds embed the bundle in the binary; minify what we embed. + if std::env::var("PROFILE").as_deref() == Ok("release") { + command.arg("--minify"); + } + let output = command.output().map_err(|error| { + format!("esbuild could not be started: {error}; install Node.js so it is on PATH") + })?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "the UI bundle failed (status {}):\n{}\n{}\n\ + If ui/node_modules is missing, run `npm install` in {} first.", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ui_dir.display(), + )) +} + +/// Builds the command that invokes esbuild. On Windows the npm shims are +/// `.cmd` files, which only run through `cmd /c`. +fn esbuild_command(ui_dir: &Path) -> Command { + let bin_dir = ui_dir.join("node_modules").join(".bin"); + + #[cfg(windows)] + { + let local = bin_dir.join("esbuild.cmd"); + if local.exists() { + let mut command = Command::new("cmd"); + command.arg("/c").arg(&local); + return command; + } + warn_no_local_install(ui_dir); + let mut command = Command::new("cmd"); + command.arg("/c").arg("npx").arg("--yes").arg("esbuild"); + command + } + + #[cfg(not(windows))] + { + let local = bin_dir.join("esbuild"); + if local.exists() { + return Command::new(local); + } + warn_no_local_install(ui_dir); + let mut command = Command::new("npx"); + command.arg("--yes").arg("esbuild"); + command + } +} + +fn warn_no_local_install(ui_dir: &Path) { + println!( + "cargo::warning=ui/node_modules is missing; falling back to `npx esbuild`. \ + Run `npm install` in {} once for a fast, offline-capable build.", + ui_dir.display() + ); +} + +/// Copies the static UI files into `ui/dist/` next to the bundle. +fn copy_static(ui_dir: &Path, dist_dir: &Path) -> Result<(), String> { + std::fs::create_dir_all(dist_dir).map_err(|error| format!("create ui/dist: {error}"))?; + for file in STATIC_FILES { + std::fs::copy(ui_dir.join(file), dist_dir.join(file)) + .map_err(|error| format!("copy ui/{file} into ui/dist: {error}"))?; + } + Ok(()) +} diff --git a/crates/promptforge-wb-server/src/app.rs b/crates/promptforge-wb-server/src/app.rs index 26c01ab4..7c2694bd 100644 --- a/crates/promptforge-wb-server/src/app.rs +++ b/crates/promptforge-wb-server/src/app.rs @@ -6,61 +6,114 @@ use std::time::{Duration, Instant}; use axum::Router; use axum::extract::State; use axum::http::header; -use axum::response::sse::Event; -use axum::response::{IntoResponse, Response, Sse}; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; -use futures_util::stream::{self, StreamExt}; -use crate::config::Config; -use crate::gateway::{ChatRequest, ChatStream, GatewayClient, GatewayError, GatewayResponse}; +use crate::catalog::CatalogBus; +use crate::chat_ws; +use crate::config::{Config, VoiceConfig}; +use crate::gateway::{ChatRequest, GatewayClient, GatewayError, GatewayResponse}; +use crate::heartbeat::GatewayHealth; +use crate::status::{Activity, StatusBus}; use crate::tape::{Tape, TapeError, TapeEvent}; -use crate::transcribe::{TranscribeError, VoiceEngine}; +use crate::transcribe::{TranscribeError, VoiceEngine, VoiceSlot}; use crate::voice; /// Address the server binds to when no override is given. pub const DEFAULT_ADDR: &str = "127.0.0.1:7910"; /// Shared handler state: the authenticated gateway client, the session -/// tape, and the voice transcription engine when one is configured. +/// tape, the status bus, and the voice transcription engine slot, filled +/// at startup from local model files or later by the provisioning task. #[derive(Debug, Clone)] pub struct AppState { gateway: GatewayClient, tape: Arc, - voice: Option>, + voice: VoiceSlot, + status: StatusBus, + health: GatewayHealth, + catalog: CatalogBus, } impl AppState { /// Builds shared state from the loaded configuration. /// - /// When `[voice]` names an interim model, the model is loaded here, so a - /// bad path fails startup rather than the first voice session. + /// When `[voice]` names an interim model whose file exists, the engine + /// loads here. A configured model that is missing or unloadable never + /// fails startup: when the model has a source URL, activation defers to + /// the provisioning task (which fetches it through the gateway cache); + /// otherwise voice degrades to disabled with a status-bar explanation. /// /// # Errors - /// Returns [`AppError::Gateway`] if the HTTP client cannot be built, - /// [`AppError::Tape`] if the session tape cannot be opened, and - /// [`AppError::Voice`] if the configured whisper model cannot be loaded. + /// Returns [`AppError::Gateway`] if the HTTP client cannot be built and + /// [`AppError::Tape`] if the session tape cannot be opened. pub fn new(config: &Config) -> Result { + let status = StatusBus::new(); + // Startup phases are reported as they run; with no client connected + // yet these land on an empty bus, ready for the first session. + status.info( + "Connecting to gateway", + format!("base URL {}", config.gateway.base_url), + Activity::General, + ); let gateway = GatewayClient::new(&config.gateway.base_url, &config.gateway.api_key) .map_err(AppError::Gateway)?; let tape = Tape::open(&config.tape.path).map_err(AppError::Tape)?; - let voice = if config.voice.enabled() { - Some(Arc::new( - VoiceEngine::new(&config.voice).map_err(AppError::Voice)?, - )) - } else { - None - }; + let voice = VoiceSlot::default(); + if let Some(engine) = startup_engine(&config.voice, &status) { + voice.activate(engine); + } + status.idle(); Ok(Self { gateway, tape: Arc::new(tape), voice, + status, + health: GatewayHealth::new(), + catalog: CatalogBus::new(), }) } - /// The voice transcription engine, when `[voice]` configured one. + /// The voice transcription engine, when one has loaded. pub(crate) fn voice_engine(&self) -> Option> { + self.voice.engine() + } + + /// The voice engine slot, shared with the provisioning task, which + /// fills it once the gateway cache has provided the models. + pub(crate) fn voice_slot(&self) -> VoiceSlot { self.voice.clone() } + + /// The status bus, shared with every subsystem that reports what it is + /// doing. + pub(crate) fn status(&self) -> StatusBus { + self.status.clone() + } + + /// The gateway client, shared with the chat WebSocket sessions. + pub(crate) fn gateway_client(&self) -> &GatewayClient { + &self.gateway + } + + /// The session tape, shared with the chat WebSocket sessions. + pub(crate) fn tape(&self) -> &Arc { + &self.tape + } + + /// Shared gateway reachability, published by the heartbeat; the + /// gateway-dependent routes read it to short-circuit while the gateway + /// is down. + pub(crate) fn health(&self) -> &GatewayHealth { + &self.health + } + + /// The catalog bus, which the heartbeat publishes the refreshed model + /// catalog to on a gateway reconnect and every `/ws` session forwards + /// from. + pub(crate) fn catalog(&self) -> CatalogBus { + self.catalog.clone() + } } /// A shared-state construction failure. @@ -76,11 +129,83 @@ pub enum AppError { #[non_exhaustive] #[error("open session tape")] Tape(#[source] TapeError), +} - /// The configured whisper model could not be loaded. - #[non_exhaustive] - #[error("load voice engine")] - Voice(#[source] TranscribeError), +/// Builds the startup voice engine from local model files only. +/// +/// Returns `None` when voice is unconfigured, when a missing model has a +/// source URL (the provisioning task fetches and activates it once the +/// gateway answers), or when voice has degraded to disabled with a +/// status-bar explanation. Never fails: a bad model path or invalid +/// `[voice]` tuning costs voice, not startup. +pub(crate) fn startup_engine(config: &VoiceConfig, status: &StatusBus) -> Option { + if !config.enabled() { + return None; + } + status.info( + "Loading whisper model", + "the interim transcription model", + Activity::General, + ); + match VoiceEngine::new(config) { + Ok(engine) => Some(engine), + Err(error) => degrade(config, status, &error), + } +} + +/// Maps a startup engine-load failure to its degraded outcome: defer to +/// the provisioning task when the failed model has a source URL, drop an +/// unsourced final pass and run interim-only, or disable voice with an +/// explanation when the interim model can neither load nor be fetched. +fn degrade( + config: &VoiceConfig, + status: &StatusBus, + error: &TranscribeError, +) -> Option { + if let TranscribeError::LoadModel { path, .. } = error { + let sourced = (path == &config.interim_model && !config.interim_source.is_empty()) + || (path == &config.final_model && !config.final_source.is_empty()); + if sourced { + // The bus is empty at startup and idle() follows, so the + // verdict also goes to the log, where it survives. + tracing::warn!(%error, "voice models not downloaded; deferring to provisioning"); + status.info( + "Voice models not downloaded", + format!("{error}; the gateway cache provides them once connected"), + Activity::General, + ); + return None; + } + if path == &config.final_model { + // The final pass is optional: an unsourced missing final model + // drops to interim-only rather than costing voice entirely. + let mut interim_only = config.clone(); + interim_only.final_model = std::path::PathBuf::new(); + return match VoiceEngine::new(&interim_only) { + Ok(engine) => { + tracing::warn!(%error, "voice final pass unavailable; running interim-only"); + status.info( + "Voice final pass unavailable", + format!("{error}; takes close with the interim model"), + Activity::General, + ); + Some(engine) + } + Err(interim_error) => { + tracing::warn!(error = %interim_error, "voice disabled at startup"); + status.error( + "Voice disabled", + interim_error.to_string(), + Activity::General, + ); + None + } + }; + } + } + tracing::warn!(%error, "voice disabled at startup"); + status.error("Voice disabled", error.to_string(), Activity::General); + None } /// Returns the workbench server router with every route mounted. @@ -88,12 +213,13 @@ pub fn router(state: AppState) -> Router { Router::new() .route("/", get(ui_index)) .route("/app.js", get(ui_app_js)) + .route("/app.css", get(ui_app_css)) .route("/style.css", get(ui_style_css)) - .route("/markdown-it.min.js", get(ui_markdown_it)) .route("/pcm-worklet.js", get(ui_pcm_worklet)) .route("/health", get(health)) .route("/v1/models", get(models)) .route("/chat", post(chat)) + .route("/ws", get(chat_ws::upgrade)) .route("/voice", get(voice::upgrade)) .with_state(state) } @@ -106,72 +232,135 @@ async fn health() -> impl IntoResponse { ) } -/// Serves the chat UI's `index.html`, embedded into the binary. -async fn ui_index() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], - include_str!("../ui/index.html"), - ) +/// The workbench UI assets under `ui/dist/`, written by the crate's build +/// script (the esbuild bundle plus copies of the static files). Debug builds +/// read the files from disk at request time, so UI edits need no Rust +/// recompile; release builds embed them into the binary. +#[derive(rust_embed::Embed)] +#[folder = "ui/dist/"] +struct UiAssets; + +/// Serves one UI asset from [`UiAssets`] with the given content type. +fn ui_asset(path: &str, content_type: &'static str) -> Response { + match UiAssets::get(path) { + Some(asset) => ( + [(header::CONTENT_TYPE, content_type)], + asset.data.into_owned(), + ) + .into_response(), + None => ( + axum::http::StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + format!("ui asset not found: {path}"), + ) + .into_response(), + } } -/// Serves the chat UI's application script, embedded into the binary. -async fn ui_app_js() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], - include_str!("../ui/app.js"), - ) +/// Serves the chat UI's `index.html`. +async fn ui_index() -> Response { + ui_asset("index.html", "text/html; charset=utf-8") } -/// Serves the chat UI's stylesheet, embedded into the binary. -async fn ui_style_css() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "text/css; charset=utf-8")], - include_str!("../ui/style.css"), - ) +/// Serves the chat UI's bundled application script. +async fn ui_app_js() -> Response { + ui_asset("app.js", "text/javascript; charset=utf-8") } -/// Serves the vendored markdown-it 14.1.0 renderer, embedded into the -/// binary. -async fn ui_markdown_it() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], - include_str!("../ui/markdown-it.min.js"), - ) +/// Serves the stylesheet esbuild extracts from the bundle's CSS imports +/// (the vendored murm-ui and dockview styles). +async fn ui_app_css() -> Response { + ui_asset("app.css", "text/css; charset=utf-8") } -/// Serves the AudioWorklet PCM capture processor, embedded into the binary. -async fn ui_pcm_worklet() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], - include_str!("../ui/pcm-worklet.js"), - ) +/// Serves the chat UI's own stylesheet. +async fn ui_style_css() -> Response { + ui_asset("style.css", "text/css; charset=utf-8") +} + +/// Serves the AudioWorklet PCM capture processor. +async fn ui_pcm_worklet() -> Response { + ui_asset("pcm-worklet.js", "text/javascript; charset=utf-8") } /// Relays the gateway's model catalog to the caller verbatim. +/// +/// While the heartbeat reports the gateway down, the catalog is not +/// attempted: the route answers 502 with a user-visible message instead. async fn models(State(state): State) -> Response { - relay(state.gateway.list_models().await) + if !state.health().is_reachable() { + return gateway_unreachable(); + } + let status = state.status(); + status.info( + "Loading models...", + "fetching the gateway model catalog", + Activity::General, + ); + let result = state.gateway.list_models().await; + report_gateway_outcome(&status, &result, "GET /v1/models"); + relay(result) } -/// Forwards a chat completion to the gateway, tapes the round-trip, and -/// relays the reply verbatim. +/// Reports a gateway call's outcome on the status bus: back to idle on +/// success, otherwise the error label matching the failure shape. +fn report_gateway_outcome( + status: &StatusBus, + result: &Result, + route: &str, +) { + match result { + Ok(upstream) if upstream.status.is_success() => status.idle(), + Ok(upstream) => status.error( + format!("Gateway error: {}", upstream.status), + format!("{route} answered a non-success status"), + Activity::General, + ), + Err(error) => status.error("Connection lost", error.to_string(), Activity::General), + } +} + +/// Forwards a buffered chat completion to the gateway, tapes the +/// round-trip, and relays the reply verbatim. /// /// A completed round-trip is recorded on the session tape; a tape failure is -/// logged and never changes the response. A request carrying -/// `"stream": true` is answered with a workbench SSE stream instead. +/// logged and never changes the response. Streaming moved to `GET /ws`: a +/// request carrying `"stream": true` is rejected with 400. async fn chat(State(state): State, body: String) -> Response { let request_value: serde_json::Value = match serde_json::from_str(&body) { Ok(value) => value, Err(error) => return bad_request(&error), }; + if request_value + .get("stream") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + return stream_unsupported(); + } let request: ChatRequest = match serde_json::from_value(request_value.clone()) { Ok(request) => request, Err(error) => return bad_request(&error), }; - if wants_stream(&request_value) { - return chat_stream(state, request, request_value).await; + // A gateway the heartbeat knows is down is not attempted, matching the + // /ws chat short-circuit. + if !state.health().is_reachable() { + return gateway_unreachable(); } + let status = state.status(); + status.info( + "Submitting request...", + "a buffered chat completion", + Activity::General, + ); + status.info( + "Waiting for response...", + "the gateway has the request", + Activity::General, + ); let started = Instant::now(); let result = state.gateway.chat_completion(&request).await; + report_gateway_outcome(&status, &result, "POST /v1/chat/completions"); let latency = started.elapsed(); if let Ok(upstream) = &result { let response_value = value_from_bytes(&upstream.body); @@ -187,102 +376,41 @@ async fn chat(State(state): State, body: String) -> Response { relay(result) } -/// Returns true when the client asked for an SSE stream with -/// `"stream": true` in the request JSON. -fn wants_stream(request: &serde_json::Value) -> bool { - request.get("stream").and_then(serde_json::Value::as_bool) == Some(true) -} - -/// Forwards a streaming chat completion as a workbench SSE stream. -/// -/// The gateway's SSE payloads are relayed event-for-event as they arrive, -/// including the terminal `[DONE]`. Exactly one tape event is written when -/// the stream ends: the assembled content on success, or an error note when -/// the gateway stream fails mid-way. A gateway that declines the stream with -/// a non-success status is relayed and taped exactly like a buffered chat. -async fn chat_stream( - state: AppState, - request: ChatRequest, - request_value: serde_json::Value, -) -> Response { - let started = Instant::now(); - let result = state.gateway.chat_completion_stream(&request).await; - let chat_stream = match result { - Ok(chat_stream) => chat_stream, - Err(error) => return relay(Err(error)), - }; - match chat_stream { - ChatStream::Relay(upstream) => { - let latency = started.elapsed(); - let response_value = value_from_bytes(&upstream.body); - tape_round_trip( - &state.tape, - request.model, - request_value, - response_value, - latency, - ) - .await; - relay(Ok(upstream)) - } - ChatStream::Stream { status, payloads } => { - let finish = StreamTape { - tape: Arc::clone(&state.tape), - model: request.model, - request: request_value, - started, - assembled: String::new(), - error: None, - }; - let events = stream::unfold( - (payloads, finish), - |(mut payloads, mut finish)| async move { - match payloads.next().await { - Some(Ok(payload)) => { - if payload != "[DONE]" - && let Some(text) = delta_content(&payload) - { - finish.assembled.push_str(&text); - } - let event = - Ok::<_, std::convert::Infallible>(Event::default().data(payload)); - Some((event, (payloads, finish))) - } - Some(Err(error)) => { - finish.error = Some(error.to_string()); - finish.record().await; - None - } - None => { - finish.record().await; - None - } - } - }, - ); - (status, Sse::new(events)).into_response() - } - } +/// Renders the 502 envelope for a gateway the heartbeat knows is down: the +/// request is not attempted, and the message is user-visible. +fn gateway_unreachable() -> Response { + ( + axum::http::StatusCode::BAD_GATEWAY, + [(header::CONTENT_TYPE, "application/json")], + serde_json::json!({ + "error": { + "message": "Gateway unreachable", + "code": "gateway_unreachable", + } + }) + .to_string(), + ) + .into_response() } -/// Extracts the text delta from one SSE payload, if it carries content. -/// -/// Role-priming and usage events have no `choices[0].delta.content` and -/// contribute nothing to the assembled response. -fn delta_content(payload: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(payload).ok()?; - let content = value - .get("choices")? - .as_array()? - .first()? - .get("delta")? - .get("content")? - .as_str()?; - Some(content.to_string()) +/// Renders the 400 envelope for a chat request that asked for a stream. +fn stream_unsupported() -> Response { + ( + axum::http::StatusCode::BAD_REQUEST, + [(header::CONTENT_TYPE, "application/json")], + serde_json::json!({ + "error": { + "message": "streaming moved to GET /ws; POST /chat is buffered only", + "code": "stream_unsupported", + } + }) + .to_string(), + ) + .into_response() } /// Parses a gateway body as JSON, falling back to a plain string. -fn value_from_bytes(body: &[u8]) -> serde_json::Value { +pub(crate) fn value_from_bytes(body: &[u8]) -> serde_json::Value { serde_json::from_slice(body) .unwrap_or_else(|_| serde_json::Value::String(String::from_utf8_lossy(body).into_owned())) } @@ -290,7 +418,7 @@ fn value_from_bytes(body: &[u8]) -> serde_json::Value { /// Records one chat round-trip on the session tape. /// /// A tape failure is logged and never changes the response. -async fn tape_round_trip( +pub(crate) async fn tape_round_trip( tape: &Arc, model: String, request: serde_json::Value, @@ -312,44 +440,6 @@ async fn tape_round_trip( } } -/// Tape bookkeeping carried through one streaming chat's SSE body stream. -/// -/// The stream's finalizer consumes this exactly once, so a streamed chat -/// always tapes exactly one event. -struct StreamTape { - tape: Arc, - model: String, - request: serde_json::Value, - started: Instant, - /// Concatenation of every content delta forwarded so far. - assembled: String, - /// The mid-stream failure note, when the gateway stream errored. - error: Option, -} - -impl StreamTape { - /// Writes the stream's single tape event: the assembled content on - /// success, or an error note plus the partial content on failure. - async fn record(self) { - let Self { - tape, - model, - request, - started, - assembled, - error, - } = self; - let response = match error { - Some(message) => serde_json::json!({ - "error": message, - "content": assembled, - }), - None => serde_json::Value::String(assembled), - }; - tape_round_trip(&tape, model, request, response, started.elapsed()).await; - } -} - /// Renders the 400 envelope for an unparseable chat body. fn bad_request(error: &serde_json::Error) -> Response { ( @@ -397,7 +487,7 @@ fn relay(result: Result) -> Response { mod tests { use super::*; - use std::path::Path; + use std::path::{Path, PathBuf}; use axum::Json; use axum::body::{Body, to_bytes}; @@ -405,6 +495,8 @@ mod tests { use tower::ServiceExt; use crate::config::{GatewayConfig, ServerConfig, TapeConfig, VoiceConfig}; + use crate::status::{Severity, StatusBarUpdate}; + use crate::transcribe::fixtures; const CATALOG: &str = r#"{"object":"list","data":[{"id":"test-model","object":"model","created":1,"owned_by":"promptforge"}]}"#; const COMPLETION: &str = r#"{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"#; @@ -414,12 +506,6 @@ mod tests { r#"{"model":"test-model","messages":[{"role":"user","content":"ping"}]}"#; const STREAM_CHAT_BODY: &str = r#"{"model":"test-model","messages":[{"role":"user","content":"ping"}],"stream":true}"#; - const STREAM_BODY: &str = concat!( - "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"po\"}}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ng\"}}]}\n\n", - "data: [DONE]\n\n", - ); fn config_for(base_url: &str, tape_path: &Path) -> Config { Config { @@ -487,66 +573,6 @@ mod tests { .into_response() } - async fn mock_chat_stream(headers: HeaderMap, Json(body): Json) -> Response { - if !authorized(&headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - assert_eq!(body["stream"], true, "the stream flag is forwarded"); - ([(header::CONTENT_TYPE, "text/event-stream")], STREAM_BODY).into_response() - } - - /// Answers with one good SSE event, then aborts the body mid-stream. - /// - /// The pause after the first chunk gives hyper time to flush the headers - /// and the event before the body errors, so the client observes a stream - /// that fails mid-way rather than a connection that never answered. - async fn mock_chat_stream_dies( - headers: HeaderMap, - Json(body): Json, - ) -> Response { - if !authorized(&headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - assert_eq!(body["stream"], true, "the stream flag is forwarded"); - let chunks = stream::unfold(0u8, |step| async move { - match step { - 0 => Some(( - Ok::<_, std::io::Error>(axum::body::Bytes::from_static( - b"data: {\"choices\":[{\"delta\":{\"content\":\"po\"}}]}\n\n", - )), - 1, - )), - 1 => { - tokio::time::sleep(Duration::from_millis(100)).await; - Some((Err(std::io::Error::other("injected upstream failure")), 2)) - } - _ => None, - } - }); - ( - [(header::CONTENT_TYPE, "text/event-stream")], - Body::from_stream(chunks), - ) - .into_response() - } - - /// Declines a streaming request with an ordinary JSON error envelope. - async fn mock_chat_declines_stream( - headers: HeaderMap, - Json(body): Json, - ) -> Response { - if !authorized(&headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - assert_eq!(body["stream"], true, "the stream flag is forwarded"); - ( - StatusCode::SERVICE_UNAVAILABLE, - [(header::CONTENT_TYPE, "application/json")], - UPSTREAM_ERROR, - ) - .into_response() - } - /// Binds `app` as a mock gateway on a free loopback port and returns its /// base URL. async fn spawn_gateway(app: Router) -> String { @@ -575,6 +601,29 @@ mod tests { spawn_gateway(Router::new().route("/v1/models", get(mock_broken_models))).await } + /// Reports whether the request carried an `Authorization` header, so + /// the client tests can observe what was sent. + async fn mock_auth_probe(headers: HeaderMap) -> Response { + let body = if headers.contains_key(header::AUTHORIZATION) { + "auth" + } else { + "no-auth" + }; + ([(header::CONTENT_TYPE, "text/plain")], body).into_response() + } + + #[tokio::test] + async fn empty_api_key_sends_no_authorization_header() { + let base_url = spawn_gateway(Router::new().route("/v1/models", get(mock_auth_probe))).await; + let anonymous = GatewayClient::new(&base_url, "").expect("client builds"); + let response = anonymous.list_models().await.expect("request completes"); + assert_eq!(response.body, b"no-auth", "empty key sends no header"); + + let keyed = GatewayClient::new(&base_url, "test-key").expect("client builds"); + let response = keyed.list_models().await.expect("request completes"); + assert_eq!(response.body, b"auth", "a set key still authenticates"); + } + async fn body_bytes(response: Response) -> axum::body::Bytes { to_bytes(response.into_body(), usize::MAX) .await @@ -644,8 +693,8 @@ mod tests { } #[tokio::test] - async fn vendored_markdown_it_is_served_as_javascript() { - assert_ui_asset("/markdown-it.min.js", "text/javascript; charset=utf-8").await; + async fn bundled_app_css_is_served_as_css() { + assert_ui_asset("/app.css", "text/css; charset=utf-8").await; } #[tokio::test] @@ -653,6 +702,23 @@ mod tests { assert_ui_asset("/pcm-worklet.js", "text/javascript; charset=utf-8").await; } + /// A plain GET to `/ws` without upgrade headers is rejected with 400, + /// which proves the route is mounted; the WebSocket chat flow is covered + /// by the `chat_ws` module's own tests over a live socket. + #[tokio::test] + async fn ws_route_rejects_a_non_upgrade_get() { + let (state, _tape_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri("/ws") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + /// A plain GET to `/voice` without upgrade headers is rejected with 400, /// which proves the route is mounted; the full WebSocket session flow is /// covered by the `voice` module's own tests over a live socket. @@ -752,6 +818,49 @@ mod tests { assert_eq!(json["error"]["code"], "gateway_unreachable"); } + #[tokio::test] + async fn a_gateway_known_down_short_circuits_the_catalog_with_bad_gateway() { + let (state, _tape_dir) = state_for("http://127.0.0.1:1"); + state.health().publish(false); + let request = Request::builder() + .uri("/v1/models") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&body).expect("error body is JSON"); + assert_eq!(json["error"]["code"], "gateway_unreachable"); + assert_eq!( + json["error"]["message"], "Gateway unreachable", + "the short-circuit message is user-visible" + ); + } + + #[tokio::test] + async fn a_gateway_known_down_short_circuits_buffered_chat_with_bad_gateway() { + let (state, tape_dir) = state_for("http://127.0.0.1:1"); + state.health().publish(false); + let response = router(state) + .oneshot(chat_request()) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&body).expect("error body is JSON"); + assert_eq!(json["error"]["code"], "gateway_unreachable"); + assert_eq!(json["error"]["message"], "Gateway unreachable"); + let raw = + std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); + assert!( + raw.trim().is_empty(), + "no upstream attempt means no tape event" + ); + } + #[tokio::test] async fn malformed_chat_body_is_a_bad_request() { let (state, _tape_dir) = state_for("http://127.0.0.1:1"); @@ -809,140 +918,122 @@ mod tests { ); } - #[tokio::test] - async fn non_json_gateway_body_is_taped_as_a_string() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_not_json))) - .await; - let (state, tape_dir) = state_for(&base_url); - let response = router(state) - .oneshot(chat_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - - let raw = - std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - let event: serde_json::Value = - serde_json::from_str(raw.lines().next().expect("one event per round-trip")) - .expect("the tape line is valid JSON"); - assert_eq!(event["response"], "gateway replied in plain text"); + /// Drains the startup phase frames emitted before the degradation + /// verdict and returns the verdict frame. + fn degradation(rx: &mut tokio::sync::broadcast::Receiver) -> StatusBarUpdate { + // The first frame is the "Loading whisper model" phase note; the + // verdict follows it. + rx.try_recv().expect("the loading phase is reported"); + rx.try_recv().expect("the degradation verdict is reported") } - #[tokio::test] - async fn streaming_chat_relays_every_event_in_order_including_done() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) - .await; - let (state, _tape_dir) = state_for(&base_url); - let response = router(state) - .oneshot(stream_chat_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .expect("an SSE response sets content-type"); - assert_eq!(content_type, "text/event-stream"); - assert_eq!( - &body_bytes(response).await[..], - STREAM_BODY.as_bytes(), - "every gateway event is relayed in order, [DONE] included" + #[test] + fn a_missing_interim_model_with_no_source_degrades_to_disabled_voice() { + let status = StatusBus::new(); + let mut rx = status.subscribe(); + let config = VoiceConfig { + interim_model: PathBuf::from("definitely-missing-model.bin"), + ..VoiceConfig::default() + }; + let engine = startup_engine(&config, &status); + assert!(engine.is_none(), "voice degrades to disabled, not fatal"); + let verdict = degradation(&mut rx); + assert_eq!(verdict.label, "Voice disabled"); + assert_eq!(verdict.severity, Severity::Error); + assert!( + verdict.description.contains("definitely-missing-model.bin"), + "the explanation names the missing path: {verdict:?}" ); } - #[tokio::test] - async fn streamed_chat_writes_one_tape_event_with_the_assembled_response() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) - .await; - let (state, tape_dir) = state_for(&base_url); - let response = router(state) - .oneshot(stream_chat_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - let _ = body_bytes(response).await; + #[test] + fn a_missing_model_with_a_source_defers_to_provisioning() { + let status = StatusBus::new(); + let mut rx = status.subscribe(); + let config = VoiceConfig { + interim_model: PathBuf::from("definitely-missing-model.bin"), + interim_source: "https://example.com/ggml.bin".to_string(), + ..VoiceConfig::default() + }; + let engine = startup_engine(&config, &status); + assert!(engine.is_none(), "the engine activates later, not now"); + let verdict = degradation(&mut rx); + assert_eq!(verdict.label, "Voice models not downloaded"); + assert_eq!(verdict.severity, Severity::Info); + assert_eq!(verdict.activity, Activity::General); + } - let raw = - std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - let lines: Vec<&str> = raw.lines().collect(); - assert_eq!(lines.len(), 1, "exactly one event per streamed chat"); - let event: serde_json::Value = - serde_json::from_str(lines[0]).expect("the tape line is valid JSON"); - assert_eq!(event["kind"], "chat"); - assert_eq!(event["model"], "test-model"); - assert_eq!( - event["request"]["stream"], true, - "the request is taped as received" + #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + fn a_missing_unsourced_final_model_drops_the_final_pass() { + let status = StatusBus::new(); + let mut rx = status.subscribe(); + let config = VoiceConfig { + interim_model: fixtures::require_model(), + final_model: PathBuf::from("definitely-missing-final-model.bin"), + ..VoiceConfig::default() + }; + let engine = startup_engine(&config, &status); + let engine = engine.expect("the interim model still loads"); + assert!( + engine.final_pass_absent_for_test(), + "the final pass was dropped" ); - assert_eq!( - event["response"], "pong", - "the tape holds the assembled content, not the raw SSE" + let verdict = degradation(&mut rx); + assert_eq!(verdict.label, "Voice final pass unavailable"); + assert_eq!(verdict.severity, Severity::Info); + } + + #[test] + fn invalid_voice_tuning_degrades_instead_of_failing_startup() { + let status = StatusBus::new(); + let mut rx = status.subscribe(); + let config = VoiceConfig { + interim_model: PathBuf::from("model.bin"), + window_seconds: 0, + ..VoiceConfig::default() + }; + let engine = startup_engine(&config, &status); + assert!(engine.is_none(), "invalid tuning costs voice, not startup"); + let verdict = degradation(&mut rx); + assert_eq!(verdict.label, "Voice disabled"); + assert!( + verdict.description.contains("window_seconds"), + "the explanation names the bad field: {verdict:?}" ); - assert!(event["latency_ms"].is_u64(), "latency_ms is an integer"); } #[tokio::test] - async fn a_mid_stream_gateway_error_is_taped_as_an_error_note() { + async fn non_json_gateway_body_is_taped_as_a_string() { let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream_dies))) + spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_not_json))) .await; let (state, tape_dir) = state_for(&base_url); let response = router(state) - .oneshot(stream_chat_request()) + .oneshot(chat_request()) .await .expect("the router is infallible"); assert_eq!(response.status(), StatusCode::OK); - let body = body_bytes(response).await; - let text = String::from_utf8(body.to_vec()).expect("the SSE body is UTF-8"); - assert!( - text.contains("data: {\"choices\":[{\"delta\":{\"content\":\"po\"}}]}"), - "the good event arrived before the failure: {text:?}" - ); - assert!( - !text.contains("[DONE]"), - "no terminal event after a mid-stream error: {text:?}" - ); let raw = std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - let lines: Vec<&str> = raw.lines().collect(); - assert_eq!(lines.len(), 1, "an errored stream still tapes one event"); let event: serde_json::Value = - serde_json::from_str(lines[0]).expect("the tape line is valid JSON"); - let message = event["response"]["error"] - .as_str() - .expect("the error note is a string"); - assert!(!message.is_empty(), "the error note names the failure"); - assert_eq!( - event["response"]["content"], "po", - "the partial content is taped alongside the error" - ); + serde_json::from_str(raw.lines().next().expect("one event per round-trip")) + .expect("the tape line is valid JSON"); + assert_eq!(event["response"], "gateway replied in plain text"); } #[tokio::test] - async fn a_declined_stream_is_relayed_and_taped_like_a_buffered_chat() { - let base_url = spawn_gateway( - Router::new().route("/v1/chat/completions", post(mock_chat_declines_stream)), - ) - .await; - let (state, tape_dir) = state_for(&base_url); + async fn a_streaming_chat_request_is_rejected_with_bad_request() { + let (state, _tape_dir) = state_for("http://127.0.0.1:1"); let response = router(state) .oneshot(stream_chat_request()) .await .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(&body_bytes(response).await[..], UPSTREAM_ERROR.as_bytes()); - - let raw = - std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - let lines: Vec<&str> = raw.lines().collect(); - assert_eq!(lines.len(), 1, "a declined stream tapes exactly one event"); - let event: serde_json::Value = - serde_json::from_str(lines[0]).expect("the tape line is valid JSON"); - assert_eq!(event["response"]["error"]["code"], "upstream_unavailable"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&body).expect("error body is JSON"); + assert_eq!(json["error"]["code"], "stream_unsupported"); } #[tokio::test] @@ -962,7 +1053,10 @@ mod tests { let state = AppState { gateway, tape: Arc::new(Tape::with_writer_for_test(FailingWriter)), - voice: None, + voice: VoiceSlot::default(), + status: StatusBus::new(), + health: GatewayHealth::new(), + catalog: CatalogBus::new(), }; let response = router(state) .oneshot(chat_request()) diff --git a/crates/promptforge-wb-server/src/catalog.rs b/crates/promptforge-wb-server/src/catalog.rs new file mode 100644 index 00000000..044f927e --- /dev/null +++ b/crates/promptforge-wb-server/src/catalog.rs @@ -0,0 +1,123 @@ +//! The model catalog push channel: the gateway's catalog, rebroadcast to +//! every connected `/ws` session as a `{"type":"models",...}` frame. +//! +//! The heartbeat republishes the catalog when the gateway comes back +//! (unreachable to connected), so a UI that booted while the gateway was +//! down refreshes its model picker without a reload. Like the status bus, +//! the channel is a tokio broadcast: publishing never blocks, a publish +//! with no sessions is a no-op, and a lagging session skips ahead - every +//! push is a complete snapshot, so an overwritten one loses nothing. + +use serde::Serialize; +use tokio::sync::broadcast; + +/// Ring capacity of the catalog bus. Pushes are rare (one per gateway +/// reconnect) and each is a full snapshot, so a handful of slots is +/// generous. +const CATALOG_CHANNEL_CAPACITY: usize = 4; + +/// One pushed model catalog. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CatalogPush { + /// The gateway's `/v1/models` `data` array, verbatim. + pub(crate) models: Vec, +} + +impl CatalogPush { + /// The push as a wire frame: `"type": "models"` beside the array. + pub(crate) fn frame(&self) -> CatalogFrame<'_> { + CatalogFrame { + kind: "models", + models: &self.models, + } + } +} + +/// The serialized shape of a catalog push on the socket, matching the chat +/// protocol's frame taxonomy. +#[derive(Debug, Serialize)] +pub(crate) struct CatalogFrame<'a> { + #[serde(rename = "type")] + kind: &'static str, + models: &'a [serde_json::Value], +} + +/// The shared catalog bus: a cloneable handle onto the broadcast channel, +/// mirroring [`crate::status::StatusBus`]. +#[derive(Debug, Clone)] +pub(crate) struct CatalogBus { + sender: broadcast::Sender, +} + +impl CatalogBus { + /// Creates a bus with no subscribers and an empty ring. + pub(crate) fn new() -> Self { + Self { + sender: broadcast::channel(CATALOG_CHANNEL_CAPACITY).0, + } + } + + /// Subscribes to every push sent from this call onward. + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } + + /// Broadcasts one catalog. With no subscribers this is a no-op; a slow + /// subscriber skips ahead rather than applying backpressure. + pub(crate) fn publish(&self, models: Vec) { + // A send only fails when there are no receivers, which is the bus's + // resting state before the first client connects. + let _ = self.sender.send(CatalogPush { models }); + } +} + +impl Default for CatalogBus { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_catalog_push_serializes_as_a_models_frame() { + let push = CatalogPush { + models: vec![serde_json::json!({"id": "test-model", "object": "model"})], + }; + let frame = serde_json::to_value(push.frame()).expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({ + "type": "models", + "models": [{"id": "test-model", "object": "model"}], + }), + "the wire shape matches the chat protocol's frame taxonomy" + ); + } + + #[tokio::test] + async fn publishing_with_no_subscribers_is_a_no_op() { + let bus = CatalogBus::new(); + bus.publish(vec![serde_json::json!({"id": "test-model"})]); + } + + #[tokio::test] + async fn a_lagged_receiver_skips_ahead_instead_of_blocking() { + let bus = CatalogBus::new(); + let mut receiver = bus.subscribe(); + for index in 0..=CATALOG_CHANNEL_CAPACITY { + bus.publish(vec![serde_json::json!({"id": format!("model-{index}")})]); + } + match receiver.recv().await { + Err(broadcast::error::RecvError::Lagged(1)) => {} + other => panic!("expected a lag report of one, got {other:?}"), + } + let resumed = receiver.recv().await.expect("the ring still holds pushes"); + assert_eq!( + resumed.models[0]["id"], "model-1", + "receiving resumes at the oldest retained push" + ); + } +} diff --git a/crates/promptforge-wb-server/src/chat_ws.rs b/crates/promptforge-wb-server/src/chat_ws.rs new file mode 100644 index 00000000..38f3da87 --- /dev/null +++ b/crates/promptforge-wb-server/src/chat_ws.rs @@ -0,0 +1,1042 @@ +//! The `/ws` WebSocket endpoint: one persistent socket carrying all +//! downstream JSON - browser chat over bidirectional text frames, relayed +//! through the gateway's streaming chat completion, plus unsolicited status +//! updates from the observer. +//! +//! A client upgrades `GET /ws` once and sends chat requests as text frames: +//! `{"type":"chat","id":N,"model":"...","messages":[...]}`. Each chat frame +//! runs one streaming gateway completion; the session answers with +//! `{"type":"delta","content":"...","id":N}` frames as content arrives, a +//! terminal `{"type":"done","id":N}` when the stream completes, or +//! `{"type":"error","message":"...","id":N}` on any failure - transport, +//! mid-stream, or a gateway that declines the stream with a non-success +//! status. The `id` is optional and echoed verbatim on every frame of that +//! chat's reply, so one socket can multiplex requests; a frame without an +//! `id` is answered untagged. A frame that is not a well-formed chat +//! request is answered with an `error` frame and the session continues. +//! Chat frames are answered strictly in order: while one streams, later +//! frames wait. A chat received while the heartbeat knows the gateway is +//! down is answered immediately with a "Gateway unreachable" error frame - +//! no upstream attempt, no tape event. +//! +//! Status updates from [`crate::status`] and model catalog pushes from +//! [`crate::catalog`] are forwarded to the socket as unsolicited +//! `{"type":"status",...}` and `{"type":"models",...}` frames by a +//! dedicated task, so they flow at any time - including while a chat is +//! streaming, when the inbound loop is parked inside the relay. +//! +//! Exactly one tape event is written per chat frame, after the stream +//! settles and before the terminal frame is sent, so a client holding +//! `done` or `error` can trust the tape to hold the exchange. A client +//! that disconnects mid-stream is taped with a `client disconnected` note +//! beside the partial content. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::Response; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::broadcast; + +use crate::app::{AppState, tape_round_trip, value_from_bytes}; +use crate::gateway::{ChatRequest, ChatStream, GatewayResponse}; +use crate::status::Activity; +use crate::tape::Tape; + +/// Session ids for log correlation, handed out in connection order. +static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); + +/// Upgrades a `GET /ws` request to a WebSocket chat session. +pub(crate) async fn upgrade(State(state): State, ws: WebSocketUpgrade) -> Response { + let session = NEXT_SESSION.fetch_add(1, Ordering::Relaxed); + ws.on_upgrade(move |socket| run_session(session, socket, state)) +} + +/// Runs one chat session until the socket closes or fails. +async fn run_session(session: u64, socket: WebSocket, state: AppState) { + tracing::info!(session, "chat session opened"); + let (mut sink, mut stream) = socket.split(); + // The receive loop and the status forwarder both speak to the client, + // so outbound messages funnel through one channel into the writer task, + // mirroring the voice session. + let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::(32); + let writer = tokio::spawn(async move { + while let Some(message) = out_rx.recv().await { + if sink.send(message).await.is_err() { + break; + } + } + }); + + // Status frames and catalog pushes are unsolicited and must flow while + // a chat relay has the inbound loop parked, so they get their own task + // off the broadcast buses rather than a branch in that loop. A client + // too slow to keep up lags the rings and skips ahead; the buses never + // block for it. + let mut status_rx = state.status().subscribe(); + let mut catalog_rx = state.catalog().subscribe(); + let status_out = out_tx.clone(); + let forwarder = tokio::spawn(async move { + loop { + let text = tokio::select! { + received = status_rx.recv() => match received { + Ok(update) => { + // Serializing strings and integers cannot fail. + let Ok(text) = serde_json::to_string(&update.frame()) else { + continue; + }; + text + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::debug!(session, skipped, "status receiver lagged; skipped updates"); + continue; + } + Err(broadcast::error::RecvError::Closed) => break, + }, + received = catalog_rx.recv() => match received { + Ok(push) => { + // Serializing a JSON value cannot fail. + let Ok(text) = serde_json::to_string(&push.frame()) else { + continue; + }; + text + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::debug!(session, skipped, "catalog receiver lagged; skipped pushes"); + continue; + } + Err(broadcast::error::RecvError::Closed) => break, + }, + }; + if status_out.send(Message::Text(text.into())).await.is_err() { + break; + } + } + }); + + while let Some(received) = stream.next().await { + match received { + Ok(Message::Text(text)) => handle_frame(&state, &text, &out_tx).await, + // Binary frames carry no chat meaning; pings and pongs are + // answered by axum itself. + Ok(Message::Ping(_) | Message::Pong(_) | Message::Binary(_)) => {} + Ok(Message::Close(_)) => break, + Err(error) => { + tracing::warn!(session, %error, "chat session socket failed"); + break; + } + } + } + drop(out_tx); + writer.abort(); + forwarder.abort(); + tracing::info!(session, "chat session closed"); +} + +/// Handles one inbound text frame: a well-formed `chat` frame runs a +/// streamed completion, anything else is answered with an `error` frame. +async fn handle_frame(state: &AppState, text: &str, out: &tokio::sync::mpsc::Sender) { + let frame: serde_json::Value = match serde_json::from_str(text) { + Ok(frame) => frame, + Err(error) => { + send_error(out, None, format!("invalid JSON frame: {error}")).await; + return; + } + }; + // The request id, echoed on every frame of this chat's reply so one + // persistent socket can multiplex requests. Absent and null both mean + // untagged. + let id = frame.get("id").cloned().filter(|id| !id.is_null()); + if frame.get("type").and_then(serde_json::Value::as_str) != Some("chat") { + send_error(out, id.as_ref(), "unknown frame type; expected \"chat\"").await; + return; + } + let request: ChatRequest = match serde_json::from_value(frame.clone()) { + Ok(request) => request, + Err(error) => { + send_error(out, id.as_ref(), format!("invalid chat request: {error}")).await; + return; + } + }; + // A gateway the heartbeat knows is down is not attempted: the chat + // fails fast with a user-visible error instead of a transport error, + // and nothing is taped because no exchange happened. + if !state.health().is_reachable() { + send_error(out, id.as_ref(), "Gateway unreachable").await; + return; + } + relay_chat(state, request, frame, id, out).await; +} + +/// Runs one streaming chat completion against the gateway, forwarding +/// content deltas as `delta` frames and settling with `done` or `error`. +async fn relay_chat( + state: &AppState, + request: ChatRequest, + frame: serde_json::Value, + id: Option, + out: &tokio::sync::mpsc::Sender, +) { + let started = Instant::now(); + let status = state.status(); + status.info( + "Submitting request...", + format!("a streaming chat completion from {}", request.model), + Activity::Thinking, + ); + let chat_stream = match state + .gateway_client() + .chat_completion_stream(&request) + .await + { + Ok(chat_stream) => chat_stream, + Err(error) => { + status.error("Connection lost", error.to_string(), Activity::General); + send_error(out, id.as_ref(), error.to_string()).await; + return; + } + }; + let mut payloads = match chat_stream { + ChatStream::Stream { payloads, .. } => { + status.info( + "Streaming response...", + "the gateway is streaming the reply", + Activity::Thinking, + ); + payloads + } + ChatStream::Relay(upstream) => { + declined_stream( + state, + request.model, + frame, + upstream, + started, + id.as_ref(), + out, + ) + .await; + return; + } + }; + let mut finish = StreamTape { + tape: Arc::clone(state.tape()), + model: request.model, + request: frame, + started, + assembled: String::new(), + error: None, + }; + loop { + match payloads.next().await { + Some(Ok(payload)) => { + // The terminal sentinel ends the wire stream but carries no + // content; role-priming and usage events have none either. + if payload == "[DONE]" { + continue; + } + let Some(text) = delta_content(&payload) else { + continue; + }; + finish.assembled.push_str(&text); + // A chunk pulse at Debug: the UI ignores the text, but the + // activity field keeps the generating LED lit. + status.debug( + "Streaming response...", + "a gateway response chunk", + Activity::Generating, + ); + let delta = tagged( + id.as_ref(), + serde_json::json!({"type": "delta", "content": text}), + ); + if !send_frame(out, delta).await { + finish.error = Some("client disconnected mid-stream".to_string()); + finish.record().await; + return; + } + } + Some(Err(error)) => { + let message = error.to_string(); + finish.error = Some(message.clone()); + finish.record().await; + status.error("Connection lost", message.clone(), Activity::General); + send_error(out, id.as_ref(), message).await; + return; + } + None => { + finish.record().await; + status.idle(); + let _ = send_frame( + out, + tagged(id.as_ref(), serde_json::json!({"type": "done"})), + ) + .await; + return; + } + } + } +} + +/// Handles a gateway that declined the stream with an ordinary response: +/// the envelope is taped like a buffered chat and reported as an `error` +/// frame and an error status. +async fn declined_stream( + state: &AppState, + model: String, + frame: serde_json::Value, + upstream: GatewayResponse, + started: Instant, + id: Option<&serde_json::Value>, + out: &tokio::sync::mpsc::Sender, +) { + let response = value_from_bytes(&upstream.body); + tape_round_trip( + state.tape(), + model, + frame, + response.clone(), + started.elapsed(), + ) + .await; + let message = response + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .map_or_else( + || { + format!( + "gateway declined the stream with status {}", + upstream.status + ) + }, + str::to_string, + ); + state.status().error( + format!("Gateway error: {}", upstream.status), + message.clone(), + Activity::General, + ); + send_error(out, id, message).await; +} + +/// Tags a reply frame with the request's `id`, when it carried one. +fn tagged(id: Option<&serde_json::Value>, mut frame: serde_json::Value) -> serde_json::Value { + if let (Some(id), Some(object)) = (id, frame.as_object_mut()) { + object.insert("id".to_string(), id.clone()); + } + frame +} + +/// Extracts the text delta from one gateway SSE payload, if it carries +/// content. +/// +/// Role-priming and usage events have no `choices[0].delta.content` and +/// contribute nothing to the assembled response. +fn delta_content(payload: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(payload).ok()?; + let content = value + .get("choices")? + .as_array()? + .first()? + .get("delta")? + .get("content")? + .as_str()?; + Some(content.to_string()) +} + +/// Tape bookkeeping carried through one streaming chat. +/// +/// The session consumes this exactly once per chat frame, so a streamed chat +/// always tapes exactly one event. +struct StreamTape { + tape: Arc, + model: String, + request: serde_json::Value, + started: Instant, + /// Concatenation of every content delta forwarded so far. + assembled: String, + /// The mid-stream failure note, when the gateway stream errored. + error: Option, +} + +impl StreamTape { + /// Writes the stream's single tape event: the assembled content on + /// success, or an error note plus the partial content on failure. + async fn record(self) { + let Self { + tape, + model, + request, + started, + assembled, + error, + } = self; + let response = match error { + Some(message) => serde_json::json!({ + "error": message, + "content": assembled, + }), + None => serde_json::Value::String(assembled), + }; + tape_round_trip(&tape, model, request, response, started.elapsed()).await; + } +} + +/// Sends one JSON text frame; a false return means the client is gone. +async fn send_frame(out: &tokio::sync::mpsc::Sender, frame: serde_json::Value) -> bool { + out.send(Message::Text(frame.to_string().into())) + .await + .is_ok() +} + +/// Sends one `error` frame carrying `message`, tagged with the request's +/// `id` when there is one, ignoring a dead client. +async fn send_error( + out: &tokio::sync::mpsc::Sender, + id: Option<&serde_json::Value>, + message: impl Into, +) { + let frame = tagged( + id, + serde_json::json!({"type": "error", "message": message.into()}), + ); + let _ = send_frame(out, frame).await; +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + use axum::Router; + use axum::body::Body; + use axum::extract::State; + use axum::http::{HeaderMap, StatusCode, header}; + use axum::response::IntoResponse; + use axum::routing::{get, post}; + use futures_util::stream; + use tokio_tungstenite::tungstenite; + + use crate::app::router; + use crate::config::{Config, GatewayConfig, ServerConfig, TapeConfig, VoiceConfig}; + use crate::status::{Activity, Progress, Severity, StatusBarUpdate}; + + const STREAM_BODY: &str = concat!( + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n", + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"po\"}}]}\n\n", + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ng\"}}]}\n\n", + "data: [DONE]\n\n", + ); + const UPSTREAM_ERROR: &str = + r#"{"error":{"message":"model unloaded","code":"upstream_unavailable"}}"#; + + fn authorized(headers: &HeaderMap) -> bool { + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + == Some("Bearer test-key") + } + + async fn mock_chat_stream(headers: HeaderMap, body: String) -> Response { + assert!(authorized(&headers)); + let body: serde_json::Value = serde_json::from_str(&body).expect("the request is JSON"); + assert_eq!(body["stream"], true, "the stream flag is forwarded"); + ([(header::CONTENT_TYPE, "text/event-stream")], STREAM_BODY).into_response() + } + + /// Answers with one good SSE event, then aborts the body mid-stream. + /// + /// The pause after the first chunk gives hyper time to flush the headers + /// and the event before the body errors, so the client observes a stream + /// that fails mid-way rather than a connection that never answered. + async fn mock_chat_stream_dies(headers: HeaderMap, body: String) -> Response { + assert!(authorized(&headers)); + let body: serde_json::Value = serde_json::from_str(&body).expect("the request is JSON"); + assert_eq!(body["stream"], true, "the stream flag is forwarded"); + let chunks = stream::unfold(0u8, |step| async move { + match step { + 0 => Some(( + Ok::<_, std::io::Error>(axum::body::Bytes::from_static( + b"data: {\"choices\":[{\"delta\":{\"content\":\"po\"}}]}\n\n", + )), + 1, + )), + 1 => { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + Some((Err(std::io::Error::other("injected upstream failure")), 2)) + } + _ => None, + } + }); + ( + [(header::CONTENT_TYPE, "text/event-stream")], + Body::from_stream(chunks), + ) + .into_response() + } + + /// Drips one delta every 50ms, giving a client time to disconnect + /// mid-stream before the drip runs out. + async fn mock_chat_stream_drips(headers: HeaderMap, body: String) -> Response { + assert!(authorized(&headers)); + let body: serde_json::Value = serde_json::from_str(&body).expect("the request is JSON"); + assert_eq!(body["stream"], true, "the stream flag is forwarded"); + let chunks = stream::unfold(0u8, |step| async move { + if step >= 40 { + return None; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let payload = + format!("data: {{\"choices\":[{{\"delta\":{{\"content\":\"x{step}\"}}}}]}}\n\n"); + Some(( + Ok::<_, std::io::Error>(axum::body::Bytes::from(payload)), + step + 1, + )) + }); + ( + [(header::CONTENT_TYPE, "text/event-stream")], + Body::from_stream(chunks), + ) + .into_response() + } + + /// Declines a streaming request with an ordinary JSON error envelope. + async fn mock_chat_declines_stream(headers: HeaderMap, body: String) -> Response { + assert!(authorized(&headers)); + let body: serde_json::Value = serde_json::from_str(&body).expect("the request is JSON"); + assert_eq!(body["stream"], true, "the stream flag is forwarded"); + ( + StatusCode::SERVICE_UNAVAILABLE, + [(header::CONTENT_TYPE, "application/json")], + UPSTREAM_ERROR, + ) + .into_response() + } + + /// Binds `app` as a mock gateway on a free loopback port and returns its + /// base URL. + async fn spawn_gateway(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") + } + + /// Binds the workbench router against the gateway at `base_url` on a + /// free loopback port and returns the `/ws` URL, the tempdir keeping + /// the tape alive, and a handle on the shared state (for poking the + /// status bus directly). + async fn spawn_chat_server(base_url: &str) -> (String, tempfile::TempDir, AppState) { + let tape_dir = tempfile::TempDir::new().expect("tempdir"); + let config = Config { + gateway: GatewayConfig { + base_url: base_url.to_string(), + api_key: "test-key".to_string(), + }, + tape: TapeConfig { + path: tape_dir.path().join("tape.jsonl"), + }, + server: ServerConfig::default(), + voice: VoiceConfig::default(), + }; + let state = AppState::new(&config).expect("state builds in tests"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind the chat test server"); + let addr = listener.local_addr().expect("chat test server address"); + let served = state.clone(); + tokio::spawn(async move { + axum::serve(listener, router(served)) + .await + .expect("chat test server serves"); + }); + (format!("ws://{addr}/ws"), tape_dir, state) + } + + /// Reads one text frame from the client socket and parses it as JSON. + async fn read_frame(socket: &mut S) -> serde_json::Value + where + S: futures_util::Stream> + Unpin, + { + let message = socket + .next() + .await + .expect("a frame follows") + .expect("the frame is not a socket error"); + let text = message.into_text().expect("the frame is text"); + serde_json::from_str(&text).expect("the frame is JSON") + } + + /// Reads frames until one arrives that is not a status update. Status + /// frames are unsolicited and may interleave with a chat's replies at + /// any point, so reply assertions skip them. + async fn read_non_status_frame(socket: &mut S) -> serde_json::Value + where + S: futures_util::Stream> + Unpin, + { + loop { + let frame = read_frame(socket).await; + if frame["type"] != "status" { + return frame; + } + } + } + + /// Sends one well-formed chat frame naming the test model. + async fn send_chat(socket: &mut S) + where + S: futures_util::Sink + Unpin, + { + let frame = serde_json::json!({ + "type": "chat", + "model": "test-model", + "messages": [{"role": "user", "content": "ping"}], + }) + .to_string(); + socket + .send(tungstenite::Message::Text(frame.into())) + .await + .expect("the chat frame is sent"); + } + + /// Reads every event on the test's tape. + fn tape_events(tape_dir: &tempfile::TempDir) -> Vec { + let raw = + std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); + raw.lines() + .map(|line| serde_json::from_str(line).expect("the tape line is valid JSON")) + .collect() + } + + #[tokio::test] + async fn chat_frames_relay_deltas_in_order_then_done() { + let base_url = + spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) + .await; + let (url, _tape_dir, _state) = spawn_chat_server(&base_url).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + send_chat(&mut socket).await; + + // The role-priming event carries no content and yields no frame. + let first = read_non_status_frame(&mut socket).await; + assert_eq!(first, serde_json::json!({"type": "delta", "content": "po"})); + let second = read_non_status_frame(&mut socket).await; + assert_eq!( + second, + serde_json::json!({"type": "delta", "content": "ng"}) + ); + let third = read_non_status_frame(&mut socket).await; + assert_eq!(third, serde_json::json!({"type": "done"})); + socket.close(None).await.expect("close the socket"); + } + + #[tokio::test] + async fn a_completed_chat_tapes_one_event_with_the_assembled_response() { + let base_url = + spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) + .await; + let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + send_chat(&mut socket).await; + // The terminal frame is sent after the tape write, so holding `done` + // means the tape is durable. + loop { + let frame = read_non_status_frame(&mut socket).await; + if frame["type"] == "done" { + break; + } + } + + let events = tape_events(&tape_dir); + assert_eq!(events.len(), 1, "exactly one event per chat frame"); + let event = &events[0]; + assert_eq!(event["kind"], "chat"); + assert_eq!(event["model"], "test-model"); + assert_eq!( + event["request"]["type"], "chat", + "the frame is taped as received" + ); + assert_eq!(event["request"]["messages"][0]["content"], "ping"); + assert_eq!( + event["response"], "pong", + "the tape holds the assembled content, not the raw frames" + ); + assert!(event["latency_ms"].is_u64(), "latency_ms is an integer"); + socket.close(None).await.expect("close the socket"); + } + + #[tokio::test] + async fn a_mid_stream_gateway_error_sends_an_error_frame_and_tapes_the_note() { + let base_url = + spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream_dies))) + .await; + let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + send_chat(&mut socket).await; + + let first = read_non_status_frame(&mut socket).await; + assert_eq!(first, serde_json::json!({"type": "delta", "content": "po"})); + let second = read_non_status_frame(&mut socket).await; + assert_eq!(second["type"], "error"); + let message = second["message"].as_str().expect("the error is a string"); + assert!(!message.is_empty(), "the error frame names the failure"); + + let events = tape_events(&tape_dir); + assert_eq!(events.len(), 1, "an errored stream still tapes one event"); + let note = events[0]["response"]["error"] + .as_str() + .expect("the error note is a string"); + assert!(!note.is_empty(), "the error note names the failure"); + assert_eq!( + events[0]["response"]["content"], "po", + "the partial content is taped alongside the error" + ); + socket.close(None).await.expect("close the socket"); + } + + #[tokio::test] + async fn a_client_disconnect_mid_stream_is_taped_with_the_partial_content() { + let base_url = spawn_gateway( + Router::new().route("/v1/chat/completions", post(mock_chat_stream_drips)), + ) + .await; + let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + send_chat(&mut socket).await; + let first = read_non_status_frame(&mut socket).await; + assert_eq!(first["type"], "delta"); + // Drop the socket without a close handshake; the server notices when + // a later delta send fails. + drop(socket); + + // The tape write follows the failed send, so poll for it. + let mut events: Vec = Vec::new(); + for _ in 0..100 { + if let Ok(raw) = std::fs::read_to_string(tape_dir.path().join("tape.jsonl")) + && !raw.trim().is_empty() + { + events = raw + .lines() + .map(|line| serde_json::from_str(line).expect("the tape line is valid JSON")) + .collect(); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!(events.len(), 1, "a mid-stream disconnect tapes one event"); + assert_eq!( + events[0]["response"]["error"], "client disconnected mid-stream", + "the disconnect is taped as an error note" + ); + let partial = events[0]["response"]["content"] + .as_str() + .expect("the partial content is a string"); + assert!( + partial.starts_with("x0"), + "the partial content is taped alongside: {partial:?}" + ); + } + + #[tokio::test] + async fn a_declined_stream_sends_an_error_frame_and_tapes_the_envelope() { + let base_url = spawn_gateway( + Router::new().route("/v1/chat/completions", post(mock_chat_declines_stream)), + ) + .await; + let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + send_chat(&mut socket).await; + + let frame = read_non_status_frame(&mut socket).await; + assert_eq!(frame["type"], "error"); + assert_eq!(frame["message"], "model unloaded"); + + let events = tape_events(&tape_dir); + assert_eq!(events.len(), 1, "a declined stream tapes exactly one event"); + assert_eq!( + events[0]["response"]["error"]["code"], "upstream_unavailable", + "the gateway's own envelope is taped" + ); + socket.close(None).await.expect("close the socket"); + } + + #[tokio::test] + async fn malformed_frames_are_answered_with_error_frames() { + let (url, _tape_dir, _state) = spawn_chat_server("http://127.0.0.1:1").await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + + for bad in [ + "not json", + r#"{"type":"bogus"}"#, + r#"{"type":"chat","model":"test-model"}"#, + ] { + socket + .send(tungstenite::Message::Text(bad.into())) + .await + .expect("the frame is sent"); + let frame = read_non_status_frame(&mut socket).await; + assert_eq!( + frame["type"], "error", + "a malformed frame is answered, not fatal: {bad}" + ); + } + // The session survives: a well-formed frame still gets through to + // the (unreachable) gateway and answers with its own error. + send_chat(&mut socket).await; + let frame = read_non_status_frame(&mut socket).await; + assert_eq!(frame["type"], "error"); + socket.close(None).await.expect("close the socket"); + } + + #[tokio::test] + async fn status_updates_reach_connected_sessions_as_status_frames() { + let (url, _tape_dir, state) = spawn_chat_server("http://127.0.0.1:1").await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + // A malformed frame's error reply proves the session's inbound loop + // is running, which means the status subscription before it is live. + socket + .send(tungstenite::Message::Text("not json".into())) + .await + .expect("the frame is sent"); + let reply = read_frame(&mut socket).await; + assert_eq!(reply["type"], "error"); + + state.status().emit(StatusBarUpdate { + label: "Downloading model".to_string(), + description: "ggml-large-v3.bin".to_string(), + progress: Some(Progress { + current: 1, + total: 2, + }), + severity: Severity::Info, + activity: Activity::Generating, + }); + + let frame = read_frame(&mut socket).await; + assert_eq!( + frame, + serde_json::json!({ + "type": "status", + "label": "Downloading model", + "description": "ggml-large-v3.bin", + "progress": {"current": 1, "total": 2}, + "severity": "info", + "activity": "generating", + }), + "the update arrives as one status frame" + ); + socket.close(None).await.expect("close the socket"); + } + + #[tokio::test] + async fn sequential_chats_on_one_socket_both_complete() { + let base_url = + spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) + .await; + let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + + for round in 1..=2 { + let frame = serde_json::json!({ + "type": "chat", + "id": round, + "model": "test-model", + "messages": [{"role": "user", "content": "ping"}], + }) + .to_string(); + socket + .send(tungstenite::Message::Text(frame.into())) + .await + .expect("the chat frame is sent"); + let first = read_non_status_frame(&mut socket).await; + assert_eq!( + first, + serde_json::json!({"type": "delta", "content": "po", "id": round}), + "round {round}: the first delta carries the request id" + ); + let second = read_non_status_frame(&mut socket).await; + assert_eq!( + second, + serde_json::json!({"type": "delta", "content": "ng", "id": round}) + ); + let third = read_non_status_frame(&mut socket).await; + assert_eq!(third, serde_json::json!({"type": "done", "id": round})); + } + + let events = tape_events(&tape_dir); + assert_eq!(events.len(), 2, "one tape event per chat frame"); + assert!( + events.iter().all(|event| event["response"] == "pong"), + "both rounds taped the assembled response" + ); + socket.close(None).await.expect("close the socket"); + } + + #[tokio::test] + async fn a_gateway_known_down_short_circuits_chat_with_an_error_frame() { + let (url, tape_dir, state) = spawn_chat_server("http://127.0.0.1:1").await; + state.health().publish(false); + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + let frame = serde_json::json!({ + "type": "chat", + "id": 7, + "model": "test-model", + "messages": [{"role": "user", "content": "ping"}], + }) + .to_string(); + socket + .send(tungstenite::Message::Text(frame.into())) + .await + .expect("the chat frame is sent"); + + let reply = read_non_status_frame(&mut socket).await; + assert_eq!( + reply, + serde_json::json!({"type": "error", "message": "Gateway unreachable", "id": 7}), + "the chat fails fast, with the request id echoed" + ); + socket.close(None).await.expect("close the socket"); + let raw = + std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); + assert!( + raw.trim().is_empty(), + "no upstream attempt means no tape event" + ); + } + + const CATALOG: &str = r#"{"object":"list","data":[{"id":"test-model","object":"model","owned_by":"promptforge"}]}"#; + + /// A mock `/health` whose answer flips under test control. + async fn flippable_health(State(healthy): State>) -> Response { + if healthy.load(Ordering::Relaxed) { + StatusCode::OK.into_response() + } else { + StatusCode::SERVICE_UNAVAILABLE.into_response() + } + } + + /// A static mock catalog for the reconnect push test. + async fn mock_models() -> Response { + ([(header::CONTENT_TYPE, "application/json")], CATALOG).into_response() + } + + #[tokio::test] + #[ignore = "flaky on CI: the catalog push races the heartbeat transition and never arrives on slow runners"] + async fn a_gateway_reconnect_pushes_the_refreshed_catalog_to_sessions() { + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = spawn_gateway( + Router::new() + .route("/health", get(flippable_health)) + .route("/v1/models", get(mock_models)) + .with_state(Arc::clone(&healthy)), + ) + .await; + let (url, _tape_dir, state) = spawn_chat_server(&base_url).await; + let heartbeat = crate::heartbeat::spawn( + state.gateway_client().clone(), + state.status(), + state.health().clone(), + state.catalog(), + Duration::from_millis(25), + ); + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + // A malformed frame's error reply proves the session's tasks - + // including its catalog subscription - are live before the flip. + socket + .send(tungstenite::Message::Text("not json".into())) + .await + .expect("the frame is sent"); + let reply = read_frame(&mut socket).await; + assert_eq!(reply["type"], "error"); + + healthy.store(true, Ordering::Relaxed); + // Status frames (the "Connected to gateway" transition) interleave + // with the push; read until the models frame arrives. + let frame = loop { + let frame = tokio::time::timeout(Duration::from_secs(30), read_frame(&mut socket)) + .await + .expect("frames keep arriving within the deadline"); + if frame["type"] == "models" { + break frame; + } + }; + assert_eq!( + frame, + serde_json::json!({ + "type": "models", + "models": [{"id": "test-model", "object": "model", "owned_by": "promptforge"}], + }), + "the refreshed catalog arrives as one models frame" + ); + socket.close(None).await.expect("close the socket"); + heartbeat.shutdown().await; + } + + #[tokio::test] + async fn a_chat_reports_submitting_then_streaming() { + let base_url = + spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) + .await; + let (url, _tape_dir, _state) = spawn_chat_server(&base_url).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /ws"); + send_chat(&mut socket).await; + + let mut labels: Vec = Vec::new(); + loop { + let frame = read_frame(&mut socket).await; + match frame["type"].as_str() { + Some("status") => labels.push( + frame["label"] + .as_str() + .expect("a status frame carries a label") + .to_string(), + ), + Some("done") => break, + _ => {} + } + } + assert!( + labels.iter().any(|label| label.contains("Submitting")), + "a Submitting status frame arrived: {labels:?}" + ); + assert!( + labels.iter().any(|label| label.contains("Streaming")), + "a Streaming status frame arrived: {labels:?}" + ); + socket.close(None).await.expect("close the socket"); + } +} diff --git a/crates/promptforge-wb-server/src/config.rs b/crates/promptforge-wb-server/src/config.rs index c3ab3766..1d4777bf 100644 --- a/crates/promptforge-wb-server/src/config.rs +++ b/crates/promptforge-wb-server/src/config.rs @@ -5,13 +5,20 @@ //! the TOML is parsed first and only string *values* are interpolated, so //! `${VAR}` inside comments or keys is never expanded and an interpolated //! value containing a quote, backslash, or newline cannot corrupt the -//! document. `$$` is a literal `$`. +//! document. `$$` is a literal `$`. An unset variable interpolates to the +//! empty string, so the generated config's `${PROMPTFORGE_GATEWAY_URL}` and +//! `${PROMPTFORGE_GATEWAY_API_KEY}` degrade to the built-in defaults instead +//! of failing startup. use std::path::{Path, PathBuf}; /// Path [`Config::load`] reads when no override is given. pub const DEFAULT_CONFIG_PATH: &str = "workbench.toml"; +/// Gateway base URL used when `gateway.base_url` interpolates to an empty +/// string, for example because `PROMPTFORGE_GATEWAY_URL` is unset. +pub const DEFAULT_GATEWAY_BASE_URL: &str = "http://127.0.0.1:8081"; + /// Workbench server configuration loaded from `workbench.toml`. #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] pub struct Config { @@ -32,99 +39,34 @@ impl Config { /// Loads and parses the workbench configuration from `path`. /// /// # Errors - /// Returns [`ConfigError::Read`] if `path` cannot be read (including a - /// missing file), [`ConfigError::Parse`] if the contents do not match the - /// workbench schema, [`ConfigError::UnresolvedVar`] if a `${VAR}` - /// references an unset environment variable, and - /// [`ConfigError::Interpolation`] if a `${...}` is malformed. + /// Returns [`ConfigError::NotFound`] if `path` does not exist, + /// [`ConfigError::Read`] if `path` exists but cannot be read, + /// [`ConfigError::Parse`] if the contents do not match the workbench + /// schema, [`ConfigError::UnresolvedVar`] if a `${VAR}` names a variable + /// whose value is not valid Unicode, and [`ConfigError::Interpolation`] + /// if a `${...}` is malformed. pub fn load(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::Read { - path: path.to_path_buf(), - source, + let raw = std::fs::read_to_string(path).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + ConfigError::NotFound { + path: path.to_path_buf(), + } + } else { + ConfigError::Read { + path: path.to_path_buf(), + source, + } + } })?; Self::parse(&raw, Some(path)) } - /// Builds configuration entirely from environment variables, used when - /// no `workbench.toml` is found. - /// - /// # Environment variables - /// - /// - `PROMPTFORGE_GATEWAY_BASE_URL` - default `http://127.0.0.1:8081` - /// - `PROMPTFORGE_GATEWAY_API_KEY` - **required** - /// - `PROMPTFORGE_TAPE_PATH` - default `tape.jsonl` - /// - `PROMPTFORGE_SERVER_BIND` - default `127.0.0.1:7910` - /// - `PROMPTFORGE_SERVER_OPEN_BROWSER` - default `false`; accepts - /// `true` or `1` - /// - `PROMPTFORGE_VOICE_INTERIM_MODEL` - default empty (disabled) - /// - `PROMPTFORGE_VOICE_FINAL_MODEL` - default empty - /// - `PROMPTFORGE_VOICE_WINDOW_SECONDS` - default `5` - /// - `PROMPTFORGE_VOICE_INTERVAL_MS` - default `800` - /// - /// # Errors - /// Returns [`ConfigError::MissingEnvVar`] when a required variable is - /// not set, or [`ConfigError::InvalidEnvVar`] when a variable cannot be - /// parsed as the expected type. - pub fn from_env() -> Result { - Self::from_env_lookup(|name| std::env::var(name).ok()) - } - - /// Builds configuration from a variable lookup function. - /// - /// This is the implementation behind [`from_env`](Self::from_env), - /// factored out so tests can supply a synthetic environment. - fn from_env_lookup(lookup: impl Fn(&str) -> Option) -> Result { - let api_key = - lookup("PROMPTFORGE_GATEWAY_API_KEY").ok_or_else(|| ConfigError::MissingEnvVar { - name: "PROMPTFORGE_GATEWAY_API_KEY".to_string(), - })?; - let base_url = lookup("PROMPTFORGE_GATEWAY_BASE_URL") - .unwrap_or_else(|| "http://127.0.0.1:8081".to_string()); - let tape_path = lookup("PROMPTFORGE_TAPE_PATH").unwrap_or_else(|| "tape.jsonl".to_string()); - let bind = - lookup("PROMPTFORGE_SERVER_BIND").unwrap_or_else(|| "127.0.0.1:7910".to_string()); - let open_browser = match lookup("PROMPTFORGE_SERVER_OPEN_BROWSER") { - None => false, - Some(v) => v == "true" || v == "1", - }; - let interim_model = lookup("PROMPTFORGE_VOICE_INTERIM_MODEL").unwrap_or_default(); - let final_model = lookup("PROMPTFORGE_VOICE_FINAL_MODEL").unwrap_or_default(); - let window_seconds = match lookup("PROMPTFORGE_VOICE_WINDOW_SECONDS") { - None => DEFAULT_VOICE_WINDOW_SECONDS, - Some(v) => v.parse::().map_err(|_| ConfigError::InvalidEnvVar { - name: "PROMPTFORGE_VOICE_WINDOW_SECONDS".to_string(), - reason: format!("expected an integer, got {v:?}"), - })?, - }; - let interval_ms = match lookup("PROMPTFORGE_VOICE_INTERVAL_MS") { - None => DEFAULT_VOICE_INTERVAL_MS, - Some(v) => v.parse::().map_err(|_| ConfigError::InvalidEnvVar { - name: "PROMPTFORGE_VOICE_INTERVAL_MS".to_string(), - reason: format!("expected an integer, got {v:?}"), - })?, - }; - - Ok(Self { - gateway: GatewayConfig { base_url, api_key }, - tape: TapeConfig { - path: PathBuf::from(tape_path), - }, - server: ServerConfig { bind, open_browser }, - voice: VoiceConfig { - interim_model: PathBuf::from(interim_model), - final_model: PathBuf::from(final_model), - window_seconds, - interval_ms, - }, - }) - } - /// Parses a workbench configuration from a TOML string. /// /// # Errors /// Returns [`ConfigError::Parse`] if `raw` is not valid TOML or does not /// match the workbench schema, [`ConfigError::UnresolvedVar`] if a - /// `${VAR}` references an unset environment variable, and + /// `${VAR}` names a variable whose value is not valid Unicode, and /// [`ConfigError::Interpolation`] if a `${...}` is malformed. /// /// # Examples @@ -146,10 +88,13 @@ impl Config { source: Box::new(source), })?; interpolate_value(&mut document)?; - let config: Self = document.try_into().map_err(|source| ConfigError::Parse { + let mut config: Self = document.try_into().map_err(|source| ConfigError::Parse { path: path.map(Path::to_path_buf), source: Box::new(source), })?; + if config.gateway.base_url.is_empty() { + config.gateway.base_url = DEFAULT_GATEWAY_BASE_URL.to_string(); + } Ok(config) } } @@ -202,10 +147,10 @@ impl Default for ServerConfig { } /// Default sliding-window length for interim transcription, in seconds. -pub const DEFAULT_VOICE_WINDOW_SECONDS: u64 = 5; +pub const DEFAULT_VOICE_WINDOW_SECONDS: u64 = 15; /// Default interval between interim transcriptions, in milliseconds. -pub const DEFAULT_VOICE_INTERVAL_MS: u64 = 800; +pub const DEFAULT_VOICE_INTERVAL_MS: u64 = 500; /// Voice transcription settings: whisper model paths and the interim loop's /// window and cadence. @@ -227,10 +172,21 @@ pub struct VoiceConfig { /// Empty disables the final pass; the final transcript then comes from /// the interim model. pub final_model: PathBuf, + /// URL the interim model can be downloaded from. Informational until + /// the gateway cache integration lands; empty means no known source. + pub interim_source: String, + /// URL the final-pass model can be downloaded from. Informational + /// until the gateway cache integration lands; empty means no known + /// source. + pub final_source: String, /// Seconds of trailing audio each interim pass transcribes. pub window_seconds: u64, /// Milliseconds between interim passes while a take is recording. pub interval_ms: u64, + /// Domain terms whisper is biased toward (for example `MCP`, `GGUF`, + /// `Lua`), formatted into a glossary conditioning prompt on both + /// workers. Empty disables biasing. + pub vocabulary: Vec, } impl Default for VoiceConfig { @@ -238,8 +194,11 @@ impl Default for VoiceConfig { Self { interim_model: PathBuf::new(), final_model: PathBuf::new(), + interim_source: String::new(), + final_source: String::new(), window_seconds: DEFAULT_VOICE_WINDOW_SECONDS, interval_ms: DEFAULT_VOICE_INTERVAL_MS, + vocabulary: Vec::new(), } } } @@ -256,6 +215,14 @@ impl VoiceConfig { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum ConfigError { + /// The configuration file does not exist. + #[non_exhaustive] + #[error("config file not found: {}", path.display())] + NotFound { + /// The path that was expected. + path: PathBuf, + }, + /// The configuration file could not be read. #[non_exhaustive] #[error("read config {}", path.display())] @@ -278,33 +245,17 @@ pub enum ConfigError { source: Box, }, - /// A `${VAR}` referenced an environment variable that was not set. + /// A `${VAR}` named an environment variable whose value is not valid + /// Unicode. An unset variable is not an error; it interpolates to the + /// empty string. #[non_exhaustive] - #[error("unresolved environment variable {0}")] + #[error("environment variable {0} is not valid Unicode")] UnresolvedVar(String), /// A `${...}` interpolation was malformed (for example, unclosed). #[non_exhaustive] #[error("interpolation: {0}")] Interpolation(String), - - /// A required environment variable was not set (env-only config path). - #[non_exhaustive] - #[error("required environment variable {name} is not set")] - MissingEnvVar { - /// The variable that was expected. - name: String, - }, - - /// An environment variable could not be parsed as the expected type. - #[non_exhaustive] - #[error("environment variable {name}: {reason}")] - InvalidEnvVar { - /// The variable that was malformed. - name: String, - /// What went wrong. - reason: String, - }, } /// Renders the optional parse-failure path as a ` (path)` suffix or empty. @@ -313,7 +264,9 @@ fn parse_location(path: Option<&Path>) -> String { .unwrap_or_default() } -/// Expands `${VAR}` from the environment; `$$` is a literal `$`. +/// Expands `${VAR}` from the environment; `$$` is a literal `$`. An unset +/// variable expands to the empty string; a variable whose value is not +/// valid Unicode is an error. fn interpolate(input: &str) -> Result { let mut out = String::with_capacity(input.len()); let mut chars = input.chars().peekable(); @@ -343,9 +296,13 @@ fn interpolate(input: &str) -> Result { "unclosed ${...} interpolation".to_string(), )); } - let value = - std::env::var(&name).map_err(|_| ConfigError::UnresolvedVar(name.clone()))?; - out.push_str(&value); + match std::env::var(&name) { + Ok(value) => out.push_str(&value), + Err(std::env::VarError::NotPresent) => {} + Err(std::env::VarError::NotUnicode(_)) => { + return Err(ConfigError::UnresolvedVar(name.clone())); + } + } } _ => out.push('$'), } @@ -454,8 +411,34 @@ api_key = "k" "#; let config = Config::from_toml_str(raw).expect("fixture parses"); assert!(!config.voice.enabled(), "no model paths means disabled"); + assert!(config.voice.interim_source.is_empty()); + assert!(config.voice.final_source.is_empty()); assert_eq!(config.voice.window_seconds, DEFAULT_VOICE_WINDOW_SECONDS); assert_eq!(config.voice.interval_ms, DEFAULT_VOICE_INTERVAL_MS); + assert!(config.voice.vocabulary.is_empty()); + } + + #[test] + fn voice_section_parses_model_source_urls() { + let raw = r#" +[gateway] +base_url = "http://127.0.0.1:8081" +api_key = "k" + +[voice] +interim_source = "https://example.com/models/ggml-large-v3-turbo.bin" +final_source = "https://example.com/models/ggml-large-v3.bin" +"#; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert!(!config.voice.enabled(), "sources alone do not enable voice"); + assert_eq!( + config.voice.interim_source, + "https://example.com/models/ggml-large-v3-turbo.bin" + ); + assert_eq!( + config.voice.final_source, + "https://example.com/models/ggml-large-v3.bin" + ); } #[test] @@ -485,6 +468,20 @@ interval_ms = 500 assert_eq!(config.voice.interval_ms, 500); } + #[test] + fn voice_section_parses_vocabulary() { + let raw = r#" +[gateway] +base_url = "http://127.0.0.1:8081" +api_key = "k" + +[voice] +vocabulary = ["MCP", "GGUF", "Lua"] +"#; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.voice.vocabulary, ["MCP", "GGUF", "Lua"]); + } + #[test] fn double_dollar_is_literal() { let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"cost $$5\"\n"; @@ -493,14 +490,25 @@ interval_ms = 500 } #[test] - fn unset_variable_is_an_error() { + fn unset_variable_interpolates_to_empty() { let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"${PFG_WB_DEFINITELY_UNSET_XYZ}\"\n"; - let err = Config::from_toml_str(raw).expect_err("unset variable must fail"); - assert!( - matches!(err, ConfigError::UnresolvedVar(ref name) if name == "PFG_WB_DEFINITELY_UNSET_XYZ"), - "expected UnresolvedVar, got {err:?}" - ); + let config = Config::from_toml_str(raw).expect("unset variable resolves to empty"); + assert_eq!(config.gateway.api_key, ""); + } + + #[test] + fn empty_base_url_falls_back_to_the_default() { + let raw = "[gateway]\nbase_url = \"${PFG_WB_DEFINITELY_UNSET_XYZ}\"\napi_key = \"k\"\n"; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.gateway.base_url, DEFAULT_GATEWAY_BASE_URL); + } + + #[test] + fn explicit_base_url_is_kept() { + let raw = "[gateway]\nbase_url = \"http://gw:9999\"\napi_key = \"k\"\n"; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.gateway.base_url, "http://gw:9999"); } #[test] @@ -528,8 +536,8 @@ interval_ms = 500 let err = Config::load(Path::new("definitely-missing-workbench.toml")) .expect_err("missing file must fail"); assert!( - matches!(err, ConfigError::Read { .. }), - "expected Read, got {err:?}" + matches!(err, ConfigError::NotFound { .. }), + "expected NotFound, got {err:?}" ); assert!( err.to_string() @@ -538,6 +546,23 @@ interval_ms = 500 ); } + #[test] + fn unreadable_existing_file_is_a_read_error() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("workbench.toml"); + std::fs::write(&path, "[gateway]\n").expect("write fixture"); + // A directory-shaped read failure: replace the file with a + // directory of the same name so the read fails for a reason other + // than NotFound. + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::create_dir(&path).expect("directory in the file's place"); + let err = Config::load(&path).expect_err("unreadable path must fail"); + assert!( + matches!(err, ConfigError::Read { .. }), + "expected Read, got {err:?}" + ); + } + #[test] fn parse_error_names_the_file() { let dir = tempfile::TempDir::new().expect("tempdir"); @@ -566,64 +591,4 @@ interval_ms = 500 let config = Config::load(&path).expect("fixture loads"); assert_eq!(config.gateway.api_key, "k"); } - - #[test] - fn from_env_produces_valid_config_with_required_vars() { - use std::collections::HashMap; - let mut env: HashMap<&str, &str> = HashMap::new(); - env.insert("PROMPTFORGE_GATEWAY_API_KEY", "test-secret"); - - let config = Config::from_env_lookup(|name| env.get(name).map(|v| (*v).to_string())) - .expect("env config with defaults"); - assert_eq!(config.gateway.api_key, "test-secret"); - assert_eq!(config.gateway.base_url, "http://127.0.0.1:8081"); - assert_eq!(config.tape.path, PathBuf::from("tape.jsonl")); - assert_eq!(config.server.bind, "127.0.0.1:7910"); - assert!(!config.server.open_browser); - assert!(!config.voice.enabled()); - assert_eq!(config.voice.window_seconds, DEFAULT_VOICE_WINDOW_SECONDS); - assert_eq!(config.voice.interval_ms, DEFAULT_VOICE_INTERVAL_MS); - } - - #[test] - fn from_env_errors_when_api_key_missing() { - let env: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); - let err = Config::from_env_lookup(|name| env.get(name).map(|v| (*v).to_string())) - .expect_err("missing key must fail"); - assert!( - matches!(err, ConfigError::MissingEnvVar { ref name } if name == "PROMPTFORGE_GATEWAY_API_KEY"), - "expected MissingEnvVar, got {err:?}" - ); - assert!( - err.to_string().contains("PROMPTFORGE_GATEWAY_API_KEY"), - "error names the variable: {err}" - ); - } - - #[test] - fn from_env_respects_all_overrides() { - use std::collections::HashMap; - let mut env: HashMap<&str, &str> = HashMap::new(); - env.insert("PROMPTFORGE_GATEWAY_API_KEY", "k"); - env.insert("PROMPTFORGE_GATEWAY_BASE_URL", "http://gw:9999"); - env.insert("PROMPTFORGE_TAPE_PATH", "custom.jsonl"); - env.insert("PROMPTFORGE_SERVER_BIND", "0.0.0.0:8080"); - env.insert("PROMPTFORGE_SERVER_OPEN_BROWSER", "1"); - env.insert("PROMPTFORGE_VOICE_INTERIM_MODEL", "m1.bin"); - env.insert("PROMPTFORGE_VOICE_FINAL_MODEL", "m2.bin"); - env.insert("PROMPTFORGE_VOICE_WINDOW_SECONDS", "10"); - env.insert("PROMPTFORGE_VOICE_INTERVAL_MS", "400"); - - let config = Config::from_env_lookup(|name| env.get(name).map(|v| (*v).to_string())) - .expect("env config with all overrides"); - assert_eq!(config.gateway.base_url, "http://gw:9999"); - assert_eq!(config.tape.path, PathBuf::from("custom.jsonl")); - assert_eq!(config.server.bind, "0.0.0.0:8080"); - assert!(config.server.open_browser); - assert!(config.voice.enabled()); - assert_eq!(config.voice.interim_model, PathBuf::from("m1.bin")); - assert_eq!(config.voice.final_model, PathBuf::from("m2.bin")); - assert_eq!(config.voice.window_seconds, 10); - assert_eq!(config.voice.interval_ms, 400); - } } diff --git a/crates/promptforge-wb-server/src/gateway.rs b/crates/promptforge-wb-server/src/gateway.rs index b6a2b758..2027f798 100644 --- a/crates/promptforge-wb-server/src/gateway.rs +++ b/crates/promptforge-wb-server/src/gateway.rs @@ -7,11 +7,29 @@ //! are decoded from SSE into a [`SsePayloadStream`] of `data:` payloads. use std::collections::VecDeque; +use std::path::PathBuf; use std::pin::Pin; +use std::time::Duration; use futures_util::stream::{self, Stream, StreamExt}; use serde::{Deserialize, Serialize}; +/// Bound on a single `GET /health` probe: a gateway that accepts the +/// connection but never answers must still read as unreachable, and two +/// seconds keeps the probe well under the heartbeat interval it serves. +const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(2); + +/// TCP connect timeout applied to every request. A gateway that is down or +/// unreachable should fail fast rather than hanging for the OS default (~21 s +/// on Linux, ~75 s on Windows). +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Whole-request timeout for non-streaming operations: model catalog fetch, +/// buffered chat completions, and the initial cache API handshake. Streaming +/// responses (SSE chat and cache downloads) use no whole-request timeout +/// since they can legitimately run for minutes. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + /// A non-streaming chat completion request forwarded to the gateway. /// /// This is the body accepted by the workbench's `POST /chat` and sent @@ -72,6 +90,84 @@ impl std::fmt::Debug for ChatStream { } } +/// The gateway's answer to a cache-ensure request, `POST /v1/cache`. +/// +/// The gateway answers a cache hit with a buffered JSON `ready` event and a +/// miss with an SSE stream of `downloading` progress events terminated by a +/// `ready` or `error` event; both event shapes decode as [`CacheEvent`]. A +/// non-success status (a declined or failed request) is buffered rather +/// than reported as an error, matching the relay contract of the other +/// client methods. +#[non_exhaustive] +pub enum CacheResponse { + /// The gateway is downloading the blob; `payloads` carries the SSE + /// stream of [`CacheEvent`] JSON documents. + #[non_exhaustive] + Download { + /// The gateway's success status. + status: reqwest::StatusCode, + /// The SSE payload stream, ending in a terminal `ready` or `error` + /// event. + payloads: SsePayloadStream, + }, + + /// Any other answer, buffered: a cache hit's `ready` JSON on a success + /// status, or the gateway's error envelope on a failure status. + #[non_exhaustive] + Buffered(GatewayResponse), +} + +// Manual because the boxed payload stream has no `Debug` impl. +impl std::fmt::Debug for CacheResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Download { status, .. } => f + .debug_struct("CacheResponse::Download") + .field("status", status) + .finish_non_exhaustive(), + Self::Buffered(response) => f + .debug_tuple("CacheResponse::Buffered") + .field(response) + .finish(), + } + } +} + +/// One event of the gateway cache API: a download progress sample, or the +/// terminal state of a cache-ensure call. +/// +/// The `path` a `Ready` event carries names a file on the gateway host, so +/// the cache API is only meaningful to a workbench sharing the gateway's +/// filesystem - the standard local deployment, where both run on loopback. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "status", rename_all = "lowercase")] +#[non_exhaustive] +pub enum CacheEvent { + /// A progress sample from a running download. + #[non_exhaustive] + Downloading { + /// Cumulative bytes downloaded so far. + bytes: u64, + /// Total bytes expected; null when the upstream server sent no + /// Content-Length. + total: Option, + }, + + /// The blob is cached and ready at `path`. + #[non_exhaustive] + Ready { + /// Local path of the cached blob on the gateway host. + path: PathBuf, + }, + + /// The download failed. + #[non_exhaustive] + Error { + /// The gateway's description of the failure. + message: String, + }, +} + /// A gateway request failure. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -99,7 +195,8 @@ pub enum GatewayError { } /// Bearer-authenticated client for the gateway's OpenAI-compatible -/// endpoints. +/// endpoints. An empty API key sends no `Authorization` header at all, for +/// gateways running with authentication disabled. #[derive(Clone)] pub struct GatewayClient { http: reqwest::Client, @@ -121,11 +218,14 @@ impl GatewayClient { /// Builds a client for `base_url` authenticating with `api_key`. /// /// A trailing slash on `base_url` is trimmed so route joins stay clean. + /// An empty `api_key` disables authentication: requests then carry no + /// `Authorization` header. /// /// # Errors /// Returns [`GatewayError::Build`] if the TLS backend cannot initialize. pub fn new(base_url: &str, api_key: &str) -> Result { let http = reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) .build() .map_err(|source| GatewayError::Build(Box::new(source)))?; Ok(Self { @@ -135,6 +235,35 @@ impl GatewayClient { }) } + /// Applies bearer authentication to `request`, unless the client was + /// built with an empty API key, in which case the request goes out with + /// no `Authorization` header. + fn authorize(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if self.api_key.is_empty() { + request + } else { + request.bearer_auth(&self.api_key) + } + } + + /// Probes the gateway's liveness endpoint, `GET /health`. + /// + /// Returns `true` only when the gateway answers with a success status: + /// a transport failure, a probe timeout, or a non-success answer all + /// read as unreachable. The request never carries the client's API key + /// (the endpoint is unauthenticated by design) and is capped at + /// [`HEALTH_PROBE_TIMEOUT`]. + pub async fn health(&self) -> bool { + let probe = self + .http + .get(format!("{}/health", self.base_url)) + .timeout(HEALTH_PROBE_TIMEOUT); + match probe.send().await { + Ok(response) => response.status().is_success(), + Err(_) => false, + } + } + /// Fetches the gateway's model catalog from `GET /v1/models`. /// /// A non-success status is relayed in the returned @@ -146,9 +275,8 @@ impl GatewayClient { /// be read. pub async fn list_models(&self) -> Result { let response = self - .http - .get(format!("{}/v1/models", self.base_url)) - .bearer_auth(&self.api_key) + .authorize(self.http.get(format!("{}/v1/models", self.base_url))) + .timeout(REQUEST_TIMEOUT) .send() .await .map_err(|source| GatewayError::Transport(Box::new(source)))?; @@ -170,10 +298,12 @@ impl GatewayClient { request: &ChatRequest, ) -> Result { let response = self - .http - .post(format!("{}/v1/chat/completions", self.base_url)) - .bearer_auth(&self.api_key) + .authorize( + self.http + .post(format!("{}/v1/chat/completions", self.base_url)), + ) .json(request) + .timeout(REQUEST_TIMEOUT) .send() .await .map_err(|source| GatewayError::Transport(Box::new(source)))?; @@ -203,9 +333,10 @@ impl GatewayClient { object.insert("stream".to_string(), serde_json::Value::Bool(true)); } let response = self - .http - .post(format!("{}/v1/chat/completions", self.base_url)) - .bearer_auth(&self.api_key) + .authorize( + self.http + .post(format!("{}/v1/chat/completions", self.base_url)), + ) .json(&body) .send() .await @@ -219,6 +350,42 @@ impl GatewayClient { payloads: payload_stream(response), }) } + + /// Posts a cache-ensure request to `POST /v1/cache`, asking the gateway + /// to make the blob at `source` available locally. + /// + /// A cache hit answers a buffered JSON `ready` event + /// ([`CacheResponse::Buffered`] on a success status); a miss answers + /// `text/event-stream` and returns [`CacheResponse::Download`], whose + /// payload stream ends in a terminal `ready` or `error` event. A + /// non-success status is buffered and returned, not reported as an + /// error. + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the request cannot be + /// completed and [`GatewayError::ReadBody`] if a buffered answer's body + /// cannot be read. + pub async fn cache_ensure(&self, source: &str) -> Result { + let response = self + .authorize(self.http.post(format!("{}/v1/cache", self.base_url))) + .json(&serde_json::json!({ "source": source })) + .send() + .await + .map_err(|source| GatewayError::Transport(Box::new(source)))?; + let status = response.status(); + let streaming = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/event-stream")); + if status.is_success() && streaming { + return Ok(CacheResponse::Download { + status, + payloads: payload_stream(response), + }); + } + read(response).await.map(CacheResponse::Buffered) + } } /// Captures the status and raw body of a gateway response. @@ -333,6 +500,8 @@ impl SseDecoder { mod tests { use super::*; + use axum::response::IntoResponse; + #[test] fn trailing_slash_is_trimmed_from_base_url() { let client = GatewayClient::new("http://127.0.0.1:8081/", "k").expect("client builds"); @@ -410,4 +579,212 @@ mod tests { assert_eq!(decoder.pop().as_deref(), Some("kept")); assert!(decoder.pop().is_none()); } + + #[test] + fn cache_event_decodes_each_wire_shape() { + let downloading: CacheEvent = + serde_json::from_str(r#"{"status":"downloading","bytes":5,"total":10}"#) + .expect("downloading decodes"); + assert_eq!( + downloading, + CacheEvent::Downloading { + bytes: 5, + total: Some(10) + } + ); + let unknown_total: CacheEvent = + serde_json::from_str(r#"{"status":"downloading","bytes":5,"total":null}"#) + .expect("a null total decodes"); + assert_eq!( + unknown_total, + CacheEvent::Downloading { + bytes: 5, + total: None + } + ); + let ready: CacheEvent = + serde_json::from_str(r#"{"status":"ready","path":"/cache/ggml.bin"}"#) + .expect("ready decodes"); + assert_eq!( + ready, + CacheEvent::Ready { + path: PathBuf::from("/cache/ggml.bin") + } + ); + let error: CacheEvent = + serde_json::from_str(r#"{"status":"error","message":"boom"}"#).expect("error decodes"); + assert_eq!( + error, + CacheEvent::Error { + message: "boom".to_string() + } + ); + } + + /// Mock cache route state: the last request's auth header and body, + /// captured so tests can assert what the client sent. + #[derive(Clone, Default)] + struct CacheProbe { + authorized: std::sync::Arc, + sources: std::sync::Arc>>, + } + + impl CacheProbe { + fn sources(&self) -> Vec { + self.sources + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + } + + /// Binds `app` on a free loopback port and returns its base URL. + async fn serve(app: axum::Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn a_cache_hit_answers_a_buffered_ready_event() { + let probe = CacheProbe::default(); + let seen = probe.clone(); + let app = axum::Router::new().route( + "/v1/cache", + axum::routing::post( + move |headers: axum::http::HeaderMap, body: axum::Json| { + let seen = seen.clone(); + async move { + seen.authorized.store( + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + == Some("Bearer test-key"), + std::sync::atomic::Ordering::Relaxed, + ); + seen.sources + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push( + body["source"] + .as_str() + .expect("source is a string") + .to_string(), + ); + axum::Json(serde_json::json!({ + "path": "/cache/ggml-large-v3-turbo.bin", + "status": "ready", + })) + .into_response() + } + }, + ), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "test-key").expect("client builds in tests"); + let response = client + .cache_ensure("https://example.com/models/ggml-large-v3-turbo.bin") + .await + .expect("the request completes"); + let CacheResponse::Buffered(answer) = response else { + panic!("a cache hit is buffered, got {response:?}"); + }; + assert!(answer.status.is_success()); + let event: CacheEvent = + serde_json::from_slice(&answer.body).expect("the hit body is a ready event"); + assert_eq!( + event, + CacheEvent::Ready { + path: PathBuf::from("/cache/ggml-large-v3-turbo.bin") + } + ); + assert!(probe.authorized.load(std::sync::atomic::Ordering::Relaxed)); + assert_eq!( + probe.sources(), + ["https://example.com/models/ggml-large-v3-turbo.bin"] + ); + } + + #[tokio::test] + async fn a_cache_miss_answers_a_download_stream() { + let app = axum::Router::new().route( + "/v1/cache", + axum::routing::post(|| async { + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + concat!( + "data: {\"status\":\"downloading\",\"bytes\":5,\"total\":null}\n\n", + "data: {\"status\":\"downloading\",\"bytes\":10,\"total\":12}\n\n", + "data: {\"status\":\"ready\",\"path\":\"/cache/ggml.bin\"}\n\n", + ), + ) + }), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let response = client + .cache_ensure("https://example.com/models/ggml.bin") + .await + .expect("the request completes"); + let CacheResponse::Download { mut payloads, .. } = response else { + panic!("a cache miss streams, got {response:?}"); + }; + let mut events = Vec::new(); + while let Some(item) = payloads.next().await { + let payload = item.expect("the stream is clean"); + events.push( + serde_json::from_str::(&payload) + .expect("each payload is a cache event"), + ); + } + assert_eq!( + events, + [ + CacheEvent::Downloading { + bytes: 5, + total: None + }, + CacheEvent::Downloading { + bytes: 10, + total: Some(12) + }, + CacheEvent::Ready { + path: PathBuf::from("/cache/ggml.bin") + }, + ], + "the stream carries progress samples then the terminal ready" + ); + } + + #[tokio::test] + async fn a_declined_cache_request_is_buffered_not_an_error() { + let app = axum::Router::new().route( + "/v1/cache", + axum::routing::post(|| async { + ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": {"message": "bad source", "code": "malformed_request"} + })), + ) + }), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let response = client + .cache_ensure("not-a-url") + .await + .expect("a declined request still completes"); + let CacheResponse::Buffered(answer) = response else { + panic!("a declined request is buffered, got {response:?}"); + }; + assert_eq!(answer.status, reqwest::StatusCode::BAD_REQUEST); + } } diff --git a/crates/promptforge-wb-server/src/heartbeat.rs b/crates/promptforge-wb-server/src/heartbeat.rs new file mode 100644 index 00000000..b8bb7926 --- /dev/null +++ b/crates/promptforge-wb-server/src/heartbeat.rs @@ -0,0 +1,468 @@ +//! The gateway heartbeat: a background task polling the gateway's +//! `GET /health` endpoint and publishing reachability to the rest of the +//! server. +//! +//! One task is spawned with the server ([`spawn`]) and loops on the fixed +//! [`HEARTBEAT_INTERVAL`]: each tick probes the gateway through +//! [`GatewayClient::health`] and publishes the outcome to the shared +//! [`GatewayHealth`] flag the gateway-dependent routes read. The observer +//! hears about transitions only - the first probe reports the initial state +//! ("Connected to gateway" or "Gateway unreachable"), and after that a +//! status update fires when the answer changes, so a steady state never +//! spams the status bar. +//! +//! The task stops through its [`Heartbeat`] handle: the signal wins the +//! loop's selects, so shutdown never waits out a tick or an in-flight +//! probe. The server runs the shutdown inside its graceful-shutdown future. + +use std::time::Duration; + +use tokio::sync::{oneshot, watch}; + +use crate::catalog::CatalogBus; +use crate::gateway::GatewayClient; +use crate::status::{Activity, StatusBus}; + +/// How often the heartbeat probes the gateway. Hardcoded for now; a +/// configuration knob may follow once someone needs one. +pub(crate) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); + +/// Shared gateway reachability, written by the heartbeat and read by the +/// gateway-dependent routes. +/// +/// The flag starts optimistic (`true`): until the first probe lands, a +/// request flows to the gateway and fails or succeeds on its own merits, +/// which keeps a server running without a heartbeat (every router-only +/// test) behaving exactly as it did before the heartbeat existed. +#[derive(Debug, Clone)] +pub(crate) struct GatewayHealth { + reachable: watch::Sender, +} + +impl GatewayHealth { + /// Starts the flag optimistic; see the type docs for why. + pub(crate) fn new() -> Self { + Self { + reachable: watch::channel(true).0, + } + } + + /// Whether the gateway is currently believed reachable. + pub(crate) fn is_reachable(&self) -> bool { + *self.reachable.borrow() + } + + /// Subscribes to reachability changes. The current value is visible + /// immediately through the receiver; each later publish that flips the + /// flag notifies. The provisioning task waits on this to run its cache + /// calls only while the gateway answers. + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.reachable.subscribe() + } + + /// Publishes one probe outcome. The heartbeat is the only production + /// writer; tests publish directly to pin the degraded paths. + pub(crate) fn publish(&self, reachable: bool) { + self.reachable.send_if_modified(|current| { + let changed = *current != reachable; + *current = reachable; + changed + }); + } +} + +/// A running heartbeat task. +/// +/// [`Heartbeat::shutdown`] signals the loop to stop and awaits the task. +/// Dropping the handle without shutting down still stops the task at its +/// next select point, because the closed channel resolves the stop branch. +#[derive(Debug)] +pub(crate) struct Heartbeat { + stop: Option>, + task: Option>, +} + +impl Heartbeat { + /// Signals the heartbeat to stop and waits for its task to finish. + pub(crate) async fn shutdown(mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + } +} + +/// Spawns the heartbeat loop against `client`, reporting transitions +/// through `status` and publishing reachability to `health`. A transition +/// back to reachable also re-fetches the model catalog and pushes it on +/// `catalog`. The first probe runs immediately, before the first interval +/// elapses. +pub(crate) fn spawn( + client: GatewayClient, + status: StatusBus, + health: GatewayHealth, + catalog: CatalogBus, + interval: Duration, +) -> Heartbeat { + let (stop, mut stopped) = oneshot::channel(); + let task = tokio::spawn(async move { + run(&client, &status, &health, &catalog, interval, &mut stopped).await; + }); + Heartbeat { + stop: Some(stop), + task: Some(task), + } +} + +/// The probe loop: one probe per interval, a status update per transition, +/// and the stop signal wins over the tick, an in-flight probe, and an +/// in-flight catalog refresh. +async fn run( + client: &GatewayClient, + status: &StatusBus, + health: &GatewayHealth, + catalog: &CatalogBus, + interval: Duration, + stop: &mut oneshot::Receiver<()>, +) { + let mut ticks = tokio::time::interval(interval); + // A probe slower than the interval (the health timeout bounds it at two + // seconds) must not bunch the missed ticks into a catch-up burst. + ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut last: Option = None; + loop { + tokio::select! { + _ = &mut *stop => break, + _ = ticks.tick() => {} + } + let reachable = tokio::select! { + _ = &mut *stop => break, + reachable = client.health() => reachable, + }; + health.publish(reachable); + if last == Some(reachable) { + continue; + } + let previous = last.replace(reachable); + if reachable { + status.info( + "Connected to gateway", + "the gateway answers its health probe", + Activity::General, + ); + // A gateway that was down and answers again may serve a + // different catalog than before the outage. The initial + // connect pushes nothing: a fresh UI fetches the catalog + // itself on boot. + if previous == Some(false) { + tokio::select! { + _ = &mut *stop => break, + () = refresh_catalog(client, catalog) => {} + } + } + } else { + status.info( + "Gateway unreachable", + "the gateway does not answer its health probe", + Activity::General, + ); + } + } +} + +/// Re-fetches the gateway's model catalog and pushes it to every session. +/// +/// A failed, declined, or malformed catalog is logged and skipped rather +/// than pushed: pushing a bad snapshot would clear pickers that still hold +/// a usable list. +async fn refresh_catalog(client: &GatewayClient, catalog: &CatalogBus) { + let response = match client.list_models().await { + Ok(response) => response, + Err(error) => { + tracing::warn!(%error, "catalog refresh after reconnect failed"); + return; + } + }; + if !response.status.is_success() { + tracing::warn!(status = %response.status, "catalog refresh after reconnect was declined"); + return; + } + let body: serde_json::Value = match serde_json::from_slice(&response.body) { + Ok(body) => body, + Err(error) => { + tracing::warn!(%error, "catalog refresh after reconnect was not JSON"); + return; + } + }; + let Some(models) = body.get("data").and_then(serde_json::Value::as_array) else { + tracing::warn!("catalog refresh after reconnect carried no data array"); + return; + }; + catalog.publish(models.clone()); +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + use axum::Router; + use axum::extract::State; + use axum::http::StatusCode; + use axum::response::{IntoResponse, Response}; + use axum::routing::get; + use tokio::sync::broadcast; + + use crate::catalog::CatalogPush; + use crate::status::{Severity, StatusBarUpdate}; + + /// Fast enough to observe transitions without real waiting, slow + /// enough that a 200 ms quiet window spans several ticks and so proves + /// the loop does not re-emit a steady state. + const TEST_INTERVAL: Duration = Duration::from_millis(25); + + const CATALOG: &str = r#"{"object":"list","data":[{"id":"test-model","object":"model","owned_by":"promptforge"}]}"#; + + /// A mock `/health` whose answer flips under test control. + async fn flippable_health(State(healthy): State>) -> Response { + if healthy.load(Ordering::Relaxed) { + StatusCode::OK.into_response() + } else { + StatusCode::SERVICE_UNAVAILABLE.into_response() + } + } + + /// A static mock catalog for the refresh-on-reconnect tests. + async fn mock_models() -> Response { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + CATALOG, + ) + .into_response() + } + + /// Binds a mock gateway whose `/health` flips with `healthy`, with a + /// static `/v1/models` beside it. + async fn spawn_gateway(healthy: Arc) -> String { + let app = Router::new() + .route("/health", get(flippable_health)) + .route("/v1/models", get(mock_models)) + .with_state(healthy); + serve(app).await + } + + /// Binds `app` on a free loopback port and returns its base URL. + async fn serve(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") + } + + /// Starts a heartbeat against `base_url` on the fast interval, wired to + /// `status` and `catalog`; returns the handle and the shared health + /// flag. + fn heartbeat_on( + base_url: &str, + status: &StatusBus, + catalog: &CatalogBus, + ) -> (Heartbeat, GatewayHealth) { + let client = GatewayClient::new(base_url, "").expect("client builds in tests"); + let health = GatewayHealth::new(); + let heartbeat = spawn( + client, + status.clone(), + health.clone(), + catalog.clone(), + TEST_INTERVAL, + ); + (heartbeat, health) + } + + /// Receives the next status update within a generous deadline. + async fn next_update(rx: &mut broadcast::Receiver) -> StatusBarUpdate { + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("a status update arrives within the deadline") + .expect("the status bus is open") + } + + /// Asserts no update arrives within a window spanning several ticks. + async fn assert_quiet(rx: &mut broadcast::Receiver) { + let quiet = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await; + assert!( + quiet.is_err(), + "a steady state must not re-emit, got {quiet:?}" + ); + } + + #[tokio::test] + async fn a_healthy_gateway_fires_connected_once_and_stays_quiet() { + let healthy = Arc::new(AtomicBool::new(true)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut rx = status.subscribe(); + let (heartbeat, health) = heartbeat_on(&base_url, &status, &catalog); + + let update = next_update(&mut rx).await; + assert_eq!(update.label, "Connected to gateway"); + assert_eq!(update.severity, Severity::Info); + assert_eq!(update.activity, Activity::General); + assert!(health.is_reachable(), "the probe published reachable"); + assert_quiet(&mut rx).await; + heartbeat.shutdown().await; + } + + #[tokio::test] + async fn an_unreachable_gateway_fires_unreachable_once_and_stays_quiet() { + // Nothing listens on port 1, so the connect fails deterministically. + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut rx = status.subscribe(); + let (heartbeat, health) = heartbeat_on("http://127.0.0.1:1", &status, &catalog); + + let update = next_update(&mut rx).await; + assert_eq!(update.label, "Gateway unreachable"); + assert_eq!(update.severity, Severity::Info); + assert_eq!(update.activity, Activity::General); + assert!(!health.is_reachable(), "the probe published unreachable"); + assert_quiet(&mut rx).await; + heartbeat.shutdown().await; + } + + #[tokio::test] + async fn each_transition_fires_exactly_one_update() { + let healthy = Arc::new(AtomicBool::new(true)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut rx = status.subscribe(); + let (heartbeat, health) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!(next_update(&mut rx).await.label, "Connected to gateway"); + healthy.store(false, Ordering::Relaxed); + assert_eq!(next_update(&mut rx).await.label, "Gateway unreachable"); + assert!(!health.is_reachable()); + healthy.store(true, Ordering::Relaxed); + assert_eq!(next_update(&mut rx).await.label, "Connected to gateway"); + assert!(health.is_reachable()); + assert_quiet(&mut rx).await; + heartbeat.shutdown().await; + } + + #[tokio::test] + async fn a_reconnect_pushes_the_refreshed_catalog() { + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut status_rx = status.subscribe(); + let mut catalog_rx = catalog.subscribe(); + let (heartbeat, _health) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Gateway unreachable" + ); + healthy.store(true, Ordering::Relaxed); + assert_eq!( + next_update(&mut status_rx).await.label, + "Connected to gateway" + ); + let push: CatalogPush = tokio::time::timeout(Duration::from_secs(5), catalog_rx.recv()) + .await + .expect("the refreshed catalog arrives within the deadline") + .expect("the catalog bus is open"); + assert_eq!( + push.models, + serde_json::json!([{"id": "test-model", "object": "model", "owned_by": "promptforge"}]) + .as_array() + .expect("the fixture is an array") + .clone(), + "the push carries the gateway's data array verbatim" + ); + heartbeat.shutdown().await; + } + + #[tokio::test] + async fn a_reconnect_whose_refresh_is_declined_pushes_no_catalog() { + // No /v1/models route: the refresh is declined with a 404, and a + // declined refresh is skipped rather than pushed - pushing it + // would empty pickers that still hold a usable list. + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = serve( + Router::new() + .route("/health", get(flippable_health)) + .with_state(Arc::clone(&healthy)), + ) + .await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut status_rx = status.subscribe(); + let mut catalog_rx = catalog.subscribe(); + let (heartbeat, _health) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Gateway unreachable" + ); + healthy.store(true, Ordering::Relaxed); + assert_eq!( + next_update(&mut status_rx).await.label, + "Connected to gateway" + ); + let quiet = tokio::time::timeout(Duration::from_millis(200), catalog_rx.recv()).await; + assert!(quiet.is_err(), "a declined refresh is skipped, not pushed"); + heartbeat.shutdown().await; + } + + #[tokio::test] + async fn the_initial_connect_pushes_no_catalog() { + // A fresh UI fetches the catalog itself on boot; the push exists + // for reconnects only. + let healthy = Arc::new(AtomicBool::new(true)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut status_rx = status.subscribe(); + let mut catalog_rx = catalog.subscribe(); + let (heartbeat, _health) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Connected to gateway" + ); + let quiet = tokio::time::timeout(Duration::from_millis(200), catalog_rx.recv()).await; + assert!(quiet.is_err(), "no catalog push on the initial connect"); + heartbeat.shutdown().await; + } + + #[tokio::test] + async fn shutdown_stops_the_task_without_waiting_out_the_interval() { + // A long interval: if the stop signal did not win the select, the + // shutdown would block for the whole minute. + let status = StatusBus::new(); + let client = GatewayClient::new("http://127.0.0.1:1", "").expect("client builds in tests"); + let heartbeat = spawn( + client, + status, + GatewayHealth::new(), + CatalogBus::new(), + Duration::from_secs(60), + ); + tokio::time::timeout(Duration::from_secs(5), heartbeat.shutdown()) + .await + .expect("shutdown does not wait out the interval"); + } +} diff --git a/crates/promptforge-wb-server/src/lib.rs b/crates/promptforge-wb-server/src/lib.rs index 8499810c..b35457a3 100644 --- a/crates/promptforge-wb-server/src/lib.rs +++ b/crates/promptforge-wb-server/src/lib.rs @@ -7,21 +7,27 @@ //! in-process on its own thread for embedding binaries. mod app; +mod catalog; +mod chat_ws; mod config; mod gateway; +mod heartbeat; +mod provision; mod segment; mod serve; +mod status; mod tape; mod transcribe; mod voice; pub use app::{AppError, AppState, DEFAULT_ADDR, router}; pub use config::{ - Config, ConfigError, DEFAULT_CONFIG_PATH, DEFAULT_VOICE_INTERVAL_MS, + Config, ConfigError, DEFAULT_CONFIG_PATH, DEFAULT_GATEWAY_BASE_URL, DEFAULT_VOICE_INTERVAL_MS, DEFAULT_VOICE_WINDOW_SECONDS, GatewayConfig, ServerConfig, TapeConfig, VoiceConfig, }; pub use gateway::{ - ChatRequest, ChatStream, GatewayClient, GatewayError, GatewayResponse, SsePayloadStream, + CacheEvent, CacheResponse, ChatRequest, ChatStream, GatewayClient, GatewayError, + GatewayResponse, SsePayloadStream, }; pub use serve::{ServerHandle, SpawnError, spawn}; pub use tape::{Tape, TapeError, TapeEvent}; diff --git a/crates/promptforge-wb-server/src/provision.rs b/crates/promptforge-wb-server/src/provision.rs new file mode 100644 index 00000000..5cb1d6be --- /dev/null +++ b/crates/promptforge-wb-server/src/provision.rs @@ -0,0 +1,845 @@ +//! Voice model provisioning: fetching the configured whisper models through +//! the gateway's cache API once the gateway is reachable, then activating +//! the voice engine from the cached paths. +//! +//! The task spawned by [`spawn`] subscribes to the heartbeat's reachability +//! flag and, whenever the gateway answers and the voice engine is not +//! loaded, calls `POST /v1/cache` for each configured model source. A cache +//! hit answers immediately with the cached path; a miss streams download +//! progress events (forwarded to the status bar) that end in a terminal +//! `ready` event carrying the path. When both models resolve, the engine +//! loads from the resolved paths - on the blocking pool, since model +//! loading waits on worker-thread init - and the shared [`VoiceSlot`] is +//! activated, so the next `/voice` session transcribes. One successful +//! provisioning ends the task; a failure is logged and reported on the +//! status bus, and the next gateway reconnect retries - a retry hits the +//! cache for every blob the failed attempt already fetched, so it is cheap. +//! +//! The task stops through its [`Provision`] handle: the stop signal wins +//! the loop's selects, so shutdown never waits out a watch change or an +//! in-flight cache call. The server runs the shutdown inside its +//! graceful-shutdown future, next to the heartbeat's. + +use std::path::{Path, PathBuf}; + +use futures_util::StreamExt; +use tokio::sync::oneshot; + +use crate::config::VoiceConfig; +use crate::gateway::{CacheEvent, CacheResponse, GatewayClient, GatewayError}; +use crate::heartbeat::GatewayHealth; +use crate::status::{Activity, Progress, StatusBus}; +use crate::transcribe::{TranscribeError, VoiceEngine, VoiceSlot}; + +/// A running provisioning task. +/// +/// [`Provision::shutdown`] signals the task to stop and awaits it. Dropping +/// the handle without shutting down still stops the task at its next +/// select point, because the closed channel resolves the stop branch. +#[derive(Debug)] +pub(crate) struct Provision { + stop: Option>, + task: Option>, +} + +impl Provision { + /// Signals the task to stop and waits for it to finish. + pub(crate) async fn shutdown(mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + } +} + +/// Spawns the provisioning task against `client`, reporting through +/// `status`, waiting on `health`, and activating `voice` on success. +pub(crate) fn spawn( + client: GatewayClient, + status: StatusBus, + health: GatewayHealth, + voice: VoiceSlot, + config: VoiceConfig, +) -> Provision { + let (stop, mut stopped) = oneshot::channel(); + let task = tokio::spawn(async move { + run(&client, &status, &health, &voice, &config, &mut stopped).await; + }); + Provision { + stop: Some(stop), + task: Some(task), + } +} + +/// The task loop: wait for a reachable gateway, provision, and either +/// finish (success) or park until the next reachability change (failure), +/// so a persistent failure can never spin. +async fn run( + client: &GatewayClient, + status: &StatusBus, + health: &GatewayHealth, + voice: &VoiceSlot, + config: &VoiceConfig, + stop: &mut oneshot::Receiver<()>, +) { + // A loaded engine is never re-provisioned; a configuration with no + // resolvable interim model can never succeed. + if voice.is_active() || !can_provision(config) { + return; + } + let mut reachable = health.subscribe(); + loop { + while !*reachable.borrow_and_update() { + tokio::select! { + _ = &mut *stop => return, + changed = reachable.changed() => { + if changed.is_err() { + return; + } + } + } + } + if voice.is_active() { + return; + } + let outcome = tokio::select! { + _ = &mut *stop => return, + outcome = provision_once(client, status, voice, config) => outcome, + }; + match outcome { + Ok(()) => return, + Err(error) => { + tracing::warn!(%error, "voice model provisioning failed"); + report_failure(status, &error); + } + } + // A failed attempt is retried on the next reconnect and only then: + // wait for the flag to move before looping. + tokio::select! { + _ = &mut *stop => return, + changed = reachable.changed() => { + if changed.is_err() { + return; + } + } + } + } +} + +/// Whether provisioning could ever resolve the interim model: a local file +/// to load, or a source URL to fetch. +fn can_provision(config: &VoiceConfig) -> bool { + config.interim_model.is_file() || !config.interim_source.is_empty() +} + +/// Resolves both whisper models to local paths - the interim model, and +/// the final-pass model when one is configured or sourced - then loads the +/// voice engine from them and activates the slot. +async fn provision_once( + client: &GatewayClient, + status: &StatusBus, + voice: &VoiceSlot, + config: &VoiceConfig, +) -> Result<(), ProvisionError> { + let interim = resolve_model( + client, + status, + &config.interim_model, + &config.interim_source, + ) + .await?; + let final_pass = resolve_final(client, status, config).await?; + let mut resolved = config.clone(); + resolved.interim_model = interim; + resolved.final_model = final_pass.unwrap_or_default(); + // VoiceEngine::new blocks on the worker threads' model init, so it + // runs on the blocking pool and never stalls the executor. + let engine = tokio::task::spawn_blocking(move || VoiceEngine::new(&resolved)) + .await + .map_err(ProvisionError::EngineTask)? + .map_err(ProvisionError::LoadEngine)?; + voice.activate(engine); + status.info( + "Voice ready", + "the whisper models are loaded; push-to-talk transcription is available", + Activity::General, + ); + Ok(()) +} + +/// Resolves one model to a local path: the configured path when the file +/// exists, otherwise a cache fetch of its source URL. +async fn resolve_model( + client: &GatewayClient, + status: &StatusBus, + path: &Path, + source: &str, +) -> Result { + if path.is_file() { + return Ok(path.to_path_buf()); + } + if source.is_empty() { + return Err(ProvisionError::NoSource { + path: path.to_path_buf(), + }); + } + cache_fetch(client, status, source).await +} + +/// Resolves the optional final-pass model. A configured final path that is +/// missing with no source URL degrades to no final pass rather than +/// failing provisioning: the final pass is an enhancement, and takes then +/// close with the interim model as they do when no final model is set. +async fn resolve_final( + client: &GatewayClient, + status: &StatusBus, + config: &VoiceConfig, +) -> Result, ProvisionError> { + if config.final_model.is_file() { + return Ok(Some(config.final_model.clone())); + } + if config.final_source.is_empty() { + if !config.final_model.as_os_str().is_empty() { + status.info( + "Voice final pass unavailable", + format!( + "{} is missing and no final_source is configured; takes close with the interim model", + config.final_model.display() + ), + Activity::General, + ); + } + return Ok(None); + } + cache_fetch(client, status, &config.final_source) + .await + .map(Some) +} + +/// The label filename for a source URL: its last path segment, or the +/// whole source when it has none. +fn source_filename(source: &str) -> &str { + source.rsplit('/').next().unwrap_or(source) +} + +/// Ensures the blob at `source` is cached, returning its local path. A +/// cache hit answers immediately; a miss consumes the download event +/// stream, forwarding each progress sample to the status bar, until the +/// terminal `ready` or `error` event. +/// +/// The stream carries no stall timeout, matching the chat stream's +/// posture: a download that stops mid-way holds the attempt until the +/// connection errors or the server shuts down (the stop signal still wins +/// the task's select, so shutdown stays prompt, and startup never waits +/// on provisioning). A reconnect does not interrupt a stalled attempt. +async fn cache_fetch( + client: &GatewayClient, + status: &StatusBus, + source: &str, +) -> Result { + match client.cache_ensure(source).await? { + CacheResponse::Buffered(answer) if answer.status.is_success() => { + match serde_json::from_slice::(&answer.body) { + Ok(CacheEvent::Ready { path }) => Ok(path), + Ok(_) => Err(ProvisionError::Malformed( + "a cache hit answered an event other than ready".to_string(), + )), + Err(error) => Err(ProvisionError::Malformed(format!( + "the cache hit answer is not a cache event: {error}" + ))), + } + } + CacheResponse::Buffered(answer) => Err(ProvisionError::Declined(answer.status)), + CacheResponse::Download { mut payloads, .. } => loop { + let filename = source_filename(source); + let Some(item) = payloads.next().await else { + return Err(ProvisionError::Malformed( + "the download stream ended without a terminal event".to_string(), + )); + }; + let payload = item?; + let event = serde_json::from_str::(&payload).map_err(|error| { + ProvisionError::Malformed(format!("a download event is not valid JSON: {error}")) + })?; + match event { + CacheEvent::Downloading { bytes, total } => { + // A null total means the upstream sent no + // Content-Length; it crosses the wire as a 0 total, + // which the status bar clamps to a degenerate bar. + status.progress( + format!("Downloading {filename}"), + format!("{source} through the gateway cache"), + Progress { + current: bytes, + total: total.unwrap_or(0), + }, + Activity::General, + ); + } + CacheEvent::Ready { path } => { + status.info( + "Download complete", + format!("{filename} is cached at {}", path.display()), + Activity::General, + ); + return Ok(path); + } + CacheEvent::Error { message } => return Err(ProvisionError::Download(message)), + } + }, + } +} + +/// Reports a provisioning failure. A transport failure means the gateway +/// is not there - the heartbeat's story to tell - so it speaks at Info +/// with the retry note; every other failure is a user-visible error. +fn report_failure(status: &StatusBus, error: &ProvisionError) { + match error { + ProvisionError::Transport(_) => status.info( + "Voice models wait on the gateway", + format!("{error}; provisioning retries when the gateway reconnects"), + Activity::General, + ), + _ => status.error( + "Voice provisioning failed", + format!("{error}; voice stays disabled; a gateway reconnect retries"), + Activity::General, + ), + } +} + +/// A provisioning failure. Voice stays disabled and the app runs on; the +/// task retries on the next gateway reconnect. +#[derive(Debug, thiserror::Error)] +enum ProvisionError { + /// The cache request or its event stream failed at the transport level. + #[error("gateway cache transport error")] + Transport(#[source] GatewayError), + + /// The gateway answered the cache request with a non-success status. + #[error("gateway declined the cache request with {0}")] + Declined(reqwest::StatusCode), + + /// The gateway reported a download failure on the event stream. + #[error("model download failed: {0}")] + Download(String), + + /// The cache answer did not match the API's event shapes. + #[error("unexpected cache response: {0}")] + Malformed(String), + + /// A model file is missing and no source URL is configured for it. + #[error("{} is missing and no source URL is configured", path.display())] + NoSource { + /// The configured model path that does not exist. + path: PathBuf, + }, + + /// The blocking task building the voice engine itself failed. + #[error("voice engine load task failed")] + EngineTask(#[source] tokio::task::JoinError), + + /// The resolved whisper models could not be loaded. + #[error("load voice engine")] + LoadEngine(#[source] TranscribeError), +} + +impl From for ProvisionError { + fn from(source: GatewayError) -> Self { + Self::Transport(source) + } +} +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + use axum::Router; + use axum::extract::State; + use axum::http::StatusCode; + use axum::response::{IntoResponse, Response}; + use axum::routing::post; + use tokio::sync::broadcast; + + use crate::status::{Severity, StatusBarUpdate}; + use crate::transcribe::fixtures; + + const INTERIM_SOURCE: &str = "http://gateway.test/models/ggml-large-v3-turbo.bin"; + const FINAL_SOURCE: &str = "http://gateway.test/models/ggml-large-v3.bin"; + + /// A voice config with both sources set and no local model paths: the + /// generated-template first-run shape. + fn sourced_config() -> VoiceConfig { + VoiceConfig { + interim_source: INTERIM_SOURCE.to_string(), + final_source: FINAL_SOURCE.to_string(), + ..VoiceConfig::default() + } + } + + /// A mock `POST /v1/cache` answering every source with an immediate + /// ready event pointing at the whisper test fixture, so the activated + /// engine is real. + async fn mock_cache_ready(axum::Json(body): axum::Json) -> Response { + assert!(body["source"].is_string(), "the request names a source"); + axum::Json(serde_json::json!({ + "path": fixtures::require_model(), + "status": "ready", + })) + .into_response() + } + + /// A mock cache whose answer flips under test control: 500 while + /// `ready` is unset, an immediate ready event once set. + async fn mock_cache_flippable( + State(ready): State>, + axum::Json(body): axum::Json, + ) -> Response { + if !ready.load(Ordering::Relaxed) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": {"message": "cache broken", "code": "cache_error"} + })), + ) + .into_response(); + } + assert!(body["source"].is_string(), "the request names a source"); + axum::Json(serde_json::json!({ + "path": fixtures::require_model(), + "status": "ready", + })) + .into_response() + } + + /// A mock cache answering with an SSE download stream whose terminal + /// ready event points at the whisper test fixture. `events` is the + /// progress prefix, verbatim. + fn mock_cache_stream(events: &'static str) -> Router { + let model = fixtures::require_model(); + Router::new().route( + "/v1/cache", + post(move || { + let model = model.clone(); + async move { + let ready = serde_json::json!({"status": "ready", "path": model}).to_string(); + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + format!("{events}data: {ready}\n\n"), + ) + } + }), + ) + } + + /// Binds `app` on a free loopback port and returns its base URL. + async fn serve(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") + } + + /// Receives the next status update within a generous deadline. + async fn next_update(rx: &mut broadcast::Receiver) -> StatusBarUpdate { + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("a status update arrives within the deadline") + .expect("the status bus is open") + } + + /// Polls the slot every 10 ms until the engine activates or the + /// deadline passes. The deadline is generous: activation loads the + /// fixture model onto real worker threads. + async fn wait_active(slot: &VoiceSlot) -> bool { + let deadline = std::time::Instant::now() + Duration::from_secs(60); + while std::time::Instant::now() < deadline { + if slot.is_active() { + return true; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + slot.is_active() + } + + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn a_cache_hit_activates_the_engine_from_the_cached_paths() { + let base_url = serve(Router::new().route("/v1/cache", post(mock_cache_ready))).await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let status = StatusBus::new(); + let mut rx = status.subscribe(); + let slot = VoiceSlot::default(); + // The health flag starts optimistic, so the task attempts + // provisioning immediately, before any publish. + let provision = spawn( + client, + status, + GatewayHealth::new(), + slot.clone(), + sourced_config(), + ); + + assert!( + wait_active(&slot).await, + "the engine activates within the deadline" + ); + let engine = slot.engine().expect("the slot holds the engine"); + let text = engine + .transcribe(fixtures::jfk_samples()) + .await + .expect("transcription succeeds"); + assert!( + text.to_lowercase().contains("country"), + "the provisioned engine transcribes the fixture: {text:?}" + ); + let update = next_update(&mut rx).await; + assert_eq!(update.label, "Voice ready"); + assert_eq!(update.severity, Severity::Info); + assert_eq!(update.activity, Activity::General); + provision.shutdown().await; + } + + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn a_failed_attempt_reports_an_error_and_retries_on_reconnect() { + let ready = Arc::new(AtomicBool::new(false)); + let base_url = serve( + Router::new() + .route("/v1/cache", post(mock_cache_flippable)) + .with_state(Arc::clone(&ready)), + ) + .await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let status = StatusBus::new(); + let mut rx = status.subscribe(); + let slot = VoiceSlot::default(); + let health = GatewayHealth::new(); + let provision = spawn( + client, + status, + health.clone(), + slot.clone(), + sourced_config(), + ); + + let update = next_update(&mut rx).await; + assert_eq!(update.label, "Voice provisioning failed"); + assert_eq!(update.severity, Severity::Error); + assert!(!slot.is_active(), "a failed attempt activates nothing"); + + // The gateway recovers: the reconnect drives a retry, which now + // succeeds. + ready.store(true, Ordering::Relaxed); + health.publish(false); + health.publish(true); + assert!( + wait_active(&slot).await, + "the retry activates the engine within the deadline" + ); + provision.shutdown().await; + } + + #[tokio::test] + async fn a_transport_failure_speaks_at_info_and_stays_inactive() { + // Nothing listens on port 1, so the cache request fails to connect. + let client = GatewayClient::new("http://127.0.0.1:1", "").expect("client builds in tests"); + let status = StatusBus::new(); + let mut rx = status.subscribe(); + let slot = VoiceSlot::default(); + let provision = spawn( + client, + status, + GatewayHealth::new(), + slot.clone(), + sourced_config(), + ); + + let update = next_update(&mut rx).await; + assert_eq!(update.label, "Voice models wait on the gateway"); + assert_eq!(update.severity, Severity::Info); + assert!(!slot.is_active()); + provision.shutdown().await; + } + + #[tokio::test] + async fn a_config_without_sources_or_models_exits_immediately() { + let client = GatewayClient::new("http://127.0.0.1:1", "").expect("client builds in tests"); + let provision = spawn( + client, + StatusBus::new(), + GatewayHealth::new(), + VoiceSlot::default(), + VoiceConfig::default(), + ); + tokio::time::timeout(Duration::from_secs(5), provision.shutdown()) + .await + .expect("the task exits without waiting on the gateway"); + } + + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn an_active_engine_is_not_reprovisioned() { + let calls = Arc::new(AtomicBool::new(false)); + let seen = Arc::clone(&calls); + let base_url = serve(Router::new().route( + "/v1/cache", + post(move || { + let seen = Arc::clone(&seen); + async move { + seen.store(true, Ordering::Relaxed); + StatusCode::INTERNAL_SERVER_ERROR + } + }), + )) + .await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let slot = VoiceSlot::default(); + slot.activate( + VoiceEngine::new(&VoiceConfig { + interim_model: fixtures::require_model(), + ..VoiceConfig::default() + }) + .expect("the fixture model loads"), + ); + let provision = spawn( + client, + StatusBus::new(), + GatewayHealth::new(), + slot, + sourced_config(), + ); + provision.shutdown().await; + assert!( + !calls.load(Ordering::Relaxed), + "a loaded engine is never re-provisioned" + ); + } + + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn a_download_stream_activates_the_engine_at_ready() { + let base_url = serve(mock_cache_stream( + "data: {\"status\":\"downloading\",\"bytes\":5,\"total\":12}\n\n", + )) + .await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let slot = VoiceSlot::default(); + let mut config = sourced_config(); + config.final_source = String::new(); + let provision = spawn( + client, + StatusBus::new(), + GatewayHealth::new(), + slot.clone(), + config, + ); + + assert!( + wait_active(&slot).await, + "the stream's terminal ready activates the engine" + ); + provision.shutdown().await; + } + + #[tokio::test] + async fn a_terminal_error_event_fails_the_attempt() { + let base_url = serve(Router::new().route( + "/v1/cache", + post(|| async { + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + "data: {\"status\":\"error\",\"message\":\"disk full\"}\n\n", + ) + }), + )) + .await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let status = StatusBus::new(); + let mut rx = status.subscribe(); + let slot = VoiceSlot::default(); + let provision = spawn( + client, + status, + GatewayHealth::new(), + slot.clone(), + sourced_config(), + ); + + let update = next_update(&mut rx).await; + assert_eq!(update.label, "Voice provisioning failed"); + assert!( + update.description.contains("disk full"), + "the gateway's message reaches the status bar: {update:?}" + ); + assert!(!slot.is_active()); + provision.shutdown().await; + } + + /// A `/ws` client socket connected to a live workbench test server, + /// plus the state pieces the provision task spawns with. + struct Workbench { + socket: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + gateway: GatewayClient, + status: StatusBus, + health: GatewayHealth, + slot: VoiceSlot, + voice: VoiceConfig, + _tape_dir: tempfile::TempDir, + } + + /// Builds a workbench router with the given voice config and gateway + /// URL, binds it on a free loopback port, and connects a `/ws` client. + async fn connect_workbench(gateway_url: String, voice: VoiceConfig) -> Workbench { + let tape_dir = tempfile::TempDir::new().expect("tempdir"); + let config = crate::config::Config { + gateway: crate::config::GatewayConfig { + base_url: gateway_url, + api_key: String::new(), + }, + tape: crate::config::TapeConfig { + path: tape_dir.path().join("tape.jsonl"), + }, + server: crate::config::ServerConfig::default(), + voice, + }; + let state = crate::AppState::new(&config).expect("state builds in tests"); + let gateway = state.gateway_client().clone(); + let status = state.status(); + let health = state.health().clone(); + let slot = state.voice_slot(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind the workbench test server"); + let addr = listener + .local_addr() + .expect("workbench test server address"); + tokio::spawn(async move { + axum::serve(listener, crate::router(state)) + .await + .expect("workbench test server serves"); + }); + let (socket, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect to /ws"); + Workbench { + socket, + gateway, + status, + health, + slot, + voice: config.voice.clone(), + _tape_dir: tape_dir, + } + } + + /// Reads one text frame off a `/ws` client socket and parses it as + /// JSON. + async fn read_frame( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + ) -> serde_json::Value { + let message = socket + .next() + .await + .expect("a frame follows") + .expect("the frame is not a socket error"); + let text = message.into_text().expect("the frame is text"); + serde_json::from_str(&text).expect("the frame is JSON") + } + + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn download_progress_flows_to_the_main_ws_status_feed() { + // A mock cache answering with an SSE download stream: the first + // sample has a null total (no Content-Length upstream), the second + // a known total, then the terminal ready. + let base_url = serve(mock_cache_stream(concat!( + "data: {\"status\":\"downloading\",\"bytes\":5,\"total\":null}\n\n", + "data: {\"status\":\"downloading\",\"bytes\":12,\"total\":12}\n\n", + ))) + .await; + let voice = VoiceConfig { + interim_source: INTERIM_SOURCE.to_string(), + ..VoiceConfig::default() + }; + let mut workbench = connect_workbench(base_url, voice).await; + + // Park the task on an unreachable flag until the session's status + // forwarder is subscribed, then flip reachable to fire the attempt. + workbench.health.publish(false); + let provision = spawn( + workbench.gateway.clone(), + workbench.status.clone(), + workbench.health.clone(), + workbench.slot.clone(), + workbench.voice.clone(), + ); + tokio::time::sleep(Duration::from_millis(50)).await; + workbench.health.publish(true); + + // Collect status frames until the terminal success frame. + let mut frames = Vec::new(); + let collect = tokio::time::timeout(Duration::from_secs(60), async { + loop { + let frame = read_frame(&mut workbench.socket).await; + if frame["type"] != "status" { + continue; + } + let terminal = frame["label"] == "Voice ready"; + frames.push(frame); + if terminal { + break; + } + } + }); + collect + .await + .expect("the download sequence completes within the deadline"); + + let labels: Vec<&str> = frames + .iter() + .map(|frame| frame["label"].as_str().expect("label is a string")) + .collect(); + assert_eq!( + labels, + [ + "Downloading ggml-large-v3-turbo.bin", + "Downloading ggml-large-v3-turbo.bin", + "Download complete", + "Voice ready", + ], + "progress samples, then the terminal pair: {labels:?}" + ); + assert_eq!( + frames[0]["progress"], + serde_json::json!({"current": 5, "total": 0}), + "a null total crosses the wire as 0" + ); + assert_eq!( + frames[1]["progress"], + serde_json::json!({"current": 12, "total": 12}) + ); + assert_eq!(frames[0]["severity"], "info"); + assert_eq!(frames[0]["activity"], "general"); + assert!( + frames[2]["progress"].is_null(), + "the terminal download frame clears the progress bar" + ); + provision.shutdown().await; + } +} diff --git a/crates/promptforge-wb-server/src/segment.rs b/crates/promptforge-wb-server/src/segment.rs index 3d9a9e2e..d4ef9745 100644 --- a/crates/promptforge-wb-server/src/segment.rs +++ b/crates/promptforge-wb-server/src/segment.rs @@ -17,9 +17,10 @@ use crate::transcribe::{self, SAMPLE_RATE}; const FRAME_SAMPLES: usize = SAMPLE_RATE * 30 / 1000; /// Silence must persist this long after speech to close a segment: 700 ms, -/// long enough to survive sentence-internal pauses, short enough that the -/// final pass starts well before the user stops talking. -const MIN_SILENCE_SAMPLES: usize = SAMPLE_RATE * 700 / 1000; +/// long enough to survive sentence-internal pauses and natural breathing +/// gaps (~2 s), short enough that the final pass starts well before the +/// user stops talking. +const MIN_SILENCE_SAMPLES: usize = SAMPLE_RATE * 2; /// Speech shorter than 250 ms is discarded as a click or cough rather than /// transcribed, where whisper would hallucinate a word for it. @@ -147,7 +148,7 @@ mod tests { #[test] fn speech_closes_after_enough_silence() { - let buffer = take(&[speech(2), silence(2)]); + let buffer = take(&[speech(2), silence(3)]); let mut segmenter = Segmenter::new(); let ranges = close_all(&mut segmenter, &buffer); assert_eq!(ranges.len(), 1, "one speech run closes one segment"); @@ -166,12 +167,8 @@ mod tests { #[test] fn a_short_pause_does_not_close_the_segment() { - // Half a second of silence is inside the 700 ms closing threshold. - let buffer = take(&[ - speech(1), - silence(1).split_at(SAMPLE_RATE / 2).0.to_vec(), - speech(1), - ]); + // One second of silence is inside the 2 s closing threshold. + let buffer = take(&[speech(1), silence(1), speech(1)]); let mut segmenter = Segmenter::new(); assert!( close_all(&mut segmenter, &buffer).is_empty(), @@ -182,7 +179,7 @@ mod tests { #[test] fn clicks_shorter_than_min_speech_are_discarded() { // 100 ms of tone followed by a full closing silence. - let buffer = take(&[speech(1).split_at(SAMPLE_RATE / 10).0.to_vec(), silence(2)]); + let buffer = take(&[speech(1).split_at(SAMPLE_RATE / 10).0.to_vec(), silence(3)]); let mut segmenter = Segmenter::new(); assert!( close_all(&mut segmenter, &buffer).is_empty(), @@ -196,7 +193,7 @@ mod tests { #[test] fn two_speech_runs_close_as_two_segments() { - let buffer = take(&[speech(1), silence(1), speech(1), silence(1)]); + let buffer = take(&[speech(1), silence(3), speech(1), silence(3)]); let mut segmenter = Segmenter::new(); let ranges = close_all(&mut segmenter, &buffer); assert_eq!(ranges.len(), 2, "each speech run closes its own segment"); @@ -212,7 +209,7 @@ mod tests { let mut buffer = speech(1); let mut segmenter = Segmenter::new(); assert!(segmenter.poll(&buffer).is_none()); - buffer.extend_from_slice(&silence(1)); + buffer.extend_from_slice(&silence(3)); let first = segmenter.poll(&buffer).expect("the segment closes"); assert_eq!(first.start, 0); // Polling again without new audio returns nothing. @@ -221,7 +218,7 @@ mod tests { #[test] fn reset_rewinds_for_a_new_take() { - let buffer = take(&[speech(1), silence(1)]); + let buffer = take(&[speech(1), silence(3)]); let mut segmenter = Segmenter::new(); assert!(segmenter.poll(&buffer).is_some()); segmenter.reset(); diff --git a/crates/promptforge-wb-server/src/serve.rs b/crates/promptforge-wb-server/src/serve.rs index c81d8ccb..99f73fe3 100644 --- a/crates/promptforge-wb-server/src/serve.rs +++ b/crates/promptforge-wb-server/src/serve.rs @@ -12,6 +12,8 @@ use std::thread::JoinHandle; use crate::app::{AppError, AppState, router}; use crate::config::Config; +use crate::heartbeat; +use crate::provision; /// A running workbench server on its own thread. /// @@ -154,7 +156,7 @@ fn serve_thread( return Ok(()); } }; - let listener = match tokio::net::TcpListener::bind(&config.server.bind).await { + let listener = match reuse_bind(&config.server.bind) { Ok(listener) => listener, Err(error) => { let _ = ready.send(Err(SpawnError::Io(error))); @@ -163,14 +165,51 @@ fn serve_thread( }; let address = listener.local_addr()?; let _ = ready.send(Ok(format!("http://{address}"))); + // The heartbeat and the voice provisioning task start with serving + // and stop inside the same graceful-shutdown signal, so they never + // outlive the server. + let heartbeat = heartbeat::spawn( + state.gateway_client().clone(), + state.status(), + state.health().clone(), + state.catalog(), + heartbeat::HEARTBEAT_INTERVAL, + ); + let provision = provision::spawn( + state.gateway_client().clone(), + state.status(), + state.health().clone(), + state.voice_slot(), + config.voice.clone(), + ); axum::serve(listener, router(state)) .with_graceful_shutdown(async move { let _ = shutdown.await; + heartbeat.shutdown().await; + provision.shutdown().await; }) .await }) } +/// Binds a TCP listener with `SO_REUSEADDR` so a restart doesn't fail on +/// TIME_WAIT sockets from the previous instance. +fn reuse_bind(address: &str) -> std::io::Result { + let addr: std::net::SocketAddr = address + .parse() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + let socket = socket2::Socket::new( + socket2::Domain::for_address(addr), + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + )?; + socket.set_reuse_address(true)?; + socket.set_nonblocking(true)?; + socket.bind(&addr.into())?; + socket.listen(1024)?; + tokio::net::TcpListener::from_std(socket.into()) +} + #[cfg(test)] mod tests { use super::*; @@ -216,6 +255,47 @@ mod tests { server.shutdown().expect("graceful shutdown succeeds"); } + /// The test config points the gateway at port 1, which never listens: + /// the server must still boot and serve - the UI and its own health + /// endpoint do not depend on the gateway, and the heartbeat reports + /// the outage instead of failing startup. + #[tokio::test] + async fn the_server_boots_and_serves_the_ui_with_an_unreachable_gateway() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn(test_config("127.0.0.1:0", dir.path())).expect("server spawns"); + let url = server.url().to_string(); + + let health = reqwest::get(format!("{url}/health")) + .await + .expect("the health endpoint answers"); + assert_eq!(health.status(), reqwest::StatusCode::OK); + let index = reqwest::get(format!("{url}/")) + .await + .expect("the UI answers"); + assert_eq!(index.status(), reqwest::StatusCode::OK); + + server.shutdown().expect("graceful shutdown succeeds"); + } + + /// A configured-but-missing voice model with no source URL degrades to + /// disabled voice with a status-bar explanation; it must never fail + /// startup. + #[tokio::test] + async fn a_missing_voice_model_without_a_source_still_boots() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let mut config = test_config("127.0.0.1:0", dir.path()); + config.voice.interim_model = std::path::PathBuf::from("definitely-missing-model.bin"); + let server = spawn(config).expect("server spawns with voice degraded"); + let url = server.url().to_string(); + + let health = reqwest::get(format!("{url}/health")) + .await + .expect("the health endpoint answers"); + assert_eq!(health.status(), reqwest::StatusCode::OK); + + server.shutdown().expect("graceful shutdown succeeds"); + } + #[tokio::test] async fn shutdown_releases_the_bound_port() { let dir = tempfile::TempDir::new().expect("tempdir"); diff --git a/crates/promptforge-wb-server/src/status.rs b/crates/promptforge-wb-server/src/status.rs new file mode 100644 index 00000000..7c2f72fe --- /dev/null +++ b/crates/promptforge-wb-server/src/status.rs @@ -0,0 +1,297 @@ +//! The observer: a broadcast bus carrying status bar updates from every +//! subsystem to every connected `/ws` session. +//! +//! Anything with user-visible latency - startup phases, gateway round +//! trips, voice capture and transcription, model downloads - reports what +//! it is doing as a [`StatusBarUpdate`]. The bus is a tokio broadcast +//! channel: updates fan out to all current subscribers, a send with no +//! subscribers is a no-op, and a subscriber that falls more than +//! [`STATUS_CHANNEL_CAPACITY`] updates behind is told it lagged and resumes +//! at the oldest retained update. Sending never blocks, so instrumenting a +//! hot path cannot stall the subsystem it observes. +//! +//! On the wire each update rides the main chat socket as an unsolicited +//! `{"type":"status",...}` frame (see [`StatusBarUpdate::frame`]), +//! interleaving freely with a chat's `delta`/`done`/`error` replies. + +use serde::Serialize; +use tokio::sync::broadcast; + +/// Ring capacity of the status bus. Covers a startup burst plus a chat's +/// phase transitions with headroom; a receiver lagging past it skips ahead +/// rather than slowing the senders. +const STATUS_CHANNEL_CAPACITY: usize = 64; + +/// One status bar update: what the bar should show right now. +/// +/// Every update is a complete snapshot, so a lagging receiver loses nothing +/// by skipping intermediates. `label` is the short text rendered in the +/// status bar; `description` is the longer tooltip shown on hover. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct StatusBarUpdate { + /// Short text rendered in the status bar. + pub(crate) label: String, + /// Longer text shown as the bar's tooltip. + pub(crate) description: String, + /// Determinate progress, when the activity can report it. + pub(crate) progress: Option, + /// How loudly the update speaks; the UI ignores `Debug` updates. + pub(crate) severity: Severity, + /// Which subsystem is active, driving the bar's activity indicator. + pub(crate) activity: Activity, +} + +impl StatusBarUpdate { + /// The update as a wire frame: its own fields plus `"type": "status"`. + pub(crate) fn frame(&self) -> StatusFrame<'_> { + StatusFrame { + kind: "status", + update: self, + } + } +} + +/// A determinate progress report for the status bar's progress slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub(crate) struct Progress { + /// Units completed so far. + pub(crate) current: u64, + /// Units expected in total. + pub(crate) total: u64, +} + +/// How loudly a status update speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Severity { + /// User-visible status text. + Info, + /// Internal instrumentation; the UI ignores it for display. + Debug, + /// A failure the user should see. + Error, +} + +/// The subsystem an update belongs to, driving the activity indicator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Activity { + /// No specific subsystem; the activity LED stays dark. + General, + /// A model turn in flight: amber on the activity LED. + Thinking, + /// Output tokens arriving: green on the activity LED. + Generating, +} + +/// The serialized shape of one update on the socket: the update's fields +/// flattened beside `"type": "status"`, matching the chat protocol's frame +/// taxonomy. +#[derive(Debug, Serialize)] +pub(crate) struct StatusFrame<'a> { + #[serde(rename = "type")] + kind: &'static str, + #[serde(flatten)] + update: &'a StatusBarUpdate, +} + +/// The shared status bus: a cloneable handle onto the broadcast channel. +/// +/// Clones are cheap (an `Arc` bump) and all of them send into the same +/// channel, so subsystems take their own copy rather than a reference. +#[derive(Debug, Clone)] +pub(crate) struct StatusBus { + sender: broadcast::Sender, +} + +impl StatusBus { + /// Creates a bus with no subscribers and an empty ring. + pub(crate) fn new() -> Self { + Self { + sender: broadcast::channel(STATUS_CHANNEL_CAPACITY).0, + } + } + + /// Subscribes to every update sent from this call onward. + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } + + /// Broadcasts one update. With no subscribers this is a no-op; a slow + /// subscriber skips ahead rather than applying backpressure. + pub(crate) fn emit(&self, update: StatusBarUpdate) { + // A send only fails when there are no receivers, which is the bus's + // resting state before the first client connects. + let _ = self.sender.send(update); + } + + /// Broadcasts one progress-free update at the given severity. + pub(crate) fn report( + &self, + label: impl Into, + description: impl Into, + severity: Severity, + activity: Activity, + ) { + self.emit(StatusBarUpdate { + label: label.into(), + description: description.into(), + progress: None, + severity, + activity, + }); + } + + /// Broadcasts a user-visible status text. + pub(crate) fn info( + &self, + label: impl Into, + description: impl Into, + activity: Activity, + ) { + self.report(label, description, Severity::Info, activity); + } + + /// Broadcasts a user-visible update carrying determinate progress, + /// which the status bar renders as its progress bar. + pub(crate) fn progress( + &self, + label: impl Into, + description: impl Into, + progress: Progress, + activity: Activity, + ) { + self.emit(StatusBarUpdate { + label: label.into(), + description: description.into(), + progress: Some(progress), + severity: Severity::Info, + activity, + }); + } + + /// Broadcasts an internal instrumentation pulse the UI does not + /// display. + pub(crate) fn debug( + &self, + label: impl Into, + description: impl Into, + activity: Activity, + ) { + self.report(label, description, Severity::Debug, activity); + } + + /// Broadcasts a failure the user should see. + pub(crate) fn error( + &self, + label: impl Into, + description: impl Into, + activity: Activity, + ) { + self.report(label, description, Severity::Error, activity); + } + + /// Returns the bar to its resting state. + pub(crate) fn idle(&self) { + self.info("Ready", "idle", Activity::General); + } +} + +impl Default for StatusBus { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a minimal update with the given label. + fn stub(label: impl Into) -> StatusBarUpdate { + StatusBarUpdate { + label: label.into(), + description: String::new(), + progress: None, + severity: Severity::Info, + activity: Activity::General, + } + } + + #[test] + fn a_status_update_serializes_as_a_status_frame() { + let frame = serde_json::to_value(stub("Ready").frame()).expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({ + "type": "status", + "label": "Ready", + "description": "", + "progress": null, + "severity": "info", + "activity": "general", + }), + "the wire shape matches the chat protocol's frame taxonomy" + ); + } + + #[test] + fn progress_and_the_remaining_variants_serialize() { + let update = StatusBarUpdate { + progress: Some(Progress { + current: 1, + total: 2, + }), + severity: Severity::Error, + activity: Activity::Thinking, + ..stub("Working") + }; + let frame = serde_json::to_value(update.frame()).expect("the frame serializes"); + assert_eq!( + frame["progress"], + serde_json::json!({"current": 1, "total": 2}) + ); + assert_eq!(frame["severity"], "error"); + assert_eq!(frame["activity"], "thinking"); + // Debug serializes too; the UI, not the bus, ignores it. + let debug = serde_json::to_value( + StatusBarUpdate { + severity: Severity::Debug, + activity: Activity::Generating, + ..stub("x") + } + .frame(), + ) + .expect("the frame serializes"); + assert_eq!(debug["severity"], "debug"); + assert_eq!(debug["activity"], "generating"); + } + + #[tokio::test] + async fn emitting_with_no_subscribers_is_a_no_op() { + let bus = StatusBus::new(); + bus.info("Ready", "idle", Activity::General); + } + + #[tokio::test] + async fn a_lagged_receiver_skips_ahead_instead_of_blocking() { + let bus = StatusBus::new(); + let mut receiver = bus.subscribe(); + let sent = STATUS_CHANNEL_CAPACITY + 10; + for index in 0..sent { + // Sends never block, however far behind the receiver is. + bus.debug(format!("update {index}"), "", Activity::General); + } + let lag = match receiver.recv().await { + Err(broadcast::error::RecvError::Lagged(skipped)) => skipped, + Ok(got) => panic!("expected a lag report, got {got:?}"), + Err(broadcast::error::RecvError::Closed) => panic!("the bus is still open"), + }; + assert_eq!(lag, 10, "the ring retained only its capacity"); + let resumed = receiver.recv().await.expect("the ring still holds updates"); + assert_eq!( + resumed.label, "update 10", + "receiving resumes at the oldest retained update" + ); + } +} diff --git a/crates/promptforge-wb-server/src/transcribe.rs b/crates/promptforge-wb-server/src/transcribe.rs index ef242fa7..32579e05 100644 --- a/crates/promptforge-wb-server/src/transcribe.rs +++ b/crates/promptforge-wb-server/src/transcribe.rs @@ -12,6 +12,7 @@ //! quiet windows are never sent to the model. use std::path::{Path, PathBuf}; +use std::sync::{Arc, PoisonError, RwLock}; use std::time::Duration; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; @@ -39,6 +40,18 @@ pub(crate) const MIN_WINDOW_SAMPLES: usize = SAMPLE_RATE / 2; /// from the front. const MAX_PROMPT_CHARS: usize = 800; +/// Whisper's prompt budget in tokens: half the text context +/// (`whisper_n_text_ctx / 2`). A prompt longer than this is truncated by +/// whisper.cpp from the front, which would silently drop a glossary +/// prefix, so prompts are fitted to the budget before being set. +const MAX_PROMPT_TOKENS: usize = 224; + +/// Token budget for the glossary on the final-pass worker; the rest of the +/// prompt budget is reserved for the segment-conditioning transcript. The +/// interim worker passes no transcript and fits its glossary to the full +/// budget. +const GLOSSARY_TOKEN_BUDGET: usize = MAX_PROMPT_TOKENS / 2; + /// Root-mean-square amplitude of a PCM buffer. #[expect( clippy::cast_precision_loss, @@ -103,11 +116,14 @@ impl VoiceEngine { "voice.window_seconds is too large".to_string(), )); }; - let transcriber = Transcriber::load(&config.interim_model)?; + let transcriber = Transcriber::load(&config.interim_model, &config.vocabulary)?; let final_pass = if config.final_model.as_os_str().is_empty() { None } else { - Some(FinalTranscriber::load(&config.final_model)?) + Some(FinalTranscriber::load( + &config.final_model, + &config.vocabulary, + )?) }; Ok(Self { transcriber, @@ -117,6 +133,21 @@ impl VoiceEngine { }) } + /// Whether the final pass is configured. Segmentation and + /// crystallization only happen when it is: without it nothing can + /// crystallize, so the segmenter must not consume audio the interim + /// model still needs. + pub(crate) fn has_final_pass(&self) -> bool { + self.final_pass.is_some() + } + + /// Whether the final pass is absent. A test seam for the startup + /// degradation policy, which drops an unsourced missing final model. + #[cfg(test)] + pub(crate) fn final_pass_absent_for_test(&self) -> bool { + !self.has_final_pass() + } + /// Samples in the sliding interim window. pub(crate) fn window_samples(&self) -> usize { self.window_samples @@ -138,10 +169,12 @@ impl VoiceEngine { } /// Starts a new take on the final-pass worker, discarding the previous - /// take's accumulated transcript. A no-op without a final model. - pub(crate) fn final_reset(&self) { + /// take's accumulated transcript and installing `on_segment` as the + /// take's completion channel: each background segment's text is sent on + /// it as the segment finishes. A no-op without a final model. + pub(crate) fn final_reset(&self, on_segment: std::sync::mpsc::Sender) { if let Some(final_pass) = &self.final_pass { - final_pass.reset(); + final_pass.reset(on_segment); } } @@ -154,9 +187,13 @@ impl VoiceEngine { } } - /// Queues the take's unprocessed tail and awaits the take's full - /// assembled transcript, or `None` when no final model is configured and - /// the caller should fall back to the interim model. + /// Queues the take's unprocessed tail and awaits the tail's own + /// transcription - not the take's full assembled transcript, which the + /// session already holds as crystallized segment text. The text is + /// empty when the tail is silent or too short to decode (the worker + /// skips those rather than hallucinating). Returns `None` when no + /// final model is configured and the caller should fall back to the + /// interim model. /// /// # Errors /// Returns [`TranscribeError::Inference`] when the model rejects the @@ -173,6 +210,42 @@ impl VoiceEngine { } } +/// Shared holder for the voice engine: empty until the engine loads, then +/// filled exactly once - at startup from local model files, or later by the +/// provisioning task once the gateway cache has provided them. +/// +/// Reads happen per `/voice` session upgrade and writes are one-shot, so a +/// std `RwLock` suffices; no guard ever crosses an `.await`. Lock poisoning +/// recovers the value, matching the tape's posture: a panicking writer +/// cannot wedge voice for the process's life. +#[derive(Debug, Clone, Default)] +pub(crate) struct VoiceSlot { + engine: Arc>>>, +} + +impl VoiceSlot { + /// The engine, when it has loaded. + pub(crate) fn engine(&self) -> Option> { + self.engine + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// Whether the engine has loaded. + pub(crate) fn is_active(&self) -> bool { + self.engine + .read() + .unwrap_or_else(PoisonError::into_inner) + .is_some() + } + + /// Installs a loaded engine. + pub(crate) fn activate(&self, engine: VoiceEngine) { + *self.engine.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(engine)); + } +} + /// One transcription request handed to the worker thread. struct Job { samples: Vec, @@ -193,13 +266,14 @@ impl Transcriber { /// Returns [`TranscribeError::LoadModel`] when the model file cannot be /// loaded and [`TranscribeError::SpawnWorker`] when the thread cannot be /// started. - fn load(model_path: &Path) -> Result { + fn load(model_path: &Path, vocabulary: &[String]) -> Result { let (job_tx, job_rx) = std::sync::mpsc::channel::(); let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); let path = model_path.to_path_buf(); + let vocabulary = vocabulary.to_vec(); std::thread::Builder::new() .name("whisper-transcribe".to_string()) - .spawn(move || worker_loop(&path, &job_rx, &init_tx)) + .spawn(move || worker_loop(&path, &vocabulary, &job_rx, &init_tx)) .map_err(TranscribeError::SpawnWorker)?; init_rx.recv().map_err(|_| TranscribeError::WorkerGone)??; Ok(Self { job_tx }) @@ -215,22 +289,29 @@ impl Transcriber { } } -/// The worker thread's body: load the model, then transcribe jobs in arrival -/// order until every sender is dropped. +/// The worker thread's body: load the model, fit the glossary prompt, then +/// transcribe jobs in arrival order until every sender is dropped. fn worker_loop( path: &Path, + vocabulary: &[String], job_rx: &std::sync::mpsc::Receiver, init_tx: &std::sync::mpsc::SyncSender>, ) { - let Some((_ctx, mut state)) = load_state(path, init_tx) else { + let Some((ctx, mut state)) = load_state(path, init_tx) else { return; }; + // The interim pass carries no transcript, so the glossary gets the full + // prompt budget. + let glossary = fit_glossary(&ctx, vocabulary, MAX_PROMPT_TOKENS); while let Ok(job) = job_rx.recv() { // The receiver may be gone (session closed mid-pass); the transcript // is computed anyway and the send failure ignored. - let _ = job - .reply - .send(transcribe_blocking(&mut state, &job.samples, None, true)); + let _ = job.reply.send(transcribe_blocking( + &mut state, + &job.samples, + glossary.as_deref(), + true, + )); } } @@ -266,17 +347,113 @@ fn load_state( } } +/// The trailing `max` bytes of `text`, cut at a char boundary. +fn tail_chars(text: &str, max: usize) -> &str { + let mut start = text.len().saturating_sub(max); + while !text.is_char_boundary(start) { + start += 1; + } + &text[start..] +} + /// The trailing `MAX_PROMPT_CHARS` chars of `prompt` with null bytes /// stripped: whisper's prompt buffer is bounded, and `set_initial_prompt` /// panics on null bytes, which a model transcript could in principle /// contain. fn sanitize_prompt(prompt: &str) -> String { let cleaned: String = prompt.chars().filter(|&c| c != '\0').collect(); - let mut start = cleaned.len().saturating_sub(MAX_PROMPT_CHARS); - while !cleaned.is_char_boundary(start) { - start += 1; + tail_chars(&cleaned, MAX_PROMPT_CHARS).to_string() +} + +/// Formats `vocabulary` as a whisper conditioning prompt in glossary form: +/// `Glossary: a, b, c.` Terms are trimmed and null bytes stripped (whisper +/// tokenization rejects them); a vocabulary with no usable terms yields +/// `None`. The glossary format is a soft probabilistic bias, and measurably +/// outperforms a raw keyword list. +pub(crate) fn glossary_prompt(vocabulary: &[String]) -> Option { + let terms: Vec = vocabulary + .iter() + .map(|term| { + term.trim() + .chars() + .filter(|&c| c != '\0') + .collect::() + }) + .filter(|term| !term.is_empty()) + .collect(); + if terms.is_empty() { + return None; + } + Some(format!("Glossary: {}.", terms.join(", "))) +} + +/// Token count of `text` under the model's tokenizer, or `usize::MAX` +/// when tokenization fails (for example on null bytes, though callers +/// strip those first). +/// +/// whisper-rs's `tokenize` cannot be asked "does this fit in N tokens": +/// the underlying `whisper_tokenize` reports overflow by returning the +/// required count, which the wrapper then passes to `Vec::set_len` on a +/// buffer of only `max_tokens` capacity. Tokenizing with one slot per byte +/// (an upper bound on the token count) and reading the real length +/// sidesteps the overflow path entirely. +fn token_count(ctx: &WhisperContext, text: &str) -> usize { + ctx.tokenize(text, text.len().max(1)) + .map_or(usize::MAX, |tokens| tokens.len()) +} + +/// Fits the glossary prompt for `vocabulary` within `budget` whisper tokens +/// (and the prompt char cap), dropping whole terms from the end until it +/// fits. Returns `None` when the vocabulary has no usable terms or no term +/// fits, and logs a warning when terms were dropped. +fn fit_glossary(ctx: &WhisperContext, vocabulary: &[String], budget: usize) -> Option { + let mut len = vocabulary.len(); + let mut fitted = glossary_prompt(vocabulary)?; + while fitted.len() > MAX_PROMPT_CHARS || token_count(ctx, &fitted) > budget { + len -= 1; + if len == 0 { + tracing::warn!("no voice vocabulary term fits the prompt budget"); + return None; + } + fitted = glossary_prompt(&vocabulary[..len])?; + } + if len < vocabulary.len() { + tracing::warn!( + kept = len, + dropped = vocabulary.len() - len, + "voice vocabulary truncated to fit whisper's prompt budget" + ); + } + Some(fitted) +} + +/// Builds the final pass's conditioning prompt: the fitted glossary +/// followed by as much of the accumulated transcript's tail as fits within +/// the char cap and whisper's 224-token prompt budget. The transcript trims +/// from the front (its tail carries the continuity); the glossary is never +/// trimmed here - it was fitted to its own budget at load. +fn final_prompt(ctx: &WhisperContext, glossary: Option<&str>, transcript: &str) -> String { + let Some(glossary) = glossary else { + return sanitize_prompt(transcript); + }; + let cleaned: String = transcript.chars().filter(|&c| c != '\0').collect(); + let char_budget = MAX_PROMPT_CHARS.saturating_sub(glossary.len() + 1); + let mut tail = tail_chars(&cleaned, char_budget).trim_start(); + loop { + if tail.is_empty() { + return glossary.to_string(); + } + let combined = format!("{glossary} {tail}"); + if token_count(ctx, &combined) <= MAX_PROMPT_TOKENS { + return combined; + } + // Drop the tail's first word and retry; a single oversized word is + // dropped whole, which ends the loop on the next iteration. + tail = match tail.find(char::is_whitespace) { + Some(index) => tail[index..].trim_start(), + None => "", + }; } - cleaned[start..].to_string() } /// Runs one blocking whisper pass over `samples` and concatenates the @@ -322,12 +499,17 @@ fn transcribe_blocking( Ok(text.trim().to_string()) } -/// One take's final-pass state: the large model's whisper state plus the -/// take's accumulated transcript, which conditions each new segment so -/// domain vocabulary and phrasing survive segmentation. +/// One take's final-pass state: the large model's whisper context and state +/// plus the take's accumulated transcript, which conditions each new +/// segment so domain vocabulary and phrasing survive segmentation. The +/// glossary prompt (fitted at load from `[voice].vocabulary`) biases every +/// segment toward the configured domain terms. #[derive(Debug)] pub(crate) struct FinalPass { + ctx: WhisperContext, state: whisper_rs::WhisperState, + /// The fitted glossary prompt, `None` when no vocabulary is configured. + glossary: Option, /// Every segment transcript so far, joined by single spaces. transcript: String, /// The conditioning prompt used on the most recent segment, kept so @@ -336,14 +518,14 @@ pub(crate) struct FinalPass { } impl FinalPass { - /// Loads the final model from `path`. + /// Loads the final model from `path` and fits the vocabulary glossary. /// /// # Errors /// Returns [`TranscribeError::LoadModel`] when the model file cannot be /// loaded. - fn load(path: &Path) -> Result { + fn load(path: &Path, vocabulary: &[String]) -> Result { let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); - let Some((_ctx, state)) = load_state(path, &init_tx) else { + let Some((ctx, state)) = load_state(path, &init_tx) else { return match init_rx.recv() { Ok(Err(error)) => Err(error), // `load_state` reports every outcome on the channel before @@ -352,8 +534,11 @@ impl FinalPass { _ => Err(TranscribeError::WorkerGone), }; }; + let glossary = fit_glossary(&ctx, vocabulary, GLOSSARY_TOKEN_BUDGET); Ok(Self { + ctx, state, + glossary, transcript: String::new(), last_prompt: String::new(), }) @@ -371,39 +556,56 @@ impl FinalPass { &self.last_prompt } + /// The take's accumulated transcript: every segment so far, joined by + /// single spaces. A test-only observation point for the conditioning + /// chain; the workers consume only each segment's own text. + #[cfg(test)] + pub(crate) fn transcript(&self) -> &str { + &self.transcript + } + /// Transcribes one segment conditioned on the accumulated transcript, - /// appends the result, and returns the full assembled transcript. - /// Silent or tiny fragments are skipped (whisper hallucinates on them) - /// and the accumulated transcript is returned unchanged. + /// appends the result, and returns the segment's own text. Silent or + /// tiny fragments are skipped (whisper hallucinates on them): the + /// accumulated transcript is left unchanged and `None` comes back. /// /// # Errors /// Returns [`TranscribeError::Inference`] when the model rejects the /// audio; the accumulated transcript is left unchanged. - fn transcribe_segment(&mut self, samples: &[f32]) -> Result { + fn transcribe_segment(&mut self, samples: &[f32]) -> Result, TranscribeError> { + let mut segment = None; if samples.len() >= MIN_WINDOW_SAMPLES && !is_silence(samples) { - let prompt = self.transcript.clone(); + let prompt = final_prompt(&self.ctx, self.glossary.as_deref(), &self.transcript); let text = transcribe_blocking(&mut self.state, samples, Some(&prompt), false)?; if !text.is_empty() { if !self.transcript.is_empty() { self.transcript.push(' '); } self.transcript.push_str(&text); + segment = Some(text); } self.last_prompt = prompt; } - Ok(self.transcript.clone()) + Ok(segment) } } /// A command for the final-pass worker thread. enum FinalJob { - /// Start a new take, discarding the accumulated transcript. - Reset, + /// Start a new take, discarding the accumulated transcript and + /// installing the take's segment-completion channel. + Reset { + on_segment: std::sync::mpsc::Sender, + }, /// Transcribe a completed segment (or the closing tail) and reply with - /// the take's full assembled transcript. + /// the segment's own text, empty when the fragment was skipped. + /// `notify` marks a background submit, whose segment text is also sent + /// on the take's channel; the closing tail reports only through its + /// reply. Segment { samples: Vec, reply: tokio::sync::oneshot::Sender>, + notify: bool, }, } @@ -422,37 +624,48 @@ impl FinalTranscriber { /// Returns [`TranscribeError::LoadModel`] when the model file cannot be /// loaded and [`TranscribeError::SpawnWorker`] when the thread cannot be /// started. - fn load(model_path: &Path) -> Result { + fn load(model_path: &Path, vocabulary: &[String]) -> Result { let (job_tx, job_rx) = std::sync::mpsc::channel::(); let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); let path = model_path.to_path_buf(); + let vocabulary = vocabulary.to_vec(); std::thread::Builder::new() .name("whisper-final".to_string()) - .spawn(move || final_worker_loop(&path, &job_rx, &init_tx)) + .spawn(move || final_worker_loop(&path, &vocabulary, &job_rx, &init_tx)) .map_err(TranscribeError::SpawnWorker)?; init_rx.recv().map_err(|_| TranscribeError::WorkerGone)??; Ok(Self { job_tx }) } - /// Starts a new take. If the worker is gone the next `finish` reports it. - fn reset(&self) { - let _ = self.job_tx.send(FinalJob::Reset); + /// Starts a new take, installing `on_segment` as the channel each + /// background segment's text is reported on. If the worker is gone the + /// next `finish` reports it. + fn reset(&self, on_segment: std::sync::mpsc::Sender) { + let _ = self.job_tx.send(FinalJob::Reset { on_segment }); } - /// Queues a completed segment for background transcription; the result - /// is observed only through the accumulated transcript at `finish`. + /// Queues a completed segment for background transcription; the + /// segment's text is reported on the take's channel. fn submit(&self, samples: Vec) { let (reply, _dropped) = tokio::sync::oneshot::channel(); - let _ = self.job_tx.send(FinalJob::Segment { samples, reply }); + let _ = self.job_tx.send(FinalJob::Segment { + samples, + reply, + notify: true, + }); } - /// Queues the take's tail and awaits the full assembled transcript. - /// Because the channel is FIFO, awaiting this reply also drains every - /// segment submitted earlier in the take. + /// Queues the take's tail and awaits the tail's own text, empty when + /// the tail was skipped. Because the channel is FIFO, awaiting this + /// reply also drains every segment submitted earlier in the take. async fn finish(&self, samples: Vec) -> Result { let (reply, reply_rx) = tokio::sync::oneshot::channel(); self.job_tx - .send(FinalJob::Segment { samples, reply }) + .send(FinalJob::Segment { + samples, + reply, + notify: false, + }) .map_err(|_| TranscribeError::WorkerGone)?; reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? } @@ -462,10 +675,11 @@ impl FinalTranscriber { /// arrival order until every sender is dropped. fn final_worker_loop( path: &Path, + vocabulary: &[String], job_rx: &std::sync::mpsc::Receiver, init_tx: &std::sync::mpsc::SyncSender>, ) { - let mut pass = match FinalPass::load(path) { + let mut pass = match FinalPass::load(path, vocabulary) { Ok(pass) => { let _ = init_tx.send(Ok(())); pass @@ -475,17 +689,41 @@ fn final_worker_loop( return; } }; + // The current take's completion channel, installed by each `Reset`; + // FIFO job order guarantees a take's segments all precede the next + // take's `Reset`, so a segment can never land on the wrong channel. + let mut on_segment: Option> = None; while let Ok(job) = job_rx.recv() { match job { - FinalJob::Reset => pass.reset(), - FinalJob::Segment { samples, reply } => { + FinalJob::Reset { + on_segment: channel, + } => { + on_segment = Some(channel); + pass.reset(); + } + FinalJob::Segment { + samples, + reply, + notify, + } => { let result = pass.transcribe_segment(&samples); - if let Err(error) = &result { - tracing::warn!(%error, "final-pass segment transcription failed"); + match &result { + Ok(segment) => { + if notify && let (Some(channel), Some(text)) = (&on_segment, segment) { + // A gone session (socket closed mid-take) is + // ordinary; the transcript was computed anyway. + if channel.send(text.clone()).is_err() { + tracing::debug!("segment completion receiver is gone"); + } + } + } + Err(error) => { + tracing::warn!(%error, "final-pass segment transcription failed"); + } } // A dropped receiver (a background segment, or a session // closed mid-take) is fine: the transcript was computed. - let _ = reply.send(result); + let _ = reply.send(result.map(Option::unwrap_or_default)); } } } @@ -586,6 +824,7 @@ mod tests { use crate::transcribe::fixtures; #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] async fn transcribes_known_speech_fixture() { let config = VoiceConfig { interim_model: fixtures::require_model(), @@ -656,6 +895,168 @@ mod tests { } #[test] + fn glossary_prompt_is_none_without_usable_terms() { + assert_eq!(glossary_prompt(&[]), None); + assert_eq!(glossary_prompt(&[String::new()]), None); + assert_eq!(glossary_prompt(&[" ".to_string()]), None); + assert_eq!(glossary_prompt(&["\0".to_string()]), None); + } + + #[test] + fn glossary_prompt_formats_a_glossary() { + let vocabulary: Vec = ["MCP", "GGUF", "Lua"].map(str::to_string).into(); + assert_eq!( + glossary_prompt(&vocabulary), + Some("Glossary: MCP, GGUF, Lua.".to_string()) + ); + } + + #[test] + fn glossary_prompt_cleans_terms() { + let vocabulary: Vec = [" tokio ", "ax\0um", ""].map(str::to_string).into(); + assert_eq!( + glossary_prompt(&vocabulary), + Some("Glossary: tokio, axum.".to_string()) + ); + } + + #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + fn fit_glossary_keeps_a_vocabulary_that_fits() { + let ctx = WhisperContext::new_with_params( + fixtures::require_model(), + WhisperContextParameters::default(), + ) + .expect("fixture model loads"); + let vocabulary: Vec = ["MCP", "GGUF", "Lua"].map(str::to_string).into(); + let fitted = + fit_glossary(&ctx, &vocabulary, GLOSSARY_TOKEN_BUDGET).expect("a short glossary fits"); + assert_eq!(fitted, "Glossary: MCP, GGUF, Lua."); + } + + #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + fn fit_glossary_drops_terms_from_the_end_to_fit() { + let ctx = WhisperContext::new_with_params( + fixtures::require_model(), + WhisperContextParameters::default(), + ) + .expect("fixture model loads"); + let mut vocabulary: Vec = ["MCP".to_string()].into(); + for index in 0..200 { + vocabulary.push(format!("internationalization{index}")); + } + let fitted = fit_glossary(&ctx, &vocabulary, GLOSSARY_TOKEN_BUDGET) + .expect("the leading terms still fit"); + assert!( + fitted.starts_with("Glossary: MCP, "), + "truncation keeps the leading terms: {fitted:?}" + ); + assert!( + fitted.len() <= MAX_PROMPT_CHARS, + "the fitted glossary respects the char cap" + ); + assert!( + token_count(&ctx, &fitted) <= GLOSSARY_TOKEN_BUDGET, + "the fitted glossary tokenizes within its budget: {fitted:?}" + ); + let kept = fitted.matches(", ").count(); + assert!( + kept < vocabulary.len(), + "terms were dropped to fit: {kept} of {}", + vocabulary.len() + ); + } + + #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + fn final_prompt_without_a_glossary_matches_sanitize() { + let ctx = WhisperContext::new_with_params( + fixtures::require_model(), + WhisperContextParameters::default(), + ) + .expect("fixture model loads"); + let transcript = "the quick brown fox ".repeat(100); + assert_eq!( + final_prompt(&ctx, None, &transcript), + sanitize_prompt(&transcript) + ); + } + + #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + fn final_prompt_prepends_the_glossary_and_caps_tokens() { + let ctx = WhisperContext::new_with_params( + fixtures::require_model(), + WhisperContextParameters::default(), + ) + .expect("fixture model loads"); + let glossary = "Glossary: MCP, GGUF, Lua."; + assert_eq!( + final_prompt(&ctx, Some(glossary), ""), + glossary, + "an empty transcript leaves the glossary alone" + ); + let transcript = "the quick brown fox jumps over the lazy dog ".repeat(100); + let prompt = final_prompt(&ctx, Some(glossary), &transcript); + assert!( + prompt.starts_with(glossary), + "the glossary leads the prompt: {prompt:?}" + ); + assert!( + prompt.len() <= MAX_PROMPT_CHARS, + "the combined prompt respects the char cap" + ); + assert!( + token_count(&ctx, &prompt) <= MAX_PROMPT_TOKENS, + "the combined prompt tokenizes within whisper's budget" + ); + assert!( + prompt.contains("lazy dog"), + "the transcript's tail survives the trim: {prompt:?}" + ); + } + + #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + fn final_pass_biases_segments_with_the_glossary() { + let vocabulary: Vec = ["MCP", "GGUF"].map(str::to_string).into(); + let mut pass = FinalPass::load(&fixtures::require_model(), &vocabulary) + .expect("final pass loads the fixture model"); + let first = pass + .transcribe_segment(&fixtures::jfk_samples()) + .expect("segment one transcribes") + .expect("segment one appended text"); + assert!( + first.to_lowercase().contains("country"), + "segment one names the fixture's words: {first:?}" + ); + assert!( + pass.last_prompt().starts_with("Glossary: MCP, GGUF."), + "the first segment was conditioned on the glossary: {:?}", + pass.last_prompt() + ); + let second = pass + .transcribe_segment(&fixtures::jfk_samples()) + .expect("segment two transcribes") + .expect("segment two appended text"); + assert!( + second.to_lowercase().contains("country"), + "segment two names the fixture's words: {second:?}" + ); + let prompt = pass.last_prompt(); + assert!( + prompt.starts_with("Glossary: MCP, GGUF. "), + "the glossary leads the conditioning prompt: {prompt:?}" + ); + assert!( + prompt.contains(&first), + "the transcript follows the glossary: {prompt:?}" + ); + } + + #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] fn missing_final_model_fails_engine_construction() { let config = VoiceConfig { interim_model: fixtures::require_model(), @@ -675,13 +1076,15 @@ mod tests { } #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] async fn final_pass_entry_points_are_no_ops_without_a_final_model() { let config = VoiceConfig { interim_model: fixtures::require_model(), ..VoiceConfig::default() }; let engine = VoiceEngine::new(&config).expect("engine loads the fixture model"); - engine.final_reset(); + let (segment_tx, _segment_rx) = std::sync::mpsc::channel(); + engine.final_reset(segment_tx); engine.final_submit(fixtures::jfk_samples()); assert!( engine.final_finish(fixtures::jfk_samples()).await.is_none(), @@ -689,15 +1092,95 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn final_submit_reports_the_segment_on_the_take_channel() { + let config = VoiceConfig { + interim_model: fixtures::require_model(), + final_model: fixtures::require_model(), + ..VoiceConfig::default() + }; + let engine = VoiceEngine::new(&config).expect("engine loads the fixture model"); + let (segment_tx, segment_rx) = std::sync::mpsc::channel(); + engine.final_reset(segment_tx); + engine.final_submit(fixtures::jfk_samples()); + + // The timeout only bounds a broken pipeline; the tiny fixture + // model transcribes the clip in seconds. + let segment = segment_rx + .recv_timeout(Duration::from_secs(120)) + .expect("the submitted segment's text arrives on the channel"); + assert!( + segment.to_lowercase().contains("country"), + "the reported segment names the fixture's words: {segment:?}" + ); + + let tail = engine + .final_finish(fixtures::jfk_samples()) + .await + .expect("a final model is configured") + .expect("the final pass succeeds"); + assert!( + tail.to_lowercase().contains("country"), + "the closing tail names the fixture's words: {tail:?}" + ); + let countries = tail.to_lowercase().matches("country").count(); + assert!( + countries < 3, + "the finish returns the tail's text only, not the assembled \ + transcript ({countries} countries): {tail:?}" + ); + assert!( + segment_rx.try_recv().is_err(), + "the closing tail reports only through its reply, not the channel" + ); + } + + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn final_finish_with_a_silent_tail_returns_empty_after_draining() { + let config = VoiceConfig { + interim_model: fixtures::require_model(), + final_model: fixtures::require_model(), + ..VoiceConfig::default() + }; + let engine = VoiceEngine::new(&config).expect("engine loads the fixture model"); + let (segment_tx, segment_rx) = std::sync::mpsc::channel(); + engine.final_reset(segment_tx); + engine.final_submit(fixtures::jfk_samples()); + + // The tail is pure silence: the worker skips it rather than + // hallucinating, and the FIFO reply still drains the take's + // submitted segment first. + let tail = engine + .final_finish(vec![0.0; SAMPLE_RATE]) + .await + .expect("a final model is configured") + .expect("the final pass succeeds"); + assert!( + tail.is_empty(), + "a silent tail is skipped, not transcribed: {tail:?}" + ); + let segment = segment_rx + .recv_timeout(Duration::from_secs(120)) + .expect("the submitted segment's text arrives on the channel"); + assert!( + segment.to_lowercase().contains("country"), + "the drained segment names the fixture's words: {segment:?}" + ); + } + #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] fn final_pass_conditions_each_segment_on_the_accumulated_transcript() { - let mut pass = FinalPass::load(&fixtures::require_model()) + let mut pass = FinalPass::load(&fixtures::require_model(), &[]) .expect("final pass loads the fixture model"); let jfk = fixtures::jfk_samples(); let first = pass .transcribe_segment(&jfk) - .expect("segment one transcribes"); + .expect("segment one transcribes") + .expect("segment one appended text"); assert!( pass.last_prompt().is_empty(), "the first segment has nothing to be conditioned on" @@ -708,20 +1191,31 @@ mod tests { "segment one names the fixture's words: {first:?}" ); let first_countries = first_lower.matches("country").count(); + assert_eq!( + pass.transcript(), + first, + "the accumulated transcript is the first segment's text" + ); let second = pass .transcribe_segment(&jfk) - .expect("segment two transcribes"); + .expect("segment two transcribes") + .expect("segment two appended text"); assert_eq!( pass.last_prompt(), first, "segment two was conditioned on the accumulated transcript" ); assert!( - second.starts_with(&first), - "segment transcripts accumulate in order: {second:?}" + second.to_lowercase().contains("country"), + "the segment's own text names the fixture's words: {second:?}" + ); + let assembled = pass.transcript(); + assert!( + assembled.starts_with(&first), + "segment transcripts accumulate in order: {assembled:?}" ); - let second_countries = second.to_lowercase().matches("country").count(); + let second_countries = assembled.to_lowercase().matches("country").count(); assert!( second_countries > first_countries, "the second segment added its own text: {first_countries} then {second_countries}" @@ -729,18 +1223,21 @@ mod tests { } #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] fn final_pass_reset_forgets_the_accumulated_transcript() { - let mut pass = FinalPass::load(&fixtures::require_model()) + let mut pass = FinalPass::load(&fixtures::require_model(), &[]) .expect("final pass loads the fixture model"); let jfk = fixtures::jfk_samples(); let first = pass .transcribe_segment(&jfk) - .expect("segment one transcribes"); + .expect("segment one transcribes") + .expect("segment one appended text"); pass.reset(); let second = pass .transcribe_segment(&jfk) - .expect("segment two transcribes"); + .expect("segment two transcribes") + .expect("segment two appended text"); assert!( pass.last_prompt().is_empty(), "after reset the next segment has nothing to be conditioned on" @@ -749,16 +1246,26 @@ mod tests { second, first, "a new take's transcript holds only its own segments" ); + assert_eq!( + pass.transcript(), + second, + "the accumulated transcript forgot the previous take" + ); } #[test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] fn final_pass_skips_silence_without_touching_the_transcript() { - let mut pass = FinalPass::load(&fixtures::require_model()) + let mut pass = FinalPass::load(&fixtures::require_model(), &[]) .expect("final pass loads the fixture model"); - let text = pass + let segment = pass .transcribe_segment(&vec![0.0; SAMPLE_RATE * 2]) .expect("silence is skipped, not an error"); - assert!(text.is_empty(), "silence transcribes to nothing"); + assert!(segment.is_none(), "a skipped segment reports no text"); + assert!( + pass.transcript().is_empty(), + "silence transcribes to nothing" + ); assert!( pass.last_prompt().is_empty(), "a skipped segment records no conditioning" diff --git a/crates/promptforge-wb-server/src/voice.rs b/crates/promptforge-wb-server/src/voice.rs index ae7a40de..b96ab21d 100644 --- a/crates/promptforge-wb-server/src/voice.rs +++ b/crates/promptforge-wb-server/src/voice.rs @@ -4,24 +4,31 @@ //! A client upgrades `GET /voice`, sends the text control message `start`, //! streams binary messages of little-endian f32 PCM (16 kHz mono), and sends //! `stop` to end a take. While a take records, an interim loop transcribes -//! the trailing `voice.window_seconds` of audio every `voice.interval_ms` -//! and pushes `{"type":"interim","text":"..."}` text messages. In parallel, -//! an energy-based segmenter ([`crate::segment::Segmenter`]) cuts completed -//! speech segments at silence boundaries and hands them to the final-pass -//! worker, which transcribes them with the `voice.final_model` model in the -//! background, each conditioned on the take's accumulated transcript. On -//! `stop` the worker transcribes the unprocessed tail and the server answers -//! with one `{"type":"final","text":"...","frames":N}` text message holding -//! the assembled transcript and the total PCM frames received since the -//! most recent `start`. Without a configured final model the final pass -//! falls back to one last interim-model window (logged); without any +//! the take's uncommitted audio every `voice.interval_ms` and pushes +//! `{"type":"interim","committed":"...","tentative":"..."}` text messages: +//! `committed` is the crystallized prefix (final-pass segment transcripts, +//! append-only within a take) and `tentative` is the interim model's decode +//! of the audio past it. In parallel, an energy-based segmenter +//! ([`crate::segment::Segmenter`]) cuts completed speech segments at +//! silence boundaries and hands them to the final-pass worker, which +//! transcribes them with the `voice.final_model` model in the background, +//! each conditioned on the take's accumulated transcript. On `stop` the +//! worker transcribes the unprocessed tail (its FIFO reply drains every +//! background segment first) and the server answers with one +//! `{"type":"final","text":"...","frames":N}` text message: the take's +//! crystallized committed prefix joined with the tail's own text, plus the +//! total PCM frames received since the most recent `start`. Without a +//! configured final model nothing crystallizes, segmentation stays off so +//! no audio is consumed early, and the final pass falls back to one last +//! interim-model decode of the uncommitted audio (logged); without any //! `[voice]` model the endpoint still captures and counts PCM, and //! transcripts come back empty. Silent audio is never transcribed (whisper -//! hallucinates on silence), and empty transcripts are never sent as -//! interims. +//! hallucinates on silence), and an interim frame is sent only when +//! `committed` or `tentative` changed since the last one. -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; @@ -30,16 +37,23 @@ use futures_util::{SinkExt, StreamExt}; use crate::app::AppState; use crate::segment::Segmenter; +use crate::status::{Activity, StatusBus}; use crate::transcribe::{self, MIN_WINDOW_SAMPLES, VoiceEngine}; /// Session ids for log correlation, handed out in connection order. static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); +/// Floor between microphone activity pulses: the worklet posts chunks far +/// faster than the status bar can usefully change, so mic activity pulses +/// at 4 Hz rather than per frame. +const MIC_PULSE_INTERVAL: Duration = Duration::from_millis(250); + /// Upgrades a `GET /voice` request to a WebSocket voice-capture session. pub(crate) async fn upgrade(State(state): State, ws: WebSocketUpgrade) -> Response { let session = NEXT_SESSION.fetch_add(1, Ordering::Relaxed); let engine = state.voice_engine(); - ws.on_upgrade(move |socket| run_session(session, socket, engine)) + let status = state.status(); + ws.on_upgrade(move |socket| run_session(session, socket, engine, status)) } /// Locks the PCM buffer, recovering from poisoning the way the tape does: a @@ -48,10 +62,64 @@ fn lock_buffer(buffer: &Mutex>) -> MutexGuard<'_, Vec> { buffer.lock().unwrap_or_else(PoisonError::into_inner) } -/// Copies the trailing interim window out of the shared PCM buffer. -fn window_snapshot(buffer: &Mutex>, window_samples: usize) -> Vec { +/// Appends `piece` to `text` with the single-space join the final-pass +/// worker uses between segments; an empty piece changes nothing. +fn append_transcript(text: &mut String, piece: &str) { + if piece.is_empty() { + return; + } + if !text.is_empty() { + text.push(' '); + } + text.push_str(piece); +} + +/// One take's crystallized transcript: the segment texts the final-pass +/// worker has reported on the take's channel, joined by single spaces +/// exactly as the worker assembles its own transcript. Shared between the +/// receive loop (drained on each binary message and once at `stop`) and +/// the interim loop (drained each tick, since a segment can finish while +/// no audio arrives), behind the same kind of std mutex as the PCM +/// buffer; no guard ever crosses an `.await`. +#[derive(Debug, Default)] +struct Committed { + text: String, + segments: Option>, +} + +impl Committed { + /// Appends every segment text the worker has reported since the last + /// drain. Append-only within a take. + fn drain(&mut self) { + if let Some(segments) = &self.segments { + while let Ok(text) = segments.try_recv() { + append_transcript(&mut self.text, &text); + } + } + } +} + +/// Locks the committed transcript, recovering from poisoning the way the +/// PCM buffer does. +fn lock_committed(committed: &Mutex) -> MutexGuard<'_, Committed> { + committed.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Copies the take's uncommitted audio - everything past the segmenter's +/// consumed offset - capped to the trailing interim window. Committed +/// audio is never re-decoded by the interim model, and the cap keeps a +/// take whose segments never close (or which has no final pass) from +/// re-decoding its whole length on every pass. +fn uncommitted_snapshot( + buffer: &Mutex>, + consumed: usize, + window_samples: usize, +) -> Vec { let guard = lock_buffer(buffer); - transcribe::tail(&guard, window_samples).to_vec() + // A take reset can clear the buffer behind a stale offset read by a + // not-yet-aborted previous interim loop; clamp rather than panic on it. + let uncommitted = &guard[consumed.min(guard.len())..]; + transcribe::tail(uncommitted, window_samples).to_vec() } /// Aborts the interim loop, if one is running. @@ -61,74 +129,125 @@ fn stop_interim(interim: &mut Option>) { } } -/// The interim loop: every `interval`, transcribe the trailing window and -/// push non-empty transcripts to the client. Runs until aborted (on `start`, -/// `stop`, or session end) or until the outbound channel closes. +/// The interim loop: every `interval`, drain newly crystallized segments, +/// transcribe the take's uncommitted audio (everything past the segmenter's +/// consumed offset, so a long take does not re-decode its own prefix), and +/// push an interim frame when either field changed since the last send. +/// Runs until aborted (on `start`, `stop`, or session end) or until the +/// outbound channel closes. fn spawn_interim_loop( session: u64, engine: Arc, buffer: Arc>>, + committed: Arc>, + consumed: Arc, out: tokio::sync::mpsc::Sender, + status: StatusBus, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { + let mut last_committed = String::new(); + let mut last_tentative = String::new(); + // The latch: committed text at the time of the last non-empty + // tentative. When tentative goes empty (the segmenter advanced + // past speech but the final worker hasn't crystallized it yet), + // suppress the frame until committed grows past this snapshot. + // This prevents the brief empty-display gap between segment + // close and crystallization. + let mut committed_at_last_speech = String::new(); loop { tokio::time::sleep(engine.interval()).await; - let window = window_snapshot(&buffer, engine.window_samples()); - if window.len() < MIN_WINDOW_SAMPLES || transcribe::is_silence(&window) { - continue; - } - match engine.transcribe(window).await { - // `transcribe` returns trimmed text, so empty here means a - // whitespace-only hallucination: suppress it. - Ok(text) if text.is_empty() => {} - Ok(text) => { - let message = serde_json::json!({"type": "interim", "text": text}).to_string(); - if out.send(Message::Text(message.into())).await.is_err() { - return; + let committed_text = { + let mut guard = lock_committed(&committed); + guard.drain(); + guard.text.clone() + }; + let window = uncommitted_snapshot( + &buffer, + consumed.load(Ordering::Relaxed), + engine.window_samples(), + ); + let tentative = if window.len() < MIN_WINDOW_SAMPLES || transcribe::is_silence(&window) + { + String::new() + } else { + status.debug( + "Transcribing...", + "an interim pass over the uncommitted audio", + Activity::General, + ); + match engine.transcribe(window).await { + Ok(text) => text, + Err(error) => { + status.debug("Transcription failed", error.to_string(), Activity::General); + tracing::warn!(session, %error, "interim transcription failed"); + continue; } } - Err(error) => { - tracing::warn!(session, %error, "interim transcription failed"); - } + }; + if !tentative.is_empty() { + committed_at_last_speech.clone_from(&committed_text); + } else if committed_text.len() <= committed_at_last_speech.len() { + // Tentative is empty and committed hasn't grown past + // the snapshot: the final worker hasn't caught up yet. + // Hold the display at the previous frame. + continue; + } + if committed_text == last_committed && tentative == last_tentative { + continue; + } + last_committed.clone_from(&committed_text); + last_tentative.clone_from(&tentative); + let message = serde_json::json!({ + "type": "interim", + "committed": committed_text, + "tentative": tentative, + }) + .to_string(); + if out.send(Message::Text(message.into())).await.is_err() { + return; } } }) } -/// The stop-message transcript when the final model is absent or fails: one -/// last interim-model pass over the trailing window, or an empty string when -/// there is no engine, the window is silent, or the pass fails (logged; the -/// client still gets its reply). +/// The stop-message tail when the final model is absent or fails: one last +/// interim-model pass over the take's uncommitted audio, or an empty string +/// when the slice is tiny or silent or the pass fails (logged; the client +/// still gets its reply). The committed prefix is already final, so it is +/// never re-transcribed here. async fn final_transcript( session: u64, - engine: Option<&VoiceEngine>, + engine: &VoiceEngine, buffer: &Mutex>, + segmenter: &Segmenter, + status: &StatusBus, ) -> String { - let Some(engine) = engine else { - return String::new(); - }; - let window = window_snapshot(buffer, engine.window_samples()); - if transcribe::is_silence(&window) { + let window = uncommitted_snapshot(buffer, segmenter.consumed(), engine.window_samples()); + if window.len() < MIN_WINDOW_SAMPLES || transcribe::is_silence(&window) { return String::new(); } match engine.transcribe(window).await { Ok(text) => text, Err(error) => { + status.error("Transcription failed", error.to_string(), Activity::General); tracing::warn!(session, %error, "final transcription failed"); String::new() } } } -/// The stop-message transcript: the pipelined final pass over the whole -/// take when a final model is configured (the tail is queued behind the -/// take's background segments, so awaiting its reply drains them), falling -/// back to the interim-model window when it is not or when it fails. +/// The stop-message transcript: the take's crystallized committed prefix +/// joined with the closing tail's own text. With a final model configured +/// the tail is queued behind the take's background segments, so awaiting +/// its reply drains them; without one (or when it fails) the tail falls +/// back to the interim-model decode of the uncommitted audio. async fn stop_transcript( session: u64, engine: Option<&VoiceEngine>, buffer: &Mutex>, + committed: &Mutex, segmenter: &Segmenter, + status: &StatusBus, ) -> String { let Some(engine) = engine else { return String::new(); @@ -137,24 +256,130 @@ async fn stop_transcript( let guard = lock_buffer(buffer); guard[segmenter.consumed()..].to_vec() }; - match engine.final_finish(tail).await { + let tail = match engine.final_finish(tail).await { Some(Ok(text)) => text, Some(Err(error)) => { + status.error("Transcription failed", error.to_string(), Activity::General); tracing::warn!(session, %error, "final-pass transcription failed; falling back to the interim model"); - final_transcript(session, Some(engine), buffer).await + final_transcript(session, engine, buffer, segmenter, status).await } None => { tracing::info!( session, "no final model configured; the final pass uses the interim model" ); - final_transcript(session, Some(engine), buffer).await + final_transcript(session, engine, buffer, segmenter, status).await } + }; + // Awaiting the tail's reply drained every segment submitted this take + // (the worker's channel is FIFO), so this crystallizes everything the + // take reported before the final frame is assembled. + let mut guard = lock_committed(committed); + guard.drain(); + append_transcript(&mut guard.text, &tail); + guard.text.clone() +} + +/// Starts a new take: clears the buffer and the committed transcript, +/// resets the segmenter and the final-pass pipeline, installs the take's +/// segment-completion receiver, and spawns the interim loop when an engine +/// is configured. +#[expect( + clippy::too_many_arguments, + reason = "the take's shared state travels piecemeal so run_session's message loop stays flat" +)] +fn begin_take( + session: u64, + engine: Option<&Arc>, + buffer: &Arc>>, + committed: &Arc>, + consumed: &Arc, + segmenter: &mut Segmenter, + interim: &mut Option>, + out: &tokio::sync::mpsc::Sender, + status: &StatusBus, +) { + lock_buffer(buffer).clear(); + segmenter.reset(); + consumed.store(0, Ordering::Relaxed); + { + let mut guard = lock_committed(committed); + guard.text.clear(); + guard.segments = engine + .filter(|engine| engine.has_final_pass()) + .map(|engine| { + let (segment_tx, segment_rx) = std::sync::mpsc::channel(); + engine.final_reset(segment_tx); + segment_rx + }); + } + stop_interim(interim); + if let Some(engine) = engine { + *interim = Some(spawn_interim_loop( + session, + Arc::clone(engine), + Arc::clone(buffer), + Arc::clone(committed), + Arc::clone(consumed), + out.clone(), + status.clone(), + )); } + status.info( + "Listening...", + "a push-to-talk take is recording", + Activity::General, + ); + tracing::info!(session, "voice capture started"); +} + +/// Cuts any speech segments the newly arrived audio completed, hands them +/// to the background final pass, publishes the segmenter's consumed offset +/// for the interim loop, and crystallizes the segments the worker has +/// finished since the last message. +fn submit_closed_segments( + engine: &VoiceEngine, + buffer: &Arc>>, + committed: &Arc>, + consumed: &Arc, + segmenter: &mut Segmenter, +) { + loop { + let segment = { + let guard = lock_buffer(buffer); + segmenter.poll(&guard).map(|range| guard[range].to_vec()) + }; + match segment { + Some(samples) => engine.final_submit(samples), + None => break, + } + } + consumed.store(segmenter.consumed(), Ordering::Relaxed); + lock_committed(committed).drain(); +} + +/// Sends the take's `final` reply; a false return means the client is gone. +async fn send_final_reply( + out: &tokio::sync::mpsc::Sender, + text: String, + frames: u64, +) -> bool { + let reply = serde_json::json!({ + "type": "final", + "text": text, + "frames": frames, + }) + .to_string(); + out.send(Message::Text(reply.into())).await.is_ok() } /// Runs one capture session until the socket closes or fails. -async fn run_session(session: u64, socket: WebSocket, engine: Option>) { +async fn run_session( + session: u64, + socket: WebSocket, + engine: Option>, + status: StatusBus, +) { tracing::info!(session, "voice session opened"); let (mut sink, mut stream) = socket.split(); // The receive loop and the interim loop both speak to the client, so @@ -169,9 +394,14 @@ async fn run_session(session: u64, socket: WebSocket, engine: Option>> = Arc::new(Mutex::new(Vec::new())); + let committed: Arc> = Arc::new(Mutex::new(Committed::default())); + // The segmenter's consumed offset, published for the interim loop, + // which cannot borrow the session-local segmenter. + let consumed = Arc::new(AtomicUsize::new(0)); let mut frames = 0u64; let mut interim: Option> = None; let mut segmenter = Segmenter::new(); + let mut last_mic_pulse: Option = None; while let Some(received) = stream.next().await { match received { @@ -186,54 +416,62 @@ async fn run_session(session: u64, socket: WebSocket, engine: Option= MIC_PULSE_INTERVAL) { + last_mic_pulse = Some(Instant::now()); + status.debug( + "Listening...", + "microphone audio is arriving", + Activity::General, + ); + } // Cut any speech segments the new audio completed and hand - // them to the background final pass. - if let Some(engine) = &engine { - loop { - let segment = { - let guard = lock_buffer(&buffer); - segmenter.poll(&guard).map(|range| guard[range].to_vec()) - }; - match segment { - Some(samples) => engine.final_submit(samples), - None => break, - } - } + // them to the background final pass. Without a final pass + // nothing can crystallize, so the segmenter stays off and + // the interim loop and stop fallback keep seeing the whole + // take as uncommitted. + if let Some(engine) = &engine + && engine.has_final_pass() + { + submit_closed_segments(engine, &buffer, &committed, &consumed, &mut segmenter); } } Ok(Message::Text(text)) => match text.as_str() { "start" => { frames = 0; - lock_buffer(&buffer).clear(); - segmenter.reset(); - if let Some(engine) = &engine { - engine.final_reset(); - } - stop_interim(&mut interim); - if let Some(engine) = &engine { - interim = Some(spawn_interim_loop( - session, - Arc::clone(engine), - Arc::clone(&buffer), - out_tx.clone(), - )); - } - tracing::info!(session, "voice capture started"); + last_mic_pulse = None; + begin_take( + session, + engine.as_ref(), + &buffer, + &committed, + &consumed, + &mut segmenter, + &mut interim, + &out_tx, + &status, + ); } "stop" => { stop_interim(&mut interim); - let text = - stop_transcript(session, engine.as_deref(), &buffer, &segmenter).await; + status.info( + "Finalizing transcript...", + "the final pass over the take", + Activity::General, + ); + let text = stop_transcript( + session, + engine.as_deref(), + &buffer, + &committed, + &segmenter, + &status, + ) + .await; tracing::info!(session, frames, "voice capture stopped"); - let reply = serde_json::json!({ - "type": "final", - "text": text, - "frames": frames, - }) - .to_string(); - if out_tx.send(Message::Text(reply.into())).await.is_err() { + if !send_final_reply(&out_tx, text, frames).await { break; } + status.idle(); } _ => {} }, @@ -247,6 +485,7 @@ async fn run_session(session: u64, socket: WebSocket, engine: Option= 3, - "both segments are in the assembled transcript ({countries} countries): {text:?}" + "both segments are in the final frame ({countries} countries): {text:?}" + ); + assert!( + countries <= committed_countries + 2, + "the tail added at most one pass over the fixture - committed \ + segments were not transcribed again ({committed_countries} \ + committed, {countries} total): {text:?}" + ); + socket.close(None).await.expect("close the socket"); + } + + /// Stopping right at a segment boundary: the speech has closed and + /// crystallized, and only the closing silence is uncommitted. The stop + /// must not transcribe the silent tail (whisper hallucinates on + /// silence); the final frame is exactly the committed prefix. + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn stop_at_a_segment_boundary_returns_the_committed_prefix() { + let voice = VoiceConfig { + interim_model: fixtures::require_model(), + final_model: fixtures::require_model(), + window_seconds: 8, + interval_ms: 400, + ..VoiceConfig::default() + }; + let (url, _tape_dir) = spawn_voice_server(voice).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /voice"); + socket + .send(tungstenite::Message::Text("start".into())) + .await + .expect("send start"); + let jfk = fixtures::jfk_samples(); + send_samples(&mut socket, &jfk).await; + send_pcm(&mut socket, 3 * 16000).await; + + // Wait for the segment to close and crystallize; the timeout only + // bounds a broken pipeline. + let committed = tokio::time::timeout(Duration::from_secs(120), async { + loop { + let message = parse_message(&read_text(&mut socket).await); + if message["type"] != "interim" { + continue; + } + let committed = message["committed"] + .as_str() + .expect("every interim frame carries a committed string") + .to_string(); + if committed.to_lowercase().contains("country") { + break committed; + } + } + }) + .await + .expect("the segment crystallizes within 120 s"); + + socket + .send(tungstenite::Message::Text("stop".into())) + .await + .expect("send stop"); + let reply = tokio::time::timeout(Duration::from_secs(180), async { + loop { + let message = parse_message(&read_text(&mut socket).await); + if message["type"] == "final" { + break message; + } + } + }) + .await + .expect("the final reply arrives within 180 s"); + let text = reply["text"].as_str().expect("final text is a string"); + assert_eq!( + text, committed, + "no uncommitted speech means no tail text and no whisper call" + ); + socket.close(None).await.expect("close the socket"); + } + + /// The drain is the append-only guarantee behind the wire protocol: + /// segment texts accumulate in arrival order, joined by single spaces + /// exactly as the final-pass worker assembles its transcript, and a + /// drain with nothing new changes nothing. + #[test] + fn committed_drain_appends_segments_in_arrival_order() { + let (segment_tx, segment_rx) = std::sync::mpsc::channel(); + let mut committed = Committed { + text: String::new(), + segments: Some(segment_rx), + }; + segment_tx + .send("ask not".to_string()) + .expect("the receiver is held"); + committed.drain(); + assert_eq!(committed.text, "ask not"); + committed.drain(); + assert_eq!(committed.text, "ask not", "an empty drain changes nothing"); + segment_tx + .send("what you can do".to_string()) + .expect("the receiver is held"); + committed.drain(); + assert_eq!( + committed.text, "ask not what you can do", + "the second segment appended with a single-space join" + ); + } + + /// The committed/tentative wire protocol: three passes over the speech + /// fixture separated by silence gaps crystallize two segments mid-take. + /// Every interim frame must carry both `committed` and `tentative` + /// strings, `committed` must be append-only across the frames that + /// arrive, and the final reply - the committed prefix joined with the + /// tail's own text - must open with the last committed prefix, which a + /// replace-instead-of-append regression would break. + #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] + async fn interim_frames_carry_append_only_committed() { + let voice = VoiceConfig { + interim_model: fixtures::require_model(), + final_model: fixtures::require_model(), + window_seconds: 8, + interval_ms: 400, + ..VoiceConfig::default() + }; + let (url, _tape_dir) = spawn_voice_server(voice).await; + let (mut socket, _) = tokio_tungstenite::connect_async(&url) + .await + .expect("connect to /voice"); + socket + .send(tungstenite::Message::Text("start".into())) + .await + .expect("send start"); + let jfk = fixtures::jfk_samples(); + send_samples(&mut socket, &jfk).await; + send_pcm(&mut socket, 3 * 16000).await; + send_samples(&mut socket, &jfk).await; + send_pcm(&mut socket, 3 * 16000).await; + send_samples(&mut socket, &jfk).await; + + // Collect interim frames until both segments have crystallized; + // the timeout only bounds a broken pipeline. Frames arrive only on + // change, and the tiny worker can finish both segments inside one + // interim pass, so intermediate committed values are not + // guaranteed to appear as their own frames. One pass over the + // fixture says "country" twice, so three or more occurrences prove + // both segments crystallized. + let frames = tokio::time::timeout(Duration::from_secs(120), async { + let mut frames: Vec = Vec::new(); + loop { + let message = parse_message(&read_text(&mut socket).await); + if message["type"] != "interim" { + continue; + } + let committed = message["committed"] + .as_str() + .expect("every interim frame carries a committed string") + .to_string(); + message["tentative"] + .as_str() + .expect("every interim frame carries a tentative string"); + let crystallized = committed.to_lowercase().matches("country").count(); + frames.push(committed); + if crystallized >= 3 { + break frames; + } + } + }) + .await + .expect("both segments crystallize into committed within 120 s"); + + for pair in frames.windows(2) { + assert!( + pair[1].starts_with(&pair[0]), + "committed is append-only across frames: {:?} then {:?}", + pair[0], + pair[1] + ); + } + let committed = frames.last().expect("frames were collected"); + + socket + .send(tungstenite::Message::Text("stop".into())) + .await + .expect("send stop"); + let reply = tokio::time::timeout(Duration::from_secs(180), async { + loop { + let message = parse_message(&read_text(&mut socket).await); + if message["type"] == "final" { + break message; + } + } + }) + .await + .expect("the final reply arrives within 180 s"); + let text = reply["text"].as_str().expect("final text is a string"); + assert!( + text.starts_with(committed.as_str()), + "the assembled transcript opens with the committed prefix: {text:?}" ); socket.close(None).await.expect("close the socket"); } #[tokio::test] + #[ignore = "requires whisper test fixtures (tests/fixtures/)"] async fn silence_produces_no_interims_and_an_empty_final() { let (url, _tape_dir) = spawn_voice_server(test_voice_config()).await; let (mut socket, _) = tokio_tungstenite::connect_async(&url) diff --git a/crates/promptforge-wb-server/ui/app.js b/crates/promptforge-wb-server/ui/app.js deleted file mode 100644 index 358d6837..00000000 --- a/crates/promptforge-wb-server/ui/app.js +++ /dev/null @@ -1,399 +0,0 @@ -"use strict"; - -const md = window.markdownit({ - breaks: true, - linkify: true, -}); - -const messagesEl = document.getElementById("messages"); -const composerEl = document.getElementById("composer"); -const inputEl = document.getElementById("input"); -const sendEl = document.getElementById("send"); -const pickerEl = document.getElementById("model-picker"); -const descriptionEl = document.getElementById("model-description"); -const micEl = document.getElementById("mic"); -const voiceStatusEl = document.getElementById("voice-status"); -const interimEl = document.getElementById("interim"); - -// OpenAI-shaped history: [{role, content}, ...], sent verbatim to /chat. -const history = []; -let streaming = false; - -function selectedModel() { - return pickerEl.value; -} - -function scrollToBottom() { - messagesEl.scrollTop = messagesEl.scrollHeight; -} - -function addBubble(role, text) { - const message = document.createElement("div"); - message.className = `message ${role}`; - const bubble = document.createElement("div"); - bubble.className = "bubble"; - bubble.textContent = text; - message.appendChild(bubble); - messagesEl.appendChild(message); - scrollToBottom(); - return bubble; -} - -function addErrorBubble(text) { - addBubble("error", text); -} - -function setStreaming(next) { - streaming = next; - sendEl.disabled = next || !pickerEl.value; - inputEl.readOnly = next; -} - -function autoResize() { - inputEl.style.height = "auto"; - inputEl.style.height = `${Math.min(inputEl.scrollHeight, 200)}px`; -} - -async function loadModels() { - try { - const response = await fetch("/v1/models"); - if (!response.ok) { - throw new Error(`GET /v1/models answered ${response.status}`); - } - const catalog = await response.json(); - const entries = Array.isArray(catalog.data) ? catalog.data : []; - pickerEl.textContent = ""; - if (entries.length === 0) { - pickerEl.appendChild(new Option("No models available", "")); - pickerEl.disabled = true; - return; - } - for (const entry of entries) { - const option = new Option(entry.id, entry.id); - option.dataset.description = entry.description || ""; - pickerEl.appendChild(option); - } - pickerEl.disabled = false; - showDescription(); - sendEl.disabled = false; - } catch (error) { - pickerEl.textContent = ""; - pickerEl.appendChild(new Option("Model catalog unavailable", "")); - pickerEl.disabled = true; - addErrorBubble(`Could not load the model catalog: ${error.message}`); - } -} - -function showDescription() { - const option = pickerEl.selectedOptions[0]; - descriptionEl.textContent = (option && option.dataset.description) || ""; -} - -async function send(text) { - history.push({ role: "user", content: text }); - addBubble("user", text); - - const message = document.createElement("div"); - message.className = "message assistant streaming"; - const bubble = document.createElement("div"); - bubble.className = "bubble"; - message.appendChild(bubble); - messagesEl.appendChild(message); - scrollToBottom(); - - setStreaming(true); - let assembled = ""; - try { - const response = await fetch("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: selectedModel(), - messages: history, - stream: true, - }), - }); - if (!response.ok) { - const detail = await response.text(); - throw new Error(`POST /chat answered ${response.status}: ${detail}`); - } - await streamInto(response.body, (delta) => { - assembled += delta; - bubble.innerHTML = md.render(assembled); - scrollToBottom(); - }); - if (assembled.length > 0) { - history.push({ role: "assistant", content: assembled }); - } - } catch (error) { - message.remove(); - addErrorBubble(error.message); - } finally { - message.classList.remove("streaming"); - setStreaming(false); - inputEl.focus(); - } -} - -// Reads an SSE body, invoking onDelta for each choices[0].delta.content. -// fetch + ReadableStream rather than EventSource because /chat is a POST. -async function streamInto(body, onDelta) { - const reader = body.pipeThrough(new TextDecoderStream()).getReader(); - let buffer = ""; - for (;;) { - const { value, done } = await reader.read(); - if (done) { - break; - } - buffer += value; - const events = buffer.split("\n\n"); - buffer = events.pop(); - for (const event of events) { - const data = event - .split("\n") - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).replace(/^ /, "")) - .join("\n"); - if (data === "") { - continue; - } - if (data === "[DONE]") { - return; - } - let chunk; - try { - chunk = JSON.parse(data); - } catch { - // A non-JSON data payload carries no chat delta; skip it and keep - // reading the stream. - continue; - } - const delta = chunk.choices?.[0]?.delta?.content; - if (typeof delta === "string") { - onDelta(delta); - } - } - } - // A stream that ends without [DONE] was truncated mid-way (design entry - // 15): the partial text stays on screen and the cursor stops blinking. -} - -// Voice capture: the active session's {ws, ctx, source, node, stream}, or -// null while idle. One session at a time; the mic button toggles it. -let voice = null; -let voiceStatusTimer = 0; - -function showVoiceStatus(text, isError) { - voiceStatusEl.textContent = text; - voiceStatusEl.classList.toggle("error", Boolean(isError)); - voiceStatusEl.classList.add("visible"); - clearTimeout(voiceStatusTimer); - voiceStatusTimer = setTimeout(() => { - voiceStatusEl.classList.remove("visible"); - }, 8000); -} - -function setRecording(next) { - micEl.classList.toggle("recording", next); - micEl.setAttribute("aria-pressed", String(next)); - micEl.title = next ? "Stop recording" : "Push to talk"; -} - -// The live interim transcript above the composer: each interim replaces the -// last, so the user watches the take rewrite itself as they speak. -function showInterim(text) { - interimEl.textContent = text; - interimEl.classList.add("visible"); -} - -function clearInterim() { - interimEl.textContent = ""; - interimEl.classList.remove("visible"); -} - -// Tears down a session's audio half. The socket half is closed by the -// caller, after any in-flight "stop" reply has had a chance to arrive. -function releaseAudio(session) { - session.node.port.onmessage = null; - session.source.disconnect(); - session.node.disconnect(); - for (const track of session.stream.getTracks()) { - track.stop(); - } - // Best effort: a failed close leaves nothing the page can still act on. - session.ctx.close().catch(() => {}); -} - -async function startVoice() { - if (!navigator.mediaDevices?.getUserMedia || !window.AudioContext || !window.WebSocket) { - showVoiceStatus("Voice capture is not available in this browser.", true); - return; - } - let stream; - try { - stream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: 1, - sampleRate: 16000, - echoCancellation: true, - noiseSuppression: true, - }, - }); - } catch (error) { - const detail = - error && error.name === "NotAllowedError" - ? "microphone permission denied" - : `microphone unavailable: ${error.message || error}`; - showVoiceStatus(detail, true); - return; - } - let ws; - let ctx; - try { - ws = new WebSocket( - `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/voice`, - ); - ws.binaryType = "arraybuffer"; - await new Promise((resolve, reject) => { - ws.addEventListener("open", resolve, { once: true }); - ws.addEventListener("error", () => reject(new Error("the /voice socket failed to open")), { - once: true, - }); - }); - // The context resamples the mic stream to 16 kHz before the worklet - // sees it, so the wire format is 16 kHz mono f32 on any device. - ctx = new AudioContext({ sampleRate: 16000 }); - await ctx.audioWorklet.addModule("/pcm-worklet.js"); - const source = ctx.createMediaStreamSource(stream); - const node = new AudioWorkletNode(ctx, "pcm-capture"); - const session = { ws, ctx, source, node, stream }; - node.port.onmessage = (event) => { - if (voice === session && ws.readyState === WebSocket.OPEN) { - ws.send(event.data); - } - }; - ws.addEventListener("message", (event) => { - if (handleVoiceMessage(event.data)) { - ws.close(); - } - }); - ws.addEventListener("close", () => { - if (voice === session) { - voice = null; - setRecording(false); - releaseAudio(session); - showVoiceStatus("The voice connection dropped.", true); - } - }); - source.connect(node); - // The worklet renders silence, so reaching the destination is safe and - // keeps the graph pulling on every engine. - node.connect(ctx.destination); - voice = session; - clearInterim(); - ws.send("start"); - setRecording(true); - showVoiceStatus("Recording - press the mic button again to stop.", false); - } catch (error) { - for (const track of stream.getTracks()) { - track.stop(); - } - // A socket or context that was created before the failure outlives this - // function unless closed here; an open socket would hold the server's - // session task until the connection times out. - if (ws) { - ws.close(); - } - if (ctx) { - ctx.close().catch(() => {}); - } - showVoiceStatus(`Voice capture failed: ${error.message || error}`, true); - } -} - -// Handles one server text message. An interim rewrites the live area above -// the composer; a final replaces the interims and lands in the input box, -// ready to edit and send. Returns true when the take is over and the socket -// should close. -function handleVoiceMessage(data) { - let msg; - try { - msg = JSON.parse(data); - } catch { - msg = null; - } - if (msg && msg.type === "interim" && typeof msg.text === "string") { - showInterim(msg.text); - return false; - } - if (msg && msg.type === "final") { - clearInterim(); - const text = typeof msg.text === "string" ? msg.text.trim() : ""; - if (text !== "") { - inputEl.value = text; - autoResize(); - inputEl.focus(); - showVoiceStatus("Transcript ready - edit, then send.", false); - } else { - const frames = typeof msg.frames === "number" ? msg.frames : 0; - showVoiceStatus(`No speech detected (${frames} PCM frames captured).`, false); - } - return true; - } - // Anything else is shown verbatim and ends the take. - showVoiceStatus(String(data), false); - return true; -} - -function stopVoice() { - const session = voice; - voice = null; - setRecording(false); - if (!session) { - return; - } - releaseAudio(session); - const { ws } = session; - if (ws.readyState === WebSocket.OPEN) { - ws.send("stop"); - // The final reply closes the socket from the message listener; this is - // the fallback if the reply never comes. - setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.close(); - } - }, 2000); - } -} - -micEl.addEventListener("click", () => { - if (voice) { - stopVoice(); - } else { - startVoice(); - } -}); - -composerEl.addEventListener("submit", (event) => { - event.preventDefault(); - const text = inputEl.value.trim(); - if (text === "" || streaming || !selectedModel()) { - return; - } - inputEl.value = ""; - autoResize(); - send(text); -}); - -inputEl.addEventListener("keydown", (event) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - composerEl.requestSubmit(); - } -}); - -inputEl.addEventListener("input", autoResize); -pickerEl.addEventListener("change", showDescription); - -loadModels(); -inputEl.focus(); diff --git a/crates/promptforge-wb-server/ui/build.mjs b/crates/promptforge-wb-server/ui/build.mjs new file mode 100644 index 00000000..e46c1f93 --- /dev/null +++ b/crates/promptforge-wb-server/ui/build.mjs @@ -0,0 +1,47 @@ +// Bundles src/main.ts into dist/app.js and copies the static assets into +// dist/. The server crate's build.rs performs the same two steps on +// `cargo build` (STATIC_FILES is mirrored there); this script exists for the +// fast iteration workflow: `npm run watch` rebuilds on save without a Rust +// recompile. +import { copyFile, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; + +const uiDir = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.join(uiDir, "dist"); + +// Mirrored in ../build.rs. +const STATIC_FILES = ["index.html", "style.css", "pcm-worklet.js"]; + +const options = { + entryPoints: [path.join(uiDir, "src", "main.ts")], + bundle: true, + format: "esm", + target: "es2022", + // `node build.mjs --minify` matches what build.rs does for cargo release + // builds. + minify: process.argv.includes("--minify"), + outfile: path.join(distDir, "app.js"), + logLevel: "info", +}; + +// dist/ is rebuilt from scratch so removed assets never linger into the +// release embed. +async function copyStatic() { + await mkdir(distDir, { recursive: true }); + await Promise.all( + STATIC_FILES.map((file) => copyFile(path.join(uiDir, file), path.join(distDir, file))), + ); +} + +if (process.argv.includes("--watch")) { + const context = await esbuild.context(options); + await copyStatic(); + await context.watch(); + console.log("watching ui/src for changes..."); +} else { + await rm(distDir, { recursive: true, force: true }); + await esbuild.build(options); + await copyStatic(); +} diff --git a/crates/promptforge-wb-server/ui/index.html b/crates/promptforge-wb-server/ui/index.html index dc8afdd0..940fe3b2 100644 --- a/crates/promptforge-wb-server/ui/index.html +++ b/crates/promptforge-wb-server/ui/index.html @@ -4,36 +4,61 @@ PromptForge +
-
-
-
-
- - - -
-
-
+
+
+
- - +
+ Ready + + REC + + + + + +
+ + diff --git a/crates/promptforge-wb-server/ui/markdown-it.min.js b/crates/promptforge-wb-server/ui/markdown-it.min.js deleted file mode 100644 index 5e6f2569..00000000 --- a/crates/promptforge-wb-server/ui/markdown-it.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! markdown-it 14.1.0 https://github.com/markdown-it/markdown-it @license MIT */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).markdownit=e()}(this,(function(){"use strict";const t={};function e(r,n){"string"!=typeof n&&(n=e.defaultChars);const s=function(e){let r=t[e];if(r)return r;r=t[e]=[];for(let t=0;t<128;t++){const e=String.fromCharCode(t);r.push(e)}for(let t=0;t=55296&&t<=57343?"\ufffd\ufffd\ufffd":String.fromCharCode(t),r+=6;continue}}if(240==(248&i)&&r+91114111?e+="\ufffd\ufffd\ufffd\ufffd":(t-=65536,e+=String.fromCharCode(55296+(t>>10),56320+(1023&t))),r+=9;continue}}e+="\ufffd"}}return e}))}e.defaultChars=";/?:@&=+$,#",e.componentChars="";const r={};function n(t,e,s){"string"!=typeof e&&(s=e,e=n.defaultChars),void 0===s&&(s=!0);const i=function(t){let e=r[t];if(e)return e;e=r[t]=[];for(let t=0;t<128;t++){const r=String.fromCharCode(t);/^[0-9a-z]$/i.test(r)?e.push(r):e.push("%"+("0"+t.toString(16).toUpperCase()).slice(-2))}for(let r=0;r=55296&&n<=57343){if(n>=55296&&n<=56319&&e+1=56320&&r<=57343){o+=encodeURIComponent(t[e]+t[e+1]),e++;continue}}o+="%EF%BF%BD"}else o+=encodeURIComponent(t[e])}return o}function s(t){let e="";return e+=t.protocol||"",e+=t.slashes?"//":"",e+=t.auth?t.auth+"@":"",t.hostname&&-1!==t.hostname.indexOf(":")?e+="["+t.hostname+"]":e+=t.hostname||"",e+=t.port?":"+t.port:"",e+=t.pathname||"",e+=t.search||"",e+=t.hash||"",e}function i(){this.protocol=null,this.slashes=null,this.auth=null,this.port=null,this.hostname=null,this.hash=null,this.search=null,this.pathname=null}n.defaultChars=";/?:@&=+$,-_.!~*'()#",n.componentChars="-_.!~*'()";const o=/^([a-z0-9.+-]+:)/i,u=/:[0-9]*$/,c=/^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/,a=["{","}","|","\\","^","`"].concat(["<",">",'"',"`"," ","\r","\n","\t"]),l=["'"].concat(a),h=["%","/","?",";","#"].concat(l),p=["/","?","#"],f=/^[+a-z0-9A-Z_-]{0,63}$/,d=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,_={javascript:!0,"javascript:":!0},m={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0};function g(t,e){if(t&&t instanceof i)return t;const r=new i;return r.parse(t,e),r}i.prototype.parse=function(t,e){let r,n,s,i=t;if(i=i.trim(),!e&&1===t.split("#").length){const t=c.exec(i);if(t)return this.pathname=t[1],t[2]&&(this.search=t[2]),this}let u=o.exec(i);if(u&&(u=u[0],r=u.toLowerCase(),this.protocol=u,i=i.substr(u.length)),(e||u||i.match(/^\/\/[^@\/]+@[^@\/]+/))&&(s="//"===i.substr(0,2),!s||u&&_[u]||(i=i.substr(2),this.slashes=!0)),!_[u]&&(s||u&&!m[u])){let t,e,r=-1;for(let t=0;t127?n+="x":n+=r[t];if(!n.match(f)){const n=t.slice(0,e),s=t.slice(e+1),o=r.match(d);o&&(n.push(o[1]),s.unshift(o[2])),s.length&&(i=s.join(".")+i),this.hostname=n.join(".");break}}}}this.hostname.length>255&&(this.hostname=""),o&&(this.hostname=this.hostname.substr(1,this.hostname.length-2))}const a=i.indexOf("#");-1!==a&&(this.hash=i.substr(a),i=i.slice(0,a));const l=i.indexOf("?");return-1!==l&&(this.search=i.substr(l),i=i.slice(0,l)),i&&(this.pathname=i),m[r]&&this.hostname&&!this.pathname&&(this.pathname=""),this},i.prototype.parseHost=function(t){let e=u.exec(t);e&&(e=e[0],":"!==e&&(this.port=e.substr(1)),t=t.substr(0,t.length-e.length)),t&&(this.hostname=t)};var k,D=Object.freeze({__proto__:null,decode:e,encode:n,format:s,parse:g}),C=/[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,y=/[\0-\x1F\x7F-\x9F]/,E=/[!-#%-\*,-\/:;\?@\[-\]_\{\}\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061D-\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u09FD\u0A76\u0AF0\u0C77\u0C84\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1B7D\u1B7E\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E4F\u2E52-\u2E5D\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]|\uD800[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD803[\uDEAD\uDF55-\uDF59\uDF86-\uDF89]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC8\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDC4B-\uDC4F\uDC5A\uDC5B\uDC5D\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDE60-\uDE6C\uDEB9\uDF3C-\uDF3E]|\uD806[\uDC3B\uDD44-\uDD46\uDDE2\uDE3F-\uDE46\uDE9A-\uDE9C\uDE9E-\uDEA2\uDF00-\uDF09]|\uD807[\uDC41-\uDC45\uDC70\uDC71\uDEF7\uDEF8\uDF43-\uDF4F\uDFFF]|\uD809[\uDC70-\uDC74]|\uD80B[\uDFF1\uDFF2]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD81B[\uDE97-\uDE9A\uDFE2]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]|\uD83A[\uDD5E\uDD5F]/,A=/[\$\+<->\^`\|~\xA2-\xA6\xA8\xA9\xAC\xAE-\xB1\xB4\xB8\xD7\xF7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u02FF\u0375\u0384\u0385\u03F6\u0482\u058D-\u058F\u0606-\u0608\u060B\u060E\u060F\u06DE\u06E9\u06FD\u06FE\u07F6\u07FE\u07FF\u0888\u09F2\u09F3\u09FA\u09FB\u0AF1\u0B70\u0BF3-\u0BFA\u0C7F\u0D4F\u0D79\u0E3F\u0F01-\u0F03\u0F13\u0F15-\u0F17\u0F1A-\u0F1F\u0F34\u0F36\u0F38\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE\u0FCF\u0FD5-\u0FD8\u109E\u109F\u1390-\u1399\u166D\u17DB\u1940\u19DE-\u19FF\u1B61-\u1B6A\u1B74-\u1B7C\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD\u1FFE\u2044\u2052\u207A-\u207C\u208A-\u208C\u20A0-\u20C0\u2100\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F\u218A\u218B\u2190-\u2307\u230C-\u2328\u232B-\u2426\u2440-\u244A\u249C-\u24E9\u2500-\u2767\u2794-\u27C4\u27C7-\u27E5\u27F0-\u2982\u2999-\u29D7\u29DC-\u29FB\u29FE-\u2B73\u2B76-\u2B95\u2B97-\u2BFF\u2CE5-\u2CEA\u2E50\u2E51\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFF\u3004\u3012\u3013\u3020\u3036\u3037\u303E\u303F\u309B\u309C\u3190\u3191\u3196-\u319F\u31C0-\u31E3\u31EF\u3200-\u321E\u322A-\u3247\u3250\u3260-\u327F\u328A-\u32B0\u32C0-\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA700-\uA716\uA720\uA721\uA789\uA78A\uA828-\uA82B\uA836-\uA839\uAA77-\uAA79\uAB5B\uAB6A\uAB6B\uFB29\uFBB2-\uFBC2\uFD40-\uFD4F\uFDCF\uFDFC-\uFDFF\uFE62\uFE64-\uFE66\uFE69\uFF04\uFF0B\uFF1C-\uFF1E\uFF3E\uFF40\uFF5C\uFF5E\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFFC\uFFFD]|\uD800[\uDD37-\uDD3F\uDD79-\uDD89\uDD8C-\uDD8E\uDD90-\uDD9C\uDDA0\uDDD0-\uDDFC]|\uD802[\uDC77\uDC78\uDEC8]|\uD805\uDF3F|\uD807[\uDFD5-\uDFF1]|\uD81A[\uDF3C-\uDF3F\uDF45]|\uD82F\uDC9C|\uD833[\uDF50-\uDFC3]|\uD834[\uDC00-\uDCF5\uDD00-\uDD26\uDD29-\uDD64\uDD6A-\uDD6C\uDD83\uDD84\uDD8C-\uDDA9\uDDAE-\uDDEA\uDE00-\uDE41\uDE45\uDF00-\uDF56]|\uD835[\uDEC1\uDEDB\uDEFB\uDF15\uDF35\uDF4F\uDF6F\uDF89\uDFA9\uDFC3]|\uD836[\uDC00-\uDDFF\uDE37-\uDE3A\uDE6D-\uDE74\uDE76-\uDE83\uDE85\uDE86]|\uD838[\uDD4F\uDEFF]|\uD83B[\uDCAC\uDCB0\uDD2E\uDEF0\uDEF1]|\uD83C[\uDC00-\uDC2B\uDC30-\uDC93\uDCA0-\uDCAE\uDCB1-\uDCBF\uDCC1-\uDCCF\uDCD1-\uDCF5\uDD0D-\uDDAD\uDDE6-\uDE02\uDE10-\uDE3B\uDE40-\uDE48\uDE50\uDE51\uDE60-\uDE65\uDF00-\uDFFF]|\uD83D[\uDC00-\uDED7\uDEDC-\uDEEC\uDEF0-\uDEFC\uDF00-\uDF76\uDF7B-\uDFD9\uDFE0-\uDFEB\uDFF0]|\uD83E[\uDC00-\uDC0B\uDC10-\uDC47\uDC50-\uDC59\uDC60-\uDC87\uDC90-\uDCAD\uDCB0\uDCB1\uDD00-\uDE53\uDE60-\uDE6D\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC5\uDECE-\uDEDB\uDEE0-\uDEE8\uDEF0-\uDEF8\uDF00-\uDF92\uDF94-\uDFCA]/,b=/[ \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/,F=Object.freeze({__proto__:null,Any:C,Cc:y,Cf:/[\xAD\u0600-\u0605\u061C\u06DD\u070F\u0890\u0891\u08E2\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF\uFFF9-\uFFFB]|\uD804[\uDCBD\uDCCD]|\uD80D[\uDC30-\uDC3F]|\uD82F[\uDCA0-\uDCA3]|\uD834[\uDD73-\uDD7A]|\uDB40[\uDC01\uDC20-\uDC7F]/,P:E,S:A,Z:b}),x=new Uint16Array('\u1d41<\xd5\u0131\u028a\u049d\u057b\u05d0\u0675\u06de\u07a2\u07d6\u080f\u0a4a\u0a91\u0da1\u0e6d\u0f09\u0f26\u10ca\u1228\u12e1\u1415\u149d\u14c3\u14df\u1525\0\0\0\0\0\0\u156b\u16cd\u198d\u1c12\u1ddd\u1f7e\u2060\u21b0\u228d\u23c0\u23fb\u2442\u2824\u2912\u2d08\u2e48\u2fce\u3016\u32ba\u3639\u37ac\u38fe\u3a28\u3a71\u3ae0\u3b2e\u0800EMabcfglmnoprstu\\bfms\x7f\x84\x8b\x90\x95\x98\xa6\xb3\xb9\xc8\xcflig\u803b\xc6\u40c6P\u803b&\u4026cute\u803b\xc1\u40c1reve;\u4102\u0100iyx}rc\u803b\xc2\u40c2;\u4410r;\uc000\ud835\udd04rave\u803b\xc0\u40c0pha;\u4391acr;\u4100d;\u6a53\u0100gp\x9d\xa1on;\u4104f;\uc000\ud835\udd38plyFunction;\u6061ing\u803b\xc5\u40c5\u0100cs\xbe\xc3r;\uc000\ud835\udc9cign;\u6254ilde\u803b\xc3\u40c3ml\u803b\xc4\u40c4\u0400aceforsu\xe5\xfb\xfe\u0117\u011c\u0122\u0127\u012a\u0100cr\xea\xf2kslash;\u6216\u0176\xf6\xf8;\u6ae7ed;\u6306y;\u4411\u0180crt\u0105\u010b\u0114ause;\u6235noullis;\u612ca;\u4392r;\uc000\ud835\udd05pf;\uc000\ud835\udd39eve;\u42d8c\xf2\u0113mpeq;\u624e\u0700HOacdefhilorsu\u014d\u0151\u0156\u0180\u019e\u01a2\u01b5\u01b7\u01ba\u01dc\u0215\u0273\u0278\u027ecy;\u4427PY\u803b\xa9\u40a9\u0180cpy\u015d\u0162\u017aute;\u4106\u0100;i\u0167\u0168\u62d2talDifferentialD;\u6145leys;\u612d\u0200aeio\u0189\u018e\u0194\u0198ron;\u410cdil\u803b\xc7\u40c7rc;\u4108nint;\u6230ot;\u410a\u0100dn\u01a7\u01adilla;\u40b8terDot;\u40b7\xf2\u017fi;\u43a7rcle\u0200DMPT\u01c7\u01cb\u01d1\u01d6ot;\u6299inus;\u6296lus;\u6295imes;\u6297o\u0100cs\u01e2\u01f8kwiseContourIntegral;\u6232eCurly\u0100DQ\u0203\u020foubleQuote;\u601duote;\u6019\u0200lnpu\u021e\u0228\u0247\u0255on\u0100;e\u0225\u0226\u6237;\u6a74\u0180git\u022f\u0236\u023aruent;\u6261nt;\u622fourIntegral;\u622e\u0100fr\u024c\u024e;\u6102oduct;\u6210nterClockwiseContourIntegral;\u6233oss;\u6a2fcr;\uc000\ud835\udc9ep\u0100;C\u0284\u0285\u62d3ap;\u624d\u0580DJSZacefios\u02a0\u02ac\u02b0\u02b4\u02b8\u02cb\u02d7\u02e1\u02e6\u0333\u048d\u0100;o\u0179\u02a5trahd;\u6911cy;\u4402cy;\u4405cy;\u440f\u0180grs\u02bf\u02c4\u02c7ger;\u6021r;\u61a1hv;\u6ae4\u0100ay\u02d0\u02d5ron;\u410e;\u4414l\u0100;t\u02dd\u02de\u6207a;\u4394r;\uc000\ud835\udd07\u0100af\u02eb\u0327\u0100cm\u02f0\u0322ritical\u0200ADGT\u0300\u0306\u0316\u031ccute;\u40b4o\u0174\u030b\u030d;\u42d9bleAcute;\u42ddrave;\u4060ilde;\u42dcond;\u62c4ferentialD;\u6146\u0470\u033d\0\0\0\u0342\u0354\0\u0405f;\uc000\ud835\udd3b\u0180;DE\u0348\u0349\u034d\u40a8ot;\u60dcqual;\u6250ble\u0300CDLRUV\u0363\u0372\u0382\u03cf\u03e2\u03f8ontourIntegra\xec\u0239o\u0274\u0379\0\0\u037b\xbb\u0349nArrow;\u61d3\u0100eo\u0387\u03a4ft\u0180ART\u0390\u0396\u03a1rrow;\u61d0ightArrow;\u61d4e\xe5\u02cang\u0100LR\u03ab\u03c4eft\u0100AR\u03b3\u03b9rrow;\u67f8ightArrow;\u67faightArrow;\u67f9ight\u0100AT\u03d8\u03derrow;\u61d2ee;\u62a8p\u0241\u03e9\0\0\u03efrrow;\u61d1ownArrow;\u61d5erticalBar;\u6225n\u0300ABLRTa\u0412\u042a\u0430\u045e\u047f\u037crrow\u0180;BU\u041d\u041e\u0422\u6193ar;\u6913pArrow;\u61f5reve;\u4311eft\u02d2\u043a\0\u0446\0\u0450ightVector;\u6950eeVector;\u695eector\u0100;B\u0459\u045a\u61bdar;\u6956ight\u01d4\u0467\0\u0471eeVector;\u695fector\u0100;B\u047a\u047b\u61c1ar;\u6957ee\u0100;A\u0486\u0487\u62a4rrow;\u61a7\u0100ct\u0492\u0497r;\uc000\ud835\udc9frok;\u4110\u0800NTacdfglmopqstux\u04bd\u04c0\u04c4\u04cb\u04de\u04e2\u04e7\u04ee\u04f5\u0521\u052f\u0536\u0552\u055d\u0560\u0565G;\u414aH\u803b\xd0\u40d0cute\u803b\xc9\u40c9\u0180aiy\u04d2\u04d7\u04dcron;\u411arc\u803b\xca\u40ca;\u442dot;\u4116r;\uc000\ud835\udd08rave\u803b\xc8\u40c8ement;\u6208\u0100ap\u04fa\u04fecr;\u4112ty\u0253\u0506\0\0\u0512mallSquare;\u65fberySmallSquare;\u65ab\u0100gp\u0526\u052aon;\u4118f;\uc000\ud835\udd3csilon;\u4395u\u0100ai\u053c\u0549l\u0100;T\u0542\u0543\u6a75ilde;\u6242librium;\u61cc\u0100ci\u0557\u055ar;\u6130m;\u6a73a;\u4397ml\u803b\xcb\u40cb\u0100ip\u056a\u056fsts;\u6203onentialE;\u6147\u0280cfios\u0585\u0588\u058d\u05b2\u05ccy;\u4424r;\uc000\ud835\udd09lled\u0253\u0597\0\0\u05a3mallSquare;\u65fcerySmallSquare;\u65aa\u0370\u05ba\0\u05bf\0\0\u05c4f;\uc000\ud835\udd3dAll;\u6200riertrf;\u6131c\xf2\u05cb\u0600JTabcdfgorst\u05e8\u05ec\u05ef\u05fa\u0600\u0612\u0616\u061b\u061d\u0623\u066c\u0672cy;\u4403\u803b>\u403emma\u0100;d\u05f7\u05f8\u4393;\u43dcreve;\u411e\u0180eiy\u0607\u060c\u0610dil;\u4122rc;\u411c;\u4413ot;\u4120r;\uc000\ud835\udd0a;\u62d9pf;\uc000\ud835\udd3eeater\u0300EFGLST\u0635\u0644\u064e\u0656\u065b\u0666qual\u0100;L\u063e\u063f\u6265ess;\u62dbullEqual;\u6267reater;\u6aa2ess;\u6277lantEqual;\u6a7eilde;\u6273cr;\uc000\ud835\udca2;\u626b\u0400Aacfiosu\u0685\u068b\u0696\u069b\u069e\u06aa\u06be\u06caRDcy;\u442a\u0100ct\u0690\u0694ek;\u42c7;\u405eirc;\u4124r;\u610clbertSpace;\u610b\u01f0\u06af\0\u06b2f;\u610dizontalLine;\u6500\u0100ct\u06c3\u06c5\xf2\u06a9rok;\u4126mp\u0144\u06d0\u06d8ownHum\xf0\u012fqual;\u624f\u0700EJOacdfgmnostu\u06fa\u06fe\u0703\u0707\u070e\u071a\u071e\u0721\u0728\u0744\u0778\u078b\u078f\u0795cy;\u4415lig;\u4132cy;\u4401cute\u803b\xcd\u40cd\u0100iy\u0713\u0718rc\u803b\xce\u40ce;\u4418ot;\u4130r;\u6111rave\u803b\xcc\u40cc\u0180;ap\u0720\u072f\u073f\u0100cg\u0734\u0737r;\u412ainaryI;\u6148lie\xf3\u03dd\u01f4\u0749\0\u0762\u0100;e\u074d\u074e\u622c\u0100gr\u0753\u0758ral;\u622bsection;\u62c2isible\u0100CT\u076c\u0772omma;\u6063imes;\u6062\u0180gpt\u077f\u0783\u0788on;\u412ef;\uc000\ud835\udd40a;\u4399cr;\u6110ilde;\u4128\u01eb\u079a\0\u079ecy;\u4406l\u803b\xcf\u40cf\u0280cfosu\u07ac\u07b7\u07bc\u07c2\u07d0\u0100iy\u07b1\u07b5rc;\u4134;\u4419r;\uc000\ud835\udd0dpf;\uc000\ud835\udd41\u01e3\u07c7\0\u07ccr;\uc000\ud835\udca5rcy;\u4408kcy;\u4404\u0380HJacfos\u07e4\u07e8\u07ec\u07f1\u07fd\u0802\u0808cy;\u4425cy;\u440cppa;\u439a\u0100ey\u07f6\u07fbdil;\u4136;\u441ar;\uc000\ud835\udd0epf;\uc000\ud835\udd42cr;\uc000\ud835\udca6\u0580JTaceflmost\u0825\u0829\u082c\u0850\u0863\u09b3\u09b8\u09c7\u09cd\u0a37\u0a47cy;\u4409\u803b<\u403c\u0280cmnpr\u0837\u083c\u0841\u0844\u084dute;\u4139bda;\u439bg;\u67ealacetrf;\u6112r;\u619e\u0180aey\u0857\u085c\u0861ron;\u413ddil;\u413b;\u441b\u0100fs\u0868\u0970t\u0500ACDFRTUVar\u087e\u08a9\u08b1\u08e0\u08e6\u08fc\u092f\u095b\u0390\u096a\u0100nr\u0883\u088fgleBracket;\u67e8row\u0180;BR\u0899\u089a\u089e\u6190ar;\u61e4ightArrow;\u61c6eiling;\u6308o\u01f5\u08b7\0\u08c3bleBracket;\u67e6n\u01d4\u08c8\0\u08d2eeVector;\u6961ector\u0100;B\u08db\u08dc\u61c3ar;\u6959loor;\u630aight\u0100AV\u08ef\u08f5rrow;\u6194ector;\u694e\u0100er\u0901\u0917e\u0180;AV\u0909\u090a\u0910\u62a3rrow;\u61a4ector;\u695aiangle\u0180;BE\u0924\u0925\u0929\u62b2ar;\u69cfqual;\u62b4p\u0180DTV\u0937\u0942\u094cownVector;\u6951eeVector;\u6960ector\u0100;B\u0956\u0957\u61bfar;\u6958ector\u0100;B\u0965\u0966\u61bcar;\u6952ight\xe1\u039cs\u0300EFGLST\u097e\u098b\u0995\u099d\u09a2\u09adqualGreater;\u62daullEqual;\u6266reater;\u6276ess;\u6aa1lantEqual;\u6a7dilde;\u6272r;\uc000\ud835\udd0f\u0100;e\u09bd\u09be\u62d8ftarrow;\u61daidot;\u413f\u0180npw\u09d4\u0a16\u0a1bg\u0200LRlr\u09de\u09f7\u0a02\u0a10eft\u0100AR\u09e6\u09ecrrow;\u67f5ightArrow;\u67f7ightArrow;\u67f6eft\u0100ar\u03b3\u0a0aight\xe1\u03bfight\xe1\u03caf;\uc000\ud835\udd43er\u0100LR\u0a22\u0a2ceftArrow;\u6199ightArrow;\u6198\u0180cht\u0a3e\u0a40\u0a42\xf2\u084c;\u61b0rok;\u4141;\u626a\u0400acefiosu\u0a5a\u0a5d\u0a60\u0a77\u0a7c\u0a85\u0a8b\u0a8ep;\u6905y;\u441c\u0100dl\u0a65\u0a6fiumSpace;\u605flintrf;\u6133r;\uc000\ud835\udd10nusPlus;\u6213pf;\uc000\ud835\udd44c\xf2\u0a76;\u439c\u0480Jacefostu\u0aa3\u0aa7\u0aad\u0ac0\u0b14\u0b19\u0d91\u0d97\u0d9ecy;\u440acute;\u4143\u0180aey\u0ab4\u0ab9\u0aberon;\u4147dil;\u4145;\u441d\u0180gsw\u0ac7\u0af0\u0b0eative\u0180MTV\u0ad3\u0adf\u0ae8ediumSpace;\u600bhi\u0100cn\u0ae6\u0ad8\xeb\u0ad9eryThi\xee\u0ad9ted\u0100GL\u0af8\u0b06reaterGreate\xf2\u0673essLes\xf3\u0a48Line;\u400ar;\uc000\ud835\udd11\u0200Bnpt\u0b22\u0b28\u0b37\u0b3areak;\u6060BreakingSpace;\u40a0f;\u6115\u0680;CDEGHLNPRSTV\u0b55\u0b56\u0b6a\u0b7c\u0ba1\u0beb\u0c04\u0c5e\u0c84\u0ca6\u0cd8\u0d61\u0d85\u6aec\u0100ou\u0b5b\u0b64ngruent;\u6262pCap;\u626doubleVerticalBar;\u6226\u0180lqx\u0b83\u0b8a\u0b9bement;\u6209ual\u0100;T\u0b92\u0b93\u6260ilde;\uc000\u2242\u0338ists;\u6204reater\u0380;EFGLST\u0bb6\u0bb7\u0bbd\u0bc9\u0bd3\u0bd8\u0be5\u626fqual;\u6271ullEqual;\uc000\u2267\u0338reater;\uc000\u226b\u0338ess;\u6279lantEqual;\uc000\u2a7e\u0338ilde;\u6275ump\u0144\u0bf2\u0bfdownHump;\uc000\u224e\u0338qual;\uc000\u224f\u0338e\u0100fs\u0c0a\u0c27tTriangle\u0180;BE\u0c1a\u0c1b\u0c21\u62eaar;\uc000\u29cf\u0338qual;\u62ecs\u0300;EGLST\u0c35\u0c36\u0c3c\u0c44\u0c4b\u0c58\u626equal;\u6270reater;\u6278ess;\uc000\u226a\u0338lantEqual;\uc000\u2a7d\u0338ilde;\u6274ested\u0100GL\u0c68\u0c79reaterGreater;\uc000\u2aa2\u0338essLess;\uc000\u2aa1\u0338recedes\u0180;ES\u0c92\u0c93\u0c9b\u6280qual;\uc000\u2aaf\u0338lantEqual;\u62e0\u0100ei\u0cab\u0cb9verseElement;\u620cghtTriangle\u0180;BE\u0ccb\u0ccc\u0cd2\u62ebar;\uc000\u29d0\u0338qual;\u62ed\u0100qu\u0cdd\u0d0cuareSu\u0100bp\u0ce8\u0cf9set\u0100;E\u0cf0\u0cf3\uc000\u228f\u0338qual;\u62e2erset\u0100;E\u0d03\u0d06\uc000\u2290\u0338qual;\u62e3\u0180bcp\u0d13\u0d24\u0d4eset\u0100;E\u0d1b\u0d1e\uc000\u2282\u20d2qual;\u6288ceeds\u0200;EST\u0d32\u0d33\u0d3b\u0d46\u6281qual;\uc000\u2ab0\u0338lantEqual;\u62e1ilde;\uc000\u227f\u0338erset\u0100;E\u0d58\u0d5b\uc000\u2283\u20d2qual;\u6289ilde\u0200;EFT\u0d6e\u0d6f\u0d75\u0d7f\u6241qual;\u6244ullEqual;\u6247ilde;\u6249erticalBar;\u6224cr;\uc000\ud835\udca9ilde\u803b\xd1\u40d1;\u439d\u0700Eacdfgmoprstuv\u0dbd\u0dc2\u0dc9\u0dd5\u0ddb\u0de0\u0de7\u0dfc\u0e02\u0e20\u0e22\u0e32\u0e3f\u0e44lig;\u4152cute\u803b\xd3\u40d3\u0100iy\u0dce\u0dd3rc\u803b\xd4\u40d4;\u441eblac;\u4150r;\uc000\ud835\udd12rave\u803b\xd2\u40d2\u0180aei\u0dee\u0df2\u0df6cr;\u414cga;\u43a9cron;\u439fpf;\uc000\ud835\udd46enCurly\u0100DQ\u0e0e\u0e1aoubleQuote;\u601cuote;\u6018;\u6a54\u0100cl\u0e27\u0e2cr;\uc000\ud835\udcaaash\u803b\xd8\u40d8i\u016c\u0e37\u0e3cde\u803b\xd5\u40d5es;\u6a37ml\u803b\xd6\u40d6er\u0100BP\u0e4b\u0e60\u0100ar\u0e50\u0e53r;\u603eac\u0100ek\u0e5a\u0e5c;\u63deet;\u63b4arenthesis;\u63dc\u0480acfhilors\u0e7f\u0e87\u0e8a\u0e8f\u0e92\u0e94\u0e9d\u0eb0\u0efcrtialD;\u6202y;\u441fr;\uc000\ud835\udd13i;\u43a6;\u43a0usMinus;\u40b1\u0100ip\u0ea2\u0eadncareplan\xe5\u069df;\u6119\u0200;eio\u0eb9\u0eba\u0ee0\u0ee4\u6abbcedes\u0200;EST\u0ec8\u0ec9\u0ecf\u0eda\u627aqual;\u6aaflantEqual;\u627cilde;\u627eme;\u6033\u0100dp\u0ee9\u0eeeuct;\u620fortion\u0100;a\u0225\u0ef9l;\u621d\u0100ci\u0f01\u0f06r;\uc000\ud835\udcab;\u43a8\u0200Ufos\u0f11\u0f16\u0f1b\u0f1fOT\u803b"\u4022r;\uc000\ud835\udd14pf;\u611acr;\uc000\ud835\udcac\u0600BEacefhiorsu\u0f3e\u0f43\u0f47\u0f60\u0f73\u0fa7\u0faa\u0fad\u1096\u10a9\u10b4\u10bearr;\u6910G\u803b\xae\u40ae\u0180cnr\u0f4e\u0f53\u0f56ute;\u4154g;\u67ebr\u0100;t\u0f5c\u0f5d\u61a0l;\u6916\u0180aey\u0f67\u0f6c\u0f71ron;\u4158dil;\u4156;\u4420\u0100;v\u0f78\u0f79\u611cerse\u0100EU\u0f82\u0f99\u0100lq\u0f87\u0f8eement;\u620builibrium;\u61cbpEquilibrium;\u696fr\xbb\u0f79o;\u43a1ght\u0400ACDFTUVa\u0fc1\u0feb\u0ff3\u1022\u1028\u105b\u1087\u03d8\u0100nr\u0fc6\u0fd2gleBracket;\u67e9row\u0180;BL\u0fdc\u0fdd\u0fe1\u6192ar;\u61e5eftArrow;\u61c4eiling;\u6309o\u01f5\u0ff9\0\u1005bleBracket;\u67e7n\u01d4\u100a\0\u1014eeVector;\u695dector\u0100;B\u101d\u101e\u61c2ar;\u6955loor;\u630b\u0100er\u102d\u1043e\u0180;AV\u1035\u1036\u103c\u62a2rrow;\u61a6ector;\u695biangle\u0180;BE\u1050\u1051\u1055\u62b3ar;\u69d0qual;\u62b5p\u0180DTV\u1063\u106e\u1078ownVector;\u694feeVector;\u695cector\u0100;B\u1082\u1083\u61bear;\u6954ector\u0100;B\u1091\u1092\u61c0ar;\u6953\u0100pu\u109b\u109ef;\u611dndImplies;\u6970ightarrow;\u61db\u0100ch\u10b9\u10bcr;\u611b;\u61b1leDelayed;\u69f4\u0680HOacfhimoqstu\u10e4\u10f1\u10f7\u10fd\u1119\u111e\u1151\u1156\u1161\u1167\u11b5\u11bb\u11bf\u0100Cc\u10e9\u10eeHcy;\u4429y;\u4428FTcy;\u442ccute;\u415a\u0280;aeiy\u1108\u1109\u110e\u1113\u1117\u6abcron;\u4160dil;\u415erc;\u415c;\u4421r;\uc000\ud835\udd16ort\u0200DLRU\u112a\u1134\u113e\u1149ownArrow\xbb\u041eeftArrow\xbb\u089aightArrow\xbb\u0fddpArrow;\u6191gma;\u43a3allCircle;\u6218pf;\uc000\ud835\udd4a\u0272\u116d\0\0\u1170t;\u621aare\u0200;ISU\u117b\u117c\u1189\u11af\u65a1ntersection;\u6293u\u0100bp\u118f\u119eset\u0100;E\u1197\u1198\u628fqual;\u6291erset\u0100;E\u11a8\u11a9\u6290qual;\u6292nion;\u6294cr;\uc000\ud835\udcaear;\u62c6\u0200bcmp\u11c8\u11db\u1209\u120b\u0100;s\u11cd\u11ce\u62d0et\u0100;E\u11cd\u11d5qual;\u6286\u0100ch\u11e0\u1205eeds\u0200;EST\u11ed\u11ee\u11f4\u11ff\u627bqual;\u6ab0lantEqual;\u627dilde;\u627fTh\xe1\u0f8c;\u6211\u0180;es\u1212\u1213\u1223\u62d1rset\u0100;E\u121c\u121d\u6283qual;\u6287et\xbb\u1213\u0580HRSacfhiors\u123e\u1244\u1249\u1255\u125e\u1271\u1276\u129f\u12c2\u12c8\u12d1ORN\u803b\xde\u40deADE;\u6122\u0100Hc\u124e\u1252cy;\u440by;\u4426\u0100bu\u125a\u125c;\u4009;\u43a4\u0180aey\u1265\u126a\u126fron;\u4164dil;\u4162;\u4422r;\uc000\ud835\udd17\u0100ei\u127b\u1289\u01f2\u1280\0\u1287efore;\u6234a;\u4398\u0100cn\u128e\u1298kSpace;\uc000\u205f\u200aSpace;\u6009lde\u0200;EFT\u12ab\u12ac\u12b2\u12bc\u623cqual;\u6243ullEqual;\u6245ilde;\u6248pf;\uc000\ud835\udd4bipleDot;\u60db\u0100ct\u12d6\u12dbr;\uc000\ud835\udcafrok;\u4166\u0ae1\u12f7\u130e\u131a\u1326\0\u132c\u1331\0\0\0\0\0\u1338\u133d\u1377\u1385\0\u13ff\u1404\u140a\u1410\u0100cr\u12fb\u1301ute\u803b\xda\u40dar\u0100;o\u1307\u1308\u619fcir;\u6949r\u01e3\u1313\0\u1316y;\u440eve;\u416c\u0100iy\u131e\u1323rc\u803b\xdb\u40db;\u4423blac;\u4170r;\uc000\ud835\udd18rave\u803b\xd9\u40d9acr;\u416a\u0100di\u1341\u1369er\u0100BP\u1348\u135d\u0100ar\u134d\u1350r;\u405fac\u0100ek\u1357\u1359;\u63dfet;\u63b5arenthesis;\u63ddon\u0100;P\u1370\u1371\u62c3lus;\u628e\u0100gp\u137b\u137fon;\u4172f;\uc000\ud835\udd4c\u0400ADETadps\u1395\u13ae\u13b8\u13c4\u03e8\u13d2\u13d7\u13f3rrow\u0180;BD\u1150\u13a0\u13a4ar;\u6912ownArrow;\u61c5ownArrow;\u6195quilibrium;\u696eee\u0100;A\u13cb\u13cc\u62a5rrow;\u61a5own\xe1\u03f3er\u0100LR\u13de\u13e8eftArrow;\u6196ightArrow;\u6197i\u0100;l\u13f9\u13fa\u43d2on;\u43a5ing;\u416ecr;\uc000\ud835\udcb0ilde;\u4168ml\u803b\xdc\u40dc\u0480Dbcdefosv\u1427\u142c\u1430\u1433\u143e\u1485\u148a\u1490\u1496ash;\u62abar;\u6aeby;\u4412ash\u0100;l\u143b\u143c\u62a9;\u6ae6\u0100er\u1443\u1445;\u62c1\u0180bty\u144c\u1450\u147aar;\u6016\u0100;i\u144f\u1455cal\u0200BLST\u1461\u1465\u146a\u1474ar;\u6223ine;\u407ceparator;\u6758ilde;\u6240ThinSpace;\u600ar;\uc000\ud835\udd19pf;\uc000\ud835\udd4dcr;\uc000\ud835\udcb1dash;\u62aa\u0280cefos\u14a7\u14ac\u14b1\u14b6\u14bcirc;\u4174dge;\u62c0r;\uc000\ud835\udd1apf;\uc000\ud835\udd4ecr;\uc000\ud835\udcb2\u0200fios\u14cb\u14d0\u14d2\u14d8r;\uc000\ud835\udd1b;\u439epf;\uc000\ud835\udd4fcr;\uc000\ud835\udcb3\u0480AIUacfosu\u14f1\u14f5\u14f9\u14fd\u1504\u150f\u1514\u151a\u1520cy;\u442fcy;\u4407cy;\u442ecute\u803b\xdd\u40dd\u0100iy\u1509\u150drc;\u4176;\u442br;\uc000\ud835\udd1cpf;\uc000\ud835\udd50cr;\uc000\ud835\udcb4ml;\u4178\u0400Hacdefos\u1535\u1539\u153f\u154b\u154f\u155d\u1560\u1564cy;\u4416cute;\u4179\u0100ay\u1544\u1549ron;\u417d;\u4417ot;\u417b\u01f2\u1554\0\u155boWidt\xe8\u0ad9a;\u4396r;\u6128pf;\u6124cr;\uc000\ud835\udcb5\u0be1\u1583\u158a\u1590\0\u15b0\u15b6\u15bf\0\0\0\0\u15c6\u15db\u15eb\u165f\u166d\0\u1695\u169b\u16b2\u16b9\0\u16becute\u803b\xe1\u40e1reve;\u4103\u0300;Ediuy\u159c\u159d\u15a1\u15a3\u15a8\u15ad\u623e;\uc000\u223e\u0333;\u623frc\u803b\xe2\u40e2te\u80bb\xb4\u0306;\u4430lig\u803b\xe6\u40e6\u0100;r\xb2\u15ba;\uc000\ud835\udd1erave\u803b\xe0\u40e0\u0100ep\u15ca\u15d6\u0100fp\u15cf\u15d4sym;\u6135\xe8\u15d3ha;\u43b1\u0100ap\u15dfc\u0100cl\u15e4\u15e7r;\u4101g;\u6a3f\u0264\u15f0\0\0\u160a\u0280;adsv\u15fa\u15fb\u15ff\u1601\u1607\u6227nd;\u6a55;\u6a5clope;\u6a58;\u6a5a\u0380;elmrsz\u1618\u1619\u161b\u161e\u163f\u164f\u1659\u6220;\u69a4e\xbb\u1619sd\u0100;a\u1625\u1626\u6221\u0461\u1630\u1632\u1634\u1636\u1638\u163a\u163c\u163e;\u69a8;\u69a9;\u69aa;\u69ab;\u69ac;\u69ad;\u69ae;\u69aft\u0100;v\u1645\u1646\u621fb\u0100;d\u164c\u164d\u62be;\u699d\u0100pt\u1654\u1657h;\u6222\xbb\xb9arr;\u637c\u0100gp\u1663\u1667on;\u4105f;\uc000\ud835\udd52\u0380;Eaeiop\u12c1\u167b\u167d\u1682\u1684\u1687\u168a;\u6a70cir;\u6a6f;\u624ad;\u624bs;\u4027rox\u0100;e\u12c1\u1692\xf1\u1683ing\u803b\xe5\u40e5\u0180cty\u16a1\u16a6\u16a8r;\uc000\ud835\udcb6;\u402amp\u0100;e\u12c1\u16af\xf1\u0288ilde\u803b\xe3\u40e3ml\u803b\xe4\u40e4\u0100ci\u16c2\u16c8onin\xf4\u0272nt;\u6a11\u0800Nabcdefiklnoprsu\u16ed\u16f1\u1730\u173c\u1743\u1748\u1778\u177d\u17e0\u17e6\u1839\u1850\u170d\u193d\u1948\u1970ot;\u6aed\u0100cr\u16f6\u171ek\u0200ceps\u1700\u1705\u170d\u1713ong;\u624cpsilon;\u43f6rime;\u6035im\u0100;e\u171a\u171b\u623dq;\u62cd\u0176\u1722\u1726ee;\u62bded\u0100;g\u172c\u172d\u6305e\xbb\u172drk\u0100;t\u135c\u1737brk;\u63b6\u0100oy\u1701\u1741;\u4431quo;\u601e\u0280cmprt\u1753\u175b\u1761\u1764\u1768aus\u0100;e\u010a\u0109ptyv;\u69b0s\xe9\u170cno\xf5\u0113\u0180ahw\u176f\u1771\u1773;\u43b2;\u6136een;\u626cr;\uc000\ud835\udd1fg\u0380costuvw\u178d\u179d\u17b3\u17c1\u17d5\u17db\u17de\u0180aiu\u1794\u1796\u179a\xf0\u0760rc;\u65efp\xbb\u1371\u0180dpt\u17a4\u17a8\u17adot;\u6a00lus;\u6a01imes;\u6a02\u0271\u17b9\0\0\u17becup;\u6a06ar;\u6605riangle\u0100du\u17cd\u17d2own;\u65bdp;\u65b3plus;\u6a04e\xe5\u1444\xe5\u14adarow;\u690d\u0180ako\u17ed\u1826\u1835\u0100cn\u17f2\u1823k\u0180lst\u17fa\u05ab\u1802ozenge;\u69ebriangle\u0200;dlr\u1812\u1813\u1818\u181d\u65b4own;\u65beeft;\u65c2ight;\u65b8k;\u6423\u01b1\u182b\0\u1833\u01b2\u182f\0\u1831;\u6592;\u65914;\u6593ck;\u6588\u0100eo\u183e\u184d\u0100;q\u1843\u1846\uc000=\u20e5uiv;\uc000\u2261\u20e5t;\u6310\u0200ptwx\u1859\u185e\u1867\u186cf;\uc000\ud835\udd53\u0100;t\u13cb\u1863om\xbb\u13cctie;\u62c8\u0600DHUVbdhmptuv\u1885\u1896\u18aa\u18bb\u18d7\u18db\u18ec\u18ff\u1905\u190a\u1910\u1921\u0200LRlr\u188e\u1890\u1892\u1894;\u6557;\u6554;\u6556;\u6553\u0280;DUdu\u18a1\u18a2\u18a4\u18a6\u18a8\u6550;\u6566;\u6569;\u6564;\u6567\u0200LRlr\u18b3\u18b5\u18b7\u18b9;\u655d;\u655a;\u655c;\u6559\u0380;HLRhlr\u18ca\u18cb\u18cd\u18cf\u18d1\u18d3\u18d5\u6551;\u656c;\u6563;\u6560;\u656b;\u6562;\u655fox;\u69c9\u0200LRlr\u18e4\u18e6\u18e8\u18ea;\u6555;\u6552;\u6510;\u650c\u0280;DUdu\u06bd\u18f7\u18f9\u18fb\u18fd;\u6565;\u6568;\u652c;\u6534inus;\u629flus;\u629eimes;\u62a0\u0200LRlr\u1919\u191b\u191d\u191f;\u655b;\u6558;\u6518;\u6514\u0380;HLRhlr\u1930\u1931\u1933\u1935\u1937\u1939\u193b\u6502;\u656a;\u6561;\u655e;\u653c;\u6524;\u651c\u0100ev\u0123\u1942bar\u803b\xa6\u40a6\u0200ceio\u1951\u1956\u195a\u1960r;\uc000\ud835\udcb7mi;\u604fm\u0100;e\u171a\u171cl\u0180;bh\u1968\u1969\u196b\u405c;\u69c5sub;\u67c8\u016c\u1974\u197el\u0100;e\u1979\u197a\u6022t\xbb\u197ap\u0180;Ee\u012f\u1985\u1987;\u6aae\u0100;q\u06dc\u06db\u0ce1\u19a7\0\u19e8\u1a11\u1a15\u1a32\0\u1a37\u1a50\0\0\u1ab4\0\0\u1ac1\0\0\u1b21\u1b2e\u1b4d\u1b52\0\u1bfd\0\u1c0c\u0180cpr\u19ad\u19b2\u19ddute;\u4107\u0300;abcds\u19bf\u19c0\u19c4\u19ca\u19d5\u19d9\u6229nd;\u6a44rcup;\u6a49\u0100au\u19cf\u19d2p;\u6a4bp;\u6a47ot;\u6a40;\uc000\u2229\ufe00\u0100eo\u19e2\u19e5t;\u6041\xee\u0693\u0200aeiu\u19f0\u19fb\u1a01\u1a05\u01f0\u19f5\0\u19f8s;\u6a4don;\u410ddil\u803b\xe7\u40e7rc;\u4109ps\u0100;s\u1a0c\u1a0d\u6a4cm;\u6a50ot;\u410b\u0180dmn\u1a1b\u1a20\u1a26il\u80bb\xb8\u01adptyv;\u69b2t\u8100\xa2;e\u1a2d\u1a2e\u40a2r\xe4\u01b2r;\uc000\ud835\udd20\u0180cei\u1a3d\u1a40\u1a4dy;\u4447ck\u0100;m\u1a47\u1a48\u6713ark\xbb\u1a48;\u43c7r\u0380;Ecefms\u1a5f\u1a60\u1a62\u1a6b\u1aa4\u1aaa\u1aae\u65cb;\u69c3\u0180;el\u1a69\u1a6a\u1a6d\u42c6q;\u6257e\u0261\u1a74\0\0\u1a88rrow\u0100lr\u1a7c\u1a81eft;\u61baight;\u61bb\u0280RSacd\u1a92\u1a94\u1a96\u1a9a\u1a9f\xbb\u0f47;\u64c8st;\u629birc;\u629aash;\u629dnint;\u6a10id;\u6aefcir;\u69c2ubs\u0100;u\u1abb\u1abc\u6663it\xbb\u1abc\u02ec\u1ac7\u1ad4\u1afa\0\u1b0aon\u0100;e\u1acd\u1ace\u403a\u0100;q\xc7\xc6\u026d\u1ad9\0\0\u1ae2a\u0100;t\u1ade\u1adf\u402c;\u4040\u0180;fl\u1ae8\u1ae9\u1aeb\u6201\xee\u1160e\u0100mx\u1af1\u1af6ent\xbb\u1ae9e\xf3\u024d\u01e7\u1afe\0\u1b07\u0100;d\u12bb\u1b02ot;\u6a6dn\xf4\u0246\u0180fry\u1b10\u1b14\u1b17;\uc000\ud835\udd54o\xe4\u0254\u8100\xa9;s\u0155\u1b1dr;\u6117\u0100ao\u1b25\u1b29rr;\u61b5ss;\u6717\u0100cu\u1b32\u1b37r;\uc000\ud835\udcb8\u0100bp\u1b3c\u1b44\u0100;e\u1b41\u1b42\u6acf;\u6ad1\u0100;e\u1b49\u1b4a\u6ad0;\u6ad2dot;\u62ef\u0380delprvw\u1b60\u1b6c\u1b77\u1b82\u1bac\u1bd4\u1bf9arr\u0100lr\u1b68\u1b6a;\u6938;\u6935\u0270\u1b72\0\0\u1b75r;\u62dec;\u62dfarr\u0100;p\u1b7f\u1b80\u61b6;\u693d\u0300;bcdos\u1b8f\u1b90\u1b96\u1ba1\u1ba5\u1ba8\u622arcap;\u6a48\u0100au\u1b9b\u1b9ep;\u6a46p;\u6a4aot;\u628dr;\u6a45;\uc000\u222a\ufe00\u0200alrv\u1bb5\u1bbf\u1bde\u1be3rr\u0100;m\u1bbc\u1bbd\u61b7;\u693cy\u0180evw\u1bc7\u1bd4\u1bd8q\u0270\u1bce\0\0\u1bd2re\xe3\u1b73u\xe3\u1b75ee;\u62ceedge;\u62cfen\u803b\xa4\u40a4earrow\u0100lr\u1bee\u1bf3eft\xbb\u1b80ight\xbb\u1bbde\xe4\u1bdd\u0100ci\u1c01\u1c07onin\xf4\u01f7nt;\u6231lcty;\u632d\u0980AHabcdefhijlorstuwz\u1c38\u1c3b\u1c3f\u1c5d\u1c69\u1c75\u1c8a\u1c9e\u1cac\u1cb7\u1cfb\u1cff\u1d0d\u1d7b\u1d91\u1dab\u1dbb\u1dc6\u1dcdr\xf2\u0381ar;\u6965\u0200glrs\u1c48\u1c4d\u1c52\u1c54ger;\u6020eth;\u6138\xf2\u1133h\u0100;v\u1c5a\u1c5b\u6010\xbb\u090a\u016b\u1c61\u1c67arow;\u690fa\xe3\u0315\u0100ay\u1c6e\u1c73ron;\u410f;\u4434\u0180;ao\u0332\u1c7c\u1c84\u0100gr\u02bf\u1c81r;\u61catseq;\u6a77\u0180glm\u1c91\u1c94\u1c98\u803b\xb0\u40b0ta;\u43b4ptyv;\u69b1\u0100ir\u1ca3\u1ca8sht;\u697f;\uc000\ud835\udd21ar\u0100lr\u1cb3\u1cb5\xbb\u08dc\xbb\u101e\u0280aegsv\u1cc2\u0378\u1cd6\u1cdc\u1ce0m\u0180;os\u0326\u1cca\u1cd4nd\u0100;s\u0326\u1cd1uit;\u6666amma;\u43ddin;\u62f2\u0180;io\u1ce7\u1ce8\u1cf8\u40f7de\u8100\xf7;o\u1ce7\u1cf0ntimes;\u62c7n\xf8\u1cf7cy;\u4452c\u026f\u1d06\0\0\u1d0arn;\u631eop;\u630d\u0280lptuw\u1d18\u1d1d\u1d22\u1d49\u1d55lar;\u4024f;\uc000\ud835\udd55\u0280;emps\u030b\u1d2d\u1d37\u1d3d\u1d42q\u0100;d\u0352\u1d33ot;\u6251inus;\u6238lus;\u6214quare;\u62a1blebarwedg\xe5\xfan\u0180adh\u112e\u1d5d\u1d67ownarrow\xf3\u1c83arpoon\u0100lr\u1d72\u1d76ef\xf4\u1cb4igh\xf4\u1cb6\u0162\u1d7f\u1d85karo\xf7\u0f42\u026f\u1d8a\0\0\u1d8ern;\u631fop;\u630c\u0180cot\u1d98\u1da3\u1da6\u0100ry\u1d9d\u1da1;\uc000\ud835\udcb9;\u4455l;\u69f6rok;\u4111\u0100dr\u1db0\u1db4ot;\u62f1i\u0100;f\u1dba\u1816\u65bf\u0100ah\u1dc0\u1dc3r\xf2\u0429a\xf2\u0fa6angle;\u69a6\u0100ci\u1dd2\u1dd5y;\u445fgrarr;\u67ff\u0900Dacdefglmnopqrstux\u1e01\u1e09\u1e19\u1e38\u0578\u1e3c\u1e49\u1e61\u1e7e\u1ea5\u1eaf\u1ebd\u1ee1\u1f2a\u1f37\u1f44\u1f4e\u1f5a\u0100Do\u1e06\u1d34o\xf4\u1c89\u0100cs\u1e0e\u1e14ute\u803b\xe9\u40e9ter;\u6a6e\u0200aioy\u1e22\u1e27\u1e31\u1e36ron;\u411br\u0100;c\u1e2d\u1e2e\u6256\u803b\xea\u40ealon;\u6255;\u444dot;\u4117\u0100Dr\u1e41\u1e45ot;\u6252;\uc000\ud835\udd22\u0180;rs\u1e50\u1e51\u1e57\u6a9aave\u803b\xe8\u40e8\u0100;d\u1e5c\u1e5d\u6a96ot;\u6a98\u0200;ils\u1e6a\u1e6b\u1e72\u1e74\u6a99nters;\u63e7;\u6113\u0100;d\u1e79\u1e7a\u6a95ot;\u6a97\u0180aps\u1e85\u1e89\u1e97cr;\u4113ty\u0180;sv\u1e92\u1e93\u1e95\u6205et\xbb\u1e93p\u01001;\u1e9d\u1ea4\u0133\u1ea1\u1ea3;\u6004;\u6005\u6003\u0100gs\u1eaa\u1eac;\u414bp;\u6002\u0100gp\u1eb4\u1eb8on;\u4119f;\uc000\ud835\udd56\u0180als\u1ec4\u1ece\u1ed2r\u0100;s\u1eca\u1ecb\u62d5l;\u69e3us;\u6a71i\u0180;lv\u1eda\u1edb\u1edf\u43b5on\xbb\u1edb;\u43f5\u0200csuv\u1eea\u1ef3\u1f0b\u1f23\u0100io\u1eef\u1e31rc\xbb\u1e2e\u0269\u1ef9\0\0\u1efb\xed\u0548ant\u0100gl\u1f02\u1f06tr\xbb\u1e5dess\xbb\u1e7a\u0180aei\u1f12\u1f16\u1f1als;\u403dst;\u625fv\u0100;D\u0235\u1f20D;\u6a78parsl;\u69e5\u0100Da\u1f2f\u1f33ot;\u6253rr;\u6971\u0180cdi\u1f3e\u1f41\u1ef8r;\u612fo\xf4\u0352\u0100ah\u1f49\u1f4b;\u43b7\u803b\xf0\u40f0\u0100mr\u1f53\u1f57l\u803b\xeb\u40ebo;\u60ac\u0180cip\u1f61\u1f64\u1f67l;\u4021s\xf4\u056e\u0100eo\u1f6c\u1f74ctatio\xee\u0559nential\xe5\u0579\u09e1\u1f92\0\u1f9e\0\u1fa1\u1fa7\0\0\u1fc6\u1fcc\0\u1fd3\0\u1fe6\u1fea\u2000\0\u2008\u205allingdotse\xf1\u1e44y;\u4444male;\u6640\u0180ilr\u1fad\u1fb3\u1fc1lig;\u8000\ufb03\u0269\u1fb9\0\0\u1fbdg;\u8000\ufb00ig;\u8000\ufb04;\uc000\ud835\udd23lig;\u8000\ufb01lig;\uc000fj\u0180alt\u1fd9\u1fdc\u1fe1t;\u666dig;\u8000\ufb02ns;\u65b1of;\u4192\u01f0\u1fee\0\u1ff3f;\uc000\ud835\udd57\u0100ak\u05bf\u1ff7\u0100;v\u1ffc\u1ffd\u62d4;\u6ad9artint;\u6a0d\u0100ao\u200c\u2055\u0100cs\u2011\u2052\u03b1\u201a\u2030\u2038\u2045\u2048\0\u2050\u03b2\u2022\u2025\u2027\u202a\u202c\0\u202e\u803b\xbd\u40bd;\u6153\u803b\xbc\u40bc;\u6155;\u6159;\u615b\u01b3\u2034\0\u2036;\u6154;\u6156\u02b4\u203e\u2041\0\0\u2043\u803b\xbe\u40be;\u6157;\u615c5;\u6158\u01b6\u204c\0\u204e;\u615a;\u615d8;\u615el;\u6044wn;\u6322cr;\uc000\ud835\udcbb\u0880Eabcdefgijlnorstv\u2082\u2089\u209f\u20a5\u20b0\u20b4\u20f0\u20f5\u20fa\u20ff\u2103\u2112\u2138\u0317\u213e\u2152\u219e\u0100;l\u064d\u2087;\u6a8c\u0180cmp\u2090\u2095\u209dute;\u41f5ma\u0100;d\u209c\u1cda\u43b3;\u6a86reve;\u411f\u0100iy\u20aa\u20aerc;\u411d;\u4433ot;\u4121\u0200;lqs\u063e\u0642\u20bd\u20c9\u0180;qs\u063e\u064c\u20c4lan\xf4\u0665\u0200;cdl\u0665\u20d2\u20d5\u20e5c;\u6aa9ot\u0100;o\u20dc\u20dd\u6a80\u0100;l\u20e2\u20e3\u6a82;\u6a84\u0100;e\u20ea\u20ed\uc000\u22db\ufe00s;\u6a94r;\uc000\ud835\udd24\u0100;g\u0673\u061bmel;\u6137cy;\u4453\u0200;Eaj\u065a\u210c\u210e\u2110;\u6a92;\u6aa5;\u6aa4\u0200Eaes\u211b\u211d\u2129\u2134;\u6269p\u0100;p\u2123\u2124\u6a8arox\xbb\u2124\u0100;q\u212e\u212f\u6a88\u0100;q\u212e\u211bim;\u62e7pf;\uc000\ud835\udd58\u0100ci\u2143\u2146r;\u610am\u0180;el\u066b\u214e\u2150;\u6a8e;\u6a90\u8300>;cdlqr\u05ee\u2160\u216a\u216e\u2173\u2179\u0100ci\u2165\u2167;\u6aa7r;\u6a7aot;\u62d7Par;\u6995uest;\u6a7c\u0280adels\u2184\u216a\u2190\u0656\u219b\u01f0\u2189\0\u218epro\xf8\u209er;\u6978q\u0100lq\u063f\u2196les\xf3\u2088i\xed\u066b\u0100en\u21a3\u21adrtneqq;\uc000\u2269\ufe00\xc5\u21aa\u0500Aabcefkosy\u21c4\u21c7\u21f1\u21f5\u21fa\u2218\u221d\u222f\u2268\u227dr\xf2\u03a0\u0200ilmr\u21d0\u21d4\u21d7\u21dbrs\xf0\u1484f\xbb\u2024il\xf4\u06a9\u0100dr\u21e0\u21e4cy;\u444a\u0180;cw\u08f4\u21eb\u21efir;\u6948;\u61adar;\u610firc;\u4125\u0180alr\u2201\u220e\u2213rts\u0100;u\u2209\u220a\u6665it\xbb\u220alip;\u6026con;\u62b9r;\uc000\ud835\udd25s\u0100ew\u2223\u2229arow;\u6925arow;\u6926\u0280amopr\u223a\u223e\u2243\u225e\u2263rr;\u61fftht;\u623bk\u0100lr\u2249\u2253eftarrow;\u61a9ightarrow;\u61aaf;\uc000\ud835\udd59bar;\u6015\u0180clt\u226f\u2274\u2278r;\uc000\ud835\udcbdas\xe8\u21f4rok;\u4127\u0100bp\u2282\u2287ull;\u6043hen\xbb\u1c5b\u0ae1\u22a3\0\u22aa\0\u22b8\u22c5\u22ce\0\u22d5\u22f3\0\0\u22f8\u2322\u2367\u2362\u237f\0\u2386\u23aa\u23b4cute\u803b\xed\u40ed\u0180;iy\u0771\u22b0\u22b5rc\u803b\xee\u40ee;\u4438\u0100cx\u22bc\u22bfy;\u4435cl\u803b\xa1\u40a1\u0100fr\u039f\u22c9;\uc000\ud835\udd26rave\u803b\xec\u40ec\u0200;ino\u073e\u22dd\u22e9\u22ee\u0100in\u22e2\u22e6nt;\u6a0ct;\u622dfin;\u69dcta;\u6129lig;\u4133\u0180aop\u22fe\u231a\u231d\u0180cgt\u2305\u2308\u2317r;\u412b\u0180elp\u071f\u230f\u2313in\xe5\u078ear\xf4\u0720h;\u4131f;\u62b7ed;\u41b5\u0280;cfot\u04f4\u232c\u2331\u233d\u2341are;\u6105in\u0100;t\u2338\u2339\u621eie;\u69dddo\xf4\u2319\u0280;celp\u0757\u234c\u2350\u235b\u2361al;\u62ba\u0100gr\u2355\u2359er\xf3\u1563\xe3\u234darhk;\u6a17rod;\u6a3c\u0200cgpt\u236f\u2372\u2376\u237by;\u4451on;\u412ff;\uc000\ud835\udd5aa;\u43b9uest\u803b\xbf\u40bf\u0100ci\u238a\u238fr;\uc000\ud835\udcben\u0280;Edsv\u04f4\u239b\u239d\u23a1\u04f3;\u62f9ot;\u62f5\u0100;v\u23a6\u23a7\u62f4;\u62f3\u0100;i\u0777\u23aelde;\u4129\u01eb\u23b8\0\u23bccy;\u4456l\u803b\xef\u40ef\u0300cfmosu\u23cc\u23d7\u23dc\u23e1\u23e7\u23f5\u0100iy\u23d1\u23d5rc;\u4135;\u4439r;\uc000\ud835\udd27ath;\u4237pf;\uc000\ud835\udd5b\u01e3\u23ec\0\u23f1r;\uc000\ud835\udcbfrcy;\u4458kcy;\u4454\u0400acfghjos\u240b\u2416\u2422\u2427\u242d\u2431\u2435\u243bppa\u0100;v\u2413\u2414\u43ba;\u43f0\u0100ey\u241b\u2420dil;\u4137;\u443ar;\uc000\ud835\udd28reen;\u4138cy;\u4445cy;\u445cpf;\uc000\ud835\udd5ccr;\uc000\ud835\udcc0\u0b80ABEHabcdefghjlmnoprstuv\u2470\u2481\u2486\u248d\u2491\u250e\u253d\u255a\u2580\u264e\u265e\u2665\u2679\u267d\u269a\u26b2\u26d8\u275d\u2768\u278b\u27c0\u2801\u2812\u0180art\u2477\u247a\u247cr\xf2\u09c6\xf2\u0395ail;\u691barr;\u690e\u0100;g\u0994\u248b;\u6a8bar;\u6962\u0963\u24a5\0\u24aa\0\u24b1\0\0\0\0\0\u24b5\u24ba\0\u24c6\u24c8\u24cd\0\u24f9ute;\u413amptyv;\u69b4ra\xee\u084cbda;\u43bbg\u0180;dl\u088e\u24c1\u24c3;\u6991\xe5\u088e;\u6a85uo\u803b\xab\u40abr\u0400;bfhlpst\u0899\u24de\u24e6\u24e9\u24eb\u24ee\u24f1\u24f5\u0100;f\u089d\u24e3s;\u691fs;\u691d\xeb\u2252p;\u61abl;\u6939im;\u6973l;\u61a2\u0180;ae\u24ff\u2500\u2504\u6aabil;\u6919\u0100;s\u2509\u250a\u6aad;\uc000\u2aad\ufe00\u0180abr\u2515\u2519\u251drr;\u690crk;\u6772\u0100ak\u2522\u252cc\u0100ek\u2528\u252a;\u407b;\u405b\u0100es\u2531\u2533;\u698bl\u0100du\u2539\u253b;\u698f;\u698d\u0200aeuy\u2546\u254b\u2556\u2558ron;\u413e\u0100di\u2550\u2554il;\u413c\xec\u08b0\xe2\u2529;\u443b\u0200cqrs\u2563\u2566\u256d\u257da;\u6936uo\u0100;r\u0e19\u1746\u0100du\u2572\u2577har;\u6967shar;\u694bh;\u61b2\u0280;fgqs\u258b\u258c\u0989\u25f3\u25ff\u6264t\u0280ahlrt\u2598\u25a4\u25b7\u25c2\u25e8rrow\u0100;t\u0899\u25a1a\xe9\u24f6arpoon\u0100du\u25af\u25b4own\xbb\u045ap\xbb\u0966eftarrows;\u61c7ight\u0180ahs\u25cd\u25d6\u25derrow\u0100;s\u08f4\u08a7arpoon\xf3\u0f98quigarro\xf7\u21f0hreetimes;\u62cb\u0180;qs\u258b\u0993\u25falan\xf4\u09ac\u0280;cdgs\u09ac\u260a\u260d\u261d\u2628c;\u6aa8ot\u0100;o\u2614\u2615\u6a7f\u0100;r\u261a\u261b\u6a81;\u6a83\u0100;e\u2622\u2625\uc000\u22da\ufe00s;\u6a93\u0280adegs\u2633\u2639\u263d\u2649\u264bppro\xf8\u24c6ot;\u62d6q\u0100gq\u2643\u2645\xf4\u0989gt\xf2\u248c\xf4\u099bi\xed\u09b2\u0180ilr\u2655\u08e1\u265asht;\u697c;\uc000\ud835\udd29\u0100;E\u099c\u2663;\u6a91\u0161\u2669\u2676r\u0100du\u25b2\u266e\u0100;l\u0965\u2673;\u696alk;\u6584cy;\u4459\u0280;acht\u0a48\u2688\u268b\u2691\u2696r\xf2\u25c1orne\xf2\u1d08ard;\u696bri;\u65fa\u0100io\u269f\u26a4dot;\u4140ust\u0100;a\u26ac\u26ad\u63b0che\xbb\u26ad\u0200Eaes\u26bb\u26bd\u26c9\u26d4;\u6268p\u0100;p\u26c3\u26c4\u6a89rox\xbb\u26c4\u0100;q\u26ce\u26cf\u6a87\u0100;q\u26ce\u26bbim;\u62e6\u0400abnoptwz\u26e9\u26f4\u26f7\u271a\u272f\u2741\u2747\u2750\u0100nr\u26ee\u26f1g;\u67ecr;\u61fdr\xeb\u08c1g\u0180lmr\u26ff\u270d\u2714eft\u0100ar\u09e6\u2707ight\xe1\u09f2apsto;\u67fcight\xe1\u09fdparrow\u0100lr\u2725\u2729ef\xf4\u24edight;\u61ac\u0180afl\u2736\u2739\u273dr;\u6985;\uc000\ud835\udd5dus;\u6a2dimes;\u6a34\u0161\u274b\u274fst;\u6217\xe1\u134e\u0180;ef\u2757\u2758\u1800\u65cange\xbb\u2758ar\u0100;l\u2764\u2765\u4028t;\u6993\u0280achmt\u2773\u2776\u277c\u2785\u2787r\xf2\u08a8orne\xf2\u1d8car\u0100;d\u0f98\u2783;\u696d;\u600eri;\u62bf\u0300achiqt\u2798\u279d\u0a40\u27a2\u27ae\u27bbquo;\u6039r;\uc000\ud835\udcc1m\u0180;eg\u09b2\u27aa\u27ac;\u6a8d;\u6a8f\u0100bu\u252a\u27b3o\u0100;r\u0e1f\u27b9;\u601arok;\u4142\u8400<;cdhilqr\u082b\u27d2\u2639\u27dc\u27e0\u27e5\u27ea\u27f0\u0100ci\u27d7\u27d9;\u6aa6r;\u6a79re\xe5\u25f2mes;\u62c9arr;\u6976uest;\u6a7b\u0100Pi\u27f5\u27f9ar;\u6996\u0180;ef\u2800\u092d\u181b\u65c3r\u0100du\u2807\u280dshar;\u694ahar;\u6966\u0100en\u2817\u2821rtneqq;\uc000\u2268\ufe00\xc5\u281e\u0700Dacdefhilnopsu\u2840\u2845\u2882\u288e\u2893\u28a0\u28a5\u28a8\u28da\u28e2\u28e4\u0a83\u28f3\u2902Dot;\u623a\u0200clpr\u284e\u2852\u2863\u287dr\u803b\xaf\u40af\u0100et\u2857\u2859;\u6642\u0100;e\u285e\u285f\u6720se\xbb\u285f\u0100;s\u103b\u2868to\u0200;dlu\u103b\u2873\u2877\u287bow\xee\u048cef\xf4\u090f\xf0\u13d1ker;\u65ae\u0100oy\u2887\u288cmma;\u6a29;\u443cash;\u6014asuredangle\xbb\u1626r;\uc000\ud835\udd2ao;\u6127\u0180cdn\u28af\u28b4\u28c9ro\u803b\xb5\u40b5\u0200;acd\u1464\u28bd\u28c0\u28c4s\xf4\u16a7ir;\u6af0ot\u80bb\xb7\u01b5us\u0180;bd\u28d2\u1903\u28d3\u6212\u0100;u\u1d3c\u28d8;\u6a2a\u0163\u28de\u28e1p;\u6adb\xf2\u2212\xf0\u0a81\u0100dp\u28e9\u28eeels;\u62a7f;\uc000\ud835\udd5e\u0100ct\u28f8\u28fdr;\uc000\ud835\udcc2pos\xbb\u159d\u0180;lm\u2909\u290a\u290d\u43bctimap;\u62b8\u0c00GLRVabcdefghijlmoprstuvw\u2942\u2953\u297e\u2989\u2998\u29da\u29e9\u2a15\u2a1a\u2a58\u2a5d\u2a83\u2a95\u2aa4\u2aa8\u2b04\u2b07\u2b44\u2b7f\u2bae\u2c34\u2c67\u2c7c\u2ce9\u0100gt\u2947\u294b;\uc000\u22d9\u0338\u0100;v\u2950\u0bcf\uc000\u226b\u20d2\u0180elt\u295a\u2972\u2976ft\u0100ar\u2961\u2967rrow;\u61cdightarrow;\u61ce;\uc000\u22d8\u0338\u0100;v\u297b\u0c47\uc000\u226a\u20d2ightarrow;\u61cf\u0100Dd\u298e\u2993ash;\u62afash;\u62ae\u0280bcnpt\u29a3\u29a7\u29ac\u29b1\u29ccla\xbb\u02deute;\u4144g;\uc000\u2220\u20d2\u0280;Eiop\u0d84\u29bc\u29c0\u29c5\u29c8;\uc000\u2a70\u0338d;\uc000\u224b\u0338s;\u4149ro\xf8\u0d84ur\u0100;a\u29d3\u29d4\u666el\u0100;s\u29d3\u0b38\u01f3\u29df\0\u29e3p\u80bb\xa0\u0b37mp\u0100;e\u0bf9\u0c00\u0280aeouy\u29f4\u29fe\u2a03\u2a10\u2a13\u01f0\u29f9\0\u29fb;\u6a43on;\u4148dil;\u4146ng\u0100;d\u0d7e\u2a0aot;\uc000\u2a6d\u0338p;\u6a42;\u443dash;\u6013\u0380;Aadqsx\u0b92\u2a29\u2a2d\u2a3b\u2a41\u2a45\u2a50rr;\u61d7r\u0100hr\u2a33\u2a36k;\u6924\u0100;o\u13f2\u13f0ot;\uc000\u2250\u0338ui\xf6\u0b63\u0100ei\u2a4a\u2a4ear;\u6928\xed\u0b98ist\u0100;s\u0ba0\u0b9fr;\uc000\ud835\udd2b\u0200Eest\u0bc5\u2a66\u2a79\u2a7c\u0180;qs\u0bbc\u2a6d\u0be1\u0180;qs\u0bbc\u0bc5\u2a74lan\xf4\u0be2i\xed\u0bea\u0100;r\u0bb6\u2a81\xbb\u0bb7\u0180Aap\u2a8a\u2a8d\u2a91r\xf2\u2971rr;\u61aear;\u6af2\u0180;sv\u0f8d\u2a9c\u0f8c\u0100;d\u2aa1\u2aa2\u62fc;\u62facy;\u445a\u0380AEadest\u2ab7\u2aba\u2abe\u2ac2\u2ac5\u2af6\u2af9r\xf2\u2966;\uc000\u2266\u0338rr;\u619ar;\u6025\u0200;fqs\u0c3b\u2ace\u2ae3\u2aeft\u0100ar\u2ad4\u2ad9rro\xf7\u2ac1ightarro\xf7\u2a90\u0180;qs\u0c3b\u2aba\u2aealan\xf4\u0c55\u0100;s\u0c55\u2af4\xbb\u0c36i\xed\u0c5d\u0100;r\u0c35\u2afei\u0100;e\u0c1a\u0c25i\xe4\u0d90\u0100pt\u2b0c\u2b11f;\uc000\ud835\udd5f\u8180\xac;in\u2b19\u2b1a\u2b36\u40acn\u0200;Edv\u0b89\u2b24\u2b28\u2b2e;\uc000\u22f9\u0338ot;\uc000\u22f5\u0338\u01e1\u0b89\u2b33\u2b35;\u62f7;\u62f6i\u0100;v\u0cb8\u2b3c\u01e1\u0cb8\u2b41\u2b43;\u62fe;\u62fd\u0180aor\u2b4b\u2b63\u2b69r\u0200;ast\u0b7b\u2b55\u2b5a\u2b5flle\xec\u0b7bl;\uc000\u2afd\u20e5;\uc000\u2202\u0338lint;\u6a14\u0180;ce\u0c92\u2b70\u2b73u\xe5\u0ca5\u0100;c\u0c98\u2b78\u0100;e\u0c92\u2b7d\xf1\u0c98\u0200Aait\u2b88\u2b8b\u2b9d\u2ba7r\xf2\u2988rr\u0180;cw\u2b94\u2b95\u2b99\u619b;\uc000\u2933\u0338;\uc000\u219d\u0338ghtarrow\xbb\u2b95ri\u0100;e\u0ccb\u0cd6\u0380chimpqu\u2bbd\u2bcd\u2bd9\u2b04\u0b78\u2be4\u2bef\u0200;cer\u0d32\u2bc6\u0d37\u2bc9u\xe5\u0d45;\uc000\ud835\udcc3ort\u026d\u2b05\0\0\u2bd6ar\xe1\u2b56m\u0100;e\u0d6e\u2bdf\u0100;q\u0d74\u0d73su\u0100bp\u2beb\u2bed\xe5\u0cf8\xe5\u0d0b\u0180bcp\u2bf6\u2c11\u2c19\u0200;Ees\u2bff\u2c00\u0d22\u2c04\u6284;\uc000\u2ac5\u0338et\u0100;e\u0d1b\u2c0bq\u0100;q\u0d23\u2c00c\u0100;e\u0d32\u2c17\xf1\u0d38\u0200;Ees\u2c22\u2c23\u0d5f\u2c27\u6285;\uc000\u2ac6\u0338et\u0100;e\u0d58\u2c2eq\u0100;q\u0d60\u2c23\u0200gilr\u2c3d\u2c3f\u2c45\u2c47\xec\u0bd7lde\u803b\xf1\u40f1\xe7\u0c43iangle\u0100lr\u2c52\u2c5ceft\u0100;e\u0c1a\u2c5a\xf1\u0c26ight\u0100;e\u0ccb\u2c65\xf1\u0cd7\u0100;m\u2c6c\u2c6d\u43bd\u0180;es\u2c74\u2c75\u2c79\u4023ro;\u6116p;\u6007\u0480DHadgilrs\u2c8f\u2c94\u2c99\u2c9e\u2ca3\u2cb0\u2cb6\u2cd3\u2ce3ash;\u62adarr;\u6904p;\uc000\u224d\u20d2ash;\u62ac\u0100et\u2ca8\u2cac;\uc000\u2265\u20d2;\uc000>\u20d2nfin;\u69de\u0180Aet\u2cbd\u2cc1\u2cc5rr;\u6902;\uc000\u2264\u20d2\u0100;r\u2cca\u2ccd\uc000<\u20d2ie;\uc000\u22b4\u20d2\u0100At\u2cd8\u2cdcrr;\u6903rie;\uc000\u22b5\u20d2im;\uc000\u223c\u20d2\u0180Aan\u2cf0\u2cf4\u2d02rr;\u61d6r\u0100hr\u2cfa\u2cfdk;\u6923\u0100;o\u13e7\u13e5ear;\u6927\u1253\u1a95\0\0\0\0\0\0\0\0\0\0\0\0\0\u2d2d\0\u2d38\u2d48\u2d60\u2d65\u2d72\u2d84\u1b07\0\0\u2d8d\u2dab\0\u2dc8\u2dce\0\u2ddc\u2e19\u2e2b\u2e3e\u2e43\u0100cs\u2d31\u1a97ute\u803b\xf3\u40f3\u0100iy\u2d3c\u2d45r\u0100;c\u1a9e\u2d42\u803b\xf4\u40f4;\u443e\u0280abios\u1aa0\u2d52\u2d57\u01c8\u2d5alac;\u4151v;\u6a38old;\u69bclig;\u4153\u0100cr\u2d69\u2d6dir;\u69bf;\uc000\ud835\udd2c\u036f\u2d79\0\0\u2d7c\0\u2d82n;\u42dbave\u803b\xf2\u40f2;\u69c1\u0100bm\u2d88\u0df4ar;\u69b5\u0200acit\u2d95\u2d98\u2da5\u2da8r\xf2\u1a80\u0100ir\u2d9d\u2da0r;\u69beoss;\u69bbn\xe5\u0e52;\u69c0\u0180aei\u2db1\u2db5\u2db9cr;\u414dga;\u43c9\u0180cdn\u2dc0\u2dc5\u01cdron;\u43bf;\u69b6pf;\uc000\ud835\udd60\u0180ael\u2dd4\u2dd7\u01d2r;\u69b7rp;\u69b9\u0380;adiosv\u2dea\u2deb\u2dee\u2e08\u2e0d\u2e10\u2e16\u6228r\xf2\u1a86\u0200;efm\u2df7\u2df8\u2e02\u2e05\u6a5dr\u0100;o\u2dfe\u2dff\u6134f\xbb\u2dff\u803b\xaa\u40aa\u803b\xba\u40bagof;\u62b6r;\u6a56lope;\u6a57;\u6a5b\u0180clo\u2e1f\u2e21\u2e27\xf2\u2e01ash\u803b\xf8\u40f8l;\u6298i\u016c\u2e2f\u2e34de\u803b\xf5\u40f5es\u0100;a\u01db\u2e3as;\u6a36ml\u803b\xf6\u40f6bar;\u633d\u0ae1\u2e5e\0\u2e7d\0\u2e80\u2e9d\0\u2ea2\u2eb9\0\0\u2ecb\u0e9c\0\u2f13\0\0\u2f2b\u2fbc\0\u2fc8r\u0200;ast\u0403\u2e67\u2e72\u0e85\u8100\xb6;l\u2e6d\u2e6e\u40b6le\xec\u0403\u0269\u2e78\0\0\u2e7bm;\u6af3;\u6afdy;\u443fr\u0280cimpt\u2e8b\u2e8f\u2e93\u1865\u2e97nt;\u4025od;\u402eil;\u6030enk;\u6031r;\uc000\ud835\udd2d\u0180imo\u2ea8\u2eb0\u2eb4\u0100;v\u2ead\u2eae\u43c6;\u43d5ma\xf4\u0a76ne;\u660e\u0180;tv\u2ebf\u2ec0\u2ec8\u43c0chfork\xbb\u1ffd;\u43d6\u0100au\u2ecf\u2edfn\u0100ck\u2ed5\u2eddk\u0100;h\u21f4\u2edb;\u610e\xf6\u21f4s\u0480;abcdemst\u2ef3\u2ef4\u1908\u2ef9\u2efd\u2f04\u2f06\u2f0a\u2f0e\u402bcir;\u6a23ir;\u6a22\u0100ou\u1d40\u2f02;\u6a25;\u6a72n\u80bb\xb1\u0e9dim;\u6a26wo;\u6a27\u0180ipu\u2f19\u2f20\u2f25ntint;\u6a15f;\uc000\ud835\udd61nd\u803b\xa3\u40a3\u0500;Eaceinosu\u0ec8\u2f3f\u2f41\u2f44\u2f47\u2f81\u2f89\u2f92\u2f7e\u2fb6;\u6ab3p;\u6ab7u\xe5\u0ed9\u0100;c\u0ece\u2f4c\u0300;acens\u0ec8\u2f59\u2f5f\u2f66\u2f68\u2f7eppro\xf8\u2f43urlye\xf1\u0ed9\xf1\u0ece\u0180aes\u2f6f\u2f76\u2f7approx;\u6ab9qq;\u6ab5im;\u62e8i\xed\u0edfme\u0100;s\u2f88\u0eae\u6032\u0180Eas\u2f78\u2f90\u2f7a\xf0\u2f75\u0180dfp\u0eec\u2f99\u2faf\u0180als\u2fa0\u2fa5\u2faalar;\u632eine;\u6312urf;\u6313\u0100;t\u0efb\u2fb4\xef\u0efbrel;\u62b0\u0100ci\u2fc0\u2fc5r;\uc000\ud835\udcc5;\u43c8ncsp;\u6008\u0300fiopsu\u2fda\u22e2\u2fdf\u2fe5\u2feb\u2ff1r;\uc000\ud835\udd2epf;\uc000\ud835\udd62rime;\u6057cr;\uc000\ud835\udcc6\u0180aeo\u2ff8\u3009\u3013t\u0100ei\u2ffe\u3005rnion\xf3\u06b0nt;\u6a16st\u0100;e\u3010\u3011\u403f\xf1\u1f19\xf4\u0f14\u0a80ABHabcdefhilmnoprstux\u3040\u3051\u3055\u3059\u30e0\u310e\u312b\u3147\u3162\u3172\u318e\u3206\u3215\u3224\u3229\u3258\u326e\u3272\u3290\u32b0\u32b7\u0180art\u3047\u304a\u304cr\xf2\u10b3\xf2\u03ddail;\u691car\xf2\u1c65ar;\u6964\u0380cdenqrt\u3068\u3075\u3078\u307f\u308f\u3094\u30cc\u0100eu\u306d\u3071;\uc000\u223d\u0331te;\u4155i\xe3\u116emptyv;\u69b3g\u0200;del\u0fd1\u3089\u308b\u308d;\u6992;\u69a5\xe5\u0fd1uo\u803b\xbb\u40bbr\u0580;abcfhlpstw\u0fdc\u30ac\u30af\u30b7\u30b9\u30bc\u30be\u30c0\u30c3\u30c7\u30cap;\u6975\u0100;f\u0fe0\u30b4s;\u6920;\u6933s;\u691e\xeb\u225d\xf0\u272el;\u6945im;\u6974l;\u61a3;\u619d\u0100ai\u30d1\u30d5il;\u691ao\u0100;n\u30db\u30dc\u6236al\xf3\u0f1e\u0180abr\u30e7\u30ea\u30eer\xf2\u17e5rk;\u6773\u0100ak\u30f3\u30fdc\u0100ek\u30f9\u30fb;\u407d;\u405d\u0100es\u3102\u3104;\u698cl\u0100du\u310a\u310c;\u698e;\u6990\u0200aeuy\u3117\u311c\u3127\u3129ron;\u4159\u0100di\u3121\u3125il;\u4157\xec\u0ff2\xe2\u30fa;\u4440\u0200clqs\u3134\u3137\u313d\u3144a;\u6937dhar;\u6969uo\u0100;r\u020e\u020dh;\u61b3\u0180acg\u314e\u315f\u0f44l\u0200;ips\u0f78\u3158\u315b\u109cn\xe5\u10bbar\xf4\u0fa9t;\u65ad\u0180ilr\u3169\u1023\u316esht;\u697d;\uc000\ud835\udd2f\u0100ao\u3177\u3186r\u0100du\u317d\u317f\xbb\u047b\u0100;l\u1091\u3184;\u696c\u0100;v\u318b\u318c\u43c1;\u43f1\u0180gns\u3195\u31f9\u31fcht\u0300ahlrst\u31a4\u31b0\u31c2\u31d8\u31e4\u31eerrow\u0100;t\u0fdc\u31ada\xe9\u30c8arpoon\u0100du\u31bb\u31bfow\xee\u317ep\xbb\u1092eft\u0100ah\u31ca\u31d0rrow\xf3\u0feaarpoon\xf3\u0551ightarrows;\u61c9quigarro\xf7\u30cbhreetimes;\u62ccg;\u42daingdotse\xf1\u1f32\u0180ahm\u320d\u3210\u3213r\xf2\u0feaa\xf2\u0551;\u600foust\u0100;a\u321e\u321f\u63b1che\xbb\u321fmid;\u6aee\u0200abpt\u3232\u323d\u3240\u3252\u0100nr\u3237\u323ag;\u67edr;\u61fer\xeb\u1003\u0180afl\u3247\u324a\u324er;\u6986;\uc000\ud835\udd63us;\u6a2eimes;\u6a35\u0100ap\u325d\u3267r\u0100;g\u3263\u3264\u4029t;\u6994olint;\u6a12ar\xf2\u31e3\u0200achq\u327b\u3280\u10bc\u3285quo;\u603ar;\uc000\ud835\udcc7\u0100bu\u30fb\u328ao\u0100;r\u0214\u0213\u0180hir\u3297\u329b\u32a0re\xe5\u31f8mes;\u62cai\u0200;efl\u32aa\u1059\u1821\u32ab\u65b9tri;\u69celuhar;\u6968;\u611e\u0d61\u32d5\u32db\u32df\u332c\u3338\u3371\0\u337a\u33a4\0\0\u33ec\u33f0\0\u3428\u3448\u345a\u34ad\u34b1\u34ca\u34f1\0\u3616\0\0\u3633cute;\u415bqu\xef\u27ba\u0500;Eaceinpsy\u11ed\u32f3\u32f5\u32ff\u3302\u330b\u330f\u331f\u3326\u3329;\u6ab4\u01f0\u32fa\0\u32fc;\u6ab8on;\u4161u\xe5\u11fe\u0100;d\u11f3\u3307il;\u415frc;\u415d\u0180Eas\u3316\u3318\u331b;\u6ab6p;\u6abaim;\u62e9olint;\u6a13i\xed\u1204;\u4441ot\u0180;be\u3334\u1d47\u3335\u62c5;\u6a66\u0380Aacmstx\u3346\u334a\u3357\u335b\u335e\u3363\u336drr;\u61d8r\u0100hr\u3350\u3352\xeb\u2228\u0100;o\u0a36\u0a34t\u803b\xa7\u40a7i;\u403bwar;\u6929m\u0100in\u3369\xf0nu\xf3\xf1t;\u6736r\u0100;o\u3376\u2055\uc000\ud835\udd30\u0200acoy\u3382\u3386\u3391\u33a0rp;\u666f\u0100hy\u338b\u338fcy;\u4449;\u4448rt\u026d\u3399\0\0\u339ci\xe4\u1464ara\xec\u2e6f\u803b\xad\u40ad\u0100gm\u33a8\u33b4ma\u0180;fv\u33b1\u33b2\u33b2\u43c3;\u43c2\u0400;deglnpr\u12ab\u33c5\u33c9\u33ce\u33d6\u33de\u33e1\u33e6ot;\u6a6a\u0100;q\u12b1\u12b0\u0100;E\u33d3\u33d4\u6a9e;\u6aa0\u0100;E\u33db\u33dc\u6a9d;\u6a9fe;\u6246lus;\u6a24arr;\u6972ar\xf2\u113d\u0200aeit\u33f8\u3408\u340f\u3417\u0100ls\u33fd\u3404lsetm\xe9\u336ahp;\u6a33parsl;\u69e4\u0100dl\u1463\u3414e;\u6323\u0100;e\u341c\u341d\u6aaa\u0100;s\u3422\u3423\u6aac;\uc000\u2aac\ufe00\u0180flp\u342e\u3433\u3442tcy;\u444c\u0100;b\u3438\u3439\u402f\u0100;a\u343e\u343f\u69c4r;\u633ff;\uc000\ud835\udd64a\u0100dr\u344d\u0402es\u0100;u\u3454\u3455\u6660it\xbb\u3455\u0180csu\u3460\u3479\u349f\u0100au\u3465\u346fp\u0100;s\u1188\u346b;\uc000\u2293\ufe00p\u0100;s\u11b4\u3475;\uc000\u2294\ufe00u\u0100bp\u347f\u348f\u0180;es\u1197\u119c\u3486et\u0100;e\u1197\u348d\xf1\u119d\u0180;es\u11a8\u11ad\u3496et\u0100;e\u11a8\u349d\xf1\u11ae\u0180;af\u117b\u34a6\u05b0r\u0165\u34ab\u05b1\xbb\u117car\xf2\u1148\u0200cemt\u34b9\u34be\u34c2\u34c5r;\uc000\ud835\udcc8tm\xee\xf1i\xec\u3415ar\xe6\u11be\u0100ar\u34ce\u34d5r\u0100;f\u34d4\u17bf\u6606\u0100an\u34da\u34edight\u0100ep\u34e3\u34eapsilo\xee\u1ee0h\xe9\u2eafs\xbb\u2852\u0280bcmnp\u34fb\u355e\u1209\u358b\u358e\u0480;Edemnprs\u350e\u350f\u3511\u3515\u351e\u3523\u352c\u3531\u3536\u6282;\u6ac5ot;\u6abd\u0100;d\u11da\u351aot;\u6ac3ult;\u6ac1\u0100Ee\u3528\u352a;\u6acb;\u628alus;\u6abfarr;\u6979\u0180eiu\u353d\u3552\u3555t\u0180;en\u350e\u3545\u354bq\u0100;q\u11da\u350feq\u0100;q\u352b\u3528m;\u6ac7\u0100bp\u355a\u355c;\u6ad5;\u6ad3c\u0300;acens\u11ed\u356c\u3572\u3579\u357b\u3326ppro\xf8\u32faurlye\xf1\u11fe\xf1\u11f3\u0180aes\u3582\u3588\u331bppro\xf8\u331aq\xf1\u3317g;\u666a\u0680123;Edehlmnps\u35a9\u35ac\u35af\u121c\u35b2\u35b4\u35c0\u35c9\u35d5\u35da\u35df\u35e8\u35ed\u803b\xb9\u40b9\u803b\xb2\u40b2\u803b\xb3\u40b3;\u6ac6\u0100os\u35b9\u35bct;\u6abeub;\u6ad8\u0100;d\u1222\u35c5ot;\u6ac4s\u0100ou\u35cf\u35d2l;\u67c9b;\u6ad7arr;\u697bult;\u6ac2\u0100Ee\u35e4\u35e6;\u6acc;\u628blus;\u6ac0\u0180eiu\u35f4\u3609\u360ct\u0180;en\u121c\u35fc\u3602q\u0100;q\u1222\u35b2eq\u0100;q\u35e7\u35e4m;\u6ac8\u0100bp\u3611\u3613;\u6ad4;\u6ad6\u0180Aan\u361c\u3620\u362drr;\u61d9r\u0100hr\u3626\u3628\xeb\u222e\u0100;o\u0a2b\u0a29war;\u692alig\u803b\xdf\u40df\u0be1\u3651\u365d\u3660\u12ce\u3673\u3679\0\u367e\u36c2\0\0\0\0\0\u36db\u3703\0\u3709\u376c\0\0\0\u3787\u0272\u3656\0\0\u365bget;\u6316;\u43c4r\xeb\u0e5f\u0180aey\u3666\u366b\u3670ron;\u4165dil;\u4163;\u4442lrec;\u6315r;\uc000\ud835\udd31\u0200eiko\u3686\u369d\u36b5\u36bc\u01f2\u368b\0\u3691e\u01004f\u1284\u1281a\u0180;sv\u3698\u3699\u369b\u43b8ym;\u43d1\u0100cn\u36a2\u36b2k\u0100as\u36a8\u36aeppro\xf8\u12c1im\xbb\u12acs\xf0\u129e\u0100as\u36ba\u36ae\xf0\u12c1rn\u803b\xfe\u40fe\u01ec\u031f\u36c6\u22e7es\u8180\xd7;bd\u36cf\u36d0\u36d8\u40d7\u0100;a\u190f\u36d5r;\u6a31;\u6a30\u0180eps\u36e1\u36e3\u3700\xe1\u2a4d\u0200;bcf\u0486\u36ec\u36f0\u36f4ot;\u6336ir;\u6af1\u0100;o\u36f9\u36fc\uc000\ud835\udd65rk;\u6ada\xe1\u3362rime;\u6034\u0180aip\u370f\u3712\u3764d\xe5\u1248\u0380adempst\u3721\u374d\u3740\u3751\u3757\u375c\u375fngle\u0280;dlqr\u3730\u3731\u3736\u3740\u3742\u65b5own\xbb\u1dbbeft\u0100;e\u2800\u373e\xf1\u092e;\u625cight\u0100;e\u32aa\u374b\xf1\u105aot;\u65ecinus;\u6a3alus;\u6a39b;\u69cdime;\u6a3bezium;\u63e2\u0180cht\u3772\u377d\u3781\u0100ry\u3777\u377b;\uc000\ud835\udcc9;\u4446cy;\u445brok;\u4167\u0100io\u378b\u378ex\xf4\u1777head\u0100lr\u3797\u37a0eftarro\xf7\u084fightarrow\xbb\u0f5d\u0900AHabcdfghlmoprstuw\u37d0\u37d3\u37d7\u37e4\u37f0\u37fc\u380e\u381c\u3823\u3834\u3851\u385d\u386b\u38a9\u38cc\u38d2\u38ea\u38f6r\xf2\u03edar;\u6963\u0100cr\u37dc\u37e2ute\u803b\xfa\u40fa\xf2\u1150r\u01e3\u37ea\0\u37edy;\u445eve;\u416d\u0100iy\u37f5\u37farc\u803b\xfb\u40fb;\u4443\u0180abh\u3803\u3806\u380br\xf2\u13adlac;\u4171a\xf2\u13c3\u0100ir\u3813\u3818sht;\u697e;\uc000\ud835\udd32rave\u803b\xf9\u40f9\u0161\u3827\u3831r\u0100lr\u382c\u382e\xbb\u0957\xbb\u1083lk;\u6580\u0100ct\u3839\u384d\u026f\u383f\0\0\u384arn\u0100;e\u3845\u3846\u631cr\xbb\u3846op;\u630fri;\u65f8\u0100al\u3856\u385acr;\u416b\u80bb\xa8\u0349\u0100gp\u3862\u3866on;\u4173f;\uc000\ud835\udd66\u0300adhlsu\u114b\u3878\u387d\u1372\u3891\u38a0own\xe1\u13b3arpoon\u0100lr\u3888\u388cef\xf4\u382digh\xf4\u382fi\u0180;hl\u3899\u389a\u389c\u43c5\xbb\u13faon\xbb\u389aparrows;\u61c8\u0180cit\u38b0\u38c4\u38c8\u026f\u38b6\0\0\u38c1rn\u0100;e\u38bc\u38bd\u631dr\xbb\u38bdop;\u630eng;\u416fri;\u65f9cr;\uc000\ud835\udcca\u0180dir\u38d9\u38dd\u38e2ot;\u62f0lde;\u4169i\u0100;f\u3730\u38e8\xbb\u1813\u0100am\u38ef\u38f2r\xf2\u38a8l\u803b\xfc\u40fcangle;\u69a7\u0780ABDacdeflnoprsz\u391c\u391f\u3929\u392d\u39b5\u39b8\u39bd\u39df\u39e4\u39e8\u39f3\u39f9\u39fd\u3a01\u3a20r\xf2\u03f7ar\u0100;v\u3926\u3927\u6ae8;\u6ae9as\xe8\u03e1\u0100nr\u3932\u3937grt;\u699c\u0380eknprst\u34e3\u3946\u394b\u3952\u395d\u3964\u3996app\xe1\u2415othin\xe7\u1e96\u0180hir\u34eb\u2ec8\u3959op\xf4\u2fb5\u0100;h\u13b7\u3962\xef\u318d\u0100iu\u3969\u396dgm\xe1\u33b3\u0100bp\u3972\u3984setneq\u0100;q\u397d\u3980\uc000\u228a\ufe00;\uc000\u2acb\ufe00setneq\u0100;q\u398f\u3992\uc000\u228b\ufe00;\uc000\u2acc\ufe00\u0100hr\u399b\u399fet\xe1\u369ciangle\u0100lr\u39aa\u39afeft\xbb\u0925ight\xbb\u1051y;\u4432ash\xbb\u1036\u0180elr\u39c4\u39d2\u39d7\u0180;be\u2dea\u39cb\u39cfar;\u62bbq;\u625alip;\u62ee\u0100bt\u39dc\u1468a\xf2\u1469r;\uc000\ud835\udd33tr\xe9\u39aesu\u0100bp\u39ef\u39f1\xbb\u0d1c\xbb\u0d59pf;\uc000\ud835\udd67ro\xf0\u0efbtr\xe9\u39b4\u0100cu\u3a06\u3a0br;\uc000\ud835\udccb\u0100bp\u3a10\u3a18n\u0100Ee\u3980\u3a16\xbb\u397en\u0100Ee\u3992\u3a1e\xbb\u3990igzag;\u699a\u0380cefoprs\u3a36\u3a3b\u3a56\u3a5b\u3a54\u3a61\u3a6airc;\u4175\u0100di\u3a40\u3a51\u0100bg\u3a45\u3a49ar;\u6a5fe\u0100;q\u15fa\u3a4f;\u6259erp;\u6118r;\uc000\ud835\udd34pf;\uc000\ud835\udd68\u0100;e\u1479\u3a66at\xe8\u1479cr;\uc000\ud835\udccc\u0ae3\u178e\u3a87\0\u3a8b\0\u3a90\u3a9b\0\0\u3a9d\u3aa8\u3aab\u3aaf\0\0\u3ac3\u3ace\0\u3ad8\u17dc\u17dftr\xe9\u17d1r;\uc000\ud835\udd35\u0100Aa\u3a94\u3a97r\xf2\u03c3r\xf2\u09f6;\u43be\u0100Aa\u3aa1\u3aa4r\xf2\u03b8r\xf2\u09eba\xf0\u2713is;\u62fb\u0180dpt\u17a4\u3ab5\u3abe\u0100fl\u3aba\u17a9;\uc000\ud835\udd69im\xe5\u17b2\u0100Aa\u3ac7\u3acar\xf2\u03cer\xf2\u0a01\u0100cq\u3ad2\u17b8r;\uc000\ud835\udccd\u0100pt\u17d6\u3adcr\xe9\u17d4\u0400acefiosu\u3af0\u3afd\u3b08\u3b0c\u3b11\u3b15\u3b1b\u3b21c\u0100uy\u3af6\u3afbte\u803b\xfd\u40fd;\u444f\u0100iy\u3b02\u3b06rc;\u4177;\u444bn\u803b\xa5\u40a5r;\uc000\ud835\udd36cy;\u4457pf;\uc000\ud835\udd6acr;\uc000\ud835\udcce\u0100cm\u3b26\u3b29y;\u444el\u803b\xff\u40ff\u0500acdefhiosw\u3b42\u3b48\u3b54\u3b58\u3b64\u3b69\u3b6d\u3b74\u3b7a\u3b80cute;\u417a\u0100ay\u3b4d\u3b52ron;\u417e;\u4437ot;\u417c\u0100et\u3b5d\u3b61tr\xe6\u155fa;\u43b6r;\uc000\ud835\udd37cy;\u4436grarr;\u61ddpf;\uc000\ud835\udd6bcr;\uc000\ud835\udccf\u0100jn\u3b85\u3b87;\u600dj;\u600c'.split("").map((t=>t.charCodeAt(0)))),w=new Uint16Array("\u0200aglq\t\x15\x18\x1b\u026d\x0f\0\0\x12p;\u4026os;\u4027t;\u403et;\u403cuot;\u4022".split("").map((t=>t.charCodeAt(0))));const v=new Map([[0,65533],[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]),z=null!==(k=String.fromCodePoint)&&void 0!==k?k:function(t){let e="";return t>65535&&(t-=65536,e+=String.fromCharCode(t>>>10&1023|55296),t=56320|1023&t),e+=String.fromCharCode(t),e};var S;!function(t){t[t.NUM=35]="NUM",t[t.SEMI=59]="SEMI",t[t.EQUALS=61]="EQUALS",t[t.ZERO=48]="ZERO",t[t.NINE=57]="NINE",t[t.LOWER_A=97]="LOWER_A",t[t.LOWER_F=102]="LOWER_F",t[t.LOWER_X=120]="LOWER_X",t[t.LOWER_Z=122]="LOWER_Z",t[t.UPPER_A=65]="UPPER_A",t[t.UPPER_F=70]="UPPER_F",t[t.UPPER_Z=90]="UPPER_Z"}(S||(S={}));var q,B,L;function I(t){return t>=S.ZERO&&t<=S.NINE}function M(t){return t>=S.UPPER_A&&t<=S.UPPER_F||t>=S.LOWER_A&&t<=S.LOWER_F}function T(t){return t===S.EQUALS||function(t){return t>=S.UPPER_A&&t<=S.UPPER_Z||t>=S.LOWER_A&&t<=S.LOWER_Z||I(t)}(t)}!function(t){t[t.VALUE_LENGTH=49152]="VALUE_LENGTH",t[t.BRANCH_LENGTH=16256]="BRANCH_LENGTH",t[t.JUMP_TABLE=127]="JUMP_TABLE"}(q||(q={})),function(t){t[t.EntityStart=0]="EntityStart",t[t.NumericStart=1]="NumericStart",t[t.NumericDecimal=2]="NumericDecimal",t[t.NumericHex=3]="NumericHex",t[t.NamedEntity=4]="NamedEntity"}(B||(B={})),function(t){t[t.Legacy=0]="Legacy",t[t.Strict=1]="Strict",t[t.Attribute=2]="Attribute"}(L||(L={}));class R{constructor(t,e,r){this.decodeTree=t,this.emitCodePoint=e,this.errors=r,this.state=B.EntityStart,this.consumed=1,this.result=0,this.treeIndex=0,this.excess=1,this.decodeMode=L.Strict}startEntity(t){this.decodeMode=t,this.state=B.EntityStart,this.result=0,this.treeIndex=0,this.excess=1,this.consumed=1}write(t,e){switch(this.state){case B.EntityStart:return t.charCodeAt(e)===S.NUM?(this.state=B.NumericStart,this.consumed+=1,this.stateNumericStart(t,e+1)):(this.state=B.NamedEntity,this.stateNamedEntity(t,e));case B.NumericStart:return this.stateNumericStart(t,e);case B.NumericDecimal:return this.stateNumericDecimal(t,e);case B.NumericHex:return this.stateNumericHex(t,e);case B.NamedEntity:return this.stateNamedEntity(t,e)}}stateNumericStart(t,e){return e>=t.length?-1:(32|t.charCodeAt(e))===S.LOWER_X?(this.state=B.NumericHex,this.consumed+=1,this.stateNumericHex(t,e+1)):(this.state=B.NumericDecimal,this.stateNumericDecimal(t,e))}addToNumericResult(t,e,r,n){if(e!==r){const s=r-e;this.result=this.result*Math.pow(n,s)+parseInt(t.substr(e,s),n),this.consumed+=s}}stateNumericHex(t,e){const r=e;for(;e=55296&&t<=57343||t>1114111?65533:null!==(e=v.get(t))&&void 0!==e?e:t}(this.result),this.consumed),this.errors&&(t!==S.SEMI&&this.errors.missingSemicolonAfterCharacterReference(),this.errors.validateNumericCharacterReference(this.result)),this.consumed}stateNamedEntity(t,e){const{decodeTree:r}=this;let n=r[this.treeIndex],s=(n&q.VALUE_LENGTH)>>14;for(;e>14,0!==s){if(i===S.SEMI)return this.emitNamedEntityData(this.treeIndex,s,this.consumed+this.excess);this.decodeMode!==L.Strict&&(this.result=this.treeIndex,this.consumed+=this.excess,this.excess=0)}}return-1}emitNotTerminatedNamedEntity(){var t;const{result:e,decodeTree:r}=this,n=(r[e]&q.VALUE_LENGTH)>>14;return this.emitNamedEntityData(e,n,this.consumed),null===(t=this.errors)||void 0===t||t.missingSemicolonAfterCharacterReference(),this.consumed}emitNamedEntityData(t,e,r){const{decodeTree:n}=this;return this.emitCodePoint(1===e?n[t]&~q.VALUE_LENGTH:n[t+1],r),3===e&&this.emitCodePoint(n[t+2],r),r}end(){var t;switch(this.state){case B.NamedEntity:return 0===this.result||this.decodeMode===L.Attribute&&this.result!==this.treeIndex?0:this.emitNotTerminatedNamedEntity();case B.NumericDecimal:return this.emitNumericEntity(0,2);case B.NumericHex:return this.emitNumericEntity(0,3);case B.NumericStart:return null===(t=this.errors)||void 0===t||t.absenceOfDigitsInNumericCharacterReference(this.consumed),0;case B.EntityStart:return 0}}}function N(t){let e="";const r=new R(t,(t=>e+=z(t)));return function(t,n){let s=0,i=0;for(;(i=t.indexOf("&",i))>=0;){e+=t.slice(s,i),r.startEntity(n);const o=r.write(t,i+1);if(o<0){s=i+r.end();break}s=i+o,i=0===o?s+1:s}const o=e+t.slice(s);return e="",o}}function P(t,e,r,n){const s=(e&q.BRANCH_LENGTH)>>7,i=e&q.JUMP_TABLE;if(0===s)return 0!==i&&n===i?r:-1;if(i){const e=n-i;return e<0||e>=s?-1:t[r+e]-1}let o=r,u=o+s-1;for(;o<=u;){const e=o+u>>>1,r=t[e];if(rn))return t[e+s];u=e-1}}return-1}const O=N(x);function j(t,e=L.Legacy){return O(t,e)}function Z(t){return"[object String]"===function(t){return Object.prototype.toString.call(t)}(t)}N(w);const $=Object.prototype.hasOwnProperty;function U(t){return Array.prototype.slice.call(arguments,1).forEach((function(e){if(e){if("object"!=typeof e)throw new TypeError(e+"must be object");Object.keys(e).forEach((function(r){t[r]=e[r]}))}})),t}function H(t,e,r){return[].concat(t.slice(0,e),r,t.slice(e+1))}function V(t){return!(t>=55296&&t<=57343)&&(!(t>=64976&&t<=65007)&&(!!(65535&~t&&65534!=(65535&t))&&(!(t>=0&&t<=8)&&(11!==t&&(!(t>=14&&t<=31)&&(!(t>=127&&t<=159)&&!(t>1114111)))))))}function G(t){if(t>65535){const e=55296+((t-=65536)>>10),r=56320+(1023&t);return String.fromCharCode(e,r)}return String.fromCharCode(t)}const W=/\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g,J=new RegExp(W.source+"|"+/&([a-z#][a-z0-9]{1,31});/gi.source,"gi"),Q=/^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))$/i;function X(t){return t.indexOf("\\")<0&&t.indexOf("&")<0?t:t.replace(J,(function(t,e,r){return e||function(t,e){if(35===e.charCodeAt(0)&&Q.test(e)){const r="x"===e[1].toLowerCase()?parseInt(e.slice(2),16):parseInt(e.slice(1),10);return V(r)?G(r):t}const r=j(t);return r!==t?r:t}(t,r)}))}const Y=/[&<>"]/,K=/[&<>"]/g,tt={"&":"&","<":"<",">":">",'"':"""};function et(t){return tt[t]}function rt(t){return Y.test(t)?t.replace(K,et):t}const nt=/[.?*+^$[\]\\(){}|-]/g;function st(t){switch(t){case 9:case 32:return!0}return!1}function it(t){if(t>=8192&&t<=8202)return!0;switch(t){case 9:case 10:case 11:case 12:case 13:case 32:case 160:case 5760:case 8239:case 8287:case 12288:return!0}return!1}function ot(t){return E.test(t)||A.test(t)}function ut(t){switch(t){case 33:case 34:case 35:case 36:case 37:case 38:case 39:case 40:case 41:case 42:case 43:case 44:case 45:case 46:case 47:case 58:case 59:case 60:case 61:case 62:case 63:case 64:case 91:case 92:case 93:case 94:case 95:case 96:case 123:case 124:case 125:case 126:return!0;default:return!1}}function ct(t){return t=t.trim().replace(/\s+/g," "),"\u1e7e"==="\u1e9e".toLowerCase()&&(t=t.replace(/\u1e9e/g,"\xdf")),t.toLowerCase().toUpperCase()}const at={mdurl:D,ucmicro:F};var lt=Object.freeze({__proto__:null,arrayReplaceAt:H,assign:U,escapeHtml:rt,escapeRE:function(t){return t.replace(nt,"\\$&")},fromCodePoint:G,has:function(t,e){return $.call(t,e)},isMdAsciiPunct:ut,isPunctChar:ot,isSpace:st,isString:Z,isValidEntityCode:V,isWhiteSpace:it,lib:at,normalizeReference:ct,unescapeAll:X,unescapeMd:function(t){return t.indexOf("\\")<0?t:t.replace(W,"$1")}});var ht=Object.freeze({__proto__:null,parseLinkDestination:function(t,e,r){let n,s=e;const i={ok:!1,pos:0,str:""};if(60===t.charCodeAt(s)){for(s++;s32))return i;if(41===n){if(0===o)break;o--}s++}return e===s||0!==o||(i.str=X(t.slice(e,s)),i.pos=s,i.ok=!0),i},parseLinkLabel:function(t,e,r){let n,s,i,o;const u=t.posMax,c=t.pos;for(t.pos=e+1,n=1;t.pos=r)return o;let n=t.charCodeAt(i);if(34!==n&&39!==n&&40!==n)return o;e++,i++,40===n&&(n=41),o.marker=n}for(;i"+rt(i.content)+""},pt.code_block=function(t,e,r,n,s){const i=t[e];return""+rt(t[e].content)+"\n"},pt.fence=function(t,e,r,n,s){const i=t[e],o=i.info?X(i.info).trim():"";let u,c="",a="";if(o){const t=o.split(/(\s+)/g);c=t[0],a=t.slice(2).join("")}if(u=r.highlight&&r.highlight(i.content,c,a)||rt(i.content),0===u.indexOf("${u}\n`}return`
${u}
\n`},pt.image=function(t,e,r,n,s){const i=t[e];return i.attrs[i.attrIndex("alt")][1]=s.renderInlineAsText(i.children,r,n),s.renderToken(t,e,r)},pt.hardbreak=function(t,e,r){return r.xhtmlOut?"
\n":"
\n"},pt.softbreak=function(t,e,r){return r.breaks?r.xhtmlOut?"
\n":"
\n":"\n"},pt.text=function(t,e){return rt(t[e].content)},pt.html_block=function(t,e){return t[e].content},pt.html_inline=function(t,e){return t[e].content},ft.prototype.renderAttrs=function(t){let e,r,n;if(!t.attrs)return"";for(n="",e=0,r=t.attrs.length;e\n":">",s},ft.prototype.renderInline=function(t,e,r){let n="";const s=this.rules;for(let i=0,o=t.length;i=0&&(r=this.attrs[e][1]),r},_t.prototype.attrJoin=function(t,e){const r=this.attrIndex(t);r<0?this.attrPush([t,e]):this.attrs[r][1]=this.attrs[r][1]+" "+e},mt.prototype.Token=_t;const gt=/\r\n?|\n/g,kt=/\0/g;function Dt(t){return/^<\/a\s*>/i.test(t)}const Ct=/\+-|\.\.|\?\?\?\?|!!!!|,,|--/,yt=/\((c|tm|r)\)/i,Et=/\((c|tm|r)\)/gi,At={c:"\xa9",r:"\xae",tm:"\u2122"};function bt(t,e){return At[e.toLowerCase()]}function Ft(t){let e=0;for(let r=t.length-1;r>=0;r--){const n=t[r];"text"!==n.type||e||(n.content=n.content.replace(Et,bt)),"link_open"===n.type&&"auto"===n.info&&e--,"link_close"===n.type&&"auto"===n.info&&e++}}function xt(t){let e=0;for(let r=t.length-1;r>=0;r--){const n=t[r];"text"!==n.type||e||Ct.test(n.content)&&(n.content=n.content.replace(/\+-/g,"\xb1").replace(/\.{2,}/g,"\u2026").replace(/([?!])\u2026/g,"$1..").replace(/([?!]){4,}/g,"$1$1$1").replace(/,{2,}/g,",").replace(/(^|[^-])---(?=[^-]|$)/gm,"$1\u2014").replace(/(^|\s)--(?=\s|$)/gm,"$1\u2013").replace(/(^|[^-\s])--(?=[^-\s]|$)/gm,"$1\u2013")),"link_open"===n.type&&"auto"===n.info&&e--,"link_close"===n.type&&"auto"===n.info&&e++}}const wt=/['"]/,vt=/['"]/g,zt="\u2019";function St(t,e,r){return t.slice(0,e)+r+t.slice(e+1)}function qt(t,e){let r;const n=[];for(let s=0;s=0&&!(n[r].level<=o);r--);if(n.length=r+1,"text"!==i.type)continue;let u=i.content,c=0,a=u.length;t:for(;c=0)d=u.charCodeAt(l.index-1);else for(r=s-1;r>=0&&("softbreak"!==t[r].type&&"hardbreak"!==t[r].type);r--)if(t[r].content){d=t[r].content.charCodeAt(t[r].content.length-1);break}let _=32;if(c=48&&d<=57&&(p=h=!1),h&&p&&(h=m,p=g),h||p){if(p)for(r=n.length-1;r>=0;r--){let h=n[r];if(n[r].level=0;o--){const u=s[o];if("link_close"!==u.type){if("html_inline"===u.type&&(r=u.content,/^\s]/i.test(r)&&i>0&&i--,Dt(u.content)&&i++),!(i>0)&&"text"===u.type&&t.md.linkify.test(u.content)){const r=u.content;let i=t.md.linkify.match(r);const c=[];let a=u.level,l=0;i.length>0&&0===i[0].index&&o>0&&"text_special"===s[o-1].type&&(i=i.slice(1));for(let e=0;el){const e=new t.Token("text","",0);e.content=r.slice(l,u),e.level=a,c.push(e)}const h=new t.Token("link_open","a",1);h.attrs=[["href",s]],h.level=a++,h.markup="linkify",h.info="auto",c.push(h);const p=new t.Token("text","",0);p.content=o,p.level=a,c.push(p);const f=new t.Token("link_close","a",-1);f.level=--a,f.markup="linkify",f.info="auto",c.push(f),l=i[e].lastIndex}if(l=0;e--)"inline"===t.tokens[e].type&&(yt.test(t.tokens[e].content)&&Ft(t.tokens[e].children),Ct.test(t.tokens[e].content)&&xt(t.tokens[e].children))}],["smartquotes",function(t){if(t.md.options.typographer)for(let e=t.tokens.length-1;e>=0;e--)"inline"===t.tokens[e].type&&wt.test(t.tokens[e].content)&&qt(t.tokens[e].children,t)}],["text_join",function(t){let e,r;const n=t.tokens,s=n.length;for(let t=0;t0&&this.level++,this.tokens.push(n),n},It.prototype.isEmpty=function(t){return this.bMarks[t]+this.tShift[t]>=this.eMarks[t]},It.prototype.skipEmptyLines=function(t){for(let e=this.lineMax;te;)if(!st(this.src.charCodeAt(--t)))return t+1;return t},It.prototype.skipChars=function(t,e){for(let r=this.src.length;tr;)if(e!==this.src.charCodeAt(--t))return t+1;return t},It.prototype.getLines=function(t,e,r,n){if(t>=e)return"";const s=new Array(e-t);for(let i=0,o=t;or?new Array(t-r+1).join(" ")+this.src.slice(a,c):this.src.slice(a,c)}return s.join("")},It.prototype.Token=_t;function Mt(t,e){const r=t.bMarks[e]+t.tShift[e],n=t.eMarks[e];return t.src.slice(r,n)}function Tt(t){const e=[],r=t.length;let n=0,s=t.charCodeAt(n),i=!1,o=0,u="";for(;n=n)return-1;let i=t.src.charCodeAt(s++);if(i<48||i>57)return-1;for(;;){if(s>=n)return-1;if(i=t.src.charCodeAt(s++),!(i>=48&&i<=57)){if(41===i||46===i)break;return-1}if(s-r>=10)return-1}return s`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*\\/?>",Ot="<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>",jt=new RegExp("^(?:"+Pt+"|"+Ot+"|\x3c!---?>|\x3c!--(?:[^-]|-[^-]|--[^>])*--\x3e|<[?][\\s\\S]*?[?]>|]*>|)"),Zt=new RegExp("^(?:"+Pt+"|"+Ot+")"),$t=[[/^<(script|pre|style|textarea)(?=(\s|>|$))/i,/<\/(script|pre|style|textarea)>/i,!0],[/^/,!0],[/^<\?/,/\?>/,!0],[/^/,!0],[/^/,!0],[new RegExp("^|$))","i"),/^$/,!0],[new RegExp(Zt.source+"\\s*$"),/^$/,!1]];const Ut=[["table",function(t,e,r,n){if(e+2>r)return!1;let s=e+1;if(t.sCount[s]=4)return!1;let i=t.bMarks[s]+t.tShift[s];if(i>=t.eMarks[s])return!1;const o=t.src.charCodeAt(i++);if(124!==o&&45!==o&&58!==o)return!1;if(i>=t.eMarks[s])return!1;const u=t.src.charCodeAt(i++);if(124!==u&&45!==u&&58!==u&&!st(u))return!1;if(45===o&&st(u))return!1;for(;i=4)return!1;a=Tt(c),a.length&&""===a[0]&&a.shift(),a.length&&""===a[a.length-1]&&a.pop();const h=a.length;if(0===h||h!==l.length)return!1;if(n)return!0;const p=t.parentType;t.parentType="table";const f=t.md.block.ruler.getRules("blockquote"),d=[e,0];t.push("table_open","table",1).map=d,t.push("thead_open","thead",1).map=[e,e+1],t.push("tr_open","tr",1).map=[e,e+1];for(let e=0;e=4)break;if(a=Tt(c),a.length&&""===a[0]&&a.shift(),a.length&&""===a[a.length-1]&&a.pop(),m+=h-a.length,m>65536)break;if(s===e+2){t.push("tbody_open","tbody",1).map=_=[e+2,0]}t.push("tr_open","tr",1).map=[s,s+1];for(let e=0;e=4))break;n++,s=n}t.line=s;const i=t.push("code_block","code",0);return i.content=t.getLines(e,s,4+t.blkIndent,!1)+"\n",i.map=[e,t.line],!0}],["fence",function(t,e,r,n){let s=t.bMarks[e]+t.tShift[e],i=t.eMarks[e];if(t.sCount[e]-t.blkIndent>=4)return!1;if(s+3>i)return!1;const o=t.src.charCodeAt(s);if(126!==o&&96!==o)return!1;let u=s;s=t.skipChars(s,o);let c=s-u;if(c<3)return!1;const a=t.src.slice(u,s),l=t.src.slice(s,i);if(96===o&&l.indexOf(String.fromCharCode(o))>=0)return!1;if(n)return!0;let h=e,p=!1;for(;(h++,!(h>=r))&&(s=u=t.bMarks[h]+t.tShift[h],i=t.eMarks[h],!(s=4||(s=t.skipChars(s,o),s-u=4)return!1;if(62!==t.src.charCodeAt(s))return!1;if(n)return!0;const u=[],c=[],a=[],l=[],h=t.md.block.ruler.getRules("blockquote"),p=t.parentType;t.parentType="blockquote";let f,d=!1;for(f=e;f=i)break;if(62===t.src.charCodeAt(s++)&&!e){let e,r,n=t.sCount[f]+1;32===t.src.charCodeAt(s)?(s++,n++,r=!1,e=!0):9===t.src.charCodeAt(s)?(e=!0,(t.bsCount[f]+n)%4==3?(s++,n++,r=!1):r=!0):e=!1;let o=n;for(u.push(t.bMarks[f]),t.bMarks[f]=s;s=i,c.push(t.bsCount[f]),t.bsCount[f]=t.sCount[f]+1+(e?1:0),a.push(t.sCount[f]),t.sCount[f]=o-n,l.push(t.tShift[f]),t.tShift[f]=s-t.bMarks[f];continue}if(d)break;let n=!1;for(let e=0,s=h.length;e";const g=[e,0];m.map=g,t.md.block.tokenize(t,e,f),t.push("blockquote_close","blockquote",-1).markup=">",t.lineMax=o,t.parentType=p,g[1]=t.line;for(let r=0;r=4)return!1;let i=t.bMarks[e]+t.tShift[e];const o=t.src.charCodeAt(i++);if(42!==o&&45!==o&&95!==o)return!1;let u=1;for(;i=4)return!1;if(t.listIndent>=0&&t.sCount[c]-t.listIndent>=4&&t.sCount[c]=t.blkIndent&&(f=!0),(p=Nt(t,c))>=0){if(l=!0,o=t.bMarks[c]+t.tShift[c],h=Number(t.src.slice(o,p-1)),f&&1!==h)return!1}else{if(!((p=Rt(t,c))>=0))return!1;l=!1}if(f&&t.skipSpaces(p)>=t.eMarks[c])return!1;if(n)return!0;const d=t.src.charCodeAt(p-1),_=t.tokens.length;l?(u=t.push("ordered_list_open","ol",1),1!==h&&(u.attrs=[["start",h]])):u=t.push("bullet_list_open","ul",1);const m=[c,0];u.map=m,u.markup=String.fromCharCode(d);let g=!1;const k=t.md.block.ruler.getRules("list"),D=t.parentType;for(t.parentType="list";c=s?1:n-e,f>4&&(f=1);const _=e+f;u=t.push("list_item_open","li",1),u.markup=String.fromCharCode(d);const m=[c,0];u.map=m,l&&(u.info=t.src.slice(o,p-1));const D=t.tight,C=t.tShift[c],y=t.sCount[c],E=t.listIndent;if(t.listIndent=t.blkIndent,t.blkIndent=_,t.tight=!0,t.tShift[c]=h-t.bMarks[c],t.sCount[c]=n,h>=s&&t.isEmpty(c+1)?t.line=Math.min(t.line+2,r):t.md.block.tokenize(t,c,r,!0),t.tight&&!g||(a=!1),g=t.line-c>1&&t.isEmpty(t.line-1),t.blkIndent=t.listIndent,t.listIndent=E,t.tShift[c]=C,t.sCount[c]=y,t.tight=D,u=t.push("list_item_close","li",-1),u.markup=String.fromCharCode(d),c=t.line,m[1]=c,c>=r)break;if(t.sCount[c]=4)break;let A=!1;for(let e=0,n=k.length;e=4)return!1;if(91!==t.src.charCodeAt(s))return!1;function u(e){const r=t.lineMax;if(e>=r||t.isEmpty(e))return null;let n=!1;if(t.sCount[e]-t.blkIndent>3&&(n=!0),t.sCount[e]<0&&(n=!0),!n){const n=t.md.block.ruler.getRules("reference"),s=t.parentType;t.parentType="reference";let i=!1;for(let s=0,o=n.length;s=4)return!1;if(!t.md.options.html)return!1;if(60!==t.src.charCodeAt(s))return!1;let o=t.src.slice(s,i),u=0;for(;u<$t.length&&!$t[u][0].test(o);u++);if(u===$t.length)return!1;if(n)return $t[u][2];let c=e+1;if(!$t[u][1].test(o))for(;c=4)return!1;let o=t.src.charCodeAt(s);if(35!==o||s>=i)return!1;let u=1;for(o=t.src.charCodeAt(++s);35===o&&s6||ss&&st(t.src.charCodeAt(c-1))&&(i=c),t.line=e+1;const a=t.push("heading_open","h"+String(u),1);a.markup="########".slice(0,u),a.map=[e,t.line];const l=t.push("inline","",0);return l.content=t.src.slice(s,i).trim(),l.map=[e,t.line],l.children=[],t.push("heading_close","h"+String(u),-1).markup="########".slice(0,u),!0},["paragraph","reference","blockquote"]],["lheading",function(t,e,r){const n=t.md.block.ruler.getRules("paragraph");if(t.sCount[e]-t.blkIndent>=4)return!1;const s=t.parentType;t.parentType="paragraph";let i,o=0,u=e+1;for(;u3)continue;if(t.sCount[u]>=t.blkIndent){let e=t.bMarks[u]+t.tShift[u];const r=t.eMarks[u];if(e=r))){o=61===i?1:2;break}}if(t.sCount[u]<0)continue;let e=!1;for(let s=0,i=n.length;s3)continue;if(t.sCount[i]<0)continue;let e=!1;for(let s=0,o=n.length;s=r))&&!(t.sCount[o]=i){t.line=r;break}const e=t.line;let c=!1;for(let i=0;i=t.line)throw new Error("block rule didn't increment state.line");break}if(!c)throw new Error("none of the block rules matched");t.tight=!u,t.isEmpty(t.line-1)&&(u=!0),o=t.line,o0&&(this.level++,this._prev_delimiters.push(this.delimiters),this.delimiters=[],s={delimiters:this.delimiters}),this.pendingLevel=this.level,this.tokens.push(n),this.tokens_meta.push(s),n},Vt.prototype.scanDelims=function(t,e){const r=this.posMax,n=this.src.charCodeAt(t),s=t>0?this.src.charCodeAt(t-1):32;let i=t;for(;i?@[]^_`{|}~-".split("").forEach((function(t){Jt[t.charCodeAt(0)]=1}));var Xt={tokenize:function(t,e){const r=t.pos,n=t.src.charCodeAt(r);if(e)return!1;if(126!==n)return!1;const s=t.scanDelims(t.pos,!0);let i=s.length;const o=String.fromCharCode(n);if(i<2)return!1;let u;i%2&&(u=t.push("text","",0),u.content=o,i--);for(let e=0;e=0;r--){const n=e[r];if(95!==n.marker&&42!==n.marker)continue;if(-1===n.end)continue;const s=e[n.end],i=r>0&&e[r-1].end===n.end+1&&e[r-1].marker===n.marker&&e[r-1].token===n.token-1&&e[n.end+1].token===s.token+1,o=String.fromCharCode(n.marker),u=t.tokens[n.token];u.type=i?"strong_open":"em_open",u.tag=i?"strong":"em",u.nesting=1,u.markup=i?o+o:o,u.content="";const c=t.tokens[s.token];c.type=i?"strong_close":"em_close",c.tag=i?"strong":"em",c.nesting=-1,c.markup=i?o+o:o,c.content="",i&&(t.tokens[e[r-1].token].content="",t.tokens[e[n.end+1].token].content="",r--)}}var Kt={tokenize:function(t,e){const r=t.pos,n=t.src.charCodeAt(r);if(e)return!1;if(95!==n&&42!==n)return!1;const s=t.scanDelims(t.pos,42===n);for(let e=0;e\x00-\x20]*)$/;const re=/^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i,ne=/^&([a-z][a-z0-9]{1,31});/i;function se(t){const e={},r=t.length;if(!r)return;let n=0,s=-2;const i=[];for(let o=0;ou;c-=i[c]+1){const e=t[c];if(e.marker===r.marker&&(e.open&&e.end<0)){let n=!1;if((e.close||r.open)&&(e.length+r.length)%3==0&&(e.length%3==0&&r.length%3==0||(n=!0)),!n){const n=c>0&&!t[c-1].open?i[c-1]+1:0;i[o]=o-c+n,i[c]=n,r.open=!1,e.end=o,e.close=!1,a=-1,s=-2;break}}}-1!==a&&(e[r.marker][(r.open?3:0)+(r.length||0)%3]=a)}}const ie=[["text",function(t,e){let r=t.pos;for(;r0)return!1;const r=t.pos;if(r+3>t.posMax)return!1;if(58!==t.src.charCodeAt(r))return!1;if(47!==t.src.charCodeAt(r+1))return!1;if(47!==t.src.charCodeAt(r+2))return!1;const n=t.pending.match(Wt);if(!n)return!1;const s=n[1],i=t.md.linkify.matchAtStart(t.src.slice(r-s.length));if(!i)return!1;let o=i.url;if(o.length<=s.length)return!1;o=o.replace(/\*+$/,"");const u=t.md.normalizeLink(o);if(!t.md.validateLink(u))return!1;if(!e){t.pending=t.pending.slice(0,-s.length);const e=t.push("link_open","a",1);e.attrs=[["href",u]],e.markup="linkify",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(o);const r=t.push("link_close","a",-1);r.markup="linkify",r.info="auto"}return t.pos+=o.length-s.length,!0}],["newline",function(t,e){let r=t.pos;if(10!==t.src.charCodeAt(r))return!1;const n=t.pending.length-1,s=t.posMax;if(!e)if(n>=0&&32===t.pending.charCodeAt(n))if(n>=1&&32===t.pending.charCodeAt(n-1)){let e=n-1;for(;e>=1&&32===t.pending.charCodeAt(e-1);)e--;t.pending=t.pending.slice(0,e),t.push("hardbreak","br",0)}else t.pending=t.pending.slice(0,-1),t.push("softbreak","br",0);else t.push("softbreak","br",0);for(r++;r=n)return!1;let s=t.src.charCodeAt(r);if(10===s){for(e||t.push("hardbreak","br",0),r++;r=55296&&s<=56319&&r+1=56320&&e<=57343&&(i+=t.src[r+1],r++)}const o="\\"+i;if(!e){const e=t.push("text_special","",0);s<256&&0!==Jt[s]?e.content=i:e.content=o,e.markup=o,e.info="escape"}return t.pos=r+1,!0}],["backticks",function(t,e){let r=t.pos;if(96!==t.src.charCodeAt(r))return!1;const n=r;r++;const s=t.posMax;for(;r=h)return!1;if(c=d,s=t.md.helpers.parseLinkDestination(t.src,d,t.posMax),s.ok){for(o=t.md.normalizeLink(s.str),t.md.validateLink(o)?d=s.pos:o="",c=d;d=h||41!==t.src.charCodeAt(d))&&(a=!0),d++}if(a){if(void 0===t.env.references)return!1;if(d=0?n=t.src.slice(c,d++):d=f+1):d=f+1,n||(n=t.src.slice(p,f)),i=t.env.references[ct(n)],!i)return t.pos=l,!1;o=i.href,u=i.title}if(!e){t.pos=p,t.posMax=f;const e=[["href",o]];t.push("link_open","a",1).attrs=e,u&&e.push(["title",u]),t.linkLevel++,t.md.inline.tokenize(t),t.linkLevel--,t.push("link_close","a",-1)}return t.pos=d,t.posMax=h,!0}],["image",function(t,e){let r,n,s,i,o,u,c,a,l="";const h=t.pos,p=t.posMax;if(33!==t.src.charCodeAt(t.pos))return!1;if(91!==t.src.charCodeAt(t.pos+1))return!1;const f=t.pos+2,d=t.md.helpers.parseLinkLabel(t,t.pos+1,!1);if(d<0)return!1;if(i=d+1,i=p)return!1;for(a=i,u=t.md.helpers.parseLinkDestination(t.src,i,t.posMax),u.ok&&(l=t.md.normalizeLink(u.str),t.md.validateLink(l)?i=u.pos:l=""),a=i;i=p||41!==t.src.charCodeAt(i))return t.pos=h,!1;i++}else{if(void 0===t.env.references)return!1;if(i=0?s=t.src.slice(a,i++):i=d+1):i=d+1,s||(s=t.src.slice(f,d)),o=t.env.references[ct(s)],!o)return t.pos=h,!1;l=o.href,c=o.title}if(!e){n=t.src.slice(f,d);const e=[];t.md.inline.parse(n,t.md,t.env,e);const r=t.push("image","img",0),s=[["src",l],["alt",""]];r.attrs=s,r.children=e,r.content=n,c&&s.push(["title",c])}return t.pos=i,t.posMax=p,!0}],["autolink",function(t,e){let r=t.pos;if(60!==t.src.charCodeAt(r))return!1;const n=t.pos,s=t.posMax;for(;;){if(++r>=s)return!1;const e=t.src.charCodeAt(r);if(60===e)return!1;if(62===e)break}const i=t.src.slice(n+1,r);if(ee.test(i)){const r=t.md.normalizeLink(i);if(!t.md.validateLink(r))return!1;if(!e){const e=t.push("link_open","a",1);e.attrs=[["href",r]],e.markup="autolink",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(i);const n=t.push("link_close","a",-1);n.markup="autolink",n.info="auto"}return t.pos+=i.length+2,!0}if(te.test(i)){const r=t.md.normalizeLink("mailto:"+i);if(!t.md.validateLink(r))return!1;if(!e){const e=t.push("link_open","a",1);e.attrs=[["href",r]],e.markup="autolink",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(i);const n=t.push("link_close","a",-1);n.markup="autolink",n.info="auto"}return t.pos+=i.length+2,!0}return!1}],["html_inline",function(t,e){if(!t.md.options.html)return!1;const r=t.posMax,n=t.pos;if(60!==t.src.charCodeAt(n)||n+2>=r)return!1;const s=t.src.charCodeAt(n+1);if(33!==s&&63!==s&&47!==s&&!function(t){const e=32|t;return e>=97&&e<=122}(s))return!1;const i=t.src.slice(n).match(jt);if(!i)return!1;if(!e){const e=t.push("html_inline","",0);e.content=i[0],o=e.content,/^\s]/i.test(o)&&t.linkLevel++,function(t){return/^<\/a\s*>/i.test(t)}(e.content)&&t.linkLevel--}var o;return t.pos+=i[0].length,!0}],["entity",function(t,e){const r=t.pos,n=t.posMax;if(38!==t.src.charCodeAt(r))return!1;if(r+1>=n)return!1;if(35===t.src.charCodeAt(r+1)){const n=t.src.slice(r).match(re);if(n){if(!e){const e="x"===n[1][0].toLowerCase()?parseInt(n[1].slice(1),16):parseInt(n[1],10),r=t.push("text_special","",0);r.content=V(e)?G(e):G(65533),r.markup=n[0],r.info="entity"}return t.pos+=n[0].length,!0}}else{const n=t.src.slice(r).match(ne);if(n){const r=j(n[0]);if(r!==n[0]){if(!e){const e=t.push("text_special","",0);e.content=r,e.markup=n[0],e.info="entity"}return t.pos+=n[0].length,!0}}}return!1}]],oe=[["balance_pairs",function(t){const e=t.tokens_meta,r=t.tokens_meta.length;se(t.delimiters);for(let t=0;t0&&n++,"text"===s[e].type&&e+1=t.pos)throw new Error("inline rule didn't increment state.pos");break}}else t.pos=t.posMax;o||t.pos++,i[e]=t.pos},ue.prototype.tokenize=function(t){const e=this.ruler.getRules(""),r=e.length,n=t.posMax,s=t.md.options.maxNesting;for(;t.pos=t.pos)throw new Error("inline rule didn't increment state.pos");break}if(o){if(t.pos>=n)break}else t.pending+=t.src[t.pos++]}t.pending&&t.pushPending()},ue.prototype.parse=function(t,e,r,n){const s=new this.State(t,e,r,n);this.tokenize(s);const i=this.ruler2.getRules(""),o=i.length;for(let t=0;t=3&&":"===t[e-3]||e>=3&&"/"===t[e-3]?0:n.match(r.re.no_http)[0].length:0}},"mailto:":{validate:function(t,e,r){const n=t.slice(e);return r.re.mailto||(r.re.mailto=new RegExp("^"+r.re.src_email_name+"@"+r.re.src_host_strict,"i")),r.re.mailto.test(n)?n.match(r.re.mailto)[0].length:0}}},de="a[cdefgilmnoqrstuwxz]|b[abdefghijmnorstvwyz]|c[acdfghiklmnoruvwxyz]|d[ejkmoz]|e[cegrstu]|f[ijkmor]|g[abdefghilmnpqrstuwy]|h[kmnrtu]|i[delmnoqrst]|j[emop]|k[eghimnprwyz]|l[abcikrstuvy]|m[acdeghklmnopqrstuvwxyz]|n[acefgilopruz]|om|p[aefghklmnrstwy]|qa|r[eosuw]|s[abcdeghijklmnortuvxyz]|t[cdfghjklmnortvwz]|u[agksyz]|v[aceginu]|w[fs]|y[et]|z[amw]",_e="biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|\u0440\u0444".split("|");function me(t){const e=t.re=function(t){const e={};t=t||{},e.src_Any=C.source,e.src_Cc=y.source,e.src_Z=b.source,e.src_P=E.source,e.src_ZPCc=[e.src_Z,e.src_P,e.src_Cc].join("|"),e.src_ZCc=[e.src_Z,e.src_Cc].join("|");const r="[><\uff5c]";return e.src_pseudo_letter="(?:(?![><\uff5c]|"+e.src_ZPCc+")"+e.src_Any+")",e.src_ip4="(?:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)",e.src_auth="(?:(?:(?!"+e.src_ZCc+"|[@/\\[\\]()]).)+@)?",e.src_port="(?::(?:6(?:[0-4]\\d{3}|5(?:[0-4]\\d{2}|5(?:[0-2]\\d|3[0-5])))|[1-5]?\\d{1,4}))?",e.src_host_terminator="(?=$|[><\uff5c]|"+e.src_ZPCc+")(?!"+(t["---"]?"-(?!--)|":"-|")+"_|:\\d|\\.-|\\.(?!$|"+e.src_ZPCc+"))",e.src_path="(?:[/?#](?:(?!"+e.src_ZCc+"|"+r+"|[()[\\]{}.,\"'?!\\-;]).|\\[(?:(?!"+e.src_ZCc+"|\\]).)*\\]|\\((?:(?!"+e.src_ZCc+"|[)]).)*\\)|\\{(?:(?!"+e.src_ZCc+'|[}]).)*\\}|\\"(?:(?!'+e.src_ZCc+'|["]).)+\\"|\\\'(?:(?!'+e.src_ZCc+"|[']).)+\\'|\\'(?="+e.src_pseudo_letter+"|[-])|\\.{2,}[a-zA-Z0-9%/&]|\\.(?!"+e.src_ZCc+"|[.]|$)|"+(t["---"]?"\\-(?!--(?:[^-]|$))(?:-*)|":"\\-+|")+",(?!"+e.src_ZCc+"|$)|;(?!"+e.src_ZCc+"|$)|\\!+(?!"+e.src_ZCc+"|[!]|$)|\\?(?!"+e.src_ZCc+"|[?]|$))+|\\/)?",e.src_email_name='[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]*',e.src_xn="xn--[a-z0-9\\-]{1,59}",e.src_domain_root="(?:"+e.src_xn+"|"+e.src_pseudo_letter+"{1,63})",e.src_domain="(?:"+e.src_xn+"|(?:"+e.src_pseudo_letter+")|(?:"+e.src_pseudo_letter+"(?:-|"+e.src_pseudo_letter+"){0,61}"+e.src_pseudo_letter+"))",e.src_host="(?:(?:(?:(?:"+e.src_domain+")\\.)*"+e.src_domain+"))",e.tpl_host_fuzzy="(?:"+e.src_ip4+"|(?:(?:(?:"+e.src_domain+")\\.)+(?:%TLDS%)))",e.tpl_host_no_ip_fuzzy="(?:(?:(?:"+e.src_domain+")\\.)+(?:%TLDS%))",e.src_host_strict=e.src_host+e.src_host_terminator,e.tpl_host_fuzzy_strict=e.tpl_host_fuzzy+e.src_host_terminator,e.src_host_port_strict=e.src_host+e.src_port+e.src_host_terminator,e.tpl_host_port_fuzzy_strict=e.tpl_host_fuzzy+e.src_port+e.src_host_terminator,e.tpl_host_port_no_ip_fuzzy_strict=e.tpl_host_no_ip_fuzzy+e.src_port+e.src_host_terminator,e.tpl_host_fuzzy_test="localhost|www\\.|\\.\\d{1,3}\\.|(?:\\.(?:%TLDS%)(?:"+e.src_ZPCc+"|>|$))",e.tpl_email_fuzzy='(^|[><\uff5c]|"|\\(|'+e.src_ZCc+")("+e.src_email_name+"@"+e.tpl_host_fuzzy_strict+")",e.tpl_link_fuzzy="(^|(?![.:/\\-_@])(?:[$+<=>^`|\uff5c]|"+e.src_ZPCc+"))((?![$+<=>^`|\uff5c])"+e.tpl_host_port_fuzzy_strict+e.src_path+")",e.tpl_link_no_ip_fuzzy="(^|(?![.:/\\-_@])(?:[$+<=>^`|\uff5c]|"+e.src_ZPCc+"))((?![$+<=>^`|\uff5c])"+e.tpl_host_port_no_ip_fuzzy_strict+e.src_path+")",e}(t.__opts__),r=t.__tlds__.slice();function n(t){return t.replace("%TLDS%",e.src_tlds)}t.onCompile(),t.__tlds_replaced__||r.push(de),r.push(e.src_xn),e.src_tlds=r.join("|"),e.email_fuzzy=RegExp(n(e.tpl_email_fuzzy),"i"),e.link_fuzzy=RegExp(n(e.tpl_link_fuzzy),"i"),e.link_no_ip_fuzzy=RegExp(n(e.tpl_link_no_ip_fuzzy),"i"),e.host_fuzzy_test=RegExp(n(e.tpl_host_fuzzy_test),"i");const s=[];function i(t,e){throw new Error('(LinkifyIt) Invalid schema "'+t+'": '+e)}t.__compiled__={},Object.keys(t.__schemas__).forEach((function(e){const r=t.__schemas__[e];if(null===r)return;const n={validate:null,link:null};if(t.__compiled__[e]=n,"[object Object]"===ae(r))return!function(t){return"[object RegExp]"===ae(t)}(r.validate)?le(r.validate)?n.validate=r.validate:i(e,r):n.validate=function(t){return function(e,r){const n=e.slice(r);return t.test(n)?n.match(t)[0].length:0}}(r.validate),void(le(r.normalize)?n.normalize=r.normalize:r.normalize?i(e,r):n.normalize=function(t,e){e.normalize(t)});!function(t){return"[object String]"===ae(t)}(r)?i(e,r):s.push(e)})),s.forEach((function(e){t.__compiled__[t.__schemas__[e]]&&(t.__compiled__[e].validate=t.__compiled__[t.__schemas__[e]].validate,t.__compiled__[e].normalize=t.__compiled__[t.__schemas__[e]].normalize)})),t.__compiled__[""]={validate:null,normalize:function(t,e){e.normalize(t)}};const o=Object.keys(t.__compiled__).filter((function(e){return e.length>0&&t.__compiled__[e]})).map(he).join("|");t.re.schema_test=RegExp("(^|(?!_)(?:[><\uff5c]|"+e.src_ZPCc+"))("+o+")","i"),t.re.schema_search=RegExp("(^|(?!_)(?:[><\uff5c]|"+e.src_ZPCc+"))("+o+")","ig"),t.re.schema_at_start=RegExp("^"+t.re.schema_search.source,"i"),t.re.pretest=RegExp("("+t.re.schema_test.source+")|("+t.re.host_fuzzy_test.source+")|@","i"),function(t){t.__index__=-1,t.__text_cache__=""}(t)}function ge(t,e){const r=t.__index__,n=t.__last_index__,s=t.__text_cache__.slice(r,n);this.schema=t.__schema__.toLowerCase(),this.index=r+e,this.lastIndex=n+e,this.raw=s,this.text=s,this.url=s}function ke(t,e){const r=new ge(t,e);return t.__compiled__[r.schema].normalize(r,t),r}function De(t,e){if(!(this instanceof De))return new De(t,e);var r;e||(r=t,Object.keys(r||{}).reduce((function(t,e){return t||pe.hasOwnProperty(e)}),!1)&&(e=t,t={})),this.__opts__=ce({},pe,e),this.__index__=-1,this.__last_index__=-1,this.__schema__="",this.__text_cache__="",this.__schemas__=ce({},fe,t),this.__compiled__={},this.__tlds__=_e,this.__tlds_replaced__=!1,this.re={},me(this)}De.prototype.add=function(t,e){return this.__schemas__[t]=e,me(this),this},De.prototype.set=function(t){return this.__opts__=ce(this.__opts__,t),this},De.prototype.test=function(t){if(this.__text_cache__=t,this.__index__=-1,!t.length)return!1;let e,r,n,s,i,o,u,c,a;if(this.re.schema_test.test(t))for(u=this.re.schema_search,u.lastIndex=0;null!==(e=u.exec(t));)if(s=this.testSchemaAt(t,e[2],u.lastIndex),s){this.__schema__=e[2],this.__index__=e.index+e[1].length,this.__last_index__=e.index+e[0].length+s;break}return this.__opts__.fuzzyLink&&this.__compiled__["http:"]&&(c=t.search(this.re.host_fuzzy_test),c>=0&&(this.__index__<0||c=0&&null!==(n=t.match(this.re.email_fuzzy))&&(i=n.index+n[1].length,o=n.index+n[0].length,(this.__index__<0||ithis.__last_index__)&&(this.__schema__="mailto:",this.__index__=i,this.__last_index__=o))),this.__index__>=0},De.prototype.pretest=function(t){return this.re.pretest.test(t)},De.prototype.testSchemaAt=function(t,e,r){return this.__compiled__[e.toLowerCase()]?this.__compiled__[e.toLowerCase()].validate(t,r,this):0},De.prototype.match=function(t){const e=[];let r=0;this.__index__>=0&&this.__text_cache__===t&&(e.push(ke(this,r)),r=this.__last_index__);let n=r?t.slice(r):t;for(;this.test(n);)e.push(ke(this,r)),n=n.slice(this.__last_index__),r+=this.__last_index__;return e.length?e:null},De.prototype.matchAtStart=function(t){if(this.__text_cache__=t,this.__index__=-1,!t.length)return null;const e=this.re.schema_at_start.exec(t);if(!e)return null;const r=this.testSchemaAt(t,e[2],e[0].length);return r?(this.__schema__=e[2],this.__index__=e.index+e[1].length,this.__last_index__=e.index+e[0].length+r,ke(this,0)):null},De.prototype.tlds=function(t,e){return t=Array.isArray(t)?t:[t],e?(this.__tlds__=this.__tlds__.concat(t).sort().filter((function(t,e,r){return t!==r[e-1]})).reverse(),me(this),this):(this.__tlds__=t.slice(),this.__tlds_replaced__=!0,me(this),this)},De.prototype.normalize=function(t){t.schema||(t.url="http://"+t.url),"mailto:"!==t.schema||/^mailto:/i.test(t.url)||(t.url="mailto:"+t.url)},De.prototype.onCompile=function(){};const Ce=2147483647,ye=36,Ee=/^xn--/,Ae=/[^\0-\x7F]/,be=/[\x2E\u3002\uFF0E\uFF61]/g,Fe={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},xe=Math.floor,we=String.fromCharCode;function ve(t){throw new RangeError(Fe[t])}function ze(t,e){const r=t.split("@");let n="";r.length>1&&(n=r[0]+"@",t=r[1]);const s=function(t,e){const r=[];let n=t.length;for(;n--;)r[n]=e(t[n]);return r}((t=t.replace(be,".")).split("."),e).join(".");return n+s}function Se(t){const e=[];let r=0;const n=t.length;for(;r=55296&&s<=56319&&r>1,t+=xe(t/e);t>455;n+=ye)t=xe(t/35);return xe(n+36*t/(t+38))},Le=function(t){const e=[],r=t.length;let n=0,s=128,i=72,o=t.lastIndexOf("-");o<0&&(o=0);for(let r=0;r=128&&ve("not-basic"),e.push(t.charCodeAt(r));for(let c=o>0?o+1:0;c=r&&ve("invalid-input");const o=(u=t.charCodeAt(c++))>=48&&u<58?u-48+26:u>=65&&u<91?u-65:u>=97&&u<123?u-97:ye;o>=ye&&ve("invalid-input"),o>xe((Ce-n)/e)&&ve("overflow"),n+=o*e;const a=s<=i?1:s>=i+26?26:s-i;if(oxe(Ce/l)&&ve("overflow"),e*=l}const a=e.length+1;i=Be(n-o,a,0==o),xe(n/a)>Ce-s&&ve("overflow"),s+=xe(n/a),n%=a,e.splice(n++,0,s)}var u;return String.fromCodePoint(...e)},Ie=function(t){const e=[],r=(t=Se(t)).length;let n=128,s=0,i=72;for(const r of t)r<128&&e.push(we(r));const o=e.length;let u=o;for(o&&e.push("-");u=n&&exe((Ce-s)/c)&&ve("overflow"),s+=(r-n)*c,n=r;for(const r of t)if(rCe&&ve("overflow"),r===n){let t=s;for(let r=ye;;r+=ye){const n=r<=i?1:r>=i+26?26:r-i;if(tString.fromCodePoint(...t)},decode:Le,encode:Ie,toASCII:function(t){return ze(t,(function(t){return Ae.test(t)?"xn--"+Ie(t):t}))},toUnicode:function(t){return ze(t,(function(t){return Ee.test(t)?Le(t.slice(4).toLowerCase()):t}))}};const Te={default:{options:{html:!1,xhtmlOut:!1,breaks:!1,langPrefix:"language-",linkify:!1,typographer:!1,quotes:"\u201c\u201d\u2018\u2019",highlight:null,maxNesting:100},components:{core:{},block:{},inline:{}}},zero:{options:{html:!1,xhtmlOut:!1,breaks:!1,langPrefix:"language-",linkify:!1,typographer:!1,quotes:"\u201c\u201d\u2018\u2019",highlight:null,maxNesting:20},components:{core:{rules:["normalize","block","inline","text_join"]},block:{rules:["paragraph"]},inline:{rules:["text"],rules2:["balance_pairs","fragments_join"]}}},commonmark:{options:{html:!0,xhtmlOut:!0,breaks:!1,langPrefix:"language-",linkify:!1,typographer:!1,quotes:"\u201c\u201d\u2018\u2019",highlight:null,maxNesting:20},components:{core:{rules:["normalize","block","inline","text_join"]},block:{rules:["blockquote","code","fence","heading","hr","html_block","lheading","list","reference","paragraph"]},inline:{rules:["autolink","backticks","emphasis","entity","escape","html_inline","image","link","newline","text"],rules2:["balance_pairs","emphasis","fragments_join"]}}}},Re=/^(vbscript|javascript|file|data):/,Ne=/^data:image\/(gif|png|jpeg|webp);/;function Pe(t){const e=t.trim().toLowerCase();return!Re.test(e)||Ne.test(e)}const Oe=["http:","https:","mailto:"];function je(t){const e=g(t,!0);if(e.hostname&&(!e.protocol||Oe.indexOf(e.protocol)>=0))try{e.hostname=Me.toASCII(e.hostname)}catch(t){}return n(s(e))}function Ze(t){const r=g(t,!0);if(r.hostname&&(!r.protocol||Oe.indexOf(r.protocol)>=0))try{r.hostname=Me.toUnicode(r.hostname)}catch(t){}return e(s(r),e.defaultChars+"%")}function $e(t,e){if(!(this instanceof $e))return new $e(t,e);e||Z(t)||(e=t||{},t="default"),this.inline=new ue,this.block=new Ht,this.core=new Lt,this.renderer=new ft,this.linkify=new De,this.validateLink=Pe,this.normalizeLink=je,this.normalizeLinkText=Ze,this.utils=lt,this.helpers=U({},ht),this.options={},this.configure(t),e&&this.set(e)}return $e.prototype.set=function(t){return U(this.options,t),this},$e.prototype.configure=function(t){const e=this;if(Z(t)){const e=t;if(!(t=Te[e]))throw new Error('Wrong `markdown-it` preset "'+e+'", check name')}if(!t)throw new Error("Wrong `markdown-it` preset, can't be empty");return t.options&&e.set(t.options),t.components&&Object.keys(t.components).forEach((function(r){t.components[r].rules&&e[r].ruler.enableOnly(t.components[r].rules),t.components[r].rules2&&e[r].ruler2.enableOnly(t.components[r].rules2)})),this},$e.prototype.enable=function(t,e){let r=[];Array.isArray(t)||(t=[t]),["core","block","inline"].forEach((function(e){r=r.concat(this[e].ruler.enable(t,!0))}),this),r=r.concat(this.inline.ruler2.enable(t,!0));const n=t.filter((function(t){return r.indexOf(t)<0}));if(n.length&&!e)throw new Error("MarkdownIt. Failed to enable unknown rule(s): "+n);return this},$e.prototype.disable=function(t,e){let r=[];Array.isArray(t)||(t=[t]),["core","block","inline"].forEach((function(e){r=r.concat(this[e].ruler.disable(t,!0))}),this),r=r.concat(this.inline.ruler2.disable(t,!0));const n=t.filter((function(t){return r.indexOf(t)<0}));if(n.length&&!e)throw new Error("MarkdownIt. Failed to disable unknown rule(s): "+n);return this},$e.prototype.use=function(t){const e=[this].concat(Array.prototype.slice.call(arguments,1));return t.apply(t,e),this},$e.prototype.parse=function(t,e){if("string"!=typeof t)throw new Error("Input data should be a String");const r=new this.core.State(t,this,e);return this.core.process(r),r.tokens},$e.prototype.render=function(t,e){return e=e||{},this.renderer.render(this.parse(t,e),this.options,e)},$e.prototype.parseInline=function(t,e){const r=new this.core.State(t,this,e);return r.inlineMode=!0,this.core.process(r),r.tokens},$e.prototype.renderInline=function(t,e){return e=e||{},this.renderer.render(this.parseInline(t,e),this.options,e)},$e})); diff --git a/crates/promptforge-wb-server/ui/package-lock.json b/crates/promptforge-wb-server/ui/package-lock.json new file mode 100644 index 00000000..e689ac28 --- /dev/null +++ b/crates/promptforge-wb-server/ui/package-lock.json @@ -0,0 +1,1446 @@ +{ + "name": "promptforge-wb-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "promptforge-wb-ui", + "version": "0.0.0", + "dependencies": { + "dockview": "^8.2.0", + "marked": "^18.0.10" + }, + "devDependencies": { + "esbuild": "^0.28.2", + "jsdom": "^30.0.1", + "typescript": "^7.0.2" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dockview": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dockview/-/dockview-8.2.0.tgz", + "integrity": "sha512-Jj+fnFWSrdEhF+1DmpGNYjEvyqf7J0FHkDqsD98wL4WOYB2CSKavDCL49B3LHIGYvWNN7Q1O1j4p7Pts38CM5A==", + "license": "MIT", + "dependencies": { + "dockview-core": "^8.2.0" + } + }, + "node_modules/dockview-core": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dockview-core/-/dockview-core-8.2.0.tgz", + "integrity": "sha512-+L+xdvmO1b4in8rVkFtQM13IkPDKqDLoHJvq5HtAIsMxsPptSVveKeViYBqquaoHF+t0xb9f6692gTeKnjCKGw==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.10", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.10.tgz", + "integrity": "sha512-FJeH4bRpYoXiggcgriCGItKCSv3xkngJc4QCZ/rkQCogU3VYaLxYJoZl8Nw/b4+x7iij/pd+09mZ6A1dXzpL0A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/crates/promptforge-wb-server/ui/package.json b/crates/promptforge-wb-server/ui/package.json new file mode 100644 index 00000000..8616ce08 --- /dev/null +++ b/crates/promptforge-wb-server/ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "promptforge-wb-ui", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "PromptForge Workbench UI: TypeScript sources bundled by esbuild into dist/ and served by promptforge-wb-server.", + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "node test/smoke.mjs" + }, + "dependencies": { + "dockview": "^8.2.0", + "marked": "^18.0.10" + }, + "devDependencies": { + "esbuild": "^0.28.2", + "jsdom": "^30.0.1", + "typescript": "^7.0.2" + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/LICENSE b/crates/promptforge-wb-server/ui/src/chat/LICENSE new file mode 100644 index 00000000..80815f36 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lev Morozov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/promptforge-wb-server/ui/src/chat/PROVENANCE.md b/crates/promptforge-wb-server/ui/src/chat/PROVENANCE.md new file mode 100644 index 00000000..a7da5a63 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/PROVENANCE.md @@ -0,0 +1,25 @@ +# Vendored: murm-ui + +- Source: +- Version: 0.2.0 (npm release; upstream has no `v0.2.0` git tag, so the + release commit was used) +- Commit: `336ff7db79d928373e83c3672db6041a0adbc868` "chore: prepare 0.2.0 + release" (main HEAD at fetch time) +- License: MIT (see `LICENSE` in this directory) +- Fetched: 2026-08-24 + +## What this is + +The full TypeScript source of murm-ui 0.2.0 (`src/` in the upstream repo), +vendored so the workbench can adapt the chat UI to its own transport +(WebSocket provider, observer integration) and palette without waiting on +upstream. The npm package ships only compiled `dist/`, which is why the +source comes from the git repository rather than the tarball. + +## Deviations from upstream + +- Test files excluded: every `*.test.ts` and `tsconfig.test.json` (they + need the upstream tsx/jsdom harness, which is not vendored). +- No import or code changes yet: relative imports are extensionless, which + esbuild resolves natively, and the lone runtime dependency `marked` is a + workspace npm dependency. diff --git a/crates/promptforge-wb-server/ui/src/chat/components/dropdown.ts b/crates/promptforge-wb-server/ui/src/chat/components/dropdown.ts new file mode 100644 index 00000000..44cf57b2 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/components/dropdown.ts @@ -0,0 +1,168 @@ +import { el } from "../utils/dom"; + +export interface DropdownItem { + id: string; + label: string; + iconHtml?: string; + danger?: boolean; + disabled?: boolean; + onClick: () => void; +} + +export interface DropdownOptions { + align?: "left" | "right"; + width?: string; +} + +let activeDropdown: { menu: HTMLElement; trigger: HTMLElement; cleanup: (restoreFocus?: boolean) => void } | null = + null; +let nextDropdownId = 0; + +export function showDropdown(trigger: HTMLElement, items: readonly DropdownItem[], options: DropdownOptions = {}) { + if (activeDropdown) { + const wasSameTrigger = activeDropdown.trigger === trigger; + activeDropdown.cleanup(wasSameTrigger); + if (wasSameTrigger) return; + } + + const menu = el("div", "mur-dropdown-menu"); + const menuId = `mur-dropdown-${++nextDropdownId}`; + menu.id = menuId; + menu.tabIndex = -1; + menu.setAttribute("role", "menu"); + menu.setAttribute("aria-orientation", "vertical"); + if (options.width) menu.style.width = options.width; + + items.forEach((item) => { + const btnClass = item.danger ? "mur-dropdown-item mur-danger" : "mur-dropdown-item"; + const btn = el("button", btnClass, { + type: "button", + disabled: item.disabled, + onclick: (e) => { + e.stopPropagation(); + if (!item.disabled) { + item.onClick(); + closeDropdown(); + } + }, + }); + btn.setAttribute("role", "menuitem"); + + if (item.iconHtml) { + btn.appendChild(el("span", "mur-dropdown-icon", { innerHTML: item.iconHtml })); + } + btn.appendChild(el("span", "mur-dropdown-label", { textContent: item.label })); + + menu.appendChild(btn); + }); + const enabledItems = Array.from(menu.querySelectorAll(".mur-dropdown-item:not(:disabled)")); + + const appContainer = trigger.closest(".mur-app") || document.body; + appContainer.appendChild(menu); + + const previousAriaHasPopup = trigger.getAttribute("aria-haspopup"); + const previousAriaExpanded = trigger.getAttribute("aria-expanded"); + const previousAriaControls = trigger.getAttribute("aria-controls"); + trigger.setAttribute("aria-haspopup", "menu"); + trigger.setAttribute("aria-expanded", "true"); + trigger.setAttribute("aria-controls", menuId); + + const triggerRect = trigger.getBoundingClientRect(); + const appRect = appContainer.getBoundingClientRect(); + const menuWidth = menu.offsetWidth; + const menuHeight = menu.offsetHeight; + const top = triggerRect.bottom - appRect.top; + const left = triggerRect.left - appRect.left; + + if (top + 4 + menuHeight > appRect.height) { + menu.style.top = `${triggerRect.top - appRect.top - menuHeight - 4}px`; + } else { + menu.style.top = `${top + 4}px`; + } + + const alignRightEdge = options.align === "right" || (!options.align && left + menuWidth > appRect.width - 16); + + if (alignRightEdge) { + const rightOffset = appRect.right - triggerRect.right; + menu.style.right = `${rightOffset}px`; + menu.style.left = "auto"; + } else { + menu.style.left = `${left}px`; + menu.style.right = "auto"; + } + + const handleOutsidePointerDown = (e: PointerEvent) => { + if (!menu.contains(e.target as Node) && !trigger.contains(e.target as Node)) { + closeDropdown(); + } + }; + + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + closeDropdown(true); + } + }; + + const focusMenuItem = (offset: number) => { + if (enabledItems.length === 0) return; + + const currentIndex = enabledItems.indexOf(document.activeElement as HTMLButtonElement); + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + offset + enabledItems.length) % enabledItems.length; + enabledItems[nextIndex].focus(); + }; + + const handleMenuKeydown = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + focusMenuItem(1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + focusMenuItem(-1); + } else if (e.key === "Home") { + e.preventDefault(); + enabledItems[0]?.focus(); + } else if (e.key === "End") { + e.preventDefault(); + enabledItems[enabledItems.length - 1]?.focus(); + } else if (e.key === "Tab") { + closeDropdown(); + } + }; + menu.addEventListener("keydown", handleMenuKeydown); + menu.focus(); + + document.addEventListener("pointerdown", handleOutsidePointerDown); + document.addEventListener("keydown", handleEsc); + + const cleanup = (restoreFocus = false) => { + menu.remove(); + menu.removeEventListener("keydown", handleMenuKeydown); + document.removeEventListener("pointerdown", handleOutsidePointerDown); + document.removeEventListener("keydown", handleEsc); + restoreAttribute(trigger, "aria-haspopup", previousAriaHasPopup); + restoreAttribute(trigger, "aria-expanded", previousAriaExpanded); + restoreAttribute(trigger, "aria-controls", previousAriaControls); + if (restoreFocus && trigger.isConnected) { + trigger.focus(); + } + if (activeDropdown?.menu === menu) activeDropdown = null; + }; + + activeDropdown = { menu, trigger, cleanup }; +} + +export function closeDropdown(restoreFocus = false) { + if (activeDropdown) { + activeDropdown.cleanup(restoreFocus); + } +} + +function restoreAttribute(element: HTMLElement, name: string, value: string | null) { + if (value === null) { + element.removeAttribute(name); + return; + } + + element.setAttribute(name, value); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/components/feed-items.ts b/crates/promptforge-wb-server/ui/src/chat/components/feed-items.ts new file mode 100644 index 00000000..112a243d --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/components/feed-items.ts @@ -0,0 +1,428 @@ +import type { AgentRunCollapse, ContentBlock, Message } from "../core/types"; + +export type FeedItem = Message | FeedAgentRunItem; + +export type FeedAgentRunSegment = FeedAgentRunMessagesSegment | FeedAgentRunWorkSegment; + +export interface FeedAgentRunMessagesSegment { + type: "messages"; + id: string; + messages: readonly Message[]; +} + +export interface FeedAgentRunWorkSegment { + type: "work"; + id: string; + runId: string; + stepMessages: readonly Message[]; + collapsed: boolean; + durationMs?: number; +} + +export interface FeedAgentRunItem { + type: "agent_run"; + id: string; + runId: string; + userMessage: Message; + segments: readonly FeedAgentRunSegment[]; + stepMessages: readonly Message[]; + visibleMessages: readonly Message[]; + finalMessage: Message; + collapsed: boolean; + durationMs?: number; +} + +export interface BuildFeedItemsOptions { + generatingMessageId: string | null; + isRunExpanded?: (runId: string) => boolean; + isWorkSegmentExpanded?: (segmentId: string) => boolean; + minAgentRunSteps?: number; + agentRunCollapse?: AgentRunCollapse; +} + +const DEFAULT_MIN_AGENT_RUN_STEPS = 1; +const DEFAULT_AGENT_RUN_COLLAPSE: AgentRunCollapse = "machinery"; + +export function buildFeedItems(messages: readonly Message[], options: BuildFeedItemsOptions): readonly FeedItem[] { + const items: FeedItem[] = []; + const minAgentRunSteps = options.minAgentRunSteps ?? DEFAULT_MIN_AGENT_RUN_STEPS; + const agentRunCollapse = options.agentRunCollapse ?? DEFAULT_AGENT_RUN_COLLAPSE; + + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + + if (message.role === "user") { + const runEndIndex = findRunEndIndex(messages, index); + const runItem = + runEndIndex - index >= 2 + ? buildAgentRunItem(messages, index, runEndIndex, options, minAgentRunSteps, agentRunCollapse) + : null; + + if (runItem) { + items.push(runItem); + index = runEndIndex - 1; + continue; + } + } + + items.push(message); + } + + return items; +} + +export function isAgentRunItem(item: FeedItem): item is FeedAgentRunItem { + return "type" in item && item.type === "agent_run"; +} + +export function feedItemType(item: FeedItem): "message" | "agent_run" { + return isAgentRunItem(item) ? "agent_run" : "message"; +} + +function findRunEndIndex(messages: readonly Message[], userIndex: number): number { + const userMessage = messages[userIndex]; + const runId = userMessage.runId; + let endIndex = userIndex + 1; + + if (runId) { + while (endIndex < messages.length && messages[endIndex].role !== "user" && messages[endIndex].runId === runId) { + endIndex++; + } + } else { + while (endIndex < messages.length && messages[endIndex].role !== "user" && !messages[endIndex].runId) { + endIndex++; + } + } + + return endIndex; +} + +function buildAgentRunItem( + messages: readonly Message[], + userIndex: number, + runEndIndex: number, + options: BuildFeedItemsOptions, + minAgentRunSteps: number, + agentRunCollapse: AgentRunCollapse, +): FeedAgentRunItem | null { + let isActiveRun = false; + if (options.generatingMessageId) { + for (let i = userIndex; i < runEndIndex; i++) { + if (messages[i].id !== options.generatingMessageId) continue; + if (agentRunCollapse !== "machinery") return null; + isActiveRun = true; + break; + } + } + + const userMessage = messages[userIndex]; + const finalMessageIndex = findFinalAssistantProseIndex(messages, userIndex + 1, runEndIndex); + if (finalMessageIndex === -1 && !isActiveRun) return null; + if (agentRunCollapse === "full" && finalMessageIndex !== runEndIndex - 1) return null; + + const runId = userMessage.runId ?? userMessage.id; + const isWorkSegmentExpanded = (segmentId: string) => + isActiveRun || options.isWorkSegmentExpanded?.(segmentId) || options.isRunExpanded?.(runId) || false; + const segments = + agentRunCollapse === "full" + ? buildFullSegments(messages, userIndex, finalMessageIndex, runId, isWorkSegmentExpanded) + : buildMachinerySegments(messages, userIndex, runEndIndex, runId, isWorkSegmentExpanded); + const stepMessages = flattenStepMessages(segments); + if (countAgentStepMessages(stepMessages) < minAgentRunSteps) return null; + + const visibleMessages = flattenVisibleMessages(segments); + const collapsed = segments + .filter((segment): segment is FeedAgentRunWorkSegment => segment.type === "work") + .every((segment) => segment.collapsed); + const finalMessage = messages[finalMessageIndex === -1 ? runEndIndex - 1 : finalMessageIndex]; + + return { + type: "agent_run", + id: `agent-run:${runId}`, + runId, + userMessage, + segments, + stepMessages, + visibleMessages, + finalMessage, + collapsed, + durationMs: calculateRunDuration(userMessage, finalMessage), + }; +} + +function buildFullSegments( + messages: readonly Message[], + userIndex: number, + finalMessageIndex: number, + runId: string, + isWorkSegmentExpanded: (segmentId: string) => boolean, +): FeedAgentRunSegment[] { + const stepMessages = buildFullStepMessages(messages, userIndex + 1, finalMessageIndex); + const finalMachineryBlocks = machineryBlocks(messages[finalMessageIndex]); + if (finalMachineryBlocks.length > 0) { + stepMessages.push(createFilteredMessage(messages[finalMessageIndex], finalMachineryBlocks)); + } + + const visibleFinalBlocks = proseBlocks(messages[finalMessageIndex]); + const segments: FeedAgentRunSegment[] = []; + if (stepMessages.length > 0) { + const id = `${runId}:work:0`; + segments.push({ + type: "work", + id, + runId, + stepMessages, + collapsed: !isWorkSegmentExpanded(id), + durationMs: calculateRunDuration(messages[userIndex], messages[finalMessageIndex]), + }); + } + if (visibleFinalBlocks.length > 0) { + segments.push({ + type: "messages", + id: `${runId}:messages:0`, + messages: [createFilteredMessage(messages[finalMessageIndex], visibleFinalBlocks)], + }); + } + return segments; +} + +function buildFullStepMessages(messages: readonly Message[], startIndex: number, finalMessageIndex: number): Message[] { + const stepMessages: Message[] = []; + for (let i = startIndex; i < finalMessageIndex; i++) { + const stepBlocks = messages[i].blocks.filter(isRenderableStepBlock); + if (stepBlocks.length > 0) stepMessages.push(createFilteredMessage(messages[i], stepBlocks)); + } + return stepMessages; +} + +function buildMachinerySegments( + messages: readonly Message[], + userIndex: number, + runEndIndex: number, + runId: string, + isWorkSegmentExpanded: (segmentId: string) => boolean, +): FeedAgentRunSegment[] { + const segments: FeedAgentRunSegment[] = []; + let pendingKind: "messages" | "work" | null = null; + let pendingMessages: Message[] = []; + + const flush = () => { + if (!pendingKind || pendingMessages.length === 0) return; + const index = segments.length; + if (pendingKind === "messages") { + segments.push({ + type: "messages", + id: `${runId}:messages:${index}`, + messages: pendingMessages, + }); + } else { + const id = `${runId}:work:${index}`; + segments.push({ + type: "work", + id, + runId, + stepMessages: pendingMessages, + collapsed: !isWorkSegmentExpanded(id), + }); + } + pendingKind = null; + pendingMessages = []; + }; + + const append = (kind: "messages" | "work", message: Message, blocks: ContentBlock[]) => { + if (blocks.length === 0) return; + if (pendingKind !== kind) flush(); + pendingKind = kind; + pendingMessages.push(createFilteredMessage(message, blocks)); + }; + + for (let i = userIndex + 1; i < runEndIndex; i++) { + appendMessageChunks(messages[i], append); + } + + flush(); + moveLeadingReasoningIntoNextWorkSegment(segments); + applyWorkDurations(segments, messages[userIndex]); + return segments; +} + +function appendMessageChunks( + message: Message, + append: (kind: "messages" | "work", message: Message, blocks: ContentBlock[]) => void, +): void { + if (message.role !== "assistant") { + append("work", message, message.blocks.filter(isRenderableStepBlock)); + return; + } + + let currentKind: "messages" | "work" | null = null; + let currentBlocks: ContentBlock[] = []; + + const flush = () => { + if (!currentKind || currentBlocks.length === 0) return; + append(currentKind, message, currentBlocks); + currentKind = null; + currentBlocks = []; + }; + + for (const block of message.blocks) { + const kind = blockKind(block); + if (!kind) continue; + if (currentKind !== kind) flush(); + currentKind = kind; + currentBlocks.push(block); + } + + flush(); +} + +function blockKind(block: ContentBlock): "messages" | "work" | null { + if (isProseBlock(block)) return "messages"; + if (isCollapsibleBlock(block)) return "work"; + return null; +} + +function flattenStepMessages(segments: readonly FeedAgentRunSegment[]): Message[] { + return segments.flatMap((segment) => (segment.type === "work" ? segment.stepMessages : [])); +} + +function countAgentStepMessages(messages: readonly Message[]): number { + return new Set(messages.map((message) => message.id)).size; +} + +function flattenVisibleMessages(segments: readonly FeedAgentRunSegment[]): Message[] { + return segments.flatMap((segment) => (segment.type === "messages" ? segment.messages : [])); +} + +function applyWorkDurations(segments: FeedAgentRunSegment[], userMessage: Message): void { + let previousVisibleMessage = userMessage; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (segment.type === "messages") { + previousVisibleMessage = segment.messages[segment.messages.length - 1] ?? previousVisibleMessage; + continue; + } + + const nextVisibleMessage = findNextVisibleMessage(segments, i + 1); + const lastStepMessage = segment.stepMessages[segment.stepMessages.length - 1]; + if (!lastStepMessage) continue; + const boundaryDurationMs = nextVisibleMessage + ? calculateRunDuration(previousVisibleMessage, nextVisibleMessage) + : calculateRunDuration(previousVisibleMessage, lastStepMessage); + if (boundaryDurationMs !== undefined) segment.durationMs = boundaryDurationMs; + } +} + +function findNextVisibleMessage(segments: readonly FeedAgentRunSegment[], startIndex: number): Message | undefined { + for (let i = startIndex; i < segments.length; i++) { + const segment = segments[i]; + if (segment.type === "messages") return segment.messages[0]; + } + return undefined; +} + +function moveLeadingReasoningIntoNextWorkSegment(segments: FeedAgentRunSegment[]): void { + const firstSegment = segments[0]; + const secondSegment = segments[1]; + if (firstSegment?.type !== "work" || secondSegment?.type !== "messages") return; + if (!isReasoningOnlyWorkSegment(firstSegment)) return; + + const nextWorkIndex = segments.findIndex((segment, index) => index > 1 && segment.type === "work"); + if (nextWorkIndex === -1) return; + + const nextWorkSegment = segments[nextWorkIndex]; + if (nextWorkSegment.type !== "work") return; + + segments[nextWorkIndex] = { + ...nextWorkSegment, + stepMessages: [...firstSegment.stepMessages, ...nextWorkSegment.stepMessages], + }; + segments.shift(); +} + +function isReasoningOnlyWorkSegment(segment: FeedAgentRunWorkSegment): boolean { + return segment.stepMessages.every( + (message) => + message.role === "assistant" && + message.blocks.length > 0 && + message.blocks.every((block) => block.type === "reasoning"), + ); +} + +function createFilteredMessage(message: Message, blocks: Message["blocks"]): Message { + return { ...message, blocks }; +} + +function findFinalAssistantProseIndex(messages: readonly Message[], startIndex: number, endIndex: number): number { + for (let i = endIndex - 1; i >= startIndex; i--) { + const message = messages[i]; + if (message.role === "assistant" && proseBlocks(message).length > 0) return i; + } + + return -1; +} + +function machineryBlocks(message: Message): ContentBlock[] { + if (message.role !== "assistant") return message.blocks.filter(isRenderableStepBlock); + return message.blocks.filter(isCollapsibleBlock); +} + +function proseBlocks(message: Message): ContentBlock[] { + if (message.role !== "assistant") return []; + return message.blocks.filter(isProseBlock); +} + +function isProseBlock(block: ContentBlock): boolean { + switch (block.type) { + case "text": + return block.text.trim().length > 0; + case "artifact": + case "file": + return true; + case "reasoning": + case "tool_call": + case "tool_result": + return false; + } +} + +function isCollapsibleBlock(block: ContentBlock): boolean { + switch (block.type) { + case "reasoning": + return hasVisibleBlock(block); + case "tool_call": + return true; + case "tool_result": + case "text": + case "artifact": + case "file": + return false; + } +} + +function hasVisibleBlock(block: ContentBlock): boolean { + switch (block.type) { + case "text": + return block.text.trim().length > 0; + case "reasoning": + return block.encrypted === true || block.text.trim().length > 0 || Boolean(block.encryptedText); + case "tool_call": + case "tool_result": + case "artifact": + case "file": + return true; + } +} + +function isRenderableStepBlock(block: ContentBlock): boolean { + return block.type !== "tool_result" && hasVisibleBlock(block); +} + +function calculateRunDuration(userMessage: Message, finalMessage: Message): number | undefined { + const startedAt = userMessage.updatedAt ?? userMessage.createdAt; + const finishedAt = finalMessage.updatedAt ?? finalMessage.createdAt; + if (startedAt === undefined || finishedAt === undefined) return undefined; + if (!Number.isFinite(startedAt) || !Number.isFinite(finishedAt) || finishedAt < startedAt) return undefined; + return finishedAt - startedAt; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/components/feed-node.ts b/crates/promptforge-wb-server/ui/src/chat/components/feed-node.ts new file mode 100644 index 00000000..69e60ac7 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/components/feed-node.ts @@ -0,0 +1,350 @@ +import type { Message, RenderConfig } from "../core/types"; +import { ICON_CHEVRON } from "../utils/icons"; +import { + type FeedAgentRunItem, + type FeedAgentRunSegment, + type FeedAgentRunWorkSegment, + type FeedItem, + isAgentRunItem, +} from "./feed-items"; +import { MessageNode } from "./message-node"; + +export interface FeedNodeUpdateContext { + messages: readonly Message[]; + generatingMessageId: string | null; + error: { message: string; id?: string } | null; + onToggleWorkSegment: (segmentId: string) => void; +} + +export interface FeedNode { + type: "message" | "agent_run"; + el: HTMLElement; + update(item: FeedItem, ctx: FeedNodeUpdateContext): void; + destroy(): void; +} + +export function createFeedNode(item: FeedItem, config: RenderConfig): FeedNode { + return isAgentRunItem(item) ? new AgentRunFeedNode(item, config) : new MessageFeedNode(item, config); +} + +class MessageFeedNode implements FeedNode { + public readonly type = "message"; + public readonly el: HTMLElement; + private readonly messageNode: MessageNode; + + constructor(message: Message, config: RenderConfig) { + this.messageNode = new MessageNode(message, config); + this.el = this.messageNode.el; + } + + public update(item: FeedItem, ctx: FeedNodeUpdateContext): void { + if (isAgentRunItem(item)) return; + updateMessageNode(this.messageNode, item, ctx); + } + + public destroy(): void { + this.messageNode.destroy(); + } +} + +class AgentRunFeedNode implements FeedNode { + public readonly type = "agent_run"; + public readonly el = document.createElement("div"); + + private readonly segmentNodes = new Map(); + + private userNode?: MessageNode; + private userMessageId?: string; + + constructor( + item: FeedAgentRunItem, + private readonly config: RenderConfig, + ) { + this.el.className = "mur-agent-run"; + this.el.dataset.runId = item.runId; + } + + public update(item: FeedItem, ctx: FeedNodeUpdateContext): void { + if (!isAgentRunItem(item)) return; + + this.el.dataset.runId = item.runId; + + this.renderUserMessage(item.userMessage, ctx); + this.renderSegments(item.segments, ctx); + } + + public destroy(): void { + this.userNode?.destroy(); + for (const node of this.segmentNodes.values()) { + node.destroy(); + } + this.segmentNodes.clear(); + this.el.remove(); + } + + private renderUserMessage(message: Message, ctx: FeedNodeUpdateContext): void { + if (!this.userNode || this.userMessageId !== message.id) { + this.userNode?.destroy(); + this.userNode = new MessageNode(message, this.config); + this.userMessageId = message.id; + } + + updateMessageNode(this.userNode, message, ctx); + if (this.el.firstElementChild !== this.userNode.el) { + this.el.insertBefore(this.userNode.el, this.el.firstChild); + } + } + + private renderSegments(segments: readonly FeedAgentRunSegment[], ctx: FeedNodeUpdateContext): void { + let previousEl: Element | null = this.userNode?.el ?? null; + + for (const segment of segments) { + let node = this.segmentNodes.get(segment.id); + + if (!node || node.type !== segment.type) { + node?.destroy(); + node = createAgentRunSegmentNode(segment, this.config); + this.segmentNodes.set(segment.id, node); + } + + if (node.el.parentElement !== this.el || node.el.previousElementSibling !== previousEl) { + this.el.insertBefore(node.el, previousEl ? previousEl.nextSibling : this.el.firstChild); + } + + node.update(segment, ctx); + previousEl = node.el; + } + + const currentIds = new Set(); + for (const segment of segments) { + currentIds.add(segment.id); + } + for (const [id, node] of this.segmentNodes) { + if (currentIds.has(id)) continue; + node.destroy(); + this.segmentNodes.delete(id); + } + } +} + +interface AgentRunSegmentNode { + type: FeedAgentRunSegment["type"]; + el: HTMLElement; + update(segment: FeedAgentRunSegment, ctx: FeedNodeUpdateContext): void; + destroy(): void; +} + +function createAgentRunSegmentNode(segment: FeedAgentRunSegment, config: RenderConfig): AgentRunSegmentNode { + return segment.type === "work" + ? new AgentRunWorkSegmentNode(segment, config) + : new AgentRunMessagesSegmentNode(config); +} + +class AgentRunMessagesSegmentNode implements AgentRunSegmentNode { + public readonly type = "messages"; + public readonly el = document.createElement("div"); + + private readonly messageNodes = new Map(); + + constructor(private readonly config: RenderConfig) { + this.el.className = "mur-agent-run-messages"; + } + + public update(segment: FeedAgentRunSegment, ctx: FeedNodeUpdateContext): void { + if (segment.type !== "messages") return; + + for (let index = 0; index < segment.messages.length; index++) { + const message = segment.messages[index]; + const key = messageNodeKey(message); + let node = this.messageNodes.get(key); + + if (!node) { + node = new MessageNode(message, this.config); + this.messageNodes.set(key, node); + } + + if (this.el.children[index] !== node.el) { + this.el.insertBefore(node.el, this.el.children[index]); + } + updateMessageNode(node, message, ctx); + } + + const currentIds = new Set(); + for (const message of segment.messages) { + currentIds.add(messageNodeKey(message)); + } + for (const [id, node] of this.messageNodes) { + if (currentIds.has(id)) continue; + node.destroy(); + this.messageNodes.delete(id); + } + } + + public destroy(): void { + clearMessageNodes(this.messageNodes); + this.el.remove(); + } +} + +class AgentRunWorkSegmentNode implements AgentRunSegmentNode { + public readonly type = "work"; + public readonly el = document.createElement("div"); + + private readonly summaryEl = document.createElement("button"); + private readonly chevronEl = document.createElement("span"); + private readonly labelEl = document.createElement("span"); + private readonly stepsEl = document.createElement("div"); + private readonly stepNodes = new Map(); + private currentSegmentId?: string; + private onToggleWorkSegment?: (segmentId: string) => void; + + constructor( + segment: FeedAgentRunWorkSegment, + private readonly config: RenderConfig, + ) { + this.currentSegmentId = segment.id; + this.el.className = "mur-agent-run-work"; + this.el.dataset.segmentId = segment.id; + + this.summaryEl.type = "button"; + this.summaryEl.className = "mur-agent-run-summary"; + this.summaryEl.addEventListener("click", () => { + if (this.currentSegmentId) this.onToggleWorkSegment?.(this.currentSegmentId); + }); + + this.chevronEl.className = "mur-agent-run-summary-chevron"; + this.chevronEl.innerHTML = ICON_CHEVRON; + this.labelEl.className = "mur-agent-run-summary-label"; + this.summaryEl.append(this.chevronEl, this.labelEl); + + this.stepsEl.className = "mur-agent-run-steps"; + this.el.append(this.summaryEl, this.stepsEl); + } + + public update(segment: FeedAgentRunSegment, ctx: FeedNodeUpdateContext): void { + if (segment.type !== "work") return; + + this.currentSegmentId = segment.id; + this.el.dataset.segmentId = segment.id; + this.onToggleWorkSegment = ctx.onToggleWorkSegment; + this.renderSummary(segment); + this.renderSteps(segment, ctx); + } + + public destroy(): void { + clearMessageNodes(this.stepNodes); + this.el.remove(); + } + + private renderSummary(segment: FeedAgentRunWorkSegment): void { + this.labelEl.textContent = formatWorkSummary(segment); + this.summaryEl.setAttribute("aria-expanded", String(!segment.collapsed)); + } + + private renderSteps(segment: FeedAgentRunWorkSegment, ctx: FeedNodeUpdateContext): void { + this.stepsEl.hidden = segment.collapsed; + + if (segment.collapsed) { + clearMessageNodes(this.stepNodes); + return; + } + + for (let index = 0; index < segment.stepMessages.length; index++) { + const message = segment.stepMessages[index]; + const key = messageNodeKey(message); + let node = this.stepNodes.get(key); + + if (!node) { + node = new MessageNode(message, this.config); + this.stepNodes.set(key, node); + } + + if (this.stepsEl.children[index] !== node.el) { + this.stepsEl.insertBefore(node.el, this.stepsEl.children[index]); + } + + updateMessageNode(node, message, ctx); + } + + const currentIds = new Set(); + for (const message of segment.stepMessages) { + currentIds.add(messageNodeKey(message)); + } + for (const [id, node] of this.stepNodes) { + if (currentIds.has(id)) continue; + node.destroy(); + this.stepNodes.delete(id); + } + } +} + +function updateMessageNode(node: MessageNode, message: Message, ctx: FeedNodeUpdateContext): void { + const targetError = ctx.error?.id === message.id ? ctx.error.message : null; + node.update(message, message.id === ctx.generatingMessageId, targetError, ctx.messages); +} + +function messageNodeKey(message: Message): string { + return `${message.id}:${message.blocks.map((block) => block.id).join(",")}`; +} + +function clearMessageNodes(nodes: Map): void { + for (const node of nodes.values()) { + node.destroy(); + } + nodes.clear(); +} + +function formatWorkSummary(segment: FeedAgentRunWorkSegment): string { + const durationText = + segment.durationMs === undefined || segment.durationMs <= 0 ? undefined : formatDuration(segment.durationMs); + const toolCallCount = countToolCalls(segment); + + if (toolCallCount > 0) { + return durationText + ? `${toolCallCount} ${pluralize("tool call", toolCallCount)}, ${durationText}` + : `${toolCallCount} ${pluralize("tool call", toolCallCount)}`; + } + + if (isReasoningOnlySegment(segment)) { + return durationText ? `Thought for ${durationText}` : "Thought"; + } + + return durationText ? `Worked for ${durationText}` : "Worked"; +} + +function countToolCalls(segment: FeedAgentRunWorkSegment): number { + let count = 0; + for (const message of segment.stepMessages) { + for (const block of message.blocks) { + if (block.type === "tool_call") count++; + } + } + return count; +} + +function isReasoningOnlySegment(segment: FeedAgentRunWorkSegment): boolean { + let hasReasoning = false; + for (const message of segment.stepMessages) { + for (const block of message.blocks) { + if (block.type !== "reasoning") return false; + hasReasoning = true; + } + } + return hasReasoning; +} + +function pluralize(label: string, count: number): string { + return count === 1 ? label : `${label}s`; +} + +function formatDuration(durationMs: number): string { + const safeDurationMs = Math.max(0, durationMs); + if (safeDurationMs < 1000) return `${Math.round(safeDurationMs)}ms`; + + const totalSeconds = Math.round(safeDurationMs / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${String(seconds).padStart(2, "0")}s`; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/components/feed.ts b/crates/promptforge-wb-server/ui/src/chat/components/feed.ts new file mode 100644 index 00000000..f4a3fef5 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/components/feed.ts @@ -0,0 +1,448 @@ +import type { Message, RenderConfig } from "../core/types"; +import { el, queryOrThrow } from "../utils/dom"; +import { ICON_CHECK, ICON_COPY } from "../utils/icons"; +import { buildFeedItems, type FeedItem, feedItemType } from "./feed-items"; +import { createFeedNode, type FeedNode } from "./feed-node"; + +const STICKY_THRESHOLD = 50; +// Distance from the top (px) at which scrolling up triggers an older-messages load. +const OLDER_LOAD_THRESHOLD = 200; +const MOBILE_SCROLL_QUERY = "(max-width: 768px)"; + +export class Feed { + private scrollArea: HTMLElement; + private historyContainer: HTMLElement; + private spinnerEl: HTMLElement; + private olderSpinnerEl: HTMLElement; + + // Upward-pagination state, driven by setOlderMessagesState. + private hasMoreOlder = false; + private isLoadingOlder = false; + // Id of the first raw message from the previous render. Feed items can + // regroup when older run fragments arrive, so raw messages are the stable + // signal for detecting prepends. + private firstMessageId: string | null = null; + + private nodes = new Map(); + private expandedWorkSegmentIds = new Set(); + private feedItemsCache: { + messages: Message[]; + messageCount: number; + generatingMessageId: string | null; + items: readonly FeedItem[]; + } | null = null; + private lastMessagesRef: Message[] | null = null; + private isStickyToBottom = true; + private isHistoryBusy = false; + private lastScrollTop = 0; + private isDestroyed = false; + private readonly onToggleWorkSegment = (segmentId: string) => this.toggleWorkSegment(segmentId); + private lastUpdateRequest: { + messages: Message[]; + generatingMessageId: string | null; + isLoadingSession: boolean; + error: { message: string; id?: string } | null; + } | null = null; + private pendingScrollFrame: number | null = null; + private pendingScrollBehavior: ScrollBehavior | null = null; + private resizeObserver?: ResizeObserver; + private mediaQueryList: MediaQueryList; + private usesWindowScroll = false; + private activeScrollTarget: "scrollArea" | "window" | null = null; + private readonly usesFullscreenLayout: boolean; + + constructor( + container: HTMLElement, + private config: RenderConfig, + ) { + this.scrollArea = queryOrThrow(container, ".mur-chat-scroll-area"); + this.historyContainer = queryOrThrow(container, ".mur-chat-history"); + this.mediaQueryList = window.matchMedia(MOBILE_SCROLL_QUERY); + this.usesFullscreenLayout = config.fullscreen !== false; + this.usesWindowScroll = this.usesFullscreenLayout && this.mediaQueryList.matches; + + this.historyContainer.addEventListener("click", this.onHistoryClick); + this.syncScrollListener(); + this.addMediaListener(); + + if (typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver(() => { + this.requestBottomScroll("auto"); + }); + this.resizeObserver.observe(this.historyContainer); + this.resizeObserver.observe(this.scrollArea); + } + + this.spinnerEl = el("div", "mur-feed-spinner", { + innerHTML: `
`, + }); + this.spinnerEl.hidden = true; + this.scrollArea.appendChild(this.spinnerEl); + + // Older-messages spinner sits above the transcript (top of the scroll area). + this.olderSpinnerEl = el("div", "mur-feed-spinner mur-feed-spinner-top", { + innerHTML: `
Loading older messages...
`, + }); + this.olderSpinnerEl.hidden = true; + this.historyContainer.parentElement?.insertBefore(this.olderSpinnerEl, this.historyContainer); + } + + // Drives the older-messages affordance: whether more history exists and + // whether a load is in flight. Wired from ChatState by the host. + public setOlderMessagesState(hasMore: boolean, isLoading: boolean): void { + this.hasMoreOlder = hasMore; + if (isLoading === this.isLoadingOlder) return; + this.isLoadingOlder = isLoading; + + // Toggling the top spinner changes the height above the transcript. While + // the user reads history, compensate so the content stays anchored rather + // than jumping by the spinner's height. + const before = this.olderSpinnerEl.offsetHeight; + this.olderSpinnerEl.hidden = !isLoading; + const delta = this.olderSpinnerEl.offsetHeight - before; + if (delta !== 0 && !this.isStickyToBottom) this.adjustScrollTop(delta); + } + + public update( + messages: Message[], + generatingMessageId: string | null, + isLoadingSession: boolean, + generationStarted: boolean, + error: { message: string; id?: string } | null = null, + ) { + this.lastUpdateRequest = { messages, generatingMessageId, isLoadingSession, error }; + this.syncHistoryBusy(generatingMessageId !== null); + this.spinnerEl.hidden = !isLoadingSession; + + if (isLoadingSession) { + this.isStickyToBottom = true; + this.lastScrollTop = 0; + this.clearAllNodes(); + this.lastMessagesRef = null; + this.firstMessageId = null; + return; + } + + if (generationStarted) { + this.isStickyToBottom = true; + } + + // Skip heavy DOM syncs if the array reference hasn't changed (e.g. during streaming). + // Hot stream updates can still adopt a placeholder id or append another assistant + // message in-place, so discovering a missing node below also marks structure dirty. + const items = this.getFeedItems(messages, generatingMessageId); + + // Detect a prepend (older messages inserted above the current head). For + // upward pagination, preserving the scrollHeight delta is more robust than + // anchoring a DOM node because feed item ids can change when a partial run + // becomes a collapsed agent_run after older messages arrive. + const previousFirstMessageId = this.firstMessageId; + const nextFirstMessageId = messages[0]?.id ?? null; + const preservesPrependScroll = + !this.isStickyToBottom && + previousFirstMessageId !== null && + nextFirstMessageId !== null && + nextFirstMessageId !== previousFirstMessageId && + messages.some((message, index) => index > 0 && message.id === previousFirstMessageId); + const scrollHeightBefore = preservesPrependScroll ? this.getScrollMetrics().scrollHeight : 0; + + let structureChanged = this.lastMessagesRef !== messages || this.nodes.size > items.length; + this.lastMessagesRef = messages; + const nodeUpdateCtx = { + messages, + generatingMessageId, + error, + onToggleWorkSegment: this.onToggleWorkSegment, + }; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + let node = this.nodes.get(item.id); + if (!node || node.type !== feedItemType(item)) { + node?.destroy(); + node = createFeedNode(item, this.config); + this.nodes.set(item.id, node); + structureChanged = true; + } + + // Ensure physical DOM order matches array order + if (structureChanged && this.historyContainer.children[i] !== node.el) { + this.historyContainer.insertBefore(node.el, this.historyContainer.children[i]); + } + + node.update(item, nodeUpdateCtx); + } + + // Cleanup removed feed items + if (structureChanged) { + const currentIds = new Set(); + for (const item of items) { + currentIds.add(item.id); + } + for (const [id, node] of this.nodes.entries()) { + if (!currentIds.has(id)) { + node.destroy(); + this.nodes.delete(id); + } + } + } + + // Compensate for height added above the viewport so prepended history + // unrolls upward without moving what the user is looking at. + if (preservesPrependScroll) { + const delta = this.getScrollMetrics().scrollHeight - scrollHeightBefore; + if (delta !== 0) this.adjustScrollTop(delta); + } + this.firstMessageId = nextFirstMessageId; + + const isActivelyStreaming = generatingMessageId !== null && !generationStarted; + this.requestBottomScroll(isActivelyStreaming ? "auto" : "smooth"); + } + + private toggleWorkSegment(segmentId: string): void { + if (this.expandedWorkSegmentIds.has(segmentId)) { + this.expandedWorkSegmentIds.delete(segmentId); + } else { + this.expandedWorkSegmentIds.add(segmentId); + } + this.feedItemsCache = null; + + const request = this.lastUpdateRequest; + if (!request || this.isDestroyed) return; + this.update(request.messages, request.generatingMessageId, request.isLoadingSession, false, request.error); + } + + private getFeedItems(messages: Message[], generatingMessageId: string | null): readonly FeedItem[] { + const cached = this.feedItemsCache; + if ( + cached && + cached.messages === messages && + cached.messageCount === messages.length && + cached.generatingMessageId === generatingMessageId + ) { + return cached.items; + } + + const items = buildFeedItems(messages, { + generatingMessageId, + isWorkSegmentExpanded: (segmentId) => this.expandedWorkSegmentIds.has(segmentId), + minAgentRunSteps: this.config.minAgentRunSteps, + agentRunCollapse: this.config.agentRunCollapse, + }); + this.feedItemsCache = { + messages, + messageCount: messages.length, + generatingMessageId, + items, + }; + return items; + } + + private syncHistoryBusy(isBusy: boolean): void { + if (this.isHistoryBusy === isBusy) return; + + this.isHistoryBusy = isBusy; + this.historyContainer.setAttribute("aria-busy", isBusy ? "true" : "false"); + } + + public destroy() { + if (this.isDestroyed) return; + this.isDestroyed = true; + + if (this.pendingScrollFrame !== null) { + cancelAnimationFrame(this.pendingScrollFrame); + this.pendingScrollFrame = null; + } + this.pendingScrollBehavior = null; + + this.resizeObserver?.disconnect(); + this.historyContainer.removeEventListener("click", this.onHistoryClick); + this.removeActiveScrollListener(); + this.removeMediaListener(); + this.clearAllNodes(); + this.spinnerEl.remove(); + this.olderSpinnerEl.remove(); + } + + private clearAllNodes(): void { + for (const node of this.nodes.values()) { + node.destroy(); + } + this.nodes.clear(); + this.feedItemsCache = null; + this.historyContainer.innerHTML = ""; + } + + private requestBottomScroll(behavior: ScrollBehavior, force = false) { + if (this.isDestroyed) return; + + if (force) { + this.isStickyToBottom = true; + } else if (!this.isStickyToBottom) { + return; + } + + if (this.pendingScrollBehavior !== "smooth") { + this.pendingScrollBehavior = behavior; + } + this.ensureBottomScrollFrame(); + } + + private ensureBottomScrollFrame() { + if (this.pendingScrollFrame !== null) return; + + this.pendingScrollFrame = requestAnimationFrame(() => { + const behavior = this.pendingScrollBehavior ?? "auto"; + + this.pendingScrollFrame = null; + this.pendingScrollBehavior = null; + + if (this.isDestroyed || !this.isStickyToBottom) return; + + if (this.usesWindowScroll) { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior, + }); + } else { + this.scrollArea.scrollTo({ + top: this.scrollArea.scrollHeight, + behavior, + }); + } + }); + } + + private onScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = this.getScrollMetrics(); + const distanceToBottom = scrollHeight - scrollTop - clientHeight; + + const delta = scrollTop - this.lastScrollTop; + this.lastScrollTop = scrollTop; + const isScrollingUp = delta < 0; + + // Break lock if user explicitly scrolls up + if (isScrollingUp && distanceToBottom > STICKY_THRESHOLD) { + this.isStickyToBottom = false; + } + // Re-engage lock if user hits the bottom + else if (distanceToBottom <= STICKY_THRESHOLD) { + this.isStickyToBottom = true; + } + + // Near the top while scrolling up: ask the host to load older messages. + // The host (and SessionManager) re-check hasMore/in-flight, so a redundant + // call here is harmless. + if (isScrollingUp && scrollTop <= OLDER_LOAD_THRESHOLD && this.hasMoreOlder && !this.isLoadingOlder) { + this.config.onReachTop?.(); + } + }; + + private onHistoryClick = (event: MouseEvent) => { + const target = event.target as Element | null; + const button = target?.closest?.(".mur-code-copy-btn") as HTMLElement | null; + if ( + !button || + button.tagName !== "BUTTON" || + !this.historyContainer.contains(button) || + !button.closest(".mur-code-header") + ) { + return; + } + + void this.copyCode(button as HTMLButtonElement); + }; + + private async copyCode(button: HTMLButtonElement): Promise { + const codeBlock = button.closest(".mur-code-block"); + const codeEl = codeBlock?.querySelector("pre > code"); + const text = codeEl?.textContent; + if (text === undefined || typeof navigator === "undefined" || !navigator.clipboard) return; + + try { + await navigator.clipboard.writeText(text); + button.innerHTML = ICON_CHECK; + window.setTimeout(() => { + if (button.isConnected) { + button.innerHTML = ICON_COPY; + } + }, 2000); + } catch { + // Copy is best-effort; leave the button unchanged on failure. + } + } + + private getScrollMetrics(): { scrollTop: number; scrollHeight: number; clientHeight: number } { + if (this.usesWindowScroll) { + const doc = document.documentElement; + + return { + scrollTop: window.scrollY || doc.scrollTop, + scrollHeight: doc.scrollHeight, + clientHeight: window.innerHeight, + }; + } + + return { + scrollTop: this.scrollArea.scrollTop, + scrollHeight: this.scrollArea.scrollHeight, + clientHeight: this.scrollArea.clientHeight, + }; + } + + private adjustScrollTop(delta: number): void { + if (this.usesWindowScroll) { + window.scrollBy(0, delta); + } else { + this.scrollArea.scrollTop += delta; + } + // Keep lastScrollTop in sync so this programmatic shift is not read as a + // user scroll-up that would spuriously re-trigger a load. + this.lastScrollTop = this.getScrollMetrics().scrollTop; + } + + private onMediaChange = (event: MediaQueryListEvent) => { + this.usesWindowScroll = this.usesFullscreenLayout && event.matches; + this.syncScrollListener(); + this.lastScrollTop = this.getScrollMetrics().scrollTop; + }; + + private syncScrollListener(): void { + const nextTarget = this.usesWindowScroll ? "window" : "scrollArea"; + if (this.activeScrollTarget === nextTarget) return; + + this.removeActiveScrollListener(); + if (nextTarget === "window") { + window.addEventListener("scroll", this.onScroll, { passive: true }); + } else { + this.scrollArea.addEventListener("scroll", this.onScroll, { passive: true }); + } + this.activeScrollTarget = nextTarget; + } + + private removeActiveScrollListener(): void { + if (this.activeScrollTarget === "window") { + window.removeEventListener("scroll", this.onScroll); + } else if (this.activeScrollTarget === "scrollArea") { + this.scrollArea.removeEventListener("scroll", this.onScroll); + } + this.activeScrollTarget = null; + } + + private addMediaListener(): void { + if (typeof this.mediaQueryList.addEventListener === "function") { + this.mediaQueryList.addEventListener("change", this.onMediaChange); + } else { + this.mediaQueryList.addListener(this.onMediaChange); + } + } + + private removeMediaListener(): void { + if (typeof this.mediaQueryList.removeEventListener === "function") { + this.mediaQueryList.removeEventListener("change", this.onMediaChange); + } else { + this.mediaQueryList.removeListener(this.onMediaChange); + } + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/components/header.ts b/crates/promptforge-wb-server/ui/src/chat/components/header.ts new file mode 100644 index 00000000..7e54ef03 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/components/header.ts @@ -0,0 +1,49 @@ +import type { ChatEngine } from "../core/chat-engine"; +import { queryOrThrow } from "../utils/dom"; + +export interface HeaderProps { + container: HTMLElement; + engine: ChatEngine; + enableSidebar: boolean; + onOpenSidebar: () => void; +} + +export class Header { + private header: HTMLElement; + private titleEl: HTMLElement | null; + private openSidebarBtn?: HTMLButtonElement; + private unsubscribeTitle: () => void = () => {}; + + private onOpenSidebarBound = (event: MouseEvent) => { + event.stopPropagation(); + this.props.onOpenSidebar(); + }; + + constructor(private props: HeaderProps) { + this.header = queryOrThrow(props.container, ".mur-main-header"); + this.titleEl = this.header.querySelector(".mur-header-title"); + + if (props.enableSidebar) { + this.openSidebarBtn = queryOrThrow(this.header, ".mur-open-sidebar-btn"); + this.openSidebarBtn.addEventListener("click", this.onOpenSidebarBound); + } + + if (this.titleEl) { + this.unsubscribeTitle = props.engine.subscribe( + (state) => state.sessions.find((session) => session.id === state.currentSessionId)?.title ?? "New Chat", + (title) => this.syncTitle(title), + ); + } + } + + public destroy() { + this.unsubscribeTitle(); + this.openSidebarBtn?.removeEventListener("click", this.onOpenSidebarBound); + } + + private syncTitle(title: string) { + if (this.titleEl) { + this.titleEl.textContent = title; + } + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/components/input.ts b/crates/promptforge-wb-server/ui/src/chat/components/input.ts new file mode 100644 index 00000000..19cb9b8d --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/components/input.ts @@ -0,0 +1,242 @@ +import type { ChatPlugin } from "../core/types"; +import { IS_TOUCH_DEVICE } from "../utils/device"; +import { queryOrThrow } from "../utils/dom"; + +const MESSAGE_INPUT_LABEL = "Message"; +const SEND_BUTTON_LABEL = "Send message"; +const STOP_BUTTON_LABEL = "Stop generation"; + +export interface InputProps { + container: HTMLElement; + onSubmit: (text: string) => boolean; + onStop: () => void; +} + +export class Input { + private form: HTMLFormElement; + private input: HTMLTextAreaElement; + private sendBtn: HTMLButtonElement; + private isGenerating = false; + private isLoadingSession = false; + private hasSubmittableText = false; + private focusTimeout: ReturnType | null = null; + + private supportsFieldSizing = typeof CSS !== "undefined" && CSS.supports("field-sizing", "content"); + + private onInputBound = this.handleInput.bind(this); + private onKeydownBound = this.handleKeydown.bind(this); + private onSubmitBound = this.handleFormSubmit.bind(this); + + constructor( + private props: InputProps, + private plugins: ChatPlugin[] = [], + ) { + this.form = queryOrThrow(this.props.container, ".mur-chat-form"); + this.input = queryOrThrow(this.props.container, ".mur-chat-input"); + this.sendBtn = queryOrThrow(this.props.container, ".mur-send-btn"); + + this.ensureInputAccessibleName(); + + for (const plugin of plugins) { + if (plugin.onInputMount) { + try { + plugin.onInputMount({ + container: this.props.container, + form: this.form, + input: this.input, + requestSubmitStateSync: () => this.syncSubmitState(), + }); + } catch (error) { + console.error(`Plugin "${plugin.name}" failed during onInputMount`, error); + } + } + } + + this.bindEvents(); + this.refreshTextState(); + this.syncSubmitState(); + } + + public focus() { + this.scheduleFocus(); + } + + public setGeneratingState(isGenerating: boolean, isLoadingSession: boolean) { + this.isGenerating = isGenerating; + this.isLoadingSession = isLoadingSession; + this.sendBtn.classList.toggle("mur-generating", isGenerating); + this.syncSubmitState(); + } + + public setText(text: string) { + this.input.value = text; + if (!this.supportsFieldSizing) { + this.adjustHeight(); + } + if (this.refreshTextState()) { + this.syncSubmitState(); + } + } + + public getText(): string { + return this.input.value; + } + + public destroy() { + this.clearPendingFocus(); + this.input.removeEventListener("input", this.onInputBound); + this.input.removeEventListener("keydown", this.onKeydownBound); + this.form.removeEventListener("submit", this.onSubmitBound); + } + + private ensureInputAccessibleName() { + if (this.input.hasAttribute("aria-label") || this.input.hasAttribute("aria-labelledby")) return; + if (this.input.labels && this.input.labels.length > 0) return; + + this.input.setAttribute("aria-label", MESSAGE_INPUT_LABEL); + } + + private clearPendingFocus() { + if (this.focusTimeout === null) return; + clearTimeout(this.focusTimeout); + this.focusTimeout = null; + } + + private scheduleFocus() { + if (IS_TOUCH_DEVICE) return; + + // Timeout ensures focus works correctly after DOM reflows + // or when transitioning state (e.g., stopping generation) + this.clearPendingFocus(); + this.focusTimeout = setTimeout(() => { + this.focusTimeout = null; + this.input.focus({ preventScroll: true }); + }, 0); + } + + private bindEvents() { + this.input.addEventListener("input", this.onInputBound); + this.input.addEventListener("keydown", this.onKeydownBound); + this.form.addEventListener("submit", this.onSubmitBound); + } + + private handleInput() { + if (!this.supportsFieldSizing) { + this.adjustHeight(); + } + if (this.refreshTextState()) { + this.syncSubmitState(); + } + } + + private handleKeydown(e: KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey && !e.isComposing && !IS_TOUCH_DEVICE) { + e.preventDefault(); + this.handleSubmit(); + } + } + + private handleFormSubmit(e: Event) { + e.preventDefault(); + this.handleSubmit(); + } + + private adjustHeight() { + const el = this.input; + el.style.height = "auto"; // Force synchronous reflow to determine natural height + const newHeight = Math.min(el.scrollHeight, this.getMaxHeight()); + el.style.height = newHeight + "px"; + } + + private getMaxHeight(): number { + const maxHeight = Number.parseFloat(window.getComputedStyle(this.input).maxHeight); + return Number.isFinite(maxHeight) && maxHeight > 0 ? maxHeight : 200; + } + + private handleSubmit() { + if (this.isGenerating) { + this.props.onStop(); + return; + } + + if (this.isLoadingSession) { + this.syncSubmitState(); + return; + } + + const textStateChanged = this.refreshTextState(); + const text = this.input.value; + + if (!this.canSubmit()) { + if (textStateChanged) { + this.syncSubmitState(); + } + return; + } + + if (!this.props.onSubmit(text)) { + // Submission rejected, keep text and sync state + this.syncSubmitState(); + return; + } + + this.focus(); + this.input.value = ""; + + this.refreshTextState(); + if (!this.supportsFieldSizing) { + this.adjustHeight(); + } + + this.syncSubmitState(); + } + + private syncSubmitState() { + const buttonLabel = this.isGenerating ? STOP_BUTTON_LABEL : SEND_BUTTON_LABEL; + this.sendBtn.setAttribute("aria-label", buttonLabel); + this.sendBtn.title = buttonLabel; + + if (this.isGenerating) { + this.sendBtn.disabled = false; + return; + } + + this.sendBtn.disabled = !this.canSubmit(); + } + + private canSubmit(): boolean { + return ( + !this.isLoadingSession && !this.isSubmitBlocked() && (this.hasSubmittableText || this.hasPendingPluginData()) + ); + } + + private isSubmitBlocked(): boolean { + return this.plugins.some((p) => { + try { + return Boolean(p.isSubmitBlocked?.()); + } catch (error) { + console.error(`Plugin "${p.name}" failed during isSubmitBlocked`, error); + return false; + } + }); + } + + private hasPendingPluginData(): boolean { + return this.plugins.some((p) => { + try { + return Boolean(p.hasPendingData?.()); + } catch (error) { + console.error(`Plugin "${p.name}" failed during hasPendingData`, error); + return false; + } + }); + } + + private refreshTextState(): boolean { + const hasSubmittableText = /\S/.test(this.input.value); + if (hasSubmittableText === this.hasSubmittableText) return false; + + this.hasSubmittableText = hasSubmittableText; + return true; + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/components/message-node.ts b/crates/promptforge-wb-server/ui/src/chat/components/message-node.ts new file mode 100644 index 00000000..614eae37 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/components/message-node.ts @@ -0,0 +1,310 @@ +import { marked } from "marked"; +import type { ActionButtonDef, BlockRenderContext, ContentBlock, Message, RenderConfig } from "../core/types"; +import { el, syncDOMChildren } from "../utils/dom"; +import { renderSafeHTML } from "../utils/html"; + +const MARKDOWN_THROTTLE_MS = 70; + +interface BlockState { + container: HTMLElement; + textCache: string | null; + renderSeq: number; + timer?: number; +} + +export class MessageNode { + public readonly el: HTMLElement; + + private blocksContainer: HTMLElement; + private loadingEl?: HTMLElement; + private errorEl?: HTMLElement; + private actionsEl?: HTMLElement; + + private activeBlocks = new Map(); + + private cacheError: string | null = null; + private cacheIsGenerating: boolean = false; + private cacheActionsVisible: boolean = false; + private actionsInitialized: boolean = false; + private currentMsg: Message | null = null; + private isDestroyed = false; + + constructor( + msg: Message, + private config: RenderConfig, + ) { + this.el = document.createElement("div"); + this.el.className = `mur-message mur-message-${msg.role}`; + if (msg.role === "assistant") { + this.el.setAttribute("role", "article"); + this.el.setAttribute("aria-label", "AI response"); + } + + this.blocksContainer = el("div", "mur-message-blocks-wrapper"); + this.el.appendChild(this.blocksContainer); + } + + public update(msg: Message, isGenerating: boolean, error: string | null, messages: readonly Message[]) { + this.currentMsg = msg; + + if (this.cacheIsGenerating !== isGenerating) { + this.el.classList.toggle("mur-generating", isGenerating); + this.cacheIsGenerating = isGenerating; + } + + this.renderBlocks(msg, isGenerating, messages); + this.renderLoading(msg, isGenerating, error); + this.renderActions(msg, isGenerating); + this.renderError(error); + } + + public destroy() { + this.isDestroyed = true; + for (const state of this.activeBlocks.values()) { + if (state.timer !== undefined) clearTimeout(state.timer); + } + this.el.remove(); + } + + private renderLoading(msg: Message, isGenerating: boolean, error: string | null) { + const hasVisibleBlocks = this.activeBlocks.size > 0; + const isLoading = isGenerating && !error && msg.role === "assistant" && !hasVisibleBlocks; + + if (isLoading) { + if (!this.loadingEl) { + this.loadingEl = el("div", "mur-message-loading", { + innerHTML: ``, + }); + this.el.appendChild(this.loadingEl); + } + } else if (this.loadingEl) { + this.loadingEl.remove(); + this.loadingEl = undefined; + } + } + + private renderBlocks(msg: Message, isGenerating: boolean, messages: readonly Message[]) { + const visibleBlockIds = new Set(); + let displayIndex = 0; + + for (let i = 0; i < msg.blocks.length; i++) { + const block = msg.blocks[i]; + const isLastBlock = i === msg.blocks.length - 1; + const isGeneratingBlock = isGenerating && isLastBlock; + + let state = this.activeBlocks.get(block.id); + let isNew = false; + + if (!state) { + const container = el("div", `mur-content-block mur-block-${block.type}`); + container.dataset.blockId = block.id; + state = { container, textCache: null, renderSeq: 0 }; + isNew = true; + } + const container = state.container; + + let handledByPlugin = false; + let blockRenderCtx: BlockRenderContext | undefined; + for (const plugin of this.config.plugins) { + if (!plugin.onBlockRender) continue; + + blockRenderCtx ??= { message: msg, messages, blockIndex: i }; + try { + if (plugin.onBlockRender(block, container, isGeneratingBlock, blockRenderCtx)) { + handledByPlugin = true; + break; + } + } catch (error) { + console.error(`Plugin "${plugin.name}" failed during onBlockRender`, error); + } + } + + if (!handledByPlugin) { + switch (block.type) { + case "reasoning": + // Fallback behavior: If no plugin (like ThinkingPlugin) handles reasoning blocks, + // we skip them entirely. No DOM node will be added or retained. + continue; + case "text": + this.renderTextBlock(block, state, isGeneratingBlock); + break; + case "file": + this.renderFileBlock(block, container); + break; + case "tool_call": + container.textContent = `🛠 Tool Call: ${block.name} (${block.status})`; + container.className = `mur-content-block mur-block-tool mur-tool-${block.status}`; + break; + case "tool_result": + case "artifact": + // These are background/contextual blocks not meant for direct rendering. + continue; + } + } + + // If we didn't 'continue', it means the block is visible + visibleBlockIds.add(block.id); + + if (isNew) { + this.blocksContainer.appendChild(container); + this.activeBlocks.set(block.id, state); + } + + // Ensure physical DOM order matches visual index order + if (this.blocksContainer.children[displayIndex] !== container) { + this.blocksContainer.insertBefore(container, this.blocksContainer.children[displayIndex]); + } + displayIndex++; + } + + // Cleanup orphaned or newly-ignored blocks + for (const [id, state] of this.activeBlocks.entries()) { + if (!visibleBlockIds.has(id)) { + state.container.remove(); + if (state.timer) clearTimeout(state.timer); + this.activeBlocks.delete(id); + } + } + } + + private renderTextBlock( + block: Extract, + state: BlockState, + isGeneratingBlock: boolean, + ) { + if (state.textCache === block.text) return; + + if (!isGeneratingBlock) { + if (state.timer) { + clearTimeout(state.timer); + state.timer = undefined; + } + state.renderSeq++; + void this.applyMarkdown(block.id, block.text, state.renderSeq); + return; + } + + if (state.timer) return; + + state.timer = window.setTimeout(() => { + state.timer = undefined; + state.renderSeq++; + void this.applyMarkdown(block.id, block.text, state.renderSeq); + }, MARKDOWN_THROTTLE_MS); + } + + private renderFileBlock(block: Extract, container: HTMLElement) { + if (container.hasChildNodes()) return; // Already rendered + + if (block.mimeType.startsWith("image/")) { + container.appendChild(el("img", "mur-attachment-image", { src: block.data })); + } else { + container.appendChild(el("div", "mur-attachment-file-pill", { textContent: `📄 ${block.name || "File"}` })); + } + } + + private async applyMarkdown(blockId: string, content: string, seq: number) { + try { + const html = await marked.parse(content); + const state = this.activeBlocks.get(blockId); + + if (this.isDestroyed || !state || seq !== state.renderSeq) return; + + const nextContent = document.createElement("div"); + await renderSafeHTML(nextContent, html, this.config.highlighter); + + if (this.isDestroyed || !state || seq !== state.renderSeq) return; + + syncDOMChildren(state.container, nextContent); + + state.textCache = content; + } catch (error) { + console.error("Failed to render markdown", error); + } + } + + private renderError(error: string | null) { + if (!error) { + if (this.errorEl) this.errorEl.hidden = true; + this.cacheError = null; + return; + } + + if (!this.errorEl) { + this.errorEl = el("div", "mur-message-error"); + this.el.appendChild(this.errorEl); + } + + if (this.cacheError !== error) { + this.errorEl.textContent = `⚠ ${error}`; + this.errorEl.hidden = false; + this.cacheError = error; + } + } + + private renderActions(msg: Message, isGenerating: boolean) { + const shouldShow = msg.blocks.length > 0; + + if (!shouldShow) { + if (this.actionsEl && this.cacheActionsVisible) { + this.actionsEl.hidden = true; + this.cacheActionsVisible = false; + } + return; + } + + if (isGenerating && !this.actionsInitialized) return; + + if (this.actionsInitialized) { + if (this.actionsEl && !this.cacheActionsVisible) { + this.actionsEl.hidden = false; + this.cacheActionsVisible = true; + } + return; + } + + const actionButtons: HTMLElement[] = []; + + for (const plugin of this.config.plugins) { + let defs: ActionButtonDef[] = []; + try { + defs = plugin.getActionButtons?.(msg) ?? []; + } catch (error) { + console.error(`Plugin "${plugin.name}" failed during getActionButtons`, error); + } + for (const def of defs) { + actionButtons.push(this.createActionButton(plugin.name, def)); + } + } + + this.actionsInitialized = true; + + if (actionButtons.length === 0) return; + + this.actionsEl = el("div", "mur-message-actions", null, actionButtons); + this.el.appendChild(this.actionsEl); + this.cacheActionsVisible = true; + } + + private createActionButton(pluginName: string, def: ActionButtonDef): HTMLButtonElement { + const btn = el("button", "mur-action-icon-btn", { + title: def.title, + innerHTML: def.iconHtml, + }); + + btn.dataset.actionId = def.id; + btn.dataset.pluginName = pluginName; + btn.addEventListener("click", () => { + if (!this.currentMsg) return; + def.onClick({ + message: this.currentMsg, + buttonEl: btn, + messageEl: this.el, + actionId: def.id, + pluginName, + }); + }); + + return btn; + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/components/sidebar.ts b/crates/promptforge-wb-server/ui/src/chat/components/sidebar.ts new file mode 100644 index 00000000..f00f96d9 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/components/sidebar.ts @@ -0,0 +1,335 @@ +import type { ChatEngine } from "../core/chat-engine"; +import { type ChatSessionMeta, MAX_PINNED_SESSIONS } from "../core/types"; +import { el, queryOrThrow, replaceNodes } from "../utils/dom"; +import { ICON_EDIT, ICON_MORE_VERTICAL, ICON_PIN, ICON_PIN_OFF, ICON_TRASH } from "../utils/icons"; +import { closeDropdown, showDropdown } from "./dropdown"; + +export interface SidebarMenuItem { + id: string; + label: string; + iconHtml?: string; + danger?: boolean; + disabled?: boolean; + onClick: () => void; +} + +export type SidebarMenuContext = { + type: "session"; + session: ChatSessionMeta; + engine: ChatEngine; +}; + +export type SidebarMenuBuilder = ( + defaultItems: readonly SidebarMenuItem[], + ctx: SidebarMenuContext, +) => readonly SidebarMenuItem[]; + +export type DeleteConfirmation = (session: ChatSessionMeta) => boolean | Promise; + +export interface SidebarProps { + container: HTMLElement; + engine: ChatEngine; + onNewChat: () => void; + onSelectSession: (id: string) => void; + onLoadMore: () => void; + onClose: () => void; + getSessionHref: (id: string) => string; + sidebarMenu?: SidebarMenuBuilder; + confirmDelete?: DeleteConfirmation; +} + +export class Sidebar { + private sidebar: HTMLElement; + private content: HTMLElement; + private newChatBtn?: HTMLButtonElement | null; + private closeBtn?: HTMLButtonElement | null; + private pinnedCount = 0; + + private loadMoreTrigger: HTMLElement; + private observer?: IntersectionObserver; + + private onNewChatBound = () => this.props.onNewChat(); + private onCloseBound = (e: MouseEvent) => { + e.stopPropagation(); + this.props.onClose(); + }; + + constructor(private props: SidebarProps) { + this.sidebar = queryOrThrow(props.container, ".mur-sidebar"); + this.content = queryOrThrow(this.sidebar, ".mur-sidebar-content"); + this.newChatBtn = this.sidebar.querySelector(".mur-new-chat-btn"); + this.closeBtn = this.sidebar.querySelector(".mur-close-sidebar-btn"); + + this.loadMoreTrigger = el("div", "mur-sidebar-load-more-trigger"); + + if (typeof IntersectionObserver !== "undefined") { + this.observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + this.props.onLoadMore(); + } + }, + { + root: this.content, // Watch scrolling inside the sidebar + rootMargin: "50px", // Trigger 50px before it actually becomes visible + }, + ); + } + + this.bindEvents(); + } + + private bindEvents() { + if (this.newChatBtn) { + this.newChatBtn.addEventListener("click", this.onNewChatBound); + } + if (this.closeBtn) { + this.closeBtn.addEventListener("click", this.onCloseBound); + } + } + + public renderSessions(sessions: ChatSessionMeta[], activeId: string, hasMore: boolean, isLoading = false) { + closeDropdown(); + this.pinnedCount = sessions.filter((session) => session.isPinned).length; + + if (isLoading && sessions.length === 0) { + replaceNodes(this.content, el("p", "mur-sidebar-status", { textContent: "Loading chats..." })); + this.observer?.unobserve(this.loadMoreTrigger); + return; + } + + if (sessions.length === 0) { + replaceNodes(this.content, el("p", "mur-sidebar-status", { textContent: "No past chats." })); + this.observer?.unobserve(this.loadMoreTrigger); + return; + } + + const fragment = document.createDocumentFragment(); + + sessions.forEach((session, index) => { + const isActive = session.id === activeId; + fragment.appendChild(this.createSessionNode(session, isActive)); + if (session.isPinned && sessions[index + 1] && !sessions[index + 1].isPinned) { + fragment.appendChild(el("div", "mur-sidebar-pin-divider")); + } + }); + + if (hasMore) { + fragment.appendChild(this.loadMoreTrigger); + } + + replaceNodes(this.content, fragment); + + if (hasMore) { + this.observer?.observe(this.loadMoreTrigger); + } else { + this.observer?.unobserve(this.loadMoreTrigger); + } + } + + private createSessionNode(session: ChatSessionMeta, isActive: boolean): HTMLElement { + const item = el("div", `mur-sidebar-item ${isActive ? "mur-active" : ""} ${session.isPinned ? "mur-pinned" : ""}`); + item.setAttribute("data-session-id", session.id); + + const link = this.createSessionLink(session, isActive); + item.appendChild(link); + + const menuItems = this.getSessionMenuItems(session); + if (menuItems.length > 0) { + const optionsBtn = el("button", "mur-sidebar-options-btn", { + type: "button", + innerHTML: ICON_MORE_VERTICAL, + title: `Options for "${session.title}"`, + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + + const currentItems = this.getSessionMenuItems(session); + if (currentItems.length > 0) { + showDropdown(optionsBtn, currentItems); + } + }, + }); + optionsBtn.setAttribute("aria-label", `Options for chat "${session.title}"`); + item.appendChild(optionsBtn); + } + + return item; + } + + private createSessionLink(session: ChatSessionMeta, isActive: boolean): HTMLAnchorElement { + const link = el("a", "mur-sidebar-item-link", { + href: this.props.getSessionHref(session.id), + title: session.title, + onclick: (e) => { + e.preventDefault(); + this.props.onSelectSession(session.id); + }, + }); + + if (session.isPinned) { + const pinIcon = el("span", "mur-sidebar-pin-icon", { innerHTML: ICON_PIN }); + pinIcon.setAttribute("aria-label", "Pinned chat"); + link.appendChild(pinIcon); + } + + link.appendChild(el("span", "mur-sidebar-item-title", { textContent: session.title })); + + if (isActive) { + link.setAttribute("aria-current", "page"); + } + + return link; + } + + private startRename(session: ChatSessionMeta): void { + const item = Array.from(this.content.querySelectorAll(".mur-sidebar-item")).find( + (node) => node.getAttribute("data-session-id") === session.id, + ); + const link = item?.querySelector(".mur-sidebar-item-link"); + if (!item || !link) return; + + item.classList.add("mur-renaming"); + const isActive = link.getAttribute("aria-current") === "page"; + const input = el("input", "mur-sidebar-rename-input", { + type: "text", + value: session.title, + ariaLabel: `Rename chat "${session.title}"`, + onclick: (e) => e.stopPropagation(), + }); + + let finished = false; + const restore = (title = session.title) => { + const nextLink = this.createSessionLink({ ...session, title }, isActive); + item.classList.remove("mur-renaming"); + if (input.isConnected) { + item.replaceChild(nextLink, input); + } else { + const currentLink = item.querySelector(".mur-sidebar-item-link"); + if (currentLink) item.replaceChild(nextLink, currentLink); + } + }; + const commit = () => { + if (finished) return; + finished = true; + const title = input.value.trim(); + if (!title || title === session.title) { + restore(); + return; + } + + restore(title); + void this.props.engine.sessions.updateTitle(session.id, title).catch((error) => { + console.error(`Failed to rename session "${session.id}"`, error); + restore(); + }); + }; + const cancel = () => { + if (finished) return; + finished = true; + restore(); + }; + + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + }); + input.addEventListener("blur", commit); + + item.replaceChild(input, link); + input.focus(); + input.select(); + } + + private getSessionMenuItems(session: ChatSessionMeta): readonly SidebarMenuItem[] { + const isPinned = Boolean(session.isPinned); + const defaultItems: SidebarMenuItem[] = [ + { + id: "rename", + label: "Rename", + iconHtml: ICON_EDIT, + onClick: () => { + this.startRename(session); + }, + }, + { + id: isPinned ? "unpin" : "pin", + label: isPinned ? "Unpin" : "Pin", + iconHtml: isPinned ? ICON_PIN_OFF : ICON_PIN, + disabled: !isPinned && this.pinnedCount >= MAX_PINNED_SESSIONS, + onClick: () => { + void this.props.engine.sessions.updatePinned(session.id, !isPinned).catch((error) => { + console.error(`Failed to update pinned state for session "${session.id}"`, error); + }); + }, + }, + { + id: "delete", + label: "Delete", + iconHtml: ICON_TRASH, + danger: true, + onClick: () => { + void this.confirmAndDelete(session); + }, + }, + ]; + + return ( + this.props.sidebarMenu?.(defaultItems, { type: "session", session, engine: this.props.engine }) ?? defaultItems + ); + } + + private async confirmAndDelete(session: ChatSessionMeta): Promise { + try { + const confirmed = this.props.confirmDelete + ? await this.props.confirmDelete(session) + : confirm(`Delete chat "${session.title}"? This cannot be undone.`); + + if (!confirmed) return; + await this.props.engine.sessions.delete(session.id); + } catch (error) { + console.error(`Failed to delete session "${session.id}"`, error); + } + } + + public setActiveSession(id: string) { + const current = this.content.querySelector(".mur-sidebar-item.mur-active"); + if (current?.getAttribute("data-session-id") === id) { + return; + } + + if (current) { + current.classList.remove("mur-active"); + current.querySelector(".mur-sidebar-item-link")?.removeAttribute("aria-current"); + } + + const next = Array.from(this.content.querySelectorAll(".mur-sidebar-item")).find( + (item) => item.getAttribute("data-session-id") === id, + ); + + if (next) { + next.classList.add("mur-active"); + next.querySelector(".mur-sidebar-item-link")?.setAttribute("aria-current", "page"); + } + } + + public setVisible(isVisible: boolean) { + this.sidebar.hidden = !isVisible; + } + + public destroy() { + closeDropdown(); + this.observer?.disconnect(); + if (this.newChatBtn) { + this.newChatBtn.removeEventListener("click", this.onNewChatBound); + } + if (this.closeBtn) { + this.closeBtn.removeEventListener("click", this.onCloseBound); + } + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/chat-engine.ts b/crates/promptforge-wb-server/ui/src/chat/core/chat-engine.ts new file mode 100644 index 00000000..f6663c81 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/chat-engine.ts @@ -0,0 +1,493 @@ +import { uuidv7 } from "../utils/uuid"; +import { cloneMessages, dropEphemeralMessages } from "./msg-utils"; +import { type ChatSessions, SessionManager } from "./session-manager"; +import { Store } from "./store"; +import { applyStreamEventToState, type StreamReducerEvent } from "./stream-reducer"; +import type { + ChatPlugin, + ChatProvider, + ChatRequest, + ChatRequestDefaults, + ChatRequestPatch, + ChatState, + ChatStorage, + Message, + ReadonlyChatRequest, + RequestOptions, +} from "./types"; + +export interface ChatEngineConfig { + provider: ChatProvider; + storage: ChatStorage; + initialSessionId?: string | null; + titleOptions?: Partial; + titleInstructions?: string; +} + +interface ActiveGeneration { + id: string; + sessionId: string; + currentMessageId: string; + controller: AbortController; + provider: ChatProvider; + requestDefaults: ChatRequestDefaults; +} + +export class ChatEngine { + private store: Store; + private readonly sessionManager: SessionManager; + public readonly sessions: ChatSessions; + + private provider: ChatProvider; + private plugins: ChatPlugin[] = []; + private requestDefaults: ChatRequestDefaults = { options: {} }; + private titleOptions: Partial = {}; + private titleInstructions?: string; + private activeGeneration: ActiveGeneration | null = null; + private autoTitleControllers = new Set(); + private isDestroyed = false; + + constructor(config: ChatEngineConfig) { + this.provider = config.provider; + this.titleOptions = this.mergeDefinedOptions({}, config.titleOptions ?? {}); + this.titleInstructions = config.titleInstructions; + + const startingId = config.initialSessionId || uuidv7(); + + this.store = new Store({ + sessions: [], + hasMoreSessions: false, + currentSessionId: startingId, + messages: [], + generatingMessageId: null, + isLoadingSession: !!config.initialSessionId, + isLoadingSessions: false, + hasMoreMessages: false, + isLoadingMessages: false, + error: null, + }); + this.sessionManager = new SessionManager({ + store: this.store, + storage: config.storage, + isGenerationActive: () => this.isBusy, + stopActiveGeneration: () => this.stopGeneration(), + }); + this.sessions = this.sessionManager; + + if (config.initialSessionId) { + void this.sessionManager.loadInitial(startingId); + } + } + + public registerPlugins(plugins: ChatPlugin[]) { + this.plugins = plugins; + } + + public get state(): ChatState { + return this.store.get(); + } + + public subscribe(selector: (state: ChatState) => U, listener: (selectedState: U) => void): () => void { + return this.store.subscribe(selector, listener); + } + + public subscribeHot(listener: (state: ChatState) => void): () => void { + return this.store.subscribeHot(listener); + } + + public onChange(selector: (state: ChatState) => U, listener: (selectedState: U) => void): () => void { + return this.store.onChange(selector, listener); + } + + private get isBusy() { + return this.activeGeneration !== null; + } + + public async setProvider(newProvider: ChatProvider) { + if (this.isBusy) await this.stopGeneration(); + this.provider = newProvider; + } + + public clearError() { + this.store.set({ error: null }); + } + + public sendMessage(content: string): boolean { + if (this.isBusy || this.state.isLoadingSession) return false; + + const currentMessages = dropEphemeralMessages(this.state.messages); + const now = Date.now(); + const userMessageId = uuidv7(); + + const userMsg: Message = { + id: userMessageId, + role: "user", + blocks: content ? [{ id: uuidv7(), type: "text", text: content }] : [], + runId: userMessageId, + createdAt: now, + updatedAt: now, + }; + + for (const plugin of this.plugins) { + try { + plugin.onUserSubmit?.(userMsg); + } catch (error) { + console.error(`Plugin "${plugin.name}" failed during onUserSubmit`, error); + } + } + + if (userMsg.blocks.length === 0) return false; + + void this.startGeneration([...currentMessages, userMsg]); + return true; + } + + public editAndResubmit(messageId: string, newContent: string): boolean { + if (this.isBusy) return false; + + const currentMessages = dropEphemeralMessages(this.state.messages); + const targetIndex = currentMessages.findIndex((m) => m.id === messageId); + + if (targetIndex === -1) return false; + if (currentMessages[targetIndex].role !== "user") return false; + + // Truncate history to remove everything AFTER the edited message + // and update the edited message itself + const updatedMessages = currentMessages.slice(0, targetIndex + 1); + + // Preserve non-text blocks (like images/files) and append the edited text + const preservedBlocks = updatedMessages[targetIndex].blocks.filter((b) => b.type !== "text"); + const newTextBlock = newContent ? [{ id: uuidv7(), type: "text" as const, text: newContent }] : []; + const finalBlocks = [...preservedBlocks, ...newTextBlock]; + const now = Date.now(); + + if (finalBlocks.length === 0) return false; + + updatedMessages[targetIndex] = { + ...updatedMessages[targetIndex], + blocks: finalBlocks, + runId: updatedMessages[targetIndex].runId ?? updatedMessages[targetIndex].id, + createdAt: updatedMessages[targetIndex].createdAt ?? now, + updatedAt: now, + }; + + void this.startGeneration(updatedMessages); + return true; + } + + /** + * Completely replaces the current session's message history and attempts to save it to storage. + * Useful for clearing history, compacting context, or modifying past messages. + */ + public async setMessages(messages: Message[]): Promise { + if (this.isBusy) { + console.warn("Cannot modify history while the AI is generating a response."); + return false; + } + + this.store.set({ messages }); + return await this.persistCurrentSession(); + } + + /** + * Sets global default request parameters for outgoing chat requests. + * `instructions` and `tools` are request-level model inputs; `options` are provider options. + */ + public setRequestDefaults(defaults: Partial) { + this.requestDefaults = { + ...this.requestDefaults, + ...defaults, + options: this.mergeDefinedOptions(this.requestDefaults.options ?? {}, defaults.options ?? {}), + }; + } + + public setTitleOptions(options: Partial) { + this.titleOptions = this.mergeDefinedOptions(this.titleOptions, options); + } + + public setTitleInstructions(instructions: string | undefined) { + this.titleInstructions = instructions; + } + + public async stopGeneration() { + if (!this.isBusy) return; + + const generation = this.activeGeneration; + if (!generation) return; + + generation.controller.abort(); + this.applyStreamEvent(generation.id, { type: "finish", reason: "aborted" }); + await this.finalizeGeneration(generation.id, true); + } + + public async destroy() { + this.isDestroyed = true; + this.abortAutoTitles(); + await this.stopGeneration(); + await this.sessionManager.close(); + this.store.clearAllListeners(); + } + + private async startGeneration(contextMessages: Message[]) { + const generationId = uuidv7(); + const initialMessageId = generationId; + const sessionId = this.state.currentSessionId; + const provider = this.provider; + const controller = new AbortController(); + const signal = controller.signal; + this.activeGeneration = { + id: generationId, + sessionId, + currentMessageId: initialMessageId, + controller, + provider, + requestDefaults: this.cloneRequestDefaults(), + }; + + // Instantly create an empty assistant message so the UI shows a loading state + const now = Date.now(); + const runId = findLastUserRunId(contextMessages) ?? initialMessageId; + const assistantMsg: Message = { + id: initialMessageId, + role: "assistant", + blocks: [], + runId, + createdAt: now, + updatedAt: now, + ephemeral: true, + }; + + const updatedMessages = [...contextMessages, assistantMsg]; + + this.store.set({ + messages: updatedMessages, + generatingMessageId: initialMessageId, + error: null, + }); + + let wasAborted = false; + try { + const payloadParams = await this.prepareRequestParams(contextMessages, signal); + if (signal.aborted) { + wasAborted = true; + return; + } + + await provider.streamChat(payloadParams, (event) => { + if (signal.aborted) return; + if (event.type === "finish" && event.reason === "aborted") { + wasAborted = true; + } + this.applyStreamEvent(generationId, event); + }); + } catch (err: unknown) { + if (signal.aborted) { + wasAborted = true; + return; + } + + const errorMessage = + err instanceof Error + ? err.message + : typeof err === "object" && err !== null + ? JSON.stringify(err) + : String(err); + + this.applyStreamEvent(generationId, { type: "error", message: errorMessage }); + } finally { + await this.finalizeGeneration(generationId, wasAborted || signal.aborted); + } + } + + /** + * Applies reducer events without cloning active stream blocks. + * @param generationId The ID we generated locally to track the active generation. + */ + private applyStreamEvent(generationId: string, event: StreamReducerEvent) { + const generation = this.activeGeneration; + if (generation?.id !== generationId) return; + + let currentMessageId = generation.currentMessageId; + this.store.mutateHot((state) => { + currentMessageId = applyStreamEventToState(state, generation.currentMessageId, event); + }); + generation.currentMessageId = currentMessageId; + } + + private async prepareRequestParams( + messages: Message[], + signal: AbortSignal, + requestDefaults: ChatRequestDefaults = this.requestDefaults, + ): Promise { + const payloadParams: ChatRequest = { + messages: [...messages], + instructions: requestDefaults.instructions, + tools: requestDefaults.tools ? [...requestDefaults.tools] : undefined, + options: { ...requestDefaults.options }, + signal, + }; + + for (const plugin of this.plugins) { + if (signal.aborted) return payloadParams; + + if (plugin.beforeSubmit) { + const request: ReadonlyChatRequest = { + messages: [...payloadParams.messages], + instructions: payloadParams.instructions, + tools: payloadParams.tools ? [...payloadParams.tools] : undefined, + options: { ...payloadParams.options }, + signal, + }; + const patch = await plugin.beforeSubmit(request); + if (signal.aborted) return payloadParams; + + if (patch) { + if (patch.messages) payloadParams.messages = patch.messages; + if (hasPatchField(patch, "instructions")) { + payloadParams.instructions = patch.instructions; + } + if (hasPatchField(patch, "tools")) { + payloadParams.tools = patch.tools ? [...patch.tools] : undefined; + } + if (patch.options) { + payloadParams.options = this.mergeDefinedOptions(payloadParams.options, patch.options) as RequestOptions; + } + } + } + } + + payloadParams.messages = dropEphemeralMessages(payloadParams.messages); + + return payloadParams; + } + + private async finalizeGeneration(generationId: string, wasAborted: boolean = false) { + const generation = this.activeGeneration; + if (generation?.id !== generationId) return; + this.activeGeneration = null; + + if (wasAborted) { + this.removeAbortedEphemeralMessage(generation.currentMessageId); + } + + if (this.state.generatingMessageId !== null) { + this.store.set({ generatingMessageId: null }); + } + + try { + const finalMessages = cloneMessages(this.state.messages); + const persistentMessages = dropEphemeralMessages(finalMessages); + const hasError = this.state.error !== null; + const saved = await this.sessionManager.persistSessionSnapshot(generation.sessionId, finalMessages); + + if (!saved) return; + + // Auto-title trigger + if (!hasError && !wasAborted && generation.provider.generateTitle) { + const assistantRepliesCount = persistentMessages.filter( + (m) => m.role === "assistant" && m.blocks.length > 0, + ).length; + + if (assistantRepliesCount === 1) { + void this.triggerAutoTitle( + generation.sessionId, + persistentMessages, + generation.provider, + generation.requestDefaults, + ); + } + } + } catch (error) { + console.error("Failed to finalize stream", error); + } + } + + private removeAbortedEphemeralMessage(pendingId: string): void { + const pendingMessage = this.state.messages.find((m) => m.id === pendingId); + if (!pendingMessage?.ephemeral) return; + + this.store.set({ + messages: this.state.messages.filter((m) => m.id !== pendingId), + }); + } + + private async persistCurrentSession(): Promise { + const { currentSessionId, messages } = this.store.get(); + return await this.sessionManager.persistSessionSnapshot(currentSessionId, cloneMessages(messages)); + } + + private async triggerAutoTitle( + sessionId: string, + messages: Message[], + provider: ChatProvider, + requestDefaults: ChatRequestDefaults, + ) { + if (this.isDestroyed || this.sessionManager.isDeleted(sessionId)) return; + + const controller = new AbortController(); + this.autoTitleControllers.add(controller); + + try { + const payloadMessages = dropEphemeralMessages(messages); + const payloadOptions = { ...requestDefaults.options, ...this.titleOptions }; + const titleRequest: ChatRequest = { + messages: payloadMessages, + instructions: this.titleInstructions, + options: payloadOptions, + signal: controller.signal, + }; + + const smartTitle = await provider.generateTitle!(titleRequest); + if (!smartTitle) return; + if (controller.signal.aborted || this.isDestroyed || this.sessionManager.isDeleted(sessionId)) return; + + await this.sessionManager.updateTitle(sessionId, smartTitle); + } catch (e) { + if (controller.signal.aborted) return; + console.error("Failed to auto-generate title", e); + } finally { + this.autoTitleControllers.delete(controller); + } + } + + private abortAutoTitles(): void { + for (const controller of this.autoTitleControllers) { + controller.abort(); + } + this.autoTitleControllers.clear(); + } + + private mergeDefinedOptions(base: Partial, patch: Partial): Partial { + const next: Partial = { ...base }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + delete next[key]; + } else { + next[key] = value; + } + } + return next; + } + + private cloneRequestDefaults(defaults: ChatRequestDefaults = this.requestDefaults): ChatRequestDefaults { + return { + instructions: defaults.instructions, + tools: defaults.tools ? [...defaults.tools] : undefined, + options: { ...defaults.options }, + }; + } +} + +function findLastUserRunId(messages: readonly Message[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === "user") return message.runId ?? message.id; + } + + return undefined; +} + +function hasPatchField(patch: ChatRequestPatch, key: keyof ChatRequestPatch): boolean { + // biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn is ES2022, but core targets ES2018. + return Object.prototype.hasOwnProperty.call(patch, key); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/msg-utils.ts b/crates/promptforge-wb-server/ui/src/chat/core/msg-utils.ts new file mode 100644 index 00000000..6322e8a2 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/msg-utils.ts @@ -0,0 +1,46 @@ +import type { JsonValue, Message } from "./types"; + +/** Extracts all plain text from text blocks */ +export function extractPlainText(msg: Message): string { + return msg.blocks + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n\n"); +} + +export function dropEphemeralMessages(messages: Message[]): Message[] { + return messages.filter((m) => !m.ephemeral); +} + +export function cloneMessages(messages: Message[]): Message[] { + return messages.map((message) => { + const cloned: Message = { + ...message, + blocks: message.blocks.map((block) => ({ ...block })), + }; + if (message.usage) { + cloned.usage = { + ...message.usage, + ...(message.usage.details !== undefined ? { details: cloneJsonValue(message.usage.details) } : {}), + }; + } + if (message.meta) { + cloned.meta = cloneJsonValue(message.meta); + } + return cloned; + }); +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (value && typeof value === "object") { + const cloned: { [key: string]: JsonValue } = {}; + for (const [key, item] of Object.entries(value)) { + cloned[key] = cloneJsonValue(item); + } + return cloned as T; + } + return value; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/providers/openai.ts b/crates/promptforge-wb-server/ui/src/chat/core/providers/openai.ts new file mode 100644 index 00000000..25f944fe --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/providers/openai.ts @@ -0,0 +1,413 @@ +import { parseSSE } from "../../utils/sse"; +import { uuidv7 } from "../../utils/uuid"; +import type { ChatProvider, ChatRequest, FinishReason, Message, StreamEvent } from "../types"; + +type OpenAIStreamDelta = { + content?: string | null; + tool_calls?: Array<{ + index: number; + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; + }>; + reasoning?: string | { encrypted?: string }; + reasoning_encrypted?: string; + reasoning_content?: string; + reasoning_text?: string; + [key: string]: unknown; +}; + +interface OpenAIStreamChunk { + id?: string; + choices?: Array<{ + delta?: OpenAIStreamDelta; + finish_reason?: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + prompt_tokens_details?: { + cached_tokens?: number; + }; + }; +} + +type OpenAIContentPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }; + +const REASONING_FIELDS = ["reasoning_content", "reasoning", "reasoning_text"] as const; +const DEFAULT_TITLE_SYSTEM_PROMPT = + "You generate concise chat titles. Reply only with the title, without quotes or extra text."; + +export class OpenAIProvider implements ChatProvider { + constructor( + private apiKey: string, + private endpoint: string, + private model: string, + ) {} + + async streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise { + const { model = this.model, ...restOptions } = request.options; + + const response = await fetch(this.endpoint, { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ + ...restOptions, + model, + messages: this.formatMessagesWithInstructions(request.messages, request.instructions), + stream: true, + ...(request.tools ? { tools: request.tools } : {}), + stream_options: { + include_usage: true, + ...((restOptions.stream_options as object) || {}), + }, + }), + signal: request.signal, + }); + + if (!response.ok) { + const errorMsg = await this.extractErrorMessage(response); + throw new Error(`API Error ${response.status}: ${errorMsg}`); + } + + let messageStarted = false; + let currentMessageId = uuidv7(); + let currentTextBlockId: string | null = null; + let currentReasoningBlockId: string | null = null; + + // Map OpenAI's tool call index to our block IDs + const activeToolCalls = new Map(); + + let finishEmitted = false; + + await parseSSE(response, (data) => { + if (data === "[DONE]") return true; + + // Flat try/catch: Just parse and exit early if it's a broken chunk + let parsed: OpenAIStreamChunk; + try { + parsed = JSON.parse(data); + } catch { + return; // Ignore partial/broken JSON payload + } + if (parsed.usage) { + const input = parsed.usage.prompt_tokens ?? 0; + const output = parsed.usage.completion_tokens ?? 0; + onEvent({ + type: "usage", + input, + output, + total: parsed.usage.total_tokens ?? input + output, + cacheRead: parsed.usage.prompt_tokens_details?.cached_tokens ?? 0, + }); + } + + const choice = parsed.choices?.[0]; + if (!choice) return; + + // 1. Emit start event on first chunk + if (!messageStarted) { + currentMessageId = parsed.id || currentMessageId; + onEvent({ + type: "message_start", + message: { id: currentMessageId, role: "assistant", blocks: [] }, + }); + messageStarted = true; + } + + const delta: OpenAIStreamDelta = choice.delta ?? {}; + + // 2. Handle Reasoning + const reasoningData = this.extractReasoning(delta); + if (reasoningData) { + if (!currentReasoningBlockId) currentReasoningBlockId = uuidv7(); + currentTextBlockId = null; + + onEvent({ + type: "reasoning_delta", + messageId: currentMessageId, + blockId: currentReasoningBlockId, + delta: reasoningData.text, + encrypted: reasoningData.encrypted, + }); + } + + // 3. Handle Text Content + if (delta.content) { + if (!currentTextBlockId) currentTextBlockId = uuidv7(); + onEvent({ + type: "text_delta", + messageId: currentMessageId, + blockId: currentTextBlockId, + delta: delta.content, + }); + } + + // 4. Handle Tool Calls + if (delta.tool_calls && Array.isArray(delta.tool_calls)) { + for (const tc of delta.tool_calls) { + const index = tc.index; + // If it has an ID, it's a new tool call + if (tc.id) { + currentTextBlockId = null; + + const blockId = uuidv7(); + activeToolCalls.set(index, blockId); + onEvent({ + type: "tool_call_start", + messageId: currentMessageId, + block: { + id: blockId, + type: "tool_call", + toolCallId: tc.id, + name: tc.function?.name || "", + argsText: tc.function?.arguments || "", + status: "streaming", + }, + }); + } + // Otherwise, it's appending arguments to an existing tool call + else if (activeToolCalls.has(index)) { + onEvent({ + type: "tool_call_delta", + messageId: currentMessageId, + blockId: activeToolCalls.get(index)!, + name: tc.function?.name, + argsDelta: tc.function?.arguments || "", + }); + } + } + } + + // 5. Handle Finish Reason + if (choice.finish_reason) { + if (choice.finish_reason === "content_filter") { + throw new Error("Generation stopped by provider content filter."); + } + if (choice.finish_reason === "network_error") { + throw new Error("Generation stopped due to a provider network error."); + } + + const reasonMap: Record = { + stop: "stop", + length: "length", + tool_calls: "tool_use", + }; + onEvent({ + type: "finish", + reason: reasonMap[choice.finish_reason] || "stop", + }); + finishEmitted = true; + } + return undefined; + }); + + // If it finishes normally but didn't emit a finish reason (some providers do this) + if (!finishEmitted) { + onEvent({ type: "finish", reason: "stop" }); + } + } + + private async extractErrorMessage(response: Response): Promise { + const text = await response.text(); + try { + const parsed = JSON.parse(text); + return parsed.error?.message || parsed.message || parsed.error?.metadata?.raw || text; + } catch { + return text; + } + } + + async generateTitle(request: ChatRequest): Promise { + try { + const { model = this.model, stream_options: _streamOptions, ...restOptions } = request.options; + const titleSystemPrompt = + typeof request.instructions === "string" && request.instructions.trim().length > 0 + ? request.instructions + : DEFAULT_TITLE_SYSTEM_PROMPT; + + let endIndex = request.messages.findIndex((m) => m.role === "assistant" && m.blocks.length > 0); + if (endIndex === -1) endIndex = Math.min(request.messages.length - 1, 3); + + const contextMessages = request.messages.slice(0, endIndex + 1); + const formattedMessages = [ + { role: "system", content: titleSystemPrompt }, + ...this.formatMessages(contextMessages), + { + role: "user", + content: + "Summarize the above conversation in 3-5 words. Reply ONLY with the title, no quotes, no extra text.", + }, + ]; + + const response = await fetch(this.endpoint, { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ + ...restOptions, + model, + messages: formattedMessages, + stream: false, + }), + signal: request.signal, + }); + + if (!response.ok) return ""; + const data = await response.json(); + return this.normalizeTitle(data.choices[0]?.message?.content); + } catch (error) { + const isAbort = error instanceof Error && error.name === "AbortError"; + if (!isAbort && !request.signal.aborted) { + console.warn("Failed to generate chat title.", error); + } + return ""; + } + } + + private normalizeTitle(title: unknown): string { + if (typeof title !== "string") return ""; + + const normalized = title.replace(/\s+/g, " ").trim(); + const unquoted = normalized.replace(/^['"]+|['"]+$/g, "").trim(); + + if (unquoted.length <= 80) return unquoted; + return `${unquoted.slice(0, 77).trimEnd()}...`; + } + + private headers(): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + const apiKey = this.apiKey.trim(); + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + return headers; + } + + private formatMessages(messages: Message[]): Record[] { + const result: Record[] = []; + const serializedToolCallIds = new Set(); + + for (const msg of messages) { + // Tool messages map 1:1 to API tool responses. + // They contain only the execution output, so we bypass standard processing. + if (msg.role === "tool") { + for (const block of msg.blocks) { + if (block.type === "tool_result" && serializedToolCallIds.has(block.toolCallId)) { + result.push({ + role: "tool", + tool_call_id: block.toolCallId, + content: block.outputText, + }); + } + } + continue; + } + + const payload: Record = { role: msg.role }; + const toolCalls: Record[] = []; + const contentArray: OpenAIContentPart[] = []; + + for (const block of msg.blocks) { + switch (block.type) { + case "tool_call": + if (block.status === "complete") { + toolCalls.push({ + id: block.toolCallId, + type: "function", + function: { name: block.name, arguments: block.argsText }, + }); + serializedToolCallIds.add(block.toolCallId); + } + break; + + case "text": + contentArray.push({ type: "text", text: block.text }); + break; + + case "file": + if (block.mimeType.startsWith("image/")) { + contentArray.push({ type: "image_url", image_url: { url: block.data } }); + } else { + contentArray.push({ + type: "text", + text: `\n\n--- File: ${block.name || "Unknown"} ---\n${block.data}`, + }); + } + break; + + case "reasoning": + case "artifact": + // Intentionally omitted. + // Reasoning tokens and internal UI artifacts are not sent back in context. + break; + } + } + + if (msg.role === "assistant" && contentArray.length === 0 && toolCalls.length === 0) { + continue; + } + + if (toolCalls.length > 0) { + payload.tool_calls = toolCalls; + } + // Conform to OpenAI's expected content structures + if (msg.role === "assistant") { + // Assistant messages strictly require a string or null (never an array) + if (contentArray.length === 0) { + payload.content = toolCalls.length > 0 ? null : ""; + } else { + // Safely flatten any multiple text blocks into a single string + payload.content = contentArray + .filter((c) => c.type === "text") + .map((c) => (c as { text: string }).text) + .join("\n\n"); + } + } else { + // User messages can safely use the multimodal array format + if (contentArray.length === 0) { + payload.content = toolCalls.length > 0 ? null : ""; + } else if (contentArray.length === 1 && contentArray[0].type === "text") { + // Fast path for simple text messages + payload.content = contentArray[0].text; + } else { + // Multimodal or multi-part message + payload.content = contentArray; + } + } + + result.push(payload); + } + return result; + } + + private formatMessagesWithInstructions(messages: Message[], instructions?: string): Record[] { + const formattedMessages = this.formatMessages(messages); + if (!instructions) return formattedMessages; + return [{ role: "system", content: instructions }, ...formattedMessages]; + } + + private extractReasoning(delta: OpenAIStreamDelta): { text: string; encrypted: boolean } | null { + // Check for encrypted reasoning (e.g., Anthropic via OpenRouter / Some DeepSeek setups) + if (delta.reasoning && typeof delta.reasoning === "object" && typeof delta.reasoning.encrypted === "string") { + return { text: "", encrypted: true }; + } + if (typeof delta.reasoning_encrypted === "string") { + return { text: "", encrypted: true }; + } + + // Check for standard reasoning + for (const field of REASONING_FIELDS) { + if (typeof delta[field] === "string" && delta[field].length > 0) { + return { text: delta[field], encrypted: false }; + } + } + + return null; + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/session-manager.ts b/crates/promptforge-wb-server/ui/src/chat/core/session-manager.ts new file mode 100644 index 00000000..07ef89a3 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/session-manager.ts @@ -0,0 +1,447 @@ +import { uuidv7 } from "../utils/uuid"; +import { dropEphemeralMessages, extractPlainText } from "./msg-utils"; +import type { Store } from "./store"; +import { + type ChatSession, + type ChatSessionMeta, + type ChatState, + type ChatStorage, + type ContentBlock, + MAX_PINNED_SESSIONS, + type Message, +} from "./types"; + +interface SessionManagerConfig { + store: Store; + storage: ChatStorage; + isGenerationActive: () => boolean; + stopActiveGeneration: () => Promise; +} + +// Page size for upward message pagination (loadOlderMessages). +const OLDER_MESSAGES_PAGE_SIZE = 100; + +export interface ChatSessions { + loadHistory(): Promise; + loadMore(): Promise; + // Loads a page of messages older than the current transcript head and + // prepends them. No-op unless the storage implements loadOlderMessages and + // the current session has more history. + loadOlderMessages(): Promise; + create(): Promise; + switch(id: string): Promise; + delete(id: string): Promise; + updateTitle(sessionId: string, title: string): Promise; + updatePinned(sessionId: string, isPinned: boolean): Promise; +} + +export class SessionManager implements ChatSessions { + private store: Store; + private storage: ChatStorage; + private isGenerationActive: () => boolean; + private stopActiveGeneration: () => Promise; + private activeSessionMeta: ChatSessionMeta | null = null; + private sessionWriteQueues = new Map>(); + private deletedSessionIds = new Set(); + private isFetchingSessions = false; + private isFetchingOlder = false; + private olderCursor: string | null = null; + private sessionPageCursor: ChatSessionMeta | null = null; + private switchSeq = 0; + + constructor(config: SessionManagerConfig) { + this.store = config.store; + this.storage = config.storage; + this.isGenerationActive = config.isGenerationActive; + this.stopActiveGeneration = config.stopActiveGeneration; + } + + public isDeleted(sessionId: string): boolean { + return this.deletedSessionIds.has(sessionId); + } + + public async loadInitial(id: string): Promise { + await this.loadSession(id, "Chat not found. Started a new one."); + } + + public async loadHistory(): Promise { + await this.fetchSessionsPage(false); + } + + // Call this when the user scrolls to the bottom of the sidebar + public async loadMore(): Promise { + await this.fetchSessionsPage(true); + } + + // Call this when the user scrolls to the top of the transcript. + public async loadOlderMessages(): Promise { + if (!this.storage.loadOlderMessages) return; + if (this.isFetchingOlder || !this.state.hasMoreMessages || !this.olderCursor) return; + + const sessionId = this.state.currentSessionId; + const cursor = this.olderCursor; + + this.isFetchingOlder = true; + const seq = this.switchSeq; + this.store.set({ isLoadingMessages: true }); + + try { + const page = await this.storage.loadOlderMessages(sessionId, cursor, OLDER_MESSAGES_PAGE_SIZE); + // Drop the result if the user switched/reloaded the session meanwhile. + if (seq !== this.switchSeq || this.state.currentSessionId !== sessionId) return; + + // Prepend onto the *current* messages (a generation may have appended + // while we awaited), de-duping any overlap with the existing head. + const current = this.state.messages; + const existing = new Set(current.map((m) => m.id)); + const older = page.messages.filter((m) => !existing.has(m.id)); + this.olderCursor = page.nextOlderMessagesCursor ?? null; + + this.store.set({ + messages: [...older, ...current], + hasMoreMessages: page.hasMore && this.olderCursor !== null, + isLoadingMessages: false, + }); + } catch (error) { + console.error("Failed to load older messages", error); + if (seq === this.switchSeq && this.state.currentSessionId === sessionId) { + this.store.set({ isLoadingMessages: false }); + } + } finally { + this.isFetchingOlder = false; + } + } + + public async create(): Promise { + if (this.isGenerationActive()) { + await this.stopActiveGeneration(); + } + this.startNewSession(); + } + + public async switch(id: string): Promise { + await this.loadSession(id, "Failed to load chat. Started a new one."); + } + + public async delete(id: string): Promise { + const isCurrent = this.state.currentSessionId === id; + this.deletedSessionIds.add(id); + this.activeSessionMeta = this.activeSessionMeta?.id === id ? null : this.activeSessionMeta; + this.store.set({ + sessions: this.state.sessions.filter((s) => s.id !== id), + }); + + try { + if (isCurrent && this.isGenerationActive()) { + await this.stopActiveGeneration(); + } + + if (isCurrent && this.state.currentSessionId === id) { + this.startNewSession(); + } + + await this.enqueueSessionWrite(id, async () => { + await this.storage.delete(id); + }); + } catch (error) { + console.error(`Failed to delete session "${id}"`, error); + } + } + + public async persistSessionSnapshot(sessionId: string, messages: Message[]): Promise { + if (this.deletedSessionIds.has(sessionId)) return false; + + const messagesToSave = dropEphemeralMessages(messages); + + try { + return await this.enqueueSessionWrite(sessionId, async () => { + if (this.deletedSessionIds.has(sessionId)) return false; + + // Resolve title/isPinned here, not at enqueue time: an earlier queued + // write (e.g. auto-title's updateTitle) may change them before this + // operation runs, and a stale snapshot would overwrite that update. + const existingMeta = this.state.sessions.find((s) => s.id === sessionId); + const title = existingMeta?.title ?? this.createFallbackTitle(messagesToSave); + const isPinned = + existingMeta?.isPinned ?? + (this.activeSessionMeta?.id === sessionId ? this.activeSessionMeta.isPinned : undefined); + + const sessionToSave: ChatSession = { + id: sessionId, + title, + updatedAt: Date.now(), + ...(typeof isPinned === "boolean" ? { isPinned } : {}), + messages: messagesToSave, + }; + + await this.storage.save(sessionToSave); + + if (this.deletedSessionIds.has(sessionId)) return false; + + const sessionMeta = this.toSessionMeta(sessionToSave); + if (this.state.currentSessionId === sessionId) { + this.activeSessionMeta = sessionMeta; + } + this.store.set({ + sessions: this.sortSessionMetas([sessionMeta, ...this.state.sessions.filter((s) => s.id !== sessionId)]), + }); + + return true; + }); + } catch (error) { + console.error(`Failed to persist session "${sessionId}"`, error); + return false; + } + } + + public async updateTitle(sessionId: string, title: string): Promise { + if (this.deletedSessionIds.has(sessionId)) return; + const nextTitle = title.trim(); + if (!nextTitle) return; + + const existingTitle = + this.state.sessions.find((s) => s.id === sessionId)?.title ?? + (this.activeSessionMeta?.id === sessionId ? this.activeSessionMeta.title : undefined); + if (existingTitle === nextTitle) return; + + await this.enqueueSessionWrite(sessionId, async () => { + if (this.deletedSessionIds.has(sessionId)) return; + + if (this.storage.updateMetadata) { + await this.storage.updateMetadata(sessionId, { title: nextTitle }); + } + + if (this.deletedSessionIds.has(sessionId)) return; + if (!this.state.sessions.find((s) => s.id === sessionId)) return; + + this.store.set({ + sessions: this.sortSessionMetas( + this.state.sessions.map((s) => (s.id === sessionId ? { ...s, title: nextTitle } : s)), + ), + }); + if (this.state.currentSessionId === sessionId && this.activeSessionMeta?.id === sessionId) { + this.activeSessionMeta = { ...this.activeSessionMeta, title: nextTitle }; + } + }); + } + + public async updatePinned(sessionId: string, isPinned: boolean): Promise { + if (this.deletedSessionIds.has(sessionId)) return; + + const current = + this.state.sessions.find((s) => s.id === sessionId) ?? + (this.activeSessionMeta?.id === sessionId ? this.activeSessionMeta : null); + if (!current) return; + if (Boolean(current.isPinned) === isPinned) return; + if (isPinned && this.countPinnedSessions(sessionId) >= MAX_PINNED_SESSIONS) return; + + await this.enqueueSessionWrite(sessionId, async () => { + if (this.deletedSessionIds.has(sessionId)) return; + + if (this.storage.updateMetadata) { + await this.storage.updateMetadata(sessionId, { isPinned }); + } + + if (this.deletedSessionIds.has(sessionId)) return; + if (!this.state.sessions.find((s) => s.id === sessionId)) return; + + this.store.set({ + sessions: this.sortSessionMetas(this.state.sessions.map((s) => (s.id === sessionId ? { ...s, isPinned } : s))), + }); + if (this.state.currentSessionId === sessionId && this.activeSessionMeta?.id === sessionId) { + this.activeSessionMeta = { ...this.activeSessionMeta, isPinned }; + } + }); + } + + public async close(): Promise { + if (this.storage.close) { + await this.storage.close(); + } + } + + private get state(): ChatState { + return this.store.get(); + } + + private async fetchSessionsPage(append: boolean): Promise { + if (this.isFetchingSessions || (append && !this.state.hasMoreSessions)) return; + + this.isFetchingSessions = true; + this.store.set({ isLoadingSessions: true }); + + try { + const cursor = append ? (this.sessionPageCursor ?? undefined) : undefined; + + const result = await this.storage.loadSessions(20, cursor); + if (!append) this.sessionPageCursor = null; + if (result.items.length > 0) { + this.sessionPageCursor = result.items[result.items.length - 1]; + } + + const resultItems = this.withoutDeletedSessions(result.items); + const nextSessions = append ? [...this.state.sessions, ...resultItems] : resultItems; + + this.store.set({ + sessions: this.withActiveSessionMeta(nextSessions), + hasMoreSessions: result.items.length > 0 ? result.hasMore : false, + isLoadingSessions: false, + }); + } catch (error) { + console.error("Failed to load sessions", error); + this.store.set( + this.state.error + ? { isLoadingSessions: false } + : { isLoadingSessions: false, error: { message: "Failed to load chat history." } }, + ); + } finally { + this.isFetchingSessions = false; + } + } + + private async loadSession(id: string, failureMessage: string): Promise { + if (this.state.currentSessionId === id && !this.state.isLoadingSession) return; + + if (this.isGenerationActive()) { + await this.stopActiveGeneration(); + } + + const seq = ++this.switchSeq; + this.activeSessionMeta = null; + this.olderCursor = null; + + this.store.set({ + currentSessionId: id, + messages: [], + isLoadingSession: true, + hasMoreMessages: false, + isLoadingMessages: false, + error: null, + }); + + try { + const session = await this.storage.loadOne(id); + if (seq !== this.switchSeq) return; // stale + + // User may have navigated again while this one was loading + if (this.state.currentSessionId !== id) return; + if (this.deletedSessionIds.has(id)) throw new Error("Chat not found"); + + if (!session) throw new Error("Chat not found"); + + this.activeSessionMeta = this.toSessionMeta(session); + this.olderCursor = session.nextOlderMessagesCursor ?? null; + this.store.set({ + sessions: this.withActiveSessionMeta(this.state.sessions), + messages: session.messages, + isLoadingSession: false, + hasMoreMessages: Boolean(session.hasMoreMessages && this.olderCursor !== null), + }); + } catch (error) { + console.error(`Failed to load session "${id}"`, error); + if (seq !== this.switchSeq) return; + if (this.state.currentSessionId !== id) return; + + this.activeSessionMeta = null; + this.olderCursor = null; + this.store.set({ + messages: [], + currentSessionId: uuidv7(), + isLoadingSession: false, + hasMoreMessages: false, + isLoadingMessages: false, + error: { message: failureMessage }, + }); + } + } + + private startNewSession(): void { + this.activeSessionMeta = null; + this.olderCursor = null; + this.store.set({ + currentSessionId: uuidv7(), + messages: [], + isLoadingSession: false, + hasMoreMessages: false, + isLoadingMessages: false, + error: null, + }); + } + + private toSessionMeta(session: ChatSession): ChatSessionMeta { + return { + id: session.id, + title: session.title, + updatedAt: session.updatedAt, + ...(typeof session.isPinned === "boolean" ? { isPinned: session.isPinned } : {}), + }; + } + + private withActiveSessionMeta(sessions: ChatSessionMeta[]): ChatSessionMeta[] { + sessions = this.withoutDeletedSessions(sessions); + const seen = new Set(); + const deduped = sessions.filter((s) => { + if (seen.has(s.id)) return false; + seen.add(s.id); + return true; + }); + + if (!this.activeSessionMeta || this.deletedSessionIds.has(this.activeSessionMeta.id)) { + return this.sortSessionMetas(deduped); + } + if (deduped.some((session) => session.id === this.activeSessionMeta?.id)) { + return this.sortSessionMetas(deduped); + } + return this.sortSessionMetas([this.activeSessionMeta, ...deduped]); + } + + private sortSessionMetas(sessions: ChatSessionMeta[]): ChatSessionMeta[] { + return [...sessions].sort((a, b) => { + const pinnedDelta = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned)); + if (pinnedDelta !== 0) return pinnedDelta; + return b.updatedAt - a.updatedAt || b.id.localeCompare(a.id); + }); + } + + private countPinnedSessions(exceptSessionId?: string): number { + return this.state.sessions.filter((session) => session.id !== exceptSessionId && session.isPinned).length; + } + + private createFallbackTitle(messages: Message[]): string { + const firstMsg = messages[0]; + if (!firstMsg) return "Empty Chat"; + + const text = extractPlainText(firstMsg); + if (text.trim().length > 0) { + return text.length > 30 ? `${text.slice(0, 30)}...` : text; + } + + const fileBlock = firstMsg.blocks.find((b): b is Extract => b.type === "file"); + if (fileBlock) return `File: ${fileBlock.name || "Upload"}`; + + return "New Chat"; + } + + private enqueueSessionWrite(sessionId: string, operation: () => Promise): Promise { + const previous = this.sessionWriteQueues.get(sessionId) ?? Promise.resolve(); + const queued = previous.catch(() => undefined).then(operation); + const tracked = queued.then( + () => undefined, + () => undefined, + ); + + this.sessionWriteQueues.set(sessionId, tracked); + void tracked.finally(() => { + if (this.sessionWriteQueues.get(sessionId) === tracked) { + this.sessionWriteQueues.delete(sessionId); + } + }); + + return queued; + } + + private withoutDeletedSessions(sessions: ChatSessionMeta[]): ChatSessionMeta[] { + if (this.deletedSessionIds.size === 0) return sessions; + return sessions.filter((session) => !this.deletedSessionIds.has(session.id)); + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/storage/indexed-db.ts b/crates/promptforge-wb-server/ui/src/chat/core/storage/indexed-db.ts new file mode 100644 index 00000000..324c12ef --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/storage/indexed-db.ts @@ -0,0 +1,242 @@ +import type { ChatSession, ChatSessionMeta, ChatStorage, PaginatedSessions } from "../types"; + +const DB_VERSION = 6; +const STORE_META = "session_meta"; +const STORE_MSGS = "session_messages"; +const INDEX_META_BY_PINNED_UPDATED_ID = "by_pinned_updated_id"; +const INDEXED_PINNED_FIELD = "isPinnedKey"; + +type StoredSessionMeta = ChatSessionMeta & { [INDEXED_PINNED_FIELD]: number }; + +export class IndexedDBStorage implements ChatStorage { + private db: IDBDatabase | null = null; + private dbPromise: Promise | null = null; + + constructor(private dbName: string = "MurmDB") {} + + private async getDB(): Promise { + if (this.db) return this.db; + if (this.dbPromise) return this.dbPromise; + + this.dbPromise = new Promise((resolve, reject) => { + try { + if (typeof indexedDB === "undefined") { + throw new Error("IndexedDB is not supported in this environment."); + } + + const request = indexedDB.open(this.dbName, DB_VERSION); + + request.onerror = () => { + this.dbPromise = null; + reject(request.error); + }; + request.onblocked = () => { + this.dbPromise = null; + reject(new Error("Database upgrade blocked. Close other tabs or DevTools and refresh.")); + }; + request.onsuccess = () => { + this.db = request.result; + resolve(this.db); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + const tx = (event.target as IDBOpenDBRequest).transaction; + if (!tx) throw new Error("IndexedDB upgrade transaction is unavailable."); + + let metaStore: IDBObjectStore; + if (!db.objectStoreNames.contains(STORE_META)) { + metaStore = db.createObjectStore(STORE_META, { + keyPath: "id", + }); + } else { + metaStore = tx.objectStore(STORE_META); + } + + if (metaStore.indexNames.contains("by_updated")) { + metaStore.deleteIndex("by_updated"); + } + if (metaStore.indexNames.contains("by_updated_id")) { + metaStore.deleteIndex("by_updated_id"); + } + if (metaStore.indexNames.contains(INDEX_META_BY_PINNED_UPDATED_ID)) { + metaStore.deleteIndex(INDEX_META_BY_PINNED_UPDATED_ID); + } + if (!metaStore.indexNames.contains(INDEX_META_BY_PINNED_UPDATED_ID)) { + metaStore.createIndex(INDEX_META_BY_PINNED_UPDATED_ID, [INDEXED_PINNED_FIELD, "updatedAt", "id"], { + unique: false, + }); + } + + if (!db.objectStoreNames.contains(STORE_MSGS)) { + db.createObjectStore(STORE_MSGS, { keyPath: "id" }); + } + + const normalizeReq = metaStore.openCursor(); + normalizeReq.onsuccess = () => { + const cursor = normalizeReq.result; + if (!cursor) return; + const value = cursor.value; + if (typeof value[INDEXED_PINNED_FIELD] !== "number") { + cursor.update(this.toStoredMeta(value)); + } + cursor.continue(); + }; + }; + } catch (err) { + this.dbPromise = null; + reject(err); + } + }); + + return this.dbPromise; + } + + async loadSessions(limit: number, cursor?: ChatSessionMeta): Promise { + return this.runTx(STORE_META, (tx, resolve, reject) => { + const index = tx.objectStore(STORE_META).index(INDEX_META_BY_PINNED_UPDATED_ID); + const sessions: ChatSessionMeta[] = []; + + const range = cursor + ? IDBKeyRange.upperBound([this.toPinnedKey(cursor.isPinned), cursor.updatedAt, cursor.id], true) + : null; + const request = index.openCursor(range, "prev"); + + request.onsuccess = () => { + const dbCursor = request.result; + if (!dbCursor) { + resolve({ items: sessions, hasMore: false }); + return; + } + + sessions.push(this.fromStoredMeta(dbCursor.value)); + + if (sessions.length <= limit) { + dbCursor.continue(); + } else { + sessions.pop(); + resolve({ items: sessions, hasMore: true }); + } + }; + + request.onerror = () => reject(request.error); + }); + } + + async loadOne(id: string): Promise { + return this.runTx([STORE_META, STORE_MSGS], (tx, resolve) => { + const metaReq = tx.objectStore(STORE_META).get(id); + const msgReq = tx.objectStore(STORE_MSGS).get(id); + + tx.oncomplete = () => { + if (!metaReq.result || !msgReq.result) resolve(null); + else resolve({ ...this.fromStoredMeta(metaReq.result), messages: msgReq.result.messages }); + }; + }); + } + + async updateMetadata(id: string, meta: Partial): Promise { + return this.runTx( + STORE_META, + (tx, resolve) => { + tx.oncomplete = () => resolve(); + const store = tx.objectStore(STORE_META); + const getReq = store.get(id); + + getReq.onsuccess = () => { + const existing = getReq.result; + if (existing) { + store.put( + this.toStoredMeta({ + ...existing, + ...meta, + isPinned: typeof meta.isPinned === "boolean" ? meta.isPinned : Boolean(existing.isPinned), + }), + ); + } + }; + }, + "readwrite", + ); + } + + async save(session: ChatSession): Promise { + return this.runTx( + [STORE_META, STORE_MSGS], + (tx, resolve) => { + tx.oncomplete = () => resolve(); + const updatedAt = session.updatedAt || Date.now(); + const metaStore = tx.objectStore(STORE_META); + const messagesStore = tx.objectStore(STORE_MSGS); + const existingReq = metaStore.get(session.id); + existingReq.onsuccess = () => { + const existingPinned = Boolean(existingReq.result?.isPinned); + const isPinned = typeof session.isPinned === "boolean" ? session.isPinned : existingPinned; + metaStore.put(this.toStoredMeta({ id: session.id, title: session.title, updatedAt, isPinned })); + messagesStore.put({ id: session.id, messages: session.messages }); + }; + }, + "readwrite", + ); + } + + async delete(id: string): Promise { + return this.runTx( + [STORE_META, STORE_MSGS], + (tx, resolve) => { + tx.oncomplete = () => resolve(); + tx.objectStore(STORE_META).delete(id); + tx.objectStore(STORE_MSGS).delete(id); + }, + "readwrite", + ); + } + + close(): void { + if (this.db) { + this.db.close(); + this.db = null; + } + if (this.dbPromise) { + this.dbPromise.then((db) => db.close()).catch(() => {}); + this.dbPromise = null; + } + } + + private async runTx( + stores: string | string[], + operation: (tx: IDBTransaction, resolve: (val: T | PromiseLike) => void, reject: (err: unknown) => void) => void, + mode: IDBTransactionMode = "readonly", + ): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(stores, mode); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted")); + operation(tx, resolve, reject); + }); + } + + private toStoredMeta(meta: ChatSessionMeta): StoredSessionMeta { + return { + id: meta.id, + title: meta.title, + updatedAt: meta.updatedAt, + isPinned: Boolean(meta.isPinned), + [INDEXED_PINNED_FIELD]: this.toPinnedKey(meta.isPinned), + }; + } + + private fromStoredMeta(meta: ChatSessionMeta): ChatSessionMeta { + return { + id: meta.id, + title: meta.title, + updatedAt: meta.updatedAt, + isPinned: Boolean(meta.isPinned), + }; + } + + private toPinnedKey(isPinned: boolean | undefined): number { + return isPinned ? 1 : 0; + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/storage/remote.ts b/crates/promptforge-wb-server/ui/src/chat/core/storage/remote.ts new file mode 100644 index 00000000..320158f9 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/storage/remote.ts @@ -0,0 +1,142 @@ +import type { ChatSession, ChatSessionMeta, ChatStorage, Message, PaginatedSessions } from "../types"; + +export class RemoteStorageError extends Error { + constructor( + action: string, + public readonly status: number, + public readonly url: string, + public readonly responseBody: string, + ) { + const bodyExcerpt = responseBody ? `: ${responseBody.slice(0, 500)}` : ""; + super(`${action} (${status}) at ${url}${bodyExcerpt}`); + this.name = "RemoteStorageError"; + } +} + +export interface RemoteStorageOptions { + /** + * Limits the number of messages sent during a save() operation. + * WARNING: If you use this, your backend must upsert messages rather than + * overwrite the entire chat record when the partial save header is present. + */ + saveLimit?: number; +} + +export class RemoteStorage implements ChatStorage { + constructor( + private baseUrl: string, + private getToken: () => string | null, + private options?: RemoteStorageOptions, + ) {} + + private get headers(): Record { + const token = this.getToken(); + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + } + + private getPath(suffix = ""): string { + const base = this.baseUrl.replace(/\/+$/, ""); + return `${base}/chats${suffix}`; + } + + async loadSessions(limit: number, cursor?: ChatSessionMeta): Promise { + const params = new URLSearchParams({ limit: limit.toString() }); + if (cursor) { + params.append("cursor", cursor.updatedAt.toString()); + params.append("cursorId", cursor.id); + params.append("cursorPinned", String(Boolean(cursor.isPinned))); + } + + const url = `${this.getPath()}?${params.toString()}`; + const res = await fetch(url, { headers: this.headers }); + await this.assertOk(res, "Failed to load chats", url); + return res.json(); + } + + async loadOne(id: string): Promise { + const url = this.getPath(`/${encodeURIComponent(id)}`); + const res = await fetch(url, { + headers: this.headers, + }); + if (res.status === 404) return null; + await this.assertOk(res, "Failed to load chat", url); + return res.json(); + } + + async save(session: ChatSession): Promise { + const limit = this.getSaveLimit(); + let payload = session; + const headers = this.headers; + + if (limit && session.messages.length > limit) { + payload = { + ...session, + messages: session.messages.slice(-limit), + }; + headers["X-Murm-Save-Mode"] = "partial"; + } + + const url = this.getPath(`/${encodeURIComponent(session.id)}`); + const res = await fetch(url, { + method: "PUT", + headers, + body: JSON.stringify(payload), + }); + await this.assertOk(res, "Failed to save chat", url); + } + + private getSaveLimit(): number | null { + const limit = this.options?.saveLimit; + if (typeof limit !== "number" || !Number.isFinite(limit)) return null; + + const wholeLimit = Math.floor(limit); + return wholeLimit > 0 ? wholeLimit : null; + } + + async updateMetadata(id: string, meta: Partial): Promise { + const url = this.getPath(`/${encodeURIComponent(id)}/meta`); + const res = await fetch(url, { + method: "POST", + headers: this.headers, + body: JSON.stringify(meta), + }); + await this.assertOk(res, "Failed to update chat metadata", url); + } + + async delete(id: string): Promise { + const url = this.getPath(`/${encodeURIComponent(id)}`); + const res = await fetch(url, { + method: "DELETE", + headers: this.headers, + }); + await this.assertOk(res, "Failed to delete chat", url); + } + + async loadOlderMessages( + sessionId: string, + cursor: string, + limit: number, + ): Promise<{ messages: Message[]; hasMore: boolean; nextOlderMessagesCursor?: string }> { + const params = new URLSearchParams({ before: cursor, limit: limit.toString() }); + const url = `${this.getPath(`/${encodeURIComponent(sessionId)}`)}?${params.toString()}`; + const res = await fetch(url, { headers: this.headers }); + await this.assertOk(res, "Failed to load older messages", url); + return res.json(); + } + + private async assertOk(res: Response, action: string, url: string): Promise { + if (res.ok) return; + + let responseBody = ""; + try { + responseBody = (await res.text()).trim(); + } catch { + responseBody = ""; + } + + throw new RemoteStorageError(action, res.status, url, responseBody); + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/store.ts b/crates/promptforge-wb-server/ui/src/chat/core/store.ts new file mode 100644 index 00000000..f28f8db0 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/store.ts @@ -0,0 +1,102 @@ +export class Store { + private state: T; + private selectorListeners: Set<(state: T) => void> = new Set(); + private hotListeners: Set<(state: T) => void> = new Set(); + + constructor(initialState: T) { + this.state = initialState; + } + + get(): T { + return this.state; + } + + /** + * Standard immutable update. + * Use this for 99% of state changes (sessions, active chat, etc). + * Safely triggers all relevant selector-based subscribers. + */ + set(partialState: Partial) { + this.state = { ...this.state, ...partialState }; + this.notifySelectorListeners(); + this.notifyHotListeners(); + } + + /** + * HIGH-PERFORMANCE HOT PATH ONLY. + * Mutates state in-place to prevent GC thrashing during LLM streaming. + * NOTE: This intentionally bypasses selector subscribers so hot updates + * do not run every selector on every token. Only hot subscribers are notified. + * Hot subscribers receive the live mutable state object; they must not retain + * references to state or nested slices across notifications. + */ + mutateHot(recipe: (state: T) => void) { + recipe(this.state); + this.notifyHotListeners(); + } + + /** + * Subscribes to a specific slice of state. + * The listener fires IMMEDIATELY with the current state, and then + * whenever the selected value actually changes. + */ + subscribe(selector: (state: T) => U, listener: (selectedState: U) => void): () => void { + const initialSlice = selector(this.state); + listener(initialSlice); + return this.onChangeFrom(selector, listener, initialSlice); + } + + /** + * Subscribes to normal set() updates and hot in-place mutations. + * Fires IMMEDIATELY with the current state, then on subsequent updates. + * Use sparingly for render paths that must observe high-frequency mutable state. + * The listener receives the live mutable state object; do not retain references + * to state or nested slices because mutateHot may change them in-place. + */ + subscribeHot(listener: (state: T) => void): () => void { + listener(this.state); + this.hotListeners.add(listener); + return () => this.hotListeners.delete(listener); + } + /** + * Subscribes to a specific slice of state. + * The listener ONLY fires on future changes, not immediately. + */ + public onChange(selector: (state: T) => U, listener: (selectedState: U) => void): () => void { + return this.onChangeFrom(selector, listener, selector(this.state)); + } + + private onChangeFrom( + selector: (state: T) => U, + listener: (selectedState: U) => void, + initialSlice: U, + ): () => void { + let lastSlice = initialSlice; + const wrappedListener = (state: T) => { + const currentSlice = selector(state); + if (currentSlice !== lastSlice) { + lastSlice = currentSlice; + listener(currentSlice); + } + }; + this.selectorListeners.add(wrappedListener); + return () => this.selectorListeners.delete(wrappedListener); + } + + public clearAllListeners(): void { + this.selectorListeners.clear(); + this.hotListeners.clear(); + } + + private notifySelectorListeners() { + for (const listener of this.selectorListeners) { + listener(this.state); + } + } + + private notifyHotListeners() { + for (const listener of this.hotListeners) { + listener(this.state); + } + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/stream-reducer.ts b/crates/promptforge-wb-server/ui/src/chat/core/stream-reducer.ts new file mode 100644 index 00000000..40b330f8 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/stream-reducer.ts @@ -0,0 +1,221 @@ +import type { ChatState, ContentBlock, Message, StreamEvent } from "./types"; + +type StreamErrorEvent = { + type: "error"; + message: string; +}; + +export type StreamReducerEvent = StreamEvent | StreamErrorEvent; + +function clearEphemeralFlag(msg: Message): void { + if (!msg.ephemeral) return; + delete msg.ephemeral; +} + +function touchMessage(msg: Message, timestamp = Date.now()): void { + msg.createdAt ??= timestamp; + msg.updatedAt = timestamp; +} + +function updateStreamingToolCalls(msg: Message, status: Extract["status"]): void { + for (const block of msg.blocks) { + if (block.type === "tool_call" && block.status === "streaming") { + block.status = status; + } + } +} + +function findMessage(state: ChatState, messageId: string | null | undefined): Message | undefined { + if (!messageId) return undefined; + + const lastMessage = state.messages[state.messages.length - 1]; + if (lastMessage?.id === messageId) return lastMessage; + + return state.messages.find((m) => m.id === messageId); +} + +function adoptMessageId(state: ChatState, msg: Message, nextId: string): void { + const previousId = msg.id; + msg.id = nextId; + if (state.generatingMessageId === previousId) { + state.generatingMessageId = nextId; + } +} + +function canAdoptMessageId(state: ChatState, msg: Message, nextId: string): boolean { + if (!msg.ephemeral) return false; + if (msg.blocks.length > 0) return false; + return findMessage(state, nextId) === undefined; +} + +function pushStreamMessage( + state: ChatState, + message: Pick, + fallbackRunId?: string, +): Message { + const timestamp = Date.now(); + const createdAt = message.createdAt ?? timestamp; + const msg: Message = { + id: message.id, + role: message.role, + blocks: [], + runId: message.runId ?? fallbackRunId, + createdAt, + updatedAt: message.updatedAt ?? createdAt, + ...(message.role === "assistant" && message.blocks.length === 0 ? { ephemeral: true } : {}), + }; + state.messages.push(msg); + if (msg.role === "assistant") { + state.generatingMessageId = msg.id; + } + return msg; +} + +function eventMessageId(event: StreamReducerEvent): string | null { + switch (event.type) { + case "message_start": + return event.message.id; + case "usage": + case "finish": + case "error": + return null; + default: + return event.messageId; + } +} + +export function applyStreamEventToState(state: ChatState, currentMessageId: string, event: StreamReducerEvent): string { + let msg = findMessage(state, currentMessageId) ?? findMessage(state, state.generatingMessageId); + if (!msg) return currentMessageId; + + // Let the empty local placeholder take the provider/adaptor message id, + // or switch to a new stream message when a later event starts one. + const nextMessageId = eventMessageId(event); + if (nextMessageId && msg.id !== nextMessageId) { + if (canAdoptMessageId(state, msg, nextMessageId)) { + adoptMessageId(state, msg, nextMessageId); + } else if (!findMessage(state, nextMessageId)) { + updateStreamingToolCalls(msg, "complete"); + touchMessage(msg); + msg = + event.type === "message_start" + ? pushStreamMessage(state, event.message, msg.runId) + : pushStreamMessage(state, { id: nextMessageId, role: "assistant", blocks: [] }, msg.runId); + } else if (event.type === "message_start") { + return msg.id; + } + } + + switch (event.type) { + case "message_start": { + msg.runId = event.message.runId ?? msg.runId; + msg.createdAt ??= event.message.createdAt ?? Date.now(); + if (event.message.updatedAt !== undefined) { + msg.updatedAt = event.message.updatedAt; + } + msg.role = event.message.role; + if (event.message.blocks.length > 0 || msg.blocks.length === 0) { + msg.blocks = event.message.blocks; + } + if (event.message.meta) { + msg.meta = { ...msg.meta, ...event.message.meta }; + } + if (event.message.blocks.length > 0) { + clearEphemeralFlag(msg); + } else if (msg.role === "assistant" && msg.blocks.length === 0) { + msg.ephemeral = true; + } + if (msg.role === "assistant") { + state.generatingMessageId = msg.id; + } + touchMessage(msg, event.message.updatedAt ?? Date.now()); + break; + } + + case "text_delta": { + let tb = msg.blocks.find((b) => b.id === event.blockId) as Extract; + if (!tb) { + tb = { id: event.blockId, type: "text", text: "" }; + msg.blocks.push(tb); + } + tb.text += event.delta; + if (event.delta.length > 0) { + clearEphemeralFlag(msg); + touchMessage(msg); + } + break; + } + + case "reasoning_delta": { + let rb = msg.blocks.find((b) => b.id === event.blockId) as Extract; + if (!rb) { + rb = { id: event.blockId, type: "reasoning", text: "", encrypted: event.encrypted }; + msg.blocks.push(rb); + } + if (event.encrypted) { + rb.encrypted = true; + if (event.delta) { + rb.encryptedText = (rb.encryptedText ?? "") + event.delta; + } + } else { + rb.text += event.delta; + } + if (event.delta.length > 0) { + clearEphemeralFlag(msg); + touchMessage(msg); + } + break; + } + + case "tool_call_start": + msg.blocks.push(event.block); + clearEphemeralFlag(msg); + touchMessage(msg); + break; + + case "tool_call_delta": { + const tcb = msg.blocks.find((b) => b.id === event.blockId) as Extract; + if (tcb) { + if (event.name !== undefined) tcb.name = event.name; + if (event.argsDelta) tcb.argsText += event.argsDelta; + if (event.status) tcb.status = event.status; + if (event.name !== undefined || event.argsDelta || event.status) { + clearEphemeralFlag(msg); + touchMessage(msg); + } + } + break; + } + + case "tool_result": + case "artifact": + msg.blocks.push(event.block); + clearEphemeralFlag(msg); + touchMessage(msg); + break; + case "usage": + msg.usage = { + input: event.input, + output: event.output, + total: event.total ?? event.input + event.output, + ...(event.cacheRead !== undefined ? { cacheRead: event.cacheRead } : {}), + ...(event.cacheWrite !== undefined ? { cacheWrite: event.cacheWrite } : {}), + ...(event.details !== undefined ? { details: event.details } : {}), + }; + touchMessage(msg); + break; + case "finish": { + const finalStatus = event.reason === "error" || event.reason === "aborted" ? "error" : "complete"; + updateStreamingToolCalls(msg, finalStatus); + touchMessage(msg); + break; + } + case "error": + state.error = { message: event.message, id: msg.id }; + updateStreamingToolCalls(msg, "error"); + touchMessage(msg); + break; + } + + return msg.id; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/core/types.ts b/crates/promptforge-wb-server/ui/src/chat/core/types.ts new file mode 100644 index 00000000..4bd196fd --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/core/types.ts @@ -0,0 +1,416 @@ +import type { ChatEngine } from "./chat-engine"; + +export type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]; + +export type ContentBlock = + | { + id: string; + type: "text"; + text: string; + } + | { + id: string; + type: "reasoning"; + text: string; + encrypted?: boolean; + encryptedText?: string; + } + | { + id: string; + type: "tool_call"; + toolCallId: string; + name: string; + argsText: string; + status: "streaming" | "pending" | "running" | "complete" | "error"; + } + | { + id: string; + type: "tool_result"; + toolCallId: string; + outputText: string; + isError?: boolean; + } + | { + id: string; + type: "artifact"; + artifactId: string; + mime: string; + title?: string; + content: string; + } + | { + id: string; + type: "file"; + mimeType: string; + name?: string; + data: string; + }; + +export type Role = "system" | "user" | "assistant" | "tool"; + +export interface TokenUsage { + input: number; + output: number; + total: number; + cacheRead?: number; + cacheWrite?: number; + details?: JsonValue; +} + +export interface Message { + id: string; + role: Role; + blocks: ContentBlock[]; + // Groups messages that belong to the same user-triggered run/turn. + // Core-generated run ids default to the user message id. + runId?: string; + createdAt?: number; + updatedAt?: number; + // Used to prevent this message from being sent to the LLM or persisted + ephemeral?: boolean; + usage?: TokenUsage; + // Durable provider/plugin metadata. Must stay JSON-serializable because it + // can be persisted with chat history. + meta?: Record; +} + +export type FinishReason = "stop" | "length" | "tool_use" | "content_filter" | "error" | "aborted"; + +/** + * Normalized streaming events emitted by ChatProvider implementations. + * + * Stream contract: + * - Providers/adapters own upstream quirks and emit Murm message ids. + * - `runId` is optional on streamed events. When omitted, the engine keeps the + * locally generated run id from the user message that started this generation. + * - The engine creates a temporary empty assistant message before streaming starts. + * The first event with a new message id may replace that placeholder id. + * - `message_start` starts a logical streamed message. A single `streamChat` + * call may emit multiple assistant `message_start` events with different ids; + * the engine appends each as a new message and continues streaming into it. + * - Delta/block events should be ordered by message. Once an event starts a new + * message id, later deltas are treated as belonging to the active message. + * Adapters should not interleave deltas for older messages after switching. + * - If an adapter cannot emit `message_start`, the first delta/block event with + * a new message id can still start an assistant message as a fallback. + * - `usage` and `finish` apply to the current active streamed message/run. + */ +export type StreamEvent = + | { + type: "message_start"; + message: Pick; + } + | { + type: "text_delta"; + messageId: string; + blockId: string; + delta: string; + } + | { + type: "reasoning_delta"; + messageId: string; + blockId: string; + delta: string; + encrypted?: boolean; + } + | { + type: "tool_call_start"; + messageId: string; + block: Extract; + } + | { + type: "tool_call_delta"; + messageId: string; + blockId: string; + name?: string; + argsDelta?: string; + status?: Extract["status"]; + } + | { + type: "tool_result"; + messageId: string; + block: Extract; + } + | { + type: "artifact"; + messageId: string; + block: Extract; + } + | { + type: "usage"; + input: number; + output: number; + total?: number; + cacheRead?: number; + cacheWrite?: number; + details?: JsonValue; + } + | { + type: "finish"; + reason: FinishReason; + }; + +export interface ChatSessionMeta { + id: string; + title: string; + updatedAt: number; + isPinned?: boolean; +} + +export interface ChatSession { + id: string; + title: string; + updatedAt: number; + isPinned?: boolean; + messages: Message[]; + // Set by backend-paginated storages whose loadOne returns only the latest + // window: true when older messages exist and can be fetched via + // ChatStorage.loadOlderMessages. Storages that load whole sessions omit it. + hasMoreMessages?: boolean; + // Opaque backend/storage cursor for the next older page. This is separate + // from Message.id, which is a UI/wire identity and may not be a storage key. + nextOlderMessagesCursor?: string; +} + +export interface PaginatedSessions { + items: ChatSessionMeta[]; + hasMore: boolean; +} + +export interface ChatState { + sessions: ChatSessionMeta[]; + hasMoreSessions: boolean; + currentSessionId: string; + messages: Message[]; + generatingMessageId: string | null; + isLoadingSession: boolean; + isLoadingSessions: boolean; + // Upward message pagination, parallel to hasMoreSessions/isLoadingSessions. + // hasMoreMessages stays false unless the storage supports loadOlderMessages + // and the loaded session reported older history. + hasMoreMessages: boolean; + isLoadingMessages: boolean; + error: { message: string; id?: string } | null; +} + +export interface ChatStorage { + loadSessions(limit: number, cursor?: ChatSessionMeta): Promise; + loadOne(id: string): Promise; + save(session: ChatSession): Promise; + updateMetadata?(id: string, meta: Partial): Promise; + delete(id: string): Promise; + /** + * Optional upward pagination for backends that return only the latest window + * from loadOne. `cursor` is an opaque storage/backend cursor previously + * returned as nextOlderMessagesCursor, not a Message.id. Returns a page of + * messages oldest-first, plus whether even-older messages remain and the + * cursor for the next page. Storages that load whole sessions (the default, + * e.g. local IndexedDB) omit this, and the UI never offers "load older". + */ + loadOlderMessages?( + sessionId: string, + cursor: string, + limit: number, + ): Promise<{ messages: Message[]; hasMore: boolean; nextOlderMessagesCursor?: string }>; + close?(): void | Promise; +} + +export const MAX_PINNED_SESSIONS = 3; + +export type ToolDefinition = Record; + +export interface RequestOptions { + model?: string; + temperature?: number; + top_p?: number; + max_tokens?: number; + stream_options?: Record; + [key: string]: unknown; +} + +export interface ChatRequest { + messages: Message[]; + instructions?: string; + tools?: ToolDefinition[]; + options: RequestOptions; + signal: AbortSignal; +} + +export interface ChatRequestDefaults { + instructions?: string; + tools?: ToolDefinition[]; + options?: Partial; +} + +export interface ChatProvider { + /** + * Streams normalized events to the engine. Provider/API failures should reject + * this promise; ChatEngine converts rejected provider calls into UI error state. + * + * Implementations should translate provider-native responses into the StreamEvent + * contract above. In particular, they should generate stable message ids when the + * upstream provider does not supply them, and should emit a new id for each logical + * assistant message produced during the run. + */ + streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise; + + generateTitle?(request: ChatRequest): Promise; +} + +export type CodeHighlighter = (code: string, lang: string) => string | Promise; + +export type AgentRunCollapse = "full" | "machinery"; + +export interface RenderConfig { + /** + * Receives code text from a sanitized code block and returns trusted HTML, + * either synchronously or after loading a grammar. + * The language is an empty string for code blocks without a language class. + * The returned HTML is injected directly, so custom highlighters must escape + * any interpolated code text and must not use untrusted highlighter output. + */ + highlighter?: CodeHighlighter; + plugins: ChatPlugin[]; + fullscreen?: boolean; + agentRunCollapse?: AgentRunCollapse; + minAgentRunSteps?: number; + /** + * Called when the user scrolls near the top of the transcript and older + * messages can be loaded. Wired to ChatEngine.sessions.loadOlderMessages. + */ + onReachTop?: () => void; +} + +type AnyFn = (...args: never[]) => unknown; +type DeepReadonlyDepth = [never, 0, 1, 2, 3, 4, 5]; + +export type DeepReadonly = [Depth] extends [never] + ? T + : T extends AnyFn + ? T + : T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; + +export interface ReadonlyChatRequest { + readonly messages: readonly DeepReadonly[]; + readonly instructions?: string; + readonly tools?: readonly DeepReadonly[]; + readonly options: DeepReadonly; + readonly signal: AbortSignal; +} + +export interface ChatRequestPatch { + messages?: Message[]; + /** + * Omit to keep the accumulated request instructions unchanged. + * Return `instructions: undefined` to clear inherited instructions. + */ + instructions?: string; + /** + * Omit to keep the accumulated request tools unchanged. + * Return `tools: undefined` to clear inherited tools. + */ + tools?: ToolDefinition[]; + options?: Partial; +} + +export interface PluginContext { + engine: ChatEngine; + container: HTMLElement; +} + +export interface PluginInputContext { + container: HTMLElement; + form: HTMLFormElement; + input: HTMLTextAreaElement; + requestSubmitStateSync: () => void; +} + +export interface MessageActionContext { + message: Message; + buttonEl: HTMLElement; + messageEl: HTMLElement; + actionId: string; + pluginName: string; +} + +export interface ActionButtonDef { + id: string; + title: string; + iconHtml: string; + onClick: (ctx: MessageActionContext) => void; +} + +export interface BlockRenderContext { + message: Message; + messages: readonly Message[]; + blockIndex: number; +} + +export interface ChatPlugin { + name: string; + + /** + * Fires once when the chat UI initializes. + */ + onMount?: (ctx: PluginContext) => void; + + /** + * Fires when the chat instance is destroyed. + */ + destroy?: () => void; + + /** + * Intercept and mutate the payload (messages, options) right before it is sent to the LLM. + * To optimize performance, the payload is typed as readonly. + * Return a ChatRequestPatch to override specific parts, or void if no changes are needed. + * This hook may be async. + */ + beforeSubmit?: (request: ReadonlyChatRequest) => ChatRequestPatch | undefined | Promise; + + /** + * Fires when the input area mounts. Use to append/prepend custom UI to the form. + */ + onInputMount?: (ctx: PluginInputContext) => void; + + /** + * Allows the input form to be submitted even if the text area is empty. + */ + hasPendingData?: () => boolean; + + /** + * Blocks user submission while a plugin is resolving async input state. + */ + isSubmitBlocked?: () => boolean; + + /** + * Intercept and mutate a newly created user message before it is saved and sent. + * This hook must finish synchronously; use beforeSubmit for async request shaping. + */ + onUserSubmit?: (msg: Message) => void; + + /** + * Declaratively registers static icon buttons for a message action bar. + * Called when the action bar is first initialized for a message node. + */ + getActionButtons?: (msg: Message) => ActionButtonDef[]; + + /** + * Intercept the rendering of an individual content block (e.g., text, reasoning, tool_call). + * Use this to inject custom UI directly inside a specific block's container. + * * @param block The content block data. + * @param containerEl The DOM element wrapping this specific block. + * @param isGenerating True if the LLM is actively streaming this block. + * @param ctx Render-time context for the current block and transcript. + * @returns `true` if the plugin handled the render, preventing the core UI from overwriting it. + */ + onBlockRender?: ( + block: ContentBlock, + containerEl: HTMLElement, + isGenerating: boolean, + ctx?: BlockRenderContext, + ) => boolean; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/.vendor-manifest.json b/crates/promptforge-wb-server/ui/src/chat/highlighter/.vendor-manifest.json new file mode 100644 index 00000000..4174a782 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/.vendor-manifest.json @@ -0,0 +1,37 @@ +{ + "source": "highlighter", + "sourceCommit": "4f6ffe4748654a4de9afc9d070942d7910dbe728", + "sourceDirty": false, + "exportedAt": "2026-05-10T20:39:43.522Z", + "managedFiles": [ + "THIRD_PARTY_NOTICES.md", + "chat.ts", + "core.ts", + "languages/bash.ts", + "languages/c.ts", + "languages/clike.ts", + "languages/cpp.ts", + "languages/csharp.ts", + "languages/diff.ts", + "languages/dockerfile.ts", + "languages/go.ts", + "languages/graphql.ts", + "languages/index.ts", + "languages/java.ts", + "languages/javascript.ts", + "languages/json.ts", + "languages/kotlin.ts", + "languages/markdown.ts", + "languages/markup.ts", + "languages/php.ts", + "languages/python.ts", + "languages/ruby.ts", + "languages/rust.ts", + "languages/shared.ts", + "languages/sql.ts", + "languages/swift.ts", + "languages/toml.ts", + "languages/typescript.ts", + "languages/yaml.ts" + ] +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/THIRD_PARTY_NOTICES.md b/crates/promptforge-wb-server/ui/src/chat/highlighter/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..0311f88e --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/THIRD_PARTY_NOTICES.md @@ -0,0 +1,31 @@ +# Third-Party Notices + +## Prism + +The tokenizer core is derived from PrismJS core and substantially modified. +Language grammars are original implementations tested for output parity with +PrismJS. + +Project: https://prismjs.com/ + +MIT LICENSE + +Copyright (c) 2012 Lea Verou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/chat.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/chat.ts new file mode 100644 index 00000000..fb989a4d --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/chat.ts @@ -0,0 +1,130 @@ +export * from "./core"; + +import { + type CreateHighlighterOptions as CoreCreateHighlighterOptions, + highlight as coreHighlight, + createHighlighter as createCoreHighlighter, + type Grammar, + type Highlighter, + type LanguageCollection, + type LanguageDefinition, + languages, +} from "./core"; +import { registerBuiltInLanguages } from "./languages/index"; + +let builtInLanguagesRegistered = false; + +ensureBuiltInLanguages(); + +export type LanguageLoadResult = LanguageDefinition | { default?: LanguageDefinition } | null | undefined; + +export interface ChatHighlighter { + registerLanguage: Highlighter["registerLanguage"]; + loadLanguage: (language: string) => Promise; + highlight: (code: string, language: string) => Promise; +} + +export interface CreateHighlighterOptions extends CoreCreateHighlighterOptions { + loadLanguage?: (language: string) => Promise; +} + +export function highlight(code: string, language: string): string { + ensureBuiltInLanguages(); + return coreHighlight(code, language); +} + +export function createHighlighter(options: CreateHighlighterOptions = {}): ChatHighlighter { + const { loadLanguage, languages: extraLanguages } = options; + const highlighter = createCoreHighlighter(); + + registerBuiltInLanguages(highlighter.languages); + registerLanguageCollection(highlighter, extraLanguages); + + async function load(language: string): Promise { + const id = language.toLowerCase(); + + if (highlighter.languages[language] || highlighter.languages[id]) { + return true; + } + + if (!loadLanguage) { + return false; + } + + try { + const definition = resolveLanguageDefinition(await loadLanguage(language)); + + if (!definition) { + return false; + } + + highlighter.registerLanguage(definition); + return true; + } catch { + return false; + } + } + + return { + registerLanguage: highlighter.registerLanguage, + loadLanguage: load, + async highlight(code: string, language: string): Promise { + await load(language); + return highlighter.highlight(code, language); + }, + }; +} + +function registerLanguageCollection(highlighter: Highlighter, collection: LanguageCollection | undefined): void { + if (!collection) { + return; + } + + if (Array.isArray(collection)) { + for (const definition of collection) { + highlighter.registerLanguage(definition); + } + + return; + } + + for (const [language, grammar] of Object.entries(collection)) { + if (isGrammar(grammar)) { + highlighter.registerLanguage(language, grammar); + } + } +} + +function isGrammar(value: unknown): value is Grammar { + return !!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof RegExp); +} + +function resolveLanguageDefinition(result: LanguageLoadResult): LanguageDefinition | null { + if (isLanguageDefinition(result)) { + return result; + } + + if (result && typeof result === "object" && "default" in result && isLanguageDefinition(result.default)) { + return result.default; + } + + return null; +} + +function isLanguageDefinition(value: unknown): value is LanguageDefinition { + return ( + !!value && + typeof value === "object" && + typeof (value as LanguageDefinition).id === "string" && + !!(value as LanguageDefinition).grammar + ); +} + +function ensureBuiltInLanguages(): void { + if (builtInLanguagesRegistered) { + return; + } + + registerBuiltInLanguages(languages); + builtInLanguagesRegistered = true; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/core.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/core.ts new file mode 100644 index 00000000..7357e3d1 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/core.ts @@ -0,0 +1,609 @@ +// Prism-compatible tokenizer core. See THIRD_PARTY_NOTICES.md for attribution. + +export type TokenStream = Array; + +export interface Grammar { + [token: string]: GrammarValue | Grammar | undefined; + rest?: Grammar; +} + +export type GrammarValue = RegExp | GrammarToken | Array; + +export interface GrammarToken { + pattern: RegExp; + lookbehind?: boolean; + greedy?: boolean; + alias?: string | string[]; + inside?: Grammar | null; +} + +export interface LanguageDefinition { + id: string; + grammar: Grammar; + aliases?: string[]; +} + +export type LanguageCollection = Array | Record; + +export interface CreateHighlighterOptions { + languages?: LanguageCollection; +} + +export interface Highlighter { + readonly languages: LanguagesRegistry; + registerLanguage: (language: string | LanguageDefinition, grammar?: Grammar) => void; + highlight: (code: string, language: string) => string; + highlightWithGrammar: (code: string, grammar: Grammar, language?: string) => string; + tokenize: (text: string, grammar: Grammar) => TokenStream; +} + +interface LinkedListNode { + value: T; + prev: LinkedListNode | null; + next: LinkedListNode | null; +} + +interface LinkedList { + head: LinkedListNode; + tail: LinkedListNode; + length: number; +} + +interface RescanState { + skipPattern: string; + maxReach: number; +} + +export class Token { + readonly type: string; + readonly content: string | TokenStream; + readonly alias?: string | string[]; + readonly length: number; + + constructor(type: string, content: string | TokenStream, alias?: string | string[], matchedText = "") { + this.type = type; + this.content = content; + this.alias = alias; + this.length = matchedText.length | 0; + } +} + +export interface LanguagesRegistry { + [language: string]: Grammar | LanguagesRegistry[keyof LanguageHelpers] | undefined; + extend: (id: string, redef: Grammar) => Grammar; + insertBefore: (inside: string, before: string, insert: Grammar, root?: Record) => Grammar; +} + +interface LanguageHelpers { + extend: LanguagesRegistry["extend"]; + insertBefore: LanguagesRegistry["insertBefore"]; +} + +const plainTextGrammar: Grammar = {}; +const globalPatternCache = new WeakMap(); +const htmlEscapePattern = /[&<\u00a0]/g; + +export function createLanguagesRegistry(): LanguagesRegistry { + const registry: LanguagesRegistry = { + plain: plainTextGrammar, + plaintext: plainTextGrammar, + text: plainTextGrammar, + txt: plainTextGrammar, + + extend(id: string, redef: Grammar): Grammar { + const base = registry[id]; + + if (!isGrammar(base)) { + throw new Error(`Cannot extend missing language "${id}".`); + } + + const grammar = cloneGrammar(base); + + for (const key of Object.keys(redef)) { + grammar[key] = redef[key]; + } + + return grammar; + }, + + insertBefore(inside: string, before: string, insert: Grammar, root: Record = registry): Grammar { + const grammar = root[inside]; + + if (!isGrammar(grammar)) { + throw new Error(`Cannot insert into missing grammar "${inside}".`); + } + + const replacement: Grammar = {}; + + for (const token of Object.keys(grammar)) { + if (token === before) { + for (const newToken of Object.keys(insert)) { + replacement[newToken] = insert[newToken]; + } + } + + // biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn is ES2022, but core targets ES2018. + if (!Object.prototype.hasOwnProperty.call(insert, token)) { + replacement[token] = grammar[token]; + } + } + + const oldGrammar = grammar; + root[inside] = replacement; + replaceGrammarReferences(registry, oldGrammar, replacement); + + return replacement; + }, + }; + + return registry; +} + +export const languages: LanguagesRegistry = createLanguagesRegistry(); + +export function registerLanguage(language: string, grammar: Grammar): void { + registerLanguageInRegistry(languages, language, grammar); +} + +export function highlight(code: string, language: string): string { + return highlightFromRegistry(languages, code, language); +} + +export function createHighlighter(options: CreateHighlighterOptions = {}): Highlighter { + const registry = createLanguagesRegistry(); + const highlighter: Highlighter = { + languages: registry, + registerLanguage(language: string | LanguageDefinition, grammar?: Grammar): void { + if (typeof language === "string") { + if (!grammar) { + throw new Error(`Missing grammar for language "${language}".`); + } + + registerLanguageInRegistry(registry, language, grammar); + return; + } + + registerLanguageDefinition(registry, language); + }, + highlight(code: string, language: string): string { + return highlightFromRegistry(registry, code, language); + }, + highlightWithGrammar, + tokenize, + }; + + registerLanguageCollection(registry, options.languages); + + return highlighter; +} + +function highlightFromRegistry(registry: LanguagesRegistry, code: string, language: string): string { + const grammar = registry[language] ?? registry[language.toLowerCase()]; + + if (!isGrammar(grammar) || grammar === plainTextGrammar) { + return escapeHtml(code); + } + + return highlightWithGrammar(code, grammar, language); +} + +function registerLanguageCollection( + registry: LanguagesRegistry, + collection: CreateHighlighterOptions["languages"], +): void { + if (!collection) { + return; + } + + if (Array.isArray(collection)) { + for (const definition of collection) { + registerLanguageDefinition(registry, definition); + } + + return; + } + + for (const [language, grammar] of Object.entries(collection)) { + if (isGrammar(grammar)) { + registerLanguageInRegistry(registry, language, grammar); + } + } +} + +function registerLanguageDefinition(registry: LanguagesRegistry, definition: LanguageDefinition): void { + const grammar = cloneGrammar(definition.grammar); + registerLanguageInRegistry(registry, definition.id, grammar); + + for (const alias of definition.aliases ?? []) { + registerLanguageInRegistry(registry, alias, grammar); + } +} + +function registerLanguageInRegistry(registry: LanguagesRegistry, language: string, grammar: Grammar): void { + registry[language] = grammar; +} + +export function highlightWithGrammar(code: string, grammar: Grammar, language = ""): string { + return renderHtml(tokenize(code, grammar), language); +} + +export function tokenize(text: string, grammar: Grammar): TokenStream { + const rest = grammar.rest; + + if (rest) { + for (const token of Object.keys(rest)) { + grammar[token] = rest[token]; + } + + delete grammar.rest; + } + + const tokenList = createLinkedList(); + insertAfter(tokenList, tokenList.head, text); + tokenizeInto(text, tokenList, grammar, tokenList.head, 0); + + return listValues(tokenList); +} + +function renderHtml(value: string | Token | TokenStream, language: string): string { + if (typeof value === "string") { + return escapeHtml(value); + } + + if (Array.isArray(value)) { + let html = ""; + + for (const item of value) { + html += renderHtml(item, language); + } + + return html; + } + + const classes = ["token", value.type]; + const aliases = value.alias; + + if (Array.isArray(aliases)) { + classes.push(...aliases); + } else if (aliases) { + classes.push(aliases); + } + + const content = renderHtml(value.content, language); + const title = value.type === "entity" ? ` title="${content.replace(/&/, "&")}"` : ""; + + return `${content}`; +} + +function execPatternAt(pattern: RegExp, position: number, text: string, lookbehind: boolean): RegExpExecArray | null { + pattern.lastIndex = position; + const match = pattern.exec(text); + + if (match && lookbehind && match[1]) { + const lookbehindLength = match[1].length; + match.index += lookbehindLength; + match[0] = match[0].slice(lookbehindLength); + } + + return match; +} + +function tokenizeInto( + text: string, + tokenList: LinkedList, + grammar: Grammar, + startNode: LinkedListNode, + startPosition: number, + rescan?: RescanState, +): void { + for (const tokenType of Object.keys(grammar)) { + if (tokenType === "rest") { + continue; + } + + const grammarValue = grammar[tokenType]; + + if (!isPatternEntry(grammarValue)) { + continue; + } + + const tokenPatterns = Array.isArray(grammarValue) ? grammarValue : [grammarValue]; + + for (let patternIndex = 0; patternIndex < tokenPatterns.length; patternIndex += 1) { + if (rescan && rescan.skipPattern === `${tokenType},${patternIndex}`) { + return; + } + + const tokenPattern = toGrammarToken(tokenPatterns[patternIndex]); + const nestedGrammar = tokenPattern.inside ?? null; + const lookbehind = !!tokenPattern.lookbehind; + const greedy = !!tokenPattern.greedy; + const alias = tokenPattern.alias; + const pattern = greedy ? asGlobalPattern(tokenPattern.pattern) : tokenPattern.pattern; + + for ( + let node = startNode.next, segmentStart = startPosition; + node && node !== tokenList.tail; + segmentStart += sourceLength(node.value), node = node.next + ) { + if (rescan && segmentStart >= rescan.maxReach) { + break; + } + + let segment = node.value; + + if (tokenList.length > text.length) { + return; + } + + if (segment instanceof Token) { + continue; + } + + let replaceCount = 1; + let match: RegExpExecArray | null; + + if (greedy) { + match = execPatternAt(pattern, segmentStart, text, lookbehind); + + if (!match || match.index >= text.length) { + break; + } + + const matchStart = match.index; + const matchEnd = match.index + match[0].length; + let scanEnd = segmentStart + segment.length; + + while (matchStart >= scanEnd) { + node = node.next; + + if (!node) { + break; + } + + segment = node.value; + scanEnd += sourceLength(segment); + } + + if (!node) { + break; + } + + scanEnd -= sourceLength(segment); + segmentStart = scanEnd; + + if (segment instanceof Token) { + continue; + } + + for (let scanNode = node; scanNode !== tokenList.tail; ) { + if (scanEnd >= matchEnd && typeof scanNode.value !== "string") { + break; + } + + replaceCount += 1; + scanEnd += sourceLength(scanNode.value); + scanNode = scanNode.next ?? tokenList.tail; + } + + replaceCount -= 1; + segment = text.slice(segmentStart, scanEnd); + match.index -= segmentStart; + } else { + match = execPatternAt(pattern, 0, segment, lookbehind); + + if (!match) { + continue; + } + } + + const matchStart = match.index; + const matchedText = match[0]; + const prefix = segment.slice(0, matchStart); + const suffix = segment.slice(matchStart + matchedText.length); + const rescanReach = segmentStart + segment.length; + + if (rescan && rescanReach > rescan.maxReach) { + rescan.maxReach = rescanReach; + } + + let beforeMatchNode = node.prev; + + if (!beforeMatchNode) { + continue; + } + + if (prefix) { + beforeMatchNode = insertAfter(tokenList, beforeMatchNode, prefix); + segmentStart += prefix.length; + } + + removeAfter(tokenList, beforeMatchNode, replaceCount); + + const wrapped = new Token( + tokenType, + nestedGrammar ? tokenize(matchedText, nestedGrammar) : matchedText, + alias, + matchedText, + ); + node = insertAfter(tokenList, beforeMatchNode, wrapped); + + if (suffix) { + insertAfter(tokenList, node, suffix); + } + + if (replaceCount > 1) { + const overlapRescan = { + skipPattern: `${tokenType},${patternIndex}`, + maxReach: rescanReach, + }; + tokenizeInto(text, tokenList, grammar, node.prev ?? tokenList.head, segmentStart, overlapRescan); + + if (rescan && overlapRescan.maxReach > rescan.maxReach) { + rescan.maxReach = overlapRescan.maxReach; + } + } + } + } + } +} + +function toGrammarToken(pattern: RegExp | GrammarToken): GrammarToken { + if (pattern instanceof RegExp) { + return { pattern }; + } + + return pattern; +} + +function isPatternEntry(value: GrammarValue | Grammar | undefined): value is GrammarValue { + if (!value) { + return false; + } + + if (value instanceof RegExp || Array.isArray(value)) { + return true; + } + + return value.pattern instanceof RegExp; +} + +function asGlobalPattern(pattern: RegExp): RegExp { + if (pattern.global) { + return pattern; + } + + let globalPattern = globalPatternCache.get(pattern); + + if (!globalPattern) { + globalPattern = new RegExp(pattern.source, `${pattern.flags}g`); + globalPatternCache.set(pattern, globalPattern); + } + + return globalPattern; +} + +function createLinkedList(): LinkedList { + const head: LinkedListNode = { value: null as T, prev: null, next: null }; + const tail: LinkedListNode = { value: null as T, prev: head, next: null }; + head.next = tail; + + return { head, tail, length: 0 }; +} + +function insertAfter(list: LinkedList, node: LinkedListNode, value: T): LinkedListNode { + const next = node.next; + + if (!next) { + throw new Error("Cannot insert after a detached linked-list node."); + } + + const newNode = { value, prev: node, next }; + node.next = newNode; + next.prev = newNode; + list.length += 1; + + return newNode; +} + +function removeAfter(list: LinkedList, node: LinkedListNode, count: number): void { + let next = node.next; + let removed = 0; + + for (; removed < count && next !== list.tail; removed += 1) { + next = next?.next ?? null; + } + + if (!next) { + throw new Error("Cannot remove past the end of a linked list."); + } + + node.next = next; + next.prev = node; + list.length -= removed; +} + +function listValues(list: LinkedList): T[] { + const array: T[] = []; + let node = list.head.next; + + while (node && node !== list.tail) { + array.push(node.value); + node = node.next; + } + + return array; +} + +function sourceLength(value: string | Token): number { + return value.length; +} + +function escapeHtml(value: string): string { + return value.replace(htmlEscapePattern, replaceHtmlCharacter); +} + +function replaceHtmlCharacter(value: string): string { + return value === "&" ? "&" : value === "<" ? "<" : " "; +} + +function isGrammar(value: unknown): value is Grammar { + return !!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof RegExp); +} + +function cloneGrammar(value: T, visited = new Map()): T { + if (!value || typeof value !== "object") { + return value; + } + + if (value instanceof RegExp) { + return value as T; + } + + if (visited.has(value)) { + return visited.get(value) as T; + } + + if (Array.isArray(value)) { + const array: unknown[] = []; + visited.set(value, array); + + for (const item of value) { + array.push(cloneGrammar(item, visited)); + } + + return array as T; + } + + const object: Record = {}; + visited.set(value, object); + + for (const key of Object.keys(value)) { + object[key] = cloneGrammar((value as Record)[key], visited); + } + + return object as T; +} + +function replaceGrammarReferences( + value: unknown, + oldGrammar: Grammar, + replacement: Grammar, + visited = new Set(), +): void { + if (!value || typeof value !== "object" || value instanceof RegExp || visited.has(value)) { + return; + } + + visited.add(value); + + const object = value as Record; + + for (const key of Object.keys(object)) { + if (object[key] === oldGrammar) { + object[key] = replacement; + } else { + replaceGrammarReferences(object[key], oldGrammar, replacement, visited); + } + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/index.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/index.ts new file mode 100644 index 00000000..d27da0d9 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/index.ts @@ -0,0 +1 @@ +export * from "./chat"; diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/bash.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/bash.ts new file mode 100644 index 00000000..68be2029 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/bash.ts @@ -0,0 +1,199 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerBashLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.bash; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const entity = /\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/; + const environment = /\$(?:HOME|PATH|PWD|SHELL|TERM|USER)\b/; + const commandSubstitution: GrammarToken = { + pattern: /\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/, + greedy: true, + inside: { + variable: /^\$\(|^`|\)$|`$/, + }, + }; + const arithmetic: GrammarToken = { + pattern: /\$?\(\([\s\S]+?\)\)/, + greedy: true, + inside: { + variable: [ + { + pattern: /(^\$\(\([\s\S]+)\)\)/, + lookbehind: true, + }, + /^\$\(\(/, + ], + number: /\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/, + operator: /--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/, + punctuation: /\(\(?|\)\)?|,|;/, + }, + }; + const braceExpansion: GrammarToken = { + pattern: /\$\{[^}]+\}/, + greedy: true, + inside: { + operator: /:[-=?+]?|[!/]|##?|%%?|\^\^?|,,?/, + punctuation: /[[\]]/, + }, + }; + const variable = [arithmetic, commandSubstitution, braceExpansion, /\$(?:\w+|[#?*!@$])/]; + const commandAfterHeredoc: GrammarToken = { + pattern: /(^(["']?)\w+\2)[ \t]+\S.*/, + lookbehind: true, + alias: "punctuation", + inside: null, + }; + const insideString: Grammar = { + bash: commandAfterHeredoc, + environment: { + pattern: environment, + alias: "constant", + }, + variable, + entity, + }; + + const bash: Grammar = { + shebang: { + pattern: /^#!\s*\/.*/, + alias: "important", + }, + comment: { + pattern: /(^|[^"{\\$])#.*/, + lookbehind: true, + greedy: true, + }, + string: [ + { + pattern: /((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/, + lookbehind: true, + greedy: true, + inside: insideString, + }, + { + pattern: /((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/, + lookbehind: true, + greedy: true, + inside: { + bash: commandAfterHeredoc, + }, + }, + { + pattern: /(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/, + lookbehind: true, + greedy: true, + inside: insideString, + }, + { + pattern: /(^|[^$\\])'[^']*'/, + lookbehind: true, + greedy: true, + }, + { + pattern: /\$'(?:[^'\\]|\\[\s\S])*'/, + greedy: true, + inside: { + entity, + }, + }, + ], + "function-name": [ + { + pattern: /(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/, + lookbehind: true, + alias: "function", + }, + { + pattern: /\b[\w-]+(?=\s*\(\s*\)\s*\{)/, + alias: "function", + }, + ], + "for-or-select": { + pattern: /((?:^|[;&|]\s*|\b(?:do|then|else)\s+)for\s+)\w+/, + lookbehind: true, + alias: "variable", + }, + "assign-left": { + pattern: /(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/, + lookbehind: true, + alias: "variable", + inside: { + environment: { + pattern: /(^|[\s;|&]|[<>]\()(?:HOME|PATH|PWD|SHELL|TERM|USER)\b/, + lookbehind: true, + alias: "constant", + }, + }, + }, + environment: { + pattern: environment, + alias: "constant", + }, + variable, + parameter: { + pattern: /(^|\s)-{1,2}[\w-]+/, + lookbehind: true, + alias: "variable", + }, + function: { + pattern: + /(^|[\s;|&]|[<>]\()(?:basename|cat|cd|chmod|cp|curl|diff|docker|find|git|grep|ls|mkdir|mv|node|npm|pnpm|rm|sed|sh|sort|sudo|tail|tar|touch|yarn)(?=$|[)\s;|&])/, + lookbehind: true, + }, + keyword: { + pattern: + /(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/, + lookbehind: true, + }, + builtin: { + pattern: + /(^|[\s;|&]|[<>]\()(?:alias|break|cd|command|continue|declare|echo|eval|exec|exit|export|local|printf|pwd|read|return|set|shift|source|test|type|unset)(?=$|[)\s;|&])/, + lookbehind: true, + alias: "class-name", + }, + boolean: { + pattern: /(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/, + lookbehind: true, + }, + operator: /\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/, + punctuation: /\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/, + number: { + pattern: /(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/, + lookbehind: true, + }, + }; + const commandSubstitutionInside = commandSubstitution.inside; + + commandAfterHeredoc.inside = bash; + + if (commandSubstitutionInside) { + for (const token of [ + "comment", + "function-name", + "for-or-select", + "assign-left", + "parameter", + "string", + "environment", + "function", + "keyword", + "builtin", + "boolean", + "operator", + "punctuation", + "number", + ]) { + commandSubstitutionInside[token] = bash[token]; + } + } + + registry.bash = bash; + registry.sh = registry.bash; + registry.shell = registry.bash; + return bash; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/c.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/c.ts new file mode 100644 index 00000000..595ffe41 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/c.ts @@ -0,0 +1,90 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { registerClikeLanguage } from "./clike"; +import { isRegisteredGrammar } from "./shared"; + +export function registerCLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.c; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerClikeLanguage(registry); + + const c = registry.extend("clike", { + comment: { + pattern: /\/\/(?:[^\r\n\\]|\\(?:\r\n?|\n|(?![\r\n])))*|\/\*[\s\S]*?(?:\*\/|$)/, + greedy: true, + }, + string: { + pattern: /"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/, + greedy: true, + }, + "class-name": { + pattern: /(\b(?:enum|struct)\s+(?:__attribute__\s*\(\([\s\S]*?\)\)\s*)?)\w+|\b[a-z]\w*_t\b/, + lookbehind: true, + }, + keyword: + /\b(?:_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local|__attribute__|asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|return|short|signed|sizeof|static|struct|switch|typedef|typeof|union|unsigned|void|volatile|while)\b/, + function: /\b[a-z_]\w*(?=\s*\()/i, + number: + /(?:\b0x(?:[\da-f]+(?:\.[\da-f]*)?|\.[\da-f]+)(?:p[+-]?\d+)?|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?)[ful]{0,4}/i, + operator: />>=?|<<=?|->|([-+&|:])\1|[?:~]|[-+*/%&|^!=<>]=?/, + }); + + registry.c = c; + registry.insertBefore("c", "string", { + char: { + pattern: /'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n]){0,32}'/, + greedy: true, + }, + }); + registry.insertBefore("c", "string", { + macro: { + pattern: /(^[\t ]*)#\s*[a-z](?:[^\r\n\\/]|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/|\\(?:\r\n|[\s\S]))*/im, + lookbehind: true, + greedy: true, + alias: "property", + inside: { + string: [ + { + pattern: /^(#\s*include\s*)<[^>]+>/, + lookbehind: true, + }, + c.string as GrammarToken, + ], + char: c.char as GrammarToken, + comment: c.comment as GrammarToken, + "macro-name": [ + { + pattern: /(^#\s*define\s+)\w+\b(?!\()/i, + lookbehind: true, + }, + { + pattern: /(^#\s*define\s+)\w+\b(?=\()/i, + lookbehind: true, + alias: "function", + }, + ], + directive: { + pattern: /^(#\s*)[a-z]+/, + lookbehind: true, + alias: "keyword", + }, + "directive-hash": /^#/, + punctuation: /##|\\(?=[\r\n])/, + expression: { + pattern: /\S[\s\S]*/, + inside: c, + }, + }, + }, + }); + registry.insertBefore("c", "function", { + constant: + /\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\b/, + }); + delete c.boolean; + + return c; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/clike.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/clike.ts new file mode 100644 index 00000000..ee71696f --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/clike.ts @@ -0,0 +1,46 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerClikeLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.clike; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const clike: Grammar = { + comment: [ + { + pattern: /(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/, + lookbehind: true, + greedy: true, + }, + { + pattern: /(^|[^\\:])\/\/.*/, + lookbehind: true, + greedy: true, + }, + ], + string: { + pattern: /(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/, + greedy: true, + }, + "class-name": { + pattern: /(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i, + lookbehind: true, + inside: { + punctuation: /[.\\]/, + }, + }, + keyword: + /\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/, + boolean: /\b(?:false|true)\b/, + function: /\b\w+(?=\()/, + number: /\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i, + operator: /[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/, + punctuation: /[{}[\];(),.:]/, + }; + + registry.clike = clike; + return clike; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/cpp.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/cpp.ts new file mode 100644 index 00000000..80c0bde9 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/cpp.ts @@ -0,0 +1,101 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { registerCLanguage } from "./c"; +import { isRegisteredGrammar } from "./shared"; + +export function registerCppLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.cpp; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerCLanguage(registry); + + const keyword = + /\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\b/; + + const cpp = registry.extend("c", { + "class-name": [ + { + pattern: + /(\b(?:class|concept|enum|struct|typename)\s+)(?!alignas|alignof|asm|auto|bool|break|case|catch|char|class|const|constexpr|continue|decltype|default|delete|do|double|else|enum|explicit|extern|float|for|friend|goto|if|inline|int|long|mutable|namespace|new|operator|private|protected|public|return|short|signed|sizeof|static|struct|switch|template|this|throw|try|typedef|typename|union|unsigned|using|virtual|void|volatile|while)\w+/, + lookbehind: true, + }, + /\b[A-Z]\w*(?=\s*::\s*\w+\s*\()/, + /\b[A-Z_]\w*(?=\s*::\s*~\w+\s*\()/i, + /\b\w+(?=\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\s*::\s*\w+\s*\()/, + ], + keyword, + number: { + pattern: + /(?:\b0b[01']+|\b0x(?:[\da-f']+(?:\.[\da-f']*)?|\.[\da-f']+)(?:p[+-]?[\d']+)?|(?:\b[\d']+(?:\.[\d']*)?|\B\.[\d']+)(?:e[+-]?[\d']+)?)[ful]{0,4}/i, + greedy: true, + }, + operator: + />>=?|<<=?|->|--|\+\+|&&|\|\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\b/, + boolean: /\b(?:false|true)\b/, + }); + + registry.cpp = cpp; + registry.insertBefore("cpp", "string", { + module: { + pattern: + /(\b(?:import|module)\s+)(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|<[^<>\r\n]*>|\b(?!alignas|alignof|asm|auto|bool|break|case|catch|char|class|const|constexpr|continue|decltype|default|delete|do|double|else|enum|explicit|extern|float|for|friend|goto|if|inline|int|long|mutable|namespace|new|operator|private|protected|public|return|short|signed|sizeof|static|struct|switch|template|this|throw|try|typedef|typename|union|unsigned|using|virtual|void|volatile|while)\w+(?:\s*\.\s*\w)*\b(?:\s*:\s*\b(?!alignas|alignof|asm|auto|bool|break|case|catch|char|class|const|constexpr|continue|decltype|default|delete|do|double|else|enum|explicit|extern|float|for|friend|goto|if|inline|int|long|mutable|namespace|new|operator|private|protected|public|return|short|signed|sizeof|static|struct|switch|template|this|throw|try|typedef|typename|union|unsigned|using|virtual|void|volatile|while)\w+(?:\s*\.\s*\w)*\b)?|:\s*\b(?!alignas|alignof|asm|auto|bool|break|case|catch|char|class|const|constexpr|continue|decltype|default|delete|do|double|else|enum|explicit|extern|float|for|friend|goto|if|inline|int|long|mutable|namespace|new|operator|private|protected|public|return|short|signed|sizeof|static|struct|switch|template|this|throw|try|typedef|typename|union|unsigned|using|virtual|void|volatile|while)\w+(?:\s*\.\s*\w)*\b)/, + lookbehind: true, + greedy: true, + inside: { + string: /^[<"][\s\S]+/, + operator: /:/, + punctuation: /\./, + }, + }, + "raw-string": { + pattern: /R"([^()\\ ]{0,16})\([\s\S]*?\)\1"/, + alias: "string", + greedy: true, + }, + }); + registry.insertBefore("cpp", "keyword", { + "generic-function": { + pattern: /\b(?!operator\b)[a-z_]\w*\s*<(?:[^<>]|<[^<>]*>)*>(?=\s*\()/i, + inside: { + function: /^\w+/, + generic: { + pattern: /<[\s\S]+/, + alias: "class-name", + inside: cpp, + }, + }, + }, + }); + registry.insertBefore("cpp", "operator", { + "double-colon": { + pattern: /::/, + alias: "punctuation", + }, + }); + const cppWithBaseClause = registry.insertBefore("cpp", "class-name", { + "base-clause": { + pattern: /(\b(?:class|struct)\s+\w+\s*:\s*)[^;{}"'\s]+(?:\s+[^;{}"'\s]+)*(?=\s*[;{])/, + lookbehind: true, + greedy: true, + inside: registry.extend("cpp", {}), + }, + }); + + const baseClause = cppWithBaseClause["base-clause"] as GrammarToken; + + if (isRegisteredGrammar(baseClause.inside)) { + registry.insertBefore( + "inside", + "double-colon", + { + "class-name": /\b[a-z_]\w*\b(?!\s*::)/i, + }, + baseClause as unknown as Record, + ); + } + + registry["c++"] = registry.cpp; + return registry.cpp as Grammar; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/csharp.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/csharp.ts new file mode 100644 index 00000000..8af9c539 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/csharp.ts @@ -0,0 +1,153 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { registerClikeLanguage } from "./clike"; +import { isRegisteredGrammar } from "./shared"; + +export function registerCSharpLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.csharp; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerClikeLanguage(registry); + + const name = /@?\b[A-Za-z_]\w*\b/.source; + const keywords = + /\b(?:abstract|add|alias|and|ascending|as|async|await|base|bool|break|byte|by|case|catch|char|checked|class|const|continue|decimal|default|delegate|descending|do|double|dynamic|else|enum|event|explicit|extern|false|finally|fixed|float|for|foreach|from(?=\s*(?:\w|$))|get|global|goto|group|if|implicit|in|init(?=\s*;)|int|interface|internal|into|is|join|let|lock|long|namespace|new|null|nameof|not|notnull|object|on|operator|or|orderby|out|override|params|partial|private|protected|public|readonly|record|ref|remove|return|sbyte|sealed|select|set|short|sizeof|stackalloc|static|string|struct|switch|this|throw|true|try|typeof|uint|ulong|unchecked|unmanaged|unsafe|ushort|using|value|var|virtual|void|volatile|when|where|while|with(?=\s*{)|yield)\b/; + + const csharp = registry.extend("clike", { + string: [ + { + pattern: /(^|[^$\\])@"(?:""|\\[\s\S]|[^\\"])*"(?!")/, + lookbehind: true, + greedy: true, + }, + { + pattern: /(^|[^@$\\])"(?:\\.|[^\\"\r\n])*"/, + lookbehind: true, + greedy: true, + }, + ], + "class-name": [ + { + pattern: + /(\b(?:class|enum|interface|record|struct)\s+)@?\b[A-Za-z_]\w*\b(?:\s*<(?:[^<>;=+\-*/%&|^]|<(?:[^<>;=+\-*/%&|^]|<[^<>]*>)*>)*>)?/, + lookbehind: true, + inside: { + keyword: keywords, + punctuation: /[<>()?,.:[\]]/, + }, + }, + { + pattern: + /\b(?:bool|byte|char|decimal|double|dynamic|float|int|long|object|sbyte|short|string|uint|ulong|ushort|var|void|[A-Z]\w*(?:\s*\.\s*[A-Z]\w*)*)(?=\s+(?!with\s*\{)@?\b[A-Za-z_]\w*\b(?:\s*[=,;:{)\]]|\s+(?:in|when)\b))/, + inside: { + keyword: keywords, + punctuation: /[<>()?,.:[\]]/, + }, + }, + { + pattern: /(\bcatch\s*\(\s*)@?\b[A-Za-z_]\w*\b/, + lookbehind: true, + }, + { + pattern: /(\bnew\s+)@?\b[A-Za-z_]\w*(?:\s*\.\s*@?\b[A-Za-z_]\w*)*(?=\s*[[({])/, + lookbehind: true, + inside: { + punctuation: /\./, + }, + }, + ], + keyword: keywords, + number: + /(?:\b0(?:x[\da-f_]*[\da-f]|b[01_]*[01])|(?:\B\.\d+(?:_+\d+)*|\b\d+(?:_+\d+)*(?:\.\d+(?:_+\d+)*)?)(?:e[-+]?\d+(?:_+\d+)*)?)(?:[dflmu]|lu|ul)?\b/i, + operator: />>=?|<<=?|[-=]>|([-+&|])\1|~|\?\?=?|[-+*/%&|^!=<>]=?/, + punctuation: /\?\.?|::|[{}[\];(),.:]/, + }); + + registry.csharp = csharp; + registry.insertBefore("csharp", "number", { + range: { + pattern: /\.\./, + alias: "operator", + }, + }); + registry.insertBefore("csharp", "punctuation", { + "named-parameter": { + pattern: RegExp(/([(,]\s*)/.source + name + /(?=\s*:)/.source), + lookbehind: true, + alias: "punctuation", + }, + }); + registry.insertBefore("csharp", "class-name", { + namespace: { + pattern: RegExp(`${/(\b(?:namespace|using)\s+)/.source}${name}(?:\\s*\\.\\s*${name})*(?=\\s*[;{])`), + lookbehind: true, + inside: { + punctuation: /\./, + }, + }, + preprocessor: { + pattern: /(^[\t ]*)#.*/m, + lookbehind: true, + alias: "property", + inside: { + directive: { + pattern: /(#)\b(?:define|elif|else|endif|endregion|error|if|line|nullable|pragma|region|undef|warning)\b/, + lookbehind: true, + alias: "keyword", + }, + }, + }, + "constructor-invocation": { + pattern: /(\bnew\s+)@?\b[A-Za-z_]\w*(?:\s*\.\s*@?\b[A-Za-z_]\w*)*(?=\s*[[({])/, + lookbehind: true, + inside: { + punctuation: /\./, + }, + alias: "class-name", + }, + attribute: { + pattern: + /((?:^|[^\s\w>)?])\s*\[\s*)(?:(?:assembly|event|field|method|module|param|property|return|type)\s*:\s*)?@?\b[A-Za-z_]\w*(?:\s*\.\s*@?\b[A-Za-z_]\w*)*(?:\s*\([^()\r\n]*\))?(?:\s*,\s*@?\b[A-Za-z_]\w*(?:\s*\.\s*@?\b[A-Za-z_]\w*)*(?:\s*\([^()\r\n]*\))?)*(?=\s*\])/, + lookbehind: true, + greedy: true, + inside: { + target: { + pattern: /^(?:assembly|event|field|method|module|param|property|return|type)(?=\s*:)/, + alias: "keyword", + }, + "class-name": { + pattern: /@?\b[A-Za-z_]\w*(?:\s*\.\s*@?\b[A-Za-z_]\w*)*/, + inside: { + punctuation: /\./, + }, + }, + punctuation: /[:,]/, + }, + }, + }); + registry.insertBefore("csharp", "string", { + "interpolation-string": [ + { + pattern: /(^|[^\\])(?:\$@|@\$)"(?:""|\\[\s\S]|\{\{|[^\\{"])*"/, + lookbehind: true, + greedy: true, + }, + { + pattern: /(^|[^@\\])\$"(?:\\.|\{\{|[^\\"{])*"/, + lookbehind: true, + greedy: true, + }, + ], + char: { + pattern: /'(?:[^\r\n'\\]|\\.|\\[Uux][\da-fA-F]{1,8})'/, + greedy: true, + }, + }); + + registry.cs = registry.csharp; + registry.dotnet = registry.csharp; + registry["c#"] = registry.csharp; + return registry.csharp as Grammar; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/diff.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/diff.ts new file mode 100644 index 00000000..e0794643 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/diff.ts @@ -0,0 +1,40 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerDiffLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.diff; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const diff: Grammar = { + coord: [/^(?:\*{3}|-{3}|\+{3}).*$/m, /^@@.*@@$/m, /^\d.*$/m], + "deleted-sign": createDiffLineToken("-", ["deleted"], "deleted"), + "deleted-arrow": createDiffLineToken("<", ["deleted"], "deleted"), + "inserted-sign": createDiffLineToken("+", ["inserted"], "inserted"), + "inserted-arrow": createDiffLineToken(">", ["inserted"], "inserted"), + unchanged: createDiffLineToken(" ", [], "unchanged"), + diff: createDiffLineToken("!", ["bold"], "diff"), + }; + + registry.diff = diff; + return diff; +} + +function createDiffLineToken(prefix: string, alias: string[], prefixAlias: string): GrammarToken { + return { + pattern: RegExp(`^(?:[${prefix}].*(?:\\r\\n?|\\n|(?![\\s\\S])))+`, "m"), + alias, + inside: { + line: { + pattern: /(.)(?=[\s\S]).*(?:\r\n?|\n)?/, + lookbehind: true, + }, + prefix: { + pattern: /[\s\S]/, + alias: prefixAlias, + }, + }, + }; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/dockerfile.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/dockerfile.ts new file mode 100644 index 00000000..faee5ab4 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/dockerfile.ts @@ -0,0 +1,83 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerDockerfileLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.dockerfile ?? registry.docker; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const stringRule = { + pattern: /"(?:[^"\\\r\n]|\\(?:\r\n|[\s\S]))*"|'(?:[^'\\\r\n]|\\(?:\r\n|[\s\S]))*'/, + greedy: true, + }; + const commentRule = { + pattern: /(^[ \t]*)#.*/m, + lookbehind: true, + greedy: true, + }; + const docker: Grammar = { + instruction: { + pattern: + /(^[ \t]*)(?:ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|HEALTHCHECK|LABEL|MAINTAINER|ONBUILD|RUN|SHELL|STOPSIGNAL|USER|VOLUME|WORKDIR)(?=\s)(?:\\.|[^\r\n\\])*(?:\\$(?:\s|#.*$)*(?![\s#])(?:\\.|[^\r\n\\])*)*/im, + lookbehind: true, + greedy: true, + inside: { + options: { + pattern: + /(^ONBUILD(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))\w+(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))|^\w+(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n])))--[\w-]+=(?:"(?:[^"\\\r\n]|\\(?:\r\n|[\s\S]))*"|'(?:[^'\\\r\n]|\\(?:\r\n|[\s\S]))*'|(?!["'])(?:[^\s\\]|\\.)+)(?:(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))--[\w-]+=(?:"(?:[^"\\\r\n]|\\(?:\r\n|[\s\S]))*"|'(?:[^'\\\r\n]|\\(?:\r\n|[\s\S]))*'|(?!["'])(?:[^\s\\]|\\.)+))*/i, + lookbehind: true, + greedy: true, + inside: { + property: { + pattern: /(^|\s)--[\w-]+/, + lookbehind: true, + }, + string: [ + stringRule, + { + pattern: /(=)(?!["'])(?:[^\s\\]|\\.)+/, + lookbehind: true, + }, + ], + operator: /\\$/m, + punctuation: /=/, + }, + }, + keyword: [ + { + pattern: + /(^ONBUILD(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))HEALTHCHECK(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))(?:--[\w-]+=(?:"(?:[^"\\\r\n]|\\(?:\r\n|[\s\S]))*"|'(?:[^'\\\r\n]|\\(?:\r\n|[\s\S]))*'|(?!["'])(?:[^\s\\]|\\.)+)(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n])))*|^HEALTHCHECK(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))(?:--[\w-]+=(?:"(?:[^"\\\r\n]|\\(?:\r\n|[\s\S]))*"|'(?:[^'\\\r\n]|\\(?:\r\n|[\s\S]))*'|(?!["'])(?:[^\s\\]|\\.)+)(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n])))*)(?:CMD|NONE)\b/i, + lookbehind: true, + greedy: true, + }, + { + pattern: + /(^ONBUILD(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))FROM(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))(?:--[\w-]+=(?:"(?:[^"\\\r\n]|\\(?:\r\n|[\s\S]))*"|'(?:[^'\\\r\n]|\\(?:\r\n|[\s\S]))*'|(?!["'])(?:[^\s\\]|\\.)+)(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n])))*(?!--)[^ \t\\]+(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))|^FROM(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n]))(?:--[\w-]+=(?:"(?:[^"\\\r\n]|\\(?:\r\n|[\s\S]))*"|'(?:[^'\\\r\n]|\\(?:\r\n|[\s\S]))*'|(?!["'])(?:[^\s\\]|\\.)+)(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n])))*(?!--)[^ \t\\]+(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n])))AS/i, + lookbehind: true, + greedy: true, + }, + { + pattern: /(^ONBUILD(?:[ \t]+(?![ \t])|\\[\r\n](?:\s|\\[\r\n]|#.*(?!.))*(?![\s#]|\\[\r\n])))\w+/i, + lookbehind: true, + greedy: true, + }, + { + pattern: /^\w+/, + greedy: true, + }, + ], + comment: commentRule, + string: stringRule, + variable: /\$(?:\w+|\{[^{}"'\\]*\})/, + operator: /\\$/m, + }, + }, + comment: commentRule, + }; + + registry.docker = docker; + registry.dockerfile = registry.docker; + return registry.docker; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/go.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/go.ts new file mode 100644 index 00000000..0aa39e33 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/go.ts @@ -0,0 +1,44 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { registerClikeLanguage } from "./clike"; +import { isRegisteredGrammar } from "./shared"; + +export function registerGoLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.go; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerClikeLanguage(registry); + + const go = registry.extend("clike", { + string: { + pattern: /(^|[^\\])"(?:\\.|[^"\\\r\n])*"|`[^`]*`/, + lookbehind: true, + greedy: true, + }, + keyword: + /\b(?:break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go(?:to)?|if|import|interface|map|package|range|return|select|struct|switch|type|var)\b/, + boolean: /\b(?:_|false|iota|nil|true)\b/, + number: [ + /\b0(?:b[01_]+|o[0-7_]+)i?\b/i, + /\b0x(?:[a-f\d_]+(?:\.[a-f\d_]*)?|\.[a-f\d_]+)(?:p[+-]?\d+(?:_\d+)*)?i?(?!\w)/i, + /(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?[\d_]+)?i?(?!\w)/i, + ], + operator: /[*/%^!=]=?|\+[=+]?|-[=-]?|\|[=|]?|&(?:=|&|\^=?)?|>(?:>=?|=)?|<(?:<=?|=|-)?|:=|\.\.\./, + builtin: + /\b(?:append|bool|byte|cap|close|complex|complex(?:64|128)|copy|delete|error|float(?:32|64)|u?int(?:8|16|32|64)?|imag|len|make|new|panic|print(?:ln)?|real|recover|rune|string|uintptr)\b/, + }); + + registry.go = go; + registry.insertBefore("go", "string", { + char: { + pattern: /'(?:\\.|[^'\\\r\n]){0,10}'/, + greedy: true, + }, + }); + delete go["class-name"]; + + registry.golang = registry.go; + return registry.go as Grammar; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/graphql.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/graphql.ts new file mode 100644 index 00000000..86593cd7 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/graphql.ts @@ -0,0 +1,70 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerGraphqlLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.graphql; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const graphql: Grammar = { + comment: /#.*/, + description: { + pattern: /(?:"""(?:[^"]|(?!""")")*"""|"(?:\\.|[^\\"\r\n])*")(?=\s*[a-z_])/i, + greedy: true, + alias: "string", + }, + string: { + pattern: /"""(?:[^"]|(?!""")")*"""|"(?:\\.|[^\\"\r\n])*"/, + greedy: true, + }, + number: /(?:\B-|\b)\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i, + boolean: /\b(?:false|true)\b/, + variable: /\$[a-z_]\w*/i, + directive: { + pattern: /@[a-z_]\w*/i, + alias: "function", + }, + "attr-name": { + pattern: /\b[a-z_]\w*(?=\s*(?:\((?:[^()"]|"(?:\\.|[^\\"\r\n])*")*\))?:)/i, + greedy: true, + }, + "atom-input": { + pattern: /\b[A-Z]\w*Input\b/, + alias: "class-name", + }, + scalar: /\b(?:Boolean|Float|ID|Int|String)\b/, + constant: /\b[A-Z][A-Z_\d]*\b/, + "class-name": { + pattern: /(\b(?:enum|implements|interface|on|scalar|type|union)\s+|&\s*|:\s*|\[)[A-Z_]\w*/, + lookbehind: true, + }, + fragment: { + pattern: /(\bfragment\s+|\.{3}\s*(?!on\b))[a-zA-Z_]\w*/, + lookbehind: true, + alias: "function", + }, + "definition-mutation": { + pattern: /(\bmutation\s+)[a-zA-Z_]\w*/, + lookbehind: true, + alias: "function", + }, + "definition-query": { + pattern: /(\bquery\s+)[a-zA-Z_]\w*/, + lookbehind: true, + alias: "function", + }, + keyword: + /\b(?:directive|enum|extend|fragment|implements|input|interface|mutation|on|query|repeatable|scalar|schema|subscription|type|union)\b/, + operator: /[!=|&]|\.{3}/, + "property-query": /\w+(?=\s*\()/, + object: /\w+(?=\s*\{)/, + punctuation: /[!(){}[\]:=,]/, + property: /\w+/, + }; + + registry.graphql = graphql; + registry.gql = registry.graphql; + return graphql; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/index.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/index.ts new file mode 100644 index 00000000..a35842c4 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/index.ts @@ -0,0 +1,78 @@ +import type { LanguagesRegistry } from "../core"; +import { registerBashLanguage } from "./bash"; +import { registerCLanguage } from "./c"; +import { registerCppLanguage } from "./cpp"; +import { registerCSharpLanguage } from "./csharp"; +import { registerDiffLanguage } from "./diff"; +import { registerDockerfileLanguage } from "./dockerfile"; +import { registerGoLanguage } from "./go"; +import { registerGraphqlLanguage } from "./graphql"; +import { registerJavaLanguage } from "./java"; +import { registerJavaScriptLanguage } from "./javascript"; +import { registerJsonLanguage } from "./json"; +import { registerKotlinLanguage } from "./kotlin"; +import { registerMarkdownLanguage } from "./markdown"; +import { registerCssLanguage, registerJsxLanguage, registerMarkupLanguage, registerTsxLanguage } from "./markup"; +import { registerPhpLanguage } from "./php"; +import { registerPythonLanguage } from "./python"; +import { registerRubyLanguage } from "./ruby"; +import { registerRustLanguage } from "./rust"; +import { registerSqlLanguage } from "./sql"; +import { registerSwiftLanguage } from "./swift"; +import { registerTomlLanguage } from "./toml"; +import { registerTypeScriptLanguage } from "./typescript"; +import { registerYamlLanguage } from "./yaml"; + +export function registerBuiltInLanguages(registry: LanguagesRegistry): void { + registerJavaScriptLanguage(registry); + registerTypeScriptLanguage(registry); + registerJsonLanguage(registry); + registerYamlLanguage(registry); + registerCssLanguage(registry); + registerMarkupLanguage(registry); + registerJsxLanguage(registry); + registerTsxLanguage(registry); + registerPythonLanguage(registry); + registerBashLanguage(registry); + registerSqlLanguage(registry); + registerDiffLanguage(registry); + registerMarkdownLanguage(registry); + registerGoLanguage(registry); + registerRustLanguage(registry); + registerJavaLanguage(registry); + registerCLanguage(registry); + registerCppLanguage(registry); + registerCSharpLanguage(registry); + registerPhpLanguage(registry); + registerRubyLanguage(registry); + registerKotlinLanguage(registry); + registerSwiftLanguage(registry); + registerDockerfileLanguage(registry); + registerTomlLanguage(registry); + registerGraphqlLanguage(registry); +} + +export { registerBashLanguage } from "./bash"; +export { registerCLanguage } from "./c"; +export { registerClikeLanguage } from "./clike"; +export { registerCppLanguage } from "./cpp"; +export { registerCSharpLanguage } from "./csharp"; +export { registerDiffLanguage } from "./diff"; +export { registerDockerfileLanguage } from "./dockerfile"; +export { registerGoLanguage } from "./go"; +export { registerGraphqlLanguage } from "./graphql"; +export { registerJavaLanguage } from "./java"; +export { registerJavaScriptLanguage } from "./javascript"; +export { registerJsonLanguage } from "./json"; +export { registerKotlinLanguage } from "./kotlin"; +export { registerMarkdownLanguage } from "./markdown"; +export { registerCssLanguage, registerJsxLanguage, registerMarkupLanguage, registerTsxLanguage } from "./markup"; +export { registerPhpLanguage } from "./php"; +export { registerPythonLanguage } from "./python"; +export { registerRubyLanguage } from "./ruby"; +export { registerRustLanguage } from "./rust"; +export { registerSqlLanguage } from "./sql"; +export { registerSwiftLanguage } from "./swift"; +export { registerTomlLanguage } from "./toml"; +export { registerTypeScriptLanguage } from "./typescript"; +export { registerYamlLanguage } from "./yaml"; diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/java.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/java.ts new file mode 100644 index 00000000..b8c1d926 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/java.ts @@ -0,0 +1,135 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { registerClikeLanguage } from "./clike"; +import { isRegisteredGrammar } from "./shared"; + +export function registerJavaLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.java; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerClikeLanguage(registry); + + const keywords = + /\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\s*[(){}[\]<>=%~.:,;?+\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\b/; + const classNamePrefix = /(?:[a-z]\w*\s*\.\s*)*(?:[A-Z]\w*\s*\.\s*)*/.source; + const className = { + pattern: RegExp(/(^|[^\w.])/.source + classNamePrefix + /[A-Z](?:[\d_A-Z]*[a-z]\w*)?\b/.source), + lookbehind: true, + inside: { + namespace: { + pattern: /^[a-z]\w*(?:\s*\.\s*[a-z]\w*)*(?:\s*\.)?/, + inside: { + punctuation: /\./, + }, + }, + punctuation: /\./, + }, + }; + + const java = registry.extend("clike", { + string: { + pattern: /(^|[^\\])"(?:\\.|[^"\\\r\n])*"/, + lookbehind: true, + greedy: true, + }, + "class-name": [ + className, + { + pattern: RegExp( + /(^|[^\w.])/.source + classNamePrefix + /[A-Z]\w*(?=\s+\w+\s*[;,=()]|\s*(?:\[[\s,]*\]\s*)?::\s*new\b)/.source, + ), + lookbehind: true, + inside: className.inside, + }, + { + pattern: RegExp( + /(\b(?:class|enum|extends|implements|instanceof|interface|new|record|throws)\s+)/.source + + classNamePrefix + + /[A-Z]\w*\b/.source, + ), + lookbehind: true, + inside: className.inside, + }, + ], + keyword: keywords, + function: [ + /\b\w+(?=\()/, + { + pattern: /(::\s*)[a-z_]\w*/, + lookbehind: true, + }, + ], + number: + /\b0b[01][01_]*L?\b|\b0x(?:\.[\da-f_p+-]+|[\da-f_]+(?:\.[\da-f_p+-]+)?)\b|(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?\d[\d_]*)?[dfl]?/i, + operator: { + pattern: /(^|[^.])(?:<<=?|>>>?=?|->|--|\+\+|&&|\|\||::|[?:~]|[-+*/%&|^!=<>]=?)/m, + lookbehind: true, + }, + constant: /\b[A-Z][A-Z_\d]+\b/, + }); + + registry.java = java; + registry.insertBefore("java", "string", { + "triple-quoted-string": { + pattern: /"""[ \t]*[\r\n](?:(?:"|"")?(?:\\.|[^"\\]))*"""/, + greedy: true, + alias: "string", + }, + char: { + pattern: /'(?:\\.|[^'\\\r\n]){1,6}'/, + greedy: true, + }, + }); + registry.insertBefore("java", "class-name", { + annotation: { + pattern: /(^|[^.])@\w+(?:\s*\.\s*\w+)*/, + lookbehind: true, + alias: "punctuation", + }, + generics: { + pattern: /<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&))*>)*>)*>)*>/, + inside: { + "class-name": className, + keyword: keywords, + punctuation: /[<>(),.:]/, + operator: /[?&|]/, + }, + }, + import: [ + { + pattern: RegExp(/(\bimport\s+)/.source + classNamePrefix + /(?:[A-Z]\w*|\*)(?=\s*;)/.source), + lookbehind: true, + inside: { + namespace: className.inside.namespace, + punctuation: /\./, + operator: /\*/, + "class-name": /\w+/, + }, + }, + { + pattern: RegExp(/(\bimport\s+static\s+)/.source + classNamePrefix + /(?:\w+|\*)(?=\s*;)/.source), + lookbehind: true, + alias: "static", + inside: { + namespace: className.inside.namespace, + static: /\b\w+$/, + punctuation: /\./, + operator: /\*/, + "class-name": /\w+/, + }, + }, + ], + namespace: { + pattern: + /(\b(?:exports|import(?:\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\s+)(?!abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)[a-z]\w*(?:\.[a-z]\w*)*\.?/, + lookbehind: true, + inside: { + punctuation: /\./, + }, + }, + }); + + return java; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/javascript.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/javascript.ts new file mode 100644 index 00000000..16f88406 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/javascript.ts @@ -0,0 +1,141 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { registerClikeLanguage } from "./clike"; +import { isRegisteredGrammar } from "./shared"; + +export function registerJavaScriptLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.javascript; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const clike = registerClikeLanguage(registry); + + const javascript = registry.extend("clike", { + "class-name": [ + clike["class-name"] as GrammarToken, + { + pattern: + /(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/, + lookbehind: true, + }, + ], + keyword: [ + { + pattern: /((?:^|\})\s*)catch\b/, + lookbehind: true, + }, + { + pattern: + /(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|get(?=\s*(?:[#[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set(?=\s*(?:[#[$\w\xA0-\uFFFF]|$))|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/, + lookbehind: true, + }, + ], + function: /#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/, + number: { + pattern: + /(^|[^\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\dA-Fa-f]+(?:_[\dA-Fa-f]+)*n?|\d+(?:_\d+)*n|(?:\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\.\d+(?:_\d+)*)(?:[Ee][+-]?\d+(?:_\d+)*)?)(?![\w$])/, + lookbehind: true, + }, + operator: /--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/, + }); + + (javascript["class-name"] as GrammarToken[])[0] = { + pattern: /(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/, + lookbehind: true, + inside: { punctuation: /[.\\]/ }, + }; + + registry.javascript = javascript; + registry.insertBefore("javascript", "keyword", { + regex: { + pattern: + /((?:^|[^$\w\xA0-\uFFFF."'`\])\s]|\b(?:return|yield))\s*)\/(?:\[(?:[^\]\\\r\n]|\\.)*\]|\\.|[^/\\[\r\n])+\/[dgimyus]{0,7}/, + lookbehind: true, + greedy: true, + inside: { + "regex-source": { + pattern: /^(\/)[\s\S]+(?=\/[a-z]*$)/, + lookbehind: true, + alias: "language-regex", + }, + "regex-delimiter": /^\/|\/$/, + "regex-flags": /^[a-z]+$/, + }, + }, + "function-variable": { + pattern: + /#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/, + alias: "function", + }, + parameter: [ + { + pattern: + /(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/, + lookbehind: true, + inside: javascript, + }, + { + pattern: /(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i, + lookbehind: true, + inside: javascript, + }, + { + pattern: /(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/, + lookbehind: true, + inside: javascript, + }, + { + pattern: + /((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/, + lookbehind: true, + inside: javascript, + }, + ], + constant: /\b[A-Z](?:[A-Z_]|\dx?)*\b/, + }); + registry.insertBefore("javascript", "string", { + hashbang: { + pattern: /^#!.*/, + greedy: true, + alias: "comment", + }, + "template-string": { + pattern: /`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/, + greedy: true, + inside: { + "template-punctuation": { + pattern: /^`|`$/, + alias: "string", + }, + interpolation: { + pattern: /((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/, + lookbehind: true, + inside: { + "interpolation-punctuation": { + pattern: /^\$\{|\}$/, + alias: "punctuation", + }, + rest: registry.javascript as Grammar, + }, + }, + string: /[\s\S]+/, + }, + }, + "string-property": { + pattern: /((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m, + lookbehind: true, + greedy: true, + alias: "property", + }, + }); + registry.insertBefore("javascript", "operator", { + "literal-property": { + pattern: /((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m, + lookbehind: true, + alias: "property", + }, + }); + registry.js = registry.javascript; + return registry.javascript as Grammar; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/json.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/json.ts new file mode 100644 index 00000000..0b7f72b4 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/json.ts @@ -0,0 +1,37 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerJsonLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.json; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const json: Grammar = { + property: { + pattern: /(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/, + lookbehind: true, + greedy: true, + }, + string: { + pattern: /(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/, + lookbehind: true, + greedy: true, + }, + comment: { + pattern: /\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/, + greedy: true, + }, + number: /-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i, + punctuation: /[{}[\],]/, + operator: /:/, + boolean: /\b(?:false|true)\b/, + null: { + pattern: /\bnull\b/, + alias: "keyword", + }, + }; + registry.json = json; + return json; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/kotlin.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/kotlin.ts new file mode 100644 index 00000000..84fe7330 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/kotlin.ts @@ -0,0 +1,98 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { registerClikeLanguage } from "./clike"; +import { isRegisteredGrammar } from "./shared"; + +export function registerKotlinLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.kotlin; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerClikeLanguage(registry); + + const kotlin = registry.extend("clike", { + keyword: { + pattern: + /(^|[^.])\b(?:abstract|actual|annotation|as|break|by|catch|class|companion|const|constructor|continue|crossinline|data|do|dynamic|else|enum|expect|external|final|finally|for|fun|get|if|import|in|infix|init|inline|inner|interface|internal|is|lateinit|noinline|null|object|open|operator|out|override|package|private|protected|public|reified|return|sealed|set|super|suspend|tailrec|this|throw|to|try|typealias|val|var|vararg|when|where|while)\b/, + lookbehind: true, + }, + function: [ + { + pattern: /(?:`[^\r\n`]+`|\b\w+)(?=\s*\()/, + greedy: true, + }, + { + pattern: /(\.)(?:`[^\r\n`]+`|\w+)(?=\s*\{)/, + lookbehind: true, + greedy: true, + }, + ], + number: + /\b(?:0[xX][\da-fA-F]+(?:_[\da-fA-F]+)*|0[bB][01]+(?:_[01]+)*|\d+(?:_\d+)*(?:\.\d+(?:_\d+)*)?(?:[eE][+-]?\d+(?:_\d+)*)?[fFL]?)\b/, + operator: /\+[+=]?|-[-=>]?|==?=?|!(?:!|==?)?|[/*%<>]=?|[?:]:?|\.\.|&&|\|\||\b(?:and|inv|or|shl|shr|ushr|xor)\b/, + }); + + delete kotlin["class-name"]; + registry.kotlin = kotlin; + + const interpolationInside = { + "interpolation-punctuation": { + pattern: /^\$\{?|\}$/, + alias: "punctuation", + }, + expression: { + pattern: /[\s\S]+/, + inside: kotlin, + }, + }; + + registry.insertBefore("kotlin", "string", { + "string-literal": [ + { + pattern: /"""(?:[^$]|\$(?:(?!\{)|\{[^{}]*\}))*?"""/, + alias: "multiline", + inside: { + interpolation: { + pattern: /\$(?:[a-z_]\w*|\{[^{}]*\})/i, + inside: interpolationInside, + }, + string: /[\s\S]+/, + }, + }, + { + pattern: /"(?:[^"\\\r\n$]|\\.|\$(?:(?!\{)|\{[^{}]*\}))*"/, + alias: "singleline", + inside: { + interpolation: { + pattern: /((?:^|[^\\])(?:\\{2})*)\$(?:[a-z_]\w*|\{[^{}]*\})/i, + lookbehind: true, + inside: interpolationInside, + }, + string: /[\s\S]+/, + }, + }, + ], + char: { + pattern: /'(?:[^'\\\r\n]|\\(?:.|u[a-fA-F0-9]{0,4}))'/, + greedy: true, + }, + }); + delete kotlin.string; + registry.insertBefore("kotlin", "keyword", { + annotation: { + pattern: /\B@(?:\w+:)?(?:[A-Z]\w*|\[[^\]]+\])/, + alias: "builtin", + }, + }); + registry.insertBefore("kotlin", "function", { + label: { + pattern: /\b\w+@|@\w+\b/, + alias: "symbol", + }, + }); + + registry.kt = registry.kotlin; + registry.kts = registry.kotlin; + return registry.kotlin as Grammar; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markdown.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markdown.ts new file mode 100644 index 00000000..9de4d199 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markdown.ts @@ -0,0 +1,286 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { registerMarkupLanguage } from "./markup"; +import { escapeRegExp, isRegisteredGrammar } from "./shared"; +import { registerYamlLanguage } from "./yaml"; + +export function registerMarkdownLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.markdown; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerMarkupLanguage(registry); + const yaml = registerYamlLanguage(registry); + const markdown = registry.extend("markup", {}); + const inner = /(?:\\.|[^\\\n\r]|(?:\n|\r\n?)(?![\r\n]))/.source; + const createInline = (source: string): RegExp => + RegExp(`${/((?:^|[^\\])(?:\\{2})*)/.source}(?:${source.replace(//g, inner)})`); + const tableCell = /(?:\\.|``(?:[^`\r\n]|`(?!`))+``|`[^`\r\n]+`|[^\\|\r\n`])+/.source; + const tableRow = /\|?__(?:\|__)+\|?(?:(?:\n|\r\n?)|(?![\s\S]))/.source.replace(/__/g, tableCell); + const tableLine = /\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)+\|?(?:\n|\r\n?)/.source; + + const fencedCodeBlocks = [ + ...createMarkdownFencedCodePatterns(registry), + { + pattern: /^(```.*(?:\n|\r\n?))[\s\S]+?(?=(?:\n|\r\n?)^```$)/m, + lookbehind: true, + }, + ]; + + registry.markdown = markdown; + registry.insertBefore("markdown", "prolog", { + "front-matter-block": { + pattern: /(^(?:\s*[\r\n])?)---(?!.)[\s\S]*?[\r\n]---(?!.)/, + lookbehind: true, + greedy: true, + inside: { + punctuation: /^---|---$/, + "front-matter": { + pattern: /\S+(?:\s+\S+)*/, + alias: ["yaml", "language-yaml"], + inside: yaml, + }, + }, + }, + blockquote: { + pattern: /^>(?:[\t ]*>)*/m, + alias: "punctuation", + }, + table: { + pattern: RegExp(`^${tableRow}${tableLine}(?:${tableRow})*`, "m"), + inside: { + "table-data-rows": { + pattern: RegExp(`^(${tableRow}${tableLine})(?:${tableRow})*$`), + lookbehind: true, + inside: { + "table-data": { + pattern: RegExp(tableCell), + inside: markdown, + }, + punctuation: /\|/, + }, + }, + "table-line": { + pattern: RegExp(`^(${tableRow})${tableLine}$`), + lookbehind: true, + inside: { + punctuation: /\||:?-{3,}:?/, + }, + }, + "table-header-row": { + pattern: RegExp(`^${tableRow}$`), + inside: { + "table-header": { + pattern: RegExp(tableCell), + alias: "important", + inside: markdown, + }, + punctuation: /\|/, + }, + }, + }, + }, + code: [ + { + pattern: /((?:^|\n)[ \t]*\n|(?:^|\r\n?)[ \t]*\r\n?)(?: {4}|\t).+(?:(?:\n|\r\n?)(?: {4}|\t).+)*/, + lookbehind: true, + alias: "keyword", + }, + { + pattern: /^```[\s\S]*?^```$/m, + greedy: true, + inside: { + "code-block": fencedCodeBlocks, + "code-language": { + pattern: /^(```).+/, + lookbehind: true, + }, + punctuation: /```/, + }, + }, + ], + title: [ + { + pattern: /\S.*(?:\n|\r\n?)(?:==+|--+)(?=[ \t]*$)/m, + alias: "important", + inside: { + punctuation: /==+$|--+$/, + }, + }, + { + pattern: /(^\s*)#.+/m, + lookbehind: true, + alias: "important", + inside: { + punctuation: /^#+|#+$/, + }, + }, + ], + hr: { + pattern: /(^\s*)([*-])(?:[\t ]*\2){2,}(?=\s*$)/m, + lookbehind: true, + alias: "punctuation", + }, + list: { + pattern: /(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m, + lookbehind: true, + alias: "punctuation", + }, + "url-reference": { + pattern: + /!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/, + inside: { + variable: { + pattern: /^(!?\[)[^\]]+/, + lookbehind: true, + }, + string: /(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/, + punctuation: /^[[\]!:]|[<>]/, + }, + alias: "url", + }, + bold: { + pattern: createInline( + /\b__(?:(?!_)|_(?:(?!_))+_)+__\b|\*\*(?:(?!\*)|\*(?:(?!\*))+\*)+\*\*/.source, + ), + lookbehind: true, + greedy: true, + inside: { + content: { + pattern: /(^..)[\s\S]+(?=..$)/, + lookbehind: true, + inside: {}, + }, + punctuation: /\*\*|__/, + }, + }, + italic: { + pattern: createInline( + /\b_(?:(?!_)|__(?:(?!_))+__)+_\b|\*(?:(?!\*)|\*\*(?:(?!\*))+\*\*)+\*/.source, + ), + lookbehind: true, + greedy: true, + inside: { + content: { + pattern: /(^.)[\s\S]+(?=.$)/, + lookbehind: true, + inside: {}, + }, + punctuation: /[*_]/, + }, + }, + strike: { + pattern: createInline("(~~?)(?:(?!~))+\\2"), + lookbehind: true, + greedy: true, + inside: { + content: { + pattern: /(^~~?)[\s\S]+(?=\1$)/, + lookbehind: true, + inside: {}, + }, + punctuation: /~~?/, + }, + }, + "code-snippet": { + pattern: /(^|[^\\`])(?:``[^`\r\n]+(?:`[^`\r\n]+)*``(?!`)|`[^`\r\n]+`(?!`))/, + lookbehind: true, + greedy: true, + alias: ["code", "keyword"], + }, + url: { + pattern: createInline( + /!?\[(?:(?!\]))+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)|[ \t]?\[(?:(?!\]))+\])/.source, + ), + lookbehind: true, + greedy: true, + inside: { + operator: /^!/, + content: { + pattern: /(^\[)[^\]]+(?=\])/, + lookbehind: true, + inside: {}, + }, + variable: { + pattern: /(^\][ \t]?\[)[^\]]+(?=\]$)/, + lookbehind: true, + }, + url: { + pattern: /(^\]\()[^\s)]+/, + lookbehind: true, + }, + string: { + pattern: /(^[ \t]+)"(?:\\.|[^"\\])*"(?=\)$)/, + lookbehind: true, + }, + }, + }, + }); + + registry.md = registry.markdown; + const recursiveTokens = ["url", "bold", "italic", "strike"] as const; + const nestedTokens = ["url", "bold", "italic", "strike", "code-snippet"] as const; + const registeredMarkdown = registry.markdown as Grammar; + + for (const token of recursiveTokens) { + const tokenValue = registeredMarkdown[token] as GrammarToken; + const content = (tokenValue.inside as Grammar).content as GrammarToken; + const inside = content.inside as Grammar; + + for (const nestedToken of nestedTokens) { + if (token !== nestedToken) { + inside[nestedToken] = registeredMarkdown[nestedToken]; + } + } + } + + return registeredMarkdown; +} + +function createMarkdownFencedCodePatterns(registry: LanguagesRegistry): GrammarToken[] { + const languages = [ + "javascript", + "js", + "typescript", + "ts", + "jsx", + "tsx", + "json", + "yaml", + "yml", + "css", + "markup", + "html", + "xml", + "svg", + "bash", + "sh", + "shell", + "python", + "py", + "diff", + "sql", + ]; + const patterns: GrammarToken[] = []; + + for (const language of languages) { + const grammar = registry[language]; + + if (!isRegisteredGrammar(grammar)) { + continue; + } + + patterns.push({ + pattern: RegExp( + `^(\`\`\`[^\\S\\r\\n]*${escapeRegExp(language)}(?=[\\t \\r\\n])[^\\r\\n]*(?:\\n|\\r\\n?))[\\s\\S]+?(?=(?:\\n|\\r\\n?)^\`\`\`$)`, + "im", + ), + lookbehind: true, + alias: `language-${language}`, + inside: grammar, + }); + } + + return patterns; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markup.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markup.ts new file mode 100644 index 00000000..9ddc8893 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markup.ts @@ -0,0 +1,378 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { registerJavaScriptLanguage } from "./javascript"; +import { isGrammarToken, isRegisteredGrammar } from "./shared"; +import { registerTypeScriptLanguage } from "./typescript"; + +export function registerCssLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.css; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const css: Grammar = { + comment: { + pattern: /\/\*[\s\S]*?\*\//, + greedy: true, + }, + atrule: { + pattern: /@[\w-](?:[^;{\s]|\s+(?!\s))*?(?:;|(?=\s*\{))/, + inside: { + rule: /^@[\w-]+/, + "selector-function-argument": { + pattern: /(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/, + lookbehind: true, + alias: "selector", + }, + keyword: { + pattern: /(^|[^\w-])(?:and|not|only|or)(?![\w-])/, + lookbehind: true, + }, + function: { + pattern: /(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i, + lookbehind: true, + }, + property: /[-_a-zA-Z\xA0-\uFFFF][-\w\xA0-\uFFFF]*(?=\s*:)/, + punctuation: /[():]/, + }, + }, + url: { + pattern: /url\((?:(["'])(?:\\[\s\S]|(?!\1)[^\\])*\1|.*?)\)/i, + greedy: true, + inside: { + function: /^url/i, + punctuation: /^\(|\)$/, + string: { + pattern: /^("|')(?:\\[\s\S]|(?!\1)[^\\])*\1$/, + alias: "url", + }, + }, + }, + selector: { + pattern: /(^|[{}]\s*)[^{}\s][^{}]*\S(?=\s*\{)/, + lookbehind: true, + }, + string: { + pattern: /(["'])(?:\\[\s\S]|(?!\1)[^\\])*\1/, + greedy: true, + }, + property: /[-_a-zA-Z\xA0-\uFFFF][-\w\xA0-\uFFFF]*(?=\s*:)/, + important: /!important\b/i, + function: /[-a-z0-9]+(?=\()/i, + punctuation: /[(){};:,]/, + }; + const atrule = css.atrule as GrammarToken; + if (atrule.inside) { + atrule.inside.rest = css; + } + registry.css = css; + return css; +} + +export function registerMarkupLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.markup; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const markup: Grammar = { + comment: { + pattern: //, + greedy: true, + }, + prolog: { + pattern: /<\?[\s\S]+?\?>/, + greedy: true, + }, + doctype: { + pattern: /"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<>"'\]]|"[^"]*"|'[^']*'|<(?!!--))*\]\s*)?>/i, + greedy: true, + inside: { + "internal-subset": { + pattern: /(^[^[]*\[)[\s\S]+(?=\]>$)/, + lookbehind: true, + greedy: true, + inside: null, + }, + string: { + pattern: /"[^"]*"|'[^']*'/, + greedy: true, + }, + punctuation: /^$|[[\]]/, + "doctype-tag": /^DOCTYPE/i, + name: /[^\s<>'"]+/, + }, + }, + cdata: { + pattern: //i, + greedy: true, + }, + tag: { + pattern: /<\/?(?!\d)[^\s>/=$<%]+(?:\s+[^\s>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+))?)*\s*\/?>/, + greedy: true, + inside: { + tag: { + pattern: /^<\/?[^\s>/]+/, + inside: { + punctuation: /^<\/?/, + namespace: /^[^\s>/:]+:/, + }, + }, + "special-attr": [], + "attr-value": { + pattern: /=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/, + inside: { + punctuation: [ + { + pattern: /^=/, + alias: "attr-equals", + }, + { + pattern: /^(\s*)["']|["']$/, + lookbehind: true, + }, + ], + entity: [ + { + pattern: /&[\da-z]{1,8};/i, + alias: "named-entity", + }, + /&#x?[\da-f]{1,8};/i, + ], + }, + }, + "attr-name": /[^\s>/=]+/, + punctuation: /\/?>/, + }, + }, + entity: [ + { + pattern: /&[\da-z]{1,8};/i, + alias: "named-entity", + }, + /&#x?[\da-f]{1,8};/i, + ], + }; + registry.markup = markup; + + const css = registry.css; + const javascript = registry.javascript; + + if (isRegisteredGrammar(css)) { + addMarkupInlinedLanguage(registry, "style", "css", css); + addMarkupAttributeLanguage(registry, "style", "css", css); + } + + if (isRegisteredGrammar(javascript)) { + addMarkupInlinedLanguage(registry, "script", "javascript", javascript); + } + + registry.html = registry.markup; + registry.xml = registry.markup; + registry.svg = registry.markup; + return registry.markup as Grammar; +} + +function addMarkupInlinedLanguage( + registry: LanguagesRegistry, + tagName: string, + language: string, + grammar: Grammar, +): void { + const includedCdataInside: Grammar = { + [`language-${language}`]: { + pattern: /(^$)/i, + lookbehind: true, + inside: grammar, + }, + cdata: /^$/i, + }; + const inside: Grammar = { + "included-cdata": { + pattern: //i, + inside: includedCdataInside, + }, + [`language-${language}`]: { + pattern: /[\s\S]+/, + inside: grammar, + }, + }; + + registry.insertBefore("markup", "cdata", { + [tagName]: { + pattern: RegExp( + /(<__[^>]*>)(?:))*\]\]>|(?!)/.source.replace( + /__/g, + () => tagName, + ), + "i", + ), + lookbehind: true, + greedy: true, + inside, + }, + }); +} + +function addMarkupAttributeLanguage( + registry: LanguagesRegistry, + attrName: string, + language: string, + grammar: Grammar, +): void { + const markup = registry.markup; + const tag = isRegisteredGrammar(markup) ? markup.tag : undefined; + const tagInside = isGrammarToken(tag) ? tag.inside : undefined; + const specialAttr = tagInside?.["special-attr"]; + + if (!Array.isArray(specialAttr)) { + return; + } + + specialAttr.push({ + pattern: RegExp( + `${/(^|["'\s])/.source}(?:${attrName})${/\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))/.source}`, + "i", + ), + lookbehind: true, + inside: { + "attr-name": /^[^\s=]+/, + "attr-value": { + pattern: /=[\s\S]+/, + inside: { + value: { + pattern: /(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/, + lookbehind: true, + alias: [language, `language-${language}`], + inside: grammar, + }, + punctuation: [ + { + pattern: /^=/, + alias: "attr-equals", + }, + /"|'/, + ], + }, + }, + }, + }); +} + +export function registerJsxLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.jsx; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const javascript = registerJavaScriptLanguage(registry); + registerMarkupLanguage(registry); + + const jsx = registry.extend("markup", javascript); + const space = /(?:\s|\/\/.*(?!.)|\/\*(?:[^*]|\*(?!\/))\*\/)/.source; + const braces = /(?:\{(?:\{(?:\{[^{}]*\}|[^{}])*\}|[^{}])*\})/.source; + const re = (source: string, flags?: string): RegExp => + RegExp( + source + .replace(//g, () => space) + .replace(//g, () => braces) + .replace(//g, () => spread), + flags, + ); + let spread = /(?:\{*\.{3}(?:[^{}]|)*\})/.source; + spread = re(spread).source; + + const tag = jsx.tag as GrammarToken; + tag.pattern = re( + /<\/?(?:[\w.:-]+(?:+(?:[\w.:$-]+(?:=(?:"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*'|[^\s{'"/>=]+|))?|))**\/?)?>/ + .source, + ); + + const tagInside = tag.inside as Grammar; + const tagName = tagInside.tag as GrammarToken; + tagName.pattern = /^<\/?[^\s>/]*/; + (tagInside["attr-value"] as GrammarToken).pattern = + /=(?!\{)(?:"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*'|[^\s'">]+)/; + (tagName.inside as Grammar)["class-name"] = /^[A-Z]\w*(?:\.[A-Z]\w*)*$/; + tagInside.comment = javascript.comment; + + registry.jsx = jsx; + registry.insertBefore("jsx", "entity", { + "plain-text": [ + { + pattern: /([^=]>)[^<>{}=()]+(?=<|\{)/, + lookbehind: true, + greedy: true, + }, + { + pattern: /[^<>{}]+(?=<\/)/, + greedy: true, + }, + ], + }); + registry.insertBefore( + "inside", + "attr-name", + { + spread: { + pattern: re(//.source), + inside: jsx, + }, + }, + tag as unknown as Record, + ); + registry.insertBefore( + "inside", + "special-attr", + { + script: { + pattern: re(/=/.source), + alias: "language-javascript", + inside: { + "script-punctuation": { + pattern: /^=(?=\{)/, + alias: "punctuation", + }, + rest: jsx, + }, + }, + }, + tag as unknown as Record, + ); + + return registry.jsx as Grammar; +} + +export function registerTsxLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.tsx; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerJsxLanguage(registry); + const typescript = registerTypeScriptLanguage(registry); + const tsx = registry.extend("jsx", typescript); + delete tsx.parameter; + delete tsx["literal-property"]; + + const tag = tsx.tag as GrammarToken; + tag.pattern = RegExp(`${/(^|[^\w$]|(?=<\/))/.source}(?:${tag.pattern.source})`, tag.pattern.flags); + tag.lookbehind = true; + const tagInside = tag.inside as Grammar; + const script = tagInside.script as GrammarToken | undefined; + const spread = tagInside.spread as GrammarToken | undefined; + + if (script?.inside) { + script.inside.rest = tsx; + } + + if (spread) { + spread.inside = tsx; + } + + registry.tsx = tsx; + return registry.tsx as Grammar; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/php.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/php.ts new file mode 100644 index 00000000..83894a1e --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/php.ts @@ -0,0 +1,211 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerPhpLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.php; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const comment = /\/\*[\s\S]*?\*\/|\/\/.*|#(?!\[).*/; + const number = + /\b0b[01]+(?:_[01]+)*\b|\b0o[0-7]+(?:_[0-7]+)*\b|\b0x[\da-f]+(?:_[\da-f]+)*\b|(?:\b\d+(?:_\d+)*\.?(?:\d+(?:_\d+)*)?|\B\.\d+)(?:e[+-]?\d+)?/i; + const operator = /|\?\?=?|\.{3}|\??->|[!=]=?=?|::|\*\*=?|--|\+\+|&&|\|\||<<|>>|[?~]|[/^|%*&<>.+-]=?/; + const punctuation = /[{}[\](),:;]/; + const constant = [ + { + pattern: /\b(?:false|true)\b/i, + alias: "boolean", + }, + { + pattern: /(::\s*)\b[a-z_]\w*\b(?!\s*\()/i, + greedy: true, + lookbehind: true, + }, + { + pattern: /(\b(?:case|const)\s+)\b[a-z_]\w*(?=\s*[;=])/i, + greedy: true, + lookbehind: true, + }, + /\b(?:null)\b/i, + /\b[A-Z_][A-Z0-9_]*\b(?!\s*\()/, + ]; + + const php: Grammar = { + delimiter: { + pattern: /\?>$|^<\?(?:php(?=\s)|=)?/i, + alias: "important", + }, + comment, + variable: /\$+(?:\w+\b|(?=\{))/, + package: { + pattern: /(namespace\s+|use\s+(?:function\s+)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i, + lookbehind: true, + inside: { + punctuation: /\\/, + }, + }, + "class-name-definition": { + pattern: /(\b(?:class|enum|interface|trait)\s+)\b[a-z_]\w*(?!\\)\b/i, + lookbehind: true, + alias: "class-name", + }, + "function-definition": { + pattern: /(\bfunction\s+)[a-z_]\w*(?=\s*\()/i, + lookbehind: true, + alias: "function", + }, + keyword: [ + { + pattern: /(\(\s*)\b(?:array|bool|boolean|float|int|integer|object|string)\b(?=\s*\))/i, + alias: "type-casting", + greedy: true, + lookbehind: true, + }, + { + pattern: + /([(,?]\s*)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|object|self|static|string)\b(?=\s*\$)/i, + alias: "type-hint", + greedy: true, + lookbehind: true, + }, + { + pattern: + /(\)\s*:\s*(?:\?\s*)?)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|never|object|self|static|string|void)\b/i, + alias: "return-type", + greedy: true, + lookbehind: true, + }, + { + pattern: /\b(?:array(?!\s*\()|bool|float|int|iterable|mixed|object|string|void)\b/i, + alias: "type-declaration", + greedy: true, + }, + { + pattern: /(\|\s*)(?:false|null)\b|\b(?:false|null)(?=\s*\|)/i, + alias: "type-declaration", + greedy: true, + lookbehind: true, + }, + { + pattern: /\b(?:parent|self|static)(?=\s*::)/i, + alias: "static-context", + greedy: true, + }, + { + pattern: /(\byield\s+)from\b/i, + lookbehind: true, + }, + /\bclass\b/i, + { + pattern: + /((?:^|[^\s>:]|(?:^|[^-])>|(?:^|[^:]):)\s*)\b(?:abstract|and|array|as|break|callable|case|catch|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|enum|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|never|new|or|parent|print|private|protected|public|readonly|require|require_once|return|self|static|switch|throw|trait|try|unset|use|var|while|xor|yield|__halt_compiler)\b/i, + lookbehind: true, + }, + ], + "argument-name": { + pattern: /([(,]\s*)\b[a-z_]\w*(?=\s*:(?!:))/i, + lookbehind: true, + }, + "class-name": [ + { + pattern: /(\b(?:extends|implements|instanceof|new(?!\s+self|\s+static))\s+|\bcatch\s*\()\b[a-z_]\w*(?!\\)\b/i, + greedy: true, + lookbehind: true, + }, + { + pattern: /(\|\s*)\b[a-z_]\w*(?!\\)\b/i, + greedy: true, + lookbehind: true, + }, + { + pattern: /\b[a-z_]\w*(?!\\)\b(?=\s*\|)/i, + greedy: true, + }, + { + pattern: /\b[a-z_]\w*(?=\s*\$)/i, + alias: "type-declaration", + greedy: true, + }, + { + pattern: /\b[a-z_]\w*(?=\s*::)/i, + alias: "static-context", + greedy: true, + }, + { + pattern: /([(,?]\s*)[a-z_]\w*(?=\s*\$)/i, + alias: "type-hint", + greedy: true, + lookbehind: true, + }, + { + pattern: /(\)\s*:\s*(?:\?\s*)?)\b[a-z_]\w*(?!\\)\b/i, + alias: "return-type", + greedy: true, + lookbehind: true, + }, + ], + constant, + function: { + pattern: /(^|[^\\\w])\\?[a-z_](?:[\w\\]*\w)?(?=\s*\()/i, + lookbehind: true, + inside: { + punctuation: /\\/, + }, + }, + property: { + pattern: /(->\s*)\w+/, + lookbehind: true, + }, + number, + operator, + punctuation, + }; + + const stringInterpolation = { + pattern: /\{\$(?:\{(?:\{[^{}]+\}|[^{}]+)\}|[^{}])+\}|(^|[^\\{])\$+(?:\w+(?:\[[^\r\n[\]]+\]|->\w+)?)/, + lookbehind: true, + inside: php, + }; + const string = [ + { + pattern: /<<<'([^']+)'[\r\n](?:.*[\r\n])*?\1;/, + alias: "nowdoc-string", + greedy: true, + }, + { + pattern: /<<<(?:"([^"]+)"[\r\n](?:.*[\r\n])*?\1;|([a-z_]\w*)[\r\n](?:.*[\r\n])*?\2;)/i, + alias: "heredoc-string", + greedy: true, + inside: { + interpolation: stringInterpolation, + }, + }, + { + pattern: /`(?:\\[\s\S]|[^\\`])*`/, + alias: "backtick-quoted-string", + greedy: true, + }, + { + pattern: /'(?:\\[\s\S]|[^\\'])*'/, + alias: "single-quoted-string", + greedy: true, + }, + { + pattern: /"(?:\\[\s\S]|[^\\"])*"/, + alias: "double-quoted-string", + greedy: true, + inside: { + interpolation: stringInterpolation, + }, + }, + ]; + + registry.php = php; + registry.insertBefore("php", "variable", { + string, + }); + + return php; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/python.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/python.ts new file mode 100644 index 00000000..549a0020 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/python.ts @@ -0,0 +1,83 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerPythonLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.python; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const python: Grammar = { + comment: { + pattern: /(^|[^\\])#.*/, + lookbehind: true, + greedy: true, + }, + "string-interpolation": { + pattern: /(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i, + greedy: true, + inside: { + interpolation: { + pattern: /((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}])+\})+\})+\}/, + lookbehind: true, + inside: { + "format-spec": { + pattern: /(:)[^:(){}]+(?=\}$)/, + lookbehind: true, + }, + "conversion-option": { + pattern: /![sra](?=[:}]$)/, + alias: "punctuation", + }, + punctuation: /^\{|\}$/, + }, + }, + string: /[\s\S]+/, + }, + }, + "triple-quoted-string": { + pattern: /(?:[rub]|br|rb)?("""|''')[\s\S]*?\1/i, + greedy: true, + alias: "string", + }, + string: { + pattern: /(?:[rub]|br|rb)?("|')(?:\\.|(?!\1)[^\\\r\n])*\1/i, + greedy: true, + }, + function: { + pattern: /((?:^|\s)def[ \t]+)[a-zA-Z_]\w*(?=\s*\()/, + lookbehind: true, + }, + "class-name": { + pattern: /(\bclass\s+)\w+/i, + lookbehind: true, + }, + decorator: { + pattern: /(^[\t ]*)@\w+(?:\.\w+)*/m, + lookbehind: true, + alias: ["annotation", "punctuation"], + inside: { + punctuation: /\./, + }, + }, + keyword: + /\b(?:and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/, + builtin: + /\b(?:abs|all|any|bool|bytes|dict|enumerate|filter|float|format|input|int|isinstance|len|list|map|max|min|object|open|range|repr|reversed|round|set|str|sum|super|tuple|type|zip)\b/, + boolean: /\b(?:False|None|True)\b/, + number: + /\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?(?!\w)/i, + operator: /[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/, + punctuation: /[{}[\];(),.:]/, + }; + const interpolation = (python["string-interpolation"] as GrammarToken).inside?.interpolation; + + if (isRegisteredGrammar(interpolation) && isRegisteredGrammar(interpolation.inside)) { + interpolation.inside.rest = python; + } + + registry.python = python; + registry.py = registry.python; + return python; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/ruby.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/ruby.ts new file mode 100644 index 00000000..0ff2a2ad --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/ruby.ts @@ -0,0 +1,168 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { registerClikeLanguage } from "./clike"; +import { isRegisteredGrammar } from "./shared"; + +export function registerRubyLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.ruby; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + registerClikeLanguage(registry); + + const ruby = registry.extend("clike", { + comment: { + pattern: /#.*|^=begin\s[\s\S]*?^=end/m, + greedy: true, + }, + "class-name": { + pattern: /(\b(?:class|module)\s+|\bcatch\s+\()[\w.\\]+|\b[A-Z_]\w*(?=\s*\.\s*new\b)/, + lookbehind: true, + inside: { + punctuation: /[.\\]/, + }, + }, + keyword: + /\b(?:BEGIN|END|alias|and|begin|break|case|class|def|define_method|defined|do|each|else|elsif|end|ensure|extend|for|if|in|include|module|new|next|nil|not|or|prepend|private|protected|public|raise|redo|require|rescue|retry|return|self|super|then|throw|undef|unless|until|when|while|yield)\b/, + operator: /\.{2,3}|&\.|===||[!=]?~|(?:&&|\|\||<<|>>|\*\*|[+\-*/%<>!^&|=])=?|[?:]/, + punctuation: /[(){}[\].,;]/, + }); + + registry.ruby = ruby; + registry.insertBefore("ruby", "operator", { + "double-colon": { + pattern: /::/, + alias: "punctuation", + }, + }); + + const interpolation = { + pattern: /((?:^|[^\\])(?:\\{2})*)#\{(?:[^{}]|\{[^{}]*\})*\}/, + lookbehind: true, + inside: { + content: { + pattern: /^(#\{)[\s\S]+(?=\}$)/, + lookbehind: true, + inside: ruby, + }, + delimiter: { + pattern: /^#\{|\}$/, + alias: "punctuation", + }, + }, + }; + const percentExpression = + /(?:([^a-zA-Z0-9\s{([<=])(?:(?!\1)[^\\]|\\[\s\S])*\1|\((?:[^()\\]|\\[\s\S]|\((?:[^()\\]|\\[\s\S])*\))*\)|\{(?:[^{}\\]|\\[\s\S]|\{(?:[^{}\\]|\\[\s\S])*\})*\}|\[(?:[^[\]\\]|\\[\s\S]|\[(?:[^[\]\\]|\\[\s\S])*\])*\]|<(?:[^<>\\]|\\[\s\S]|<(?:[^<>\\]|\\[\s\S])*>)*>)/ + .source; + const symbolName = /(?:"(?:\\.|[^"\\\r\n])*"|(?:\b[a-zA-Z_]\w*|[^\s\0-\x7F]+)[?!]?|\$.)/.source; + + delete ruby.function; + registry.insertBefore("ruby", "keyword", { + "regex-literal": [ + { + pattern: RegExp(/%r/.source + percentExpression + /[egimnosux]{0,6}/.source), + greedy: true, + inside: { + interpolation, + regex: /[\s\S]+/, + }, + }, + { + pattern: /(^|[^/])\/(?!\/)(?:\[[^\r\n\]]+\]|\\.|[^[/\\\r\n])+\/[egimnosux]{0,6}(?=\s*(?:$|[\r\n,.;})#]))/, + lookbehind: true, + greedy: true, + inside: { + interpolation, + regex: /[\s\S]+/, + }, + }, + ], + variable: /[@$]+[a-zA-Z_]\w*(?:[?!]|\b)/, + symbol: [ + { + pattern: RegExp(/(^|[^:]):/.source + symbolName), + lookbehind: true, + greedy: true, + }, + { + pattern: RegExp(/([\r\n{(,][ \t]*)/.source + symbolName + /(?=:(?!:))/.source), + lookbehind: true, + greedy: true, + }, + ], + "method-definition": { + pattern: /(\bdef\s+)\w+(?:\s*\.\s*\w+)?/, + lookbehind: true, + inside: { + function: /\b\w+$/, + keyword: /^self\b/, + "class-name": /^\w+/, + punctuation: /\./, + }, + }, + }); + registry.insertBefore("ruby", "string", { + "string-literal": [ + { + pattern: RegExp(/%[qQiIwWs]?/.source + percentExpression), + greedy: true, + inside: { + interpolation, + string: /[\s\S]+/, + }, + }, + { + pattern: /("|')(?:#\{[^}]+\}|#(?!\{)|\\(?:\r\n|[\s\S])|(?!\1)[^\\#\r\n])*\1/, + greedy: true, + inside: { + interpolation, + string: /[\s\S]+/, + }, + }, + { + pattern: /<<[-~]?([a-z_]\w*)[\r\n](?:.*[\r\n])*?[\t ]*\1/i, + alias: "heredoc-string", + greedy: true, + }, + { + pattern: /<<[-~]?'([a-z_]\w*)'[\r\n](?:.*[\r\n])*?[\t ]*\1/i, + alias: "heredoc-string", + greedy: true, + }, + ], + "command-literal": [ + { + pattern: RegExp(/%x/.source + percentExpression), + greedy: true, + inside: { + interpolation, + command: { + pattern: /[\s\S]+/, + alias: "string", + }, + }, + }, + { + pattern: /`(?:#\{[^}]+\}|#(?!\{)|\\(?:\r\n|[\s\S])|[^\\`#\r\n])*`/, + greedy: true, + inside: { + interpolation, + command: { + pattern: /[\s\S]+/, + alias: "string", + }, + }, + }, + ], + }); + delete ruby.string; + registry.insertBefore("ruby", "number", { + builtin: + /\b(?:Array|Bignum|Binding|Class|Continuation|Dir|Exception|FalseClass|File|Fixnum|Float|Hash|IO|Integer|MatchData|Method|Module|NilClass|Numeric|Object|Proc|Range|Regexp|Stat|String|Struct|Symbol|TMS|Thread|ThreadGroup|Time|TrueClass)\b/, + constant: /\b[A-Z][A-Z0-9_]*(?:[?!]|\b)/, + }); + + registry.rb = registry.ruby; + return registry.ruby as Grammar; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/rust.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/rust.ts new file mode 100644 index 00000000..37825563 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/rust.ts @@ -0,0 +1,122 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerRustLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.rust; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const rust: Grammar = { + comment: [ + { + pattern: /(^|[^\\])\/\*(?:[^*/]|\*(?!\/)|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/)*\*\//, + lookbehind: true, + greedy: true, + }, + { + pattern: /(^|[^\\:])\/\/.*/, + lookbehind: true, + greedy: true, + }, + ], + string: { + pattern: /b?"(?:\\[\s\S]|[^\\"])*"|b?r(#*)"(?:[^"]|"(?!\1))*"\1/, + greedy: true, + }, + char: { + pattern: /b?'(?:\\(?:x[0-7][\da-fA-F]|u\{(?:[\da-fA-F]_*){1,6}\}|.)|[^\\\r\n\t'])'/, + greedy: true, + }, + attribute: { + pattern: /#!?\[(?:[^[\]"]|"(?:\\[\s\S]|[^\\"])*")*\]/, + greedy: true, + alias: "attr-name", + inside: {}, + }, + "closure-params": { + pattern: /([=(,:]\s*|\bmove\s*)\|[^|]*\||\|[^|]*\|(?=\s*(?:\{|->))/, + lookbehind: true, + greedy: true, + inside: { + "closure-punctuation": { + pattern: /^\||\|$/, + alias: "punctuation", + }, + }, + }, + "lifetime-annotation": { + pattern: /'\w+/, + alias: "symbol", + }, + "fragment-specifier": { + pattern: /(\$\w+:)[a-z]+/, + lookbehind: true, + alias: "punctuation", + }, + variable: /\$\w+/, + "function-definition": { + pattern: /(\bfn\s+)\w+/, + lookbehind: true, + alias: "function", + }, + "type-definition": { + pattern: /(\b(?:enum|struct|trait|type|union)\s+)\w+/, + lookbehind: true, + alias: "class-name", + }, + "module-declaration": [ + { + pattern: /(\b(?:crate|mod)\s+)[a-z][a-z_\d]*/, + lookbehind: true, + alias: "namespace", + }, + { + pattern: /(\b(?:crate|self|super)\s*)::\s*[a-z][a-z_\d]*\b(?:\s*::(?:\s*[a-z][a-z_\d]*\s*::)*)?/, + lookbehind: true, + alias: "namespace", + inside: { + punctuation: /::/, + }, + }, + ], + keyword: [ + /\b(?:Self|abstract|as|async|await|become|box|break|const|continue|crate|do|dyn|else|enum|extern|final|fn|for|if|impl|in|let|loop|macro|match|mod|move|mut|override|priv|pub|ref|return|self|static|struct|super|trait|try|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\b/, + /\b(?:bool|char|f(?:32|64)|[ui](?:8|16|32|64|128|size)|str)\b/, + ], + function: /\b[a-z_]\w*(?=\s*(?:::\s*<|\())/, + macro: { + pattern: /\b\w+!/, + alias: "property", + }, + constant: /\b[A-Z_][A-Z_\d]+\b/, + "class-name": /\b[A-Z]\w*\b/, + namespace: { + pattern: /(?:\b[a-z][a-z_\d]*\s*::\s*)*\b[a-z][a-z_\d]*\s*::(?!\s*<)/, + inside: { + punctuation: /::/, + }, + }, + number: + /\b(?:0x[\dA-Fa-f](?:_?[\dA-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|(?:(?:\d(?:_?\d)*)?\.)?\d(?:_?\d)*(?:[Ee][+-]?\d+)?)(?:_?(?:f32|f64|[iu](?:8|16|32|64|size)?))?\b/, + boolean: /\b(?:false|true)\b/, + punctuation: /->|\.\.=|\.{1,3}|::|[{}[\];(),:]/, + operator: /[-+*/%!^]=?|=[=>]?|&[&=]?|\|[|=]?|<>?=?|[@?]/, + }; + + const closureParams = rust["closure-params"] as GrammarToken; + const attribute = rust.attribute as GrammarToken; + + if (isRegisteredGrammar(closureParams.inside)) { + closureParams.inside.rest = rust; + } + + if (isRegisteredGrammar(attribute.inside)) { + attribute.inside.string = rust.string; + } + + registry.rust = rust; + registry.rs = registry.rust; + return rust; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/shared.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/shared.ts new file mode 100644 index 00000000..663902b0 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/shared.ts @@ -0,0 +1,13 @@ +import type { Grammar, GrammarToken } from "../core"; + +export function isRegisteredGrammar(value: unknown): value is Grammar { + return !!value && typeof value === "object"; +} + +export function isGrammarToken(value: unknown): value is GrammarToken { + return !!value && typeof value === "object" && "pattern" in value; +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/sql.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/sql.ts new file mode 100644 index 00000000..314ac6da --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/sql.ts @@ -0,0 +1,48 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerSqlLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.sql; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const sql: Grammar = { + comment: { + pattern: /(^|[^\\])(?:\/\*[\s\S]*?\*\/|(?:--|\/\/|#).*)/, + lookbehind: true, + }, + variable: [ + { + pattern: /@(["'`])(?:\\[\s\S]|(?!\1)[^\\])+\1/, + greedy: true, + }, + /@[\w.$]+/, + ], + string: { + pattern: /(^|[^@\\])("|')(?:\\[\s\S]|(?!\2)[^\\]|\2\2)*\2/, + greedy: true, + lookbehind: true, + }, + identifier: { + pattern: /(^|[^@\\])`(?:\\[\s\S]|[^`\\]|``)*`/, + greedy: true, + lookbehind: true, + inside: { + punctuation: /^`|`$/, + }, + }, + function: /\b(?:AVG|COUNT|FIRST|FORMAT|LAST|LCASE|LEN|MAX|MID|MIN|MOD|NOW|ROUND|SUM|UCASE)(?=\s*\()/i, + keyword: + /\b(?:ACTION|ADD|AFTER|ALGORITHM|ALL|ALTER|ANALYZE|ANY|APPLY|AS|ASC|AUTHORIZATION|AUTO_INCREMENT|BACKUP|BDB|BEGIN|BERKELEYDB|BIGINT|BINARY|BIT|BLOB|BOOL|BOOLEAN|BREAK|BROWSE|BTREE|BULK|BY|CALL|CASCADED?|CASE|CHAIN|CHAR(?:ACTER|SET)?|CHECK(?:POINT)?|CLOSE|CLUSTERED|COALESCE|COLLATE|COLUMNS?|COMMENT|COMMIT(?:TED)?|COMPUTE|CONNECT|CONSISTENT|CONSTRAINT|CONTAINS(?:TABLE)?|CONTINUE|CONVERT|CREATE|CROSS|CURRENT(?:_DATE|_TIME|_TIMESTAMP|_USER)?|CURSOR|CYCLE|DATA(?:BASES?)?|DATE(?:TIME)?|DAY|DBCC|DEALLOCATE|DEC|DECIMAL|DECLARE|DEFAULT|DEFINER|DELAYED|DELETE|DELIMITERS?|DENY|DESC|DESCRIBE|DETERMINISTIC|DISABLE|DISCARD|DISK|DISTINCT|DISTINCTROW|DISTRIBUTED|DO|DOUBLE|DROP|DUMMY|DUMP(?:FILE)?|DUPLICATE|ELSE(?:IF)?|ENABLE|ENCLOSED|END|ENGINE|ENUM|ERRLVL|ERRORS|ESCAPED?|EXCEPT|EXEC(?:UTE)?|EXISTS|EXIT|EXPLAIN|EXTENDED|FETCH|FIELDS|FILE|FILLFACTOR|FIRST|FIXED|FLOAT|FOLLOWING|FOR(?: EACH ROW)?|FORCE|FOREIGN|FREETEXT(?:TABLE)?|FROM|FULL|FUNCTION|GEOMETRY(?:COLLECTION)?|GLOBAL|GOTO|GRANT|GROUP|HANDLER|HASH|HAVING|HOLDLOCK|HOUR|IDENTITY(?:COL|_INSERT)?|IF|IGNORE|IMPORT|INDEX|INFILE|INNER|INNODB|INOUT|INSERT|INT|INTEGER|INTERSECT|INTERVAL|INTO|INVOKER|ISOLATION|ITERATE|JOIN|KEYS?|KILL|LANGUAGE|LAST|LEAVE|LEFT|LEVEL|LIMIT|LINENO|LINES|LINESTRING|LOAD|LOCAL|LOCK|LONG(?:BLOB|TEXT)|LOOP|MATCH(?:ED)?|MEDIUM(?:BLOB|INT|TEXT)|MERGE|MIDDLEINT|MINUTE|MODE|MODIFIES|MODIFY|MONTH|MULTI(?:LINESTRING|POINT|POLYGON)|NATIONAL|NATURAL|NCHAR|NEXT|NO|NONCLUSTERED|NULLIF|NUMERIC|OFF?|OFFSETS?|ON|OPEN(?:DATASOURCE|QUERY|ROWSET)?|OPTIMIZE|OPTION(?:ALLY)?|ORDER|OUT(?:ER|FILE)?|OVER|PARTIAL|PARTITION|PERCENT|PIVOT|PLAN|POINT|POLYGON|PRECEDING|PRECISION|PREPARE|PREV|PRIMARY|PRINT|PRIVILEGES|PROC(?:EDURE)?|PUBLIC|PURGE|QUICK|RAISERROR|READS?|REAL|RECONFIGURE|REFERENCES|RELEASE|RENAME|REPEAT(?:ABLE)?|REPLACE|REPLICATION|REQUIRE|RESIGNAL|RESTORE|RESTRICT|RETURN(?:ING|S)?|REVOKE|RIGHT|ROLLBACK|ROUTINE|ROW(?:COUNT|GUIDCOL|S)?|RTREE|RULE|SAVE(?:POINT)?|SCHEMA|SECOND|SELECT|SERIAL(?:IZABLE)?|SESSION(?:_USER)?|SET(?:USER)?|SHARE|SHOW|SHUTDOWN|SIMPLE|SMALLINT|SNAPSHOT|SOME|SONAME|SQL|START(?:ING)?|STATISTICS|STATUS|STRIPED|SYSTEM_USER|TABLES?|TABLESPACE|TEMP(?:ORARY|TABLE)?|TERMINATED|TEXT(?:SIZE)?|THEN|TIME(?:STAMP)?|TINY(?:BLOB|INT|TEXT)|TOP?|TRAN(?:SACTIONS?)?|TRIGGER|TRUNCATE|TSEQUAL|TYPES?|UNBOUNDED|UNCOMMITTED|UNDEFINED|UNION|UNIQUE|UNLOCK|UNPIVOT|UNSIGNED|UPDATE(?:TEXT)?|USAGE|USE|USER|USING|VALUES?|VAR(?:BINARY|CHAR|CHARACTER|YING)|VIEW|WAITFOR|WARNINGS|WHEN|WHERE|WHILE|WITH(?: ROLLUP|IN)?|WORK|WRITE(?:TEXT)?|YEAR)\b/i, + boolean: /\b(?:FALSE|NULL|TRUE)\b/i, + number: /\b0x[\da-f]+\b|\b\d+(?:\.\d*)?|\B\.\d+\b/i, + operator: + /[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?|\b(?:AND|BETWEEN|DIV|ILIKE|IN|IS|LIKE|NOT|OR|REGEXP|RLIKE|SOUNDS LIKE|XOR)\b/i, + punctuation: /[;[\]()`,.]/, + }; + + registry.sql = sql; + return sql; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/swift.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/swift.ts new file mode 100644 index 00000000..655f6bf4 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/swift.ts @@ -0,0 +1,120 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerSwiftLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.swift; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const swift: Grammar = { + comment: { + pattern: /(^|[^\\:])(?:\/\/.*|\/\*(?:[^/*]|\/(?!\*)|\*(?!\/)|\/\*(?:[^*]|\*(?!\/))*\*\/)*\*\/)/, + lookbehind: true, + greedy: true, + }, + "string-literal": [ + { + pattern: + /(^|[^"#])(?:"(?:\\(?:\((?:[^()]|\([^()]*\))*\)|\r\n|[^(])|[^\\\r\n"])*"|"""(?:\\(?:\((?:[^()]|\([^()]*\))*\)|[^(])|[^\\"]|"(?!""))*""")(?!["#])/, + lookbehind: true, + greedy: true, + inside: { + interpolation: { + pattern: /(\\\()(?:[^()]|\([^()]*\))*(?=\))/, + lookbehind: true, + inside: null, + }, + "interpolation-punctuation": { + pattern: /^\)|\\\($/, + alias: "punctuation", + }, + punctuation: /\\(?=[\r\n])/, + string: /[\s\S]+/, + }, + }, + { + pattern: + /(^|[^"#])(#+)(?:"(?:\\(?:#+\((?:[^()]|\([^()]*\))*\)|\r\n|[^#])|[^\\\r\n])*?"|"""(?:\\(?:#+\((?:[^()]|\([^()]*\))*\)|[^#])|[^\\])*?""")\2/, + lookbehind: true, + greedy: true, + inside: { + interpolation: { + pattern: /(\\#+\()(?:[^()]|\([^()]*\))*(?=\))/, + lookbehind: true, + inside: null, + }, + "interpolation-punctuation": { + pattern: /^\)|\\#+\($/, + alias: "punctuation", + }, + string: /[\s\S]+/, + }, + }, + ], + directive: { + pattern: + /#(?:(?:elseif|if)\b(?:[ \t]*(?:![ \t]*)?(?:\b\w+\b(?:[ \t]*\((?:[^()]|\([^()]*\))*\))?|\((?:[^()]|\([^()]*\))*\))(?:[ \t]*(?:&&|\|\|))?)+|(?:else|endif)\b)/, + alias: "property", + inside: { + "directive-name": /^#\w+/, + boolean: /\b(?:false|true)\b/, + number: /\b\d+(?:\.\d+)*\b/, + operator: /!|&&|\|\||[<>]=?/, + punctuation: /[(),]/, + }, + }, + literal: { + pattern: /#(?:colorLiteral|column|dsohandle|file(?:ID|Literal|Path)?|function|imageLiteral|line)\b/, + alias: "constant", + }, + "other-directive": { + pattern: /#\w+\b/, + alias: "property", + }, + attribute: { + pattern: /@\w+/, + alias: "atrule", + }, + "function-definition": { + pattern: /(\bfunc\s+)\w+/, + lookbehind: true, + alias: "function", + }, + label: { + pattern: /\b(break|continue)\s+\w+|\b[a-zA-Z_]\w*(?=\s*:\s*(?:for|repeat|while)\b)/, + lookbehind: true, + alias: "important", + }, + keyword: + /\b(?:Any|Protocol|Self|Type|actor|as|assignment|associatedtype|associativity|async|await|break|case|catch|class|continue|convenience|default|defer|deinit|didSet|do|dynamic|else|enum|extension|fallthrough|fileprivate|final|for|func|get|guard|higherThan|if|import|in|indirect|infix|init|inout|internal|is|isolated|lazy|left|let|lowerThan|mutating|none|nonisolated|nonmutating|open|operator|optional|override|postfix|precedencegroup|prefix|private|protocol|public|repeat|required|rethrows|return|right|safe|self|set|some|static|struct|subscript|super|switch|throw|throws|try|typealias|unowned|unsafe|var|weak|where|while|willSet)\b/, + boolean: /\b(?:false|true)\b/, + nil: { + pattern: /\bnil\b/, + alias: "constant", + }, + "short-argument": /\$\d+\b/, + omit: { + pattern: /\b_\b/, + alias: "keyword", + }, + number: /\b(?:[\d_]+(?:\.[\de_]+)?|0x[a-f0-9_]+(?:\.[a-f0-9p_]+)?|0b[01_]+|0o[0-7_]+)\b/i, + "class-name": /\b[A-Z](?:[A-Z_\d]*[a-z]\w*)?\b/, + function: /\b[a-z_]\w*(?=\s*\()/i, + constant: /\b(?:[A-Z_]{2,}|k[A-Z][A-Za-z_]+)\b/, + operator: /[-+*/%=!<>&|^~?]+|\.[.\-+*/%=!<>&|^~?]+/, + punctuation: /[{}[\]();,.:\\]/, + }; + + for (const rule of swift["string-literal"] as GrammarToken[]) { + const interpolation = rule.inside?.interpolation; + + if (isRegisteredGrammar(interpolation)) { + interpolation.inside = swift; + } + } + + registry.swift = swift; + return swift; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/toml.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/toml.ts new file mode 100644 index 00000000..ee8bd927 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/toml.ts @@ -0,0 +1,52 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerTomlLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.toml; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const key = /(?:[\w-]+|'[^'\n\r]*'|"(?:\\.|[^\\"\r\n])*")/.source; + const insertKey = (pattern: string): string => pattern.replace(/__/g, key); + const toml: Grammar = { + comment: { + pattern: /#.*/, + greedy: true, + }, + table: { + pattern: RegExp(insertKey(/(^[\t ]*\[\s*(?:\[\s*)?)__(?:\s*\.\s*__)*(?=\s*\])/.source), "m"), + lookbehind: true, + greedy: true, + alias: "class-name", + }, + key: { + pattern: RegExp(insertKey(/(^[\t ]*|[{,]\s*)__(?:\s*\.\s*__)*(?=\s*=)/.source), "m"), + lookbehind: true, + greedy: true, + alias: "property", + }, + string: { + pattern: /"""(?:\\[\s\S]|[^\\])*?"""|'''[\s\S]*?'''|'[^'\n\r]*'|"(?:\\.|[^\\"\r\n])*"/, + greedy: true, + }, + date: [ + { + pattern: /\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?\b/i, + alias: "number", + }, + { + pattern: /\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b/, + alias: "number", + }, + ], + number: + /(?:\b0(?:x[\da-zA-Z]+(?:_[\da-zA-Z]+)*|o[0-7]+(?:_[0-7]+)*|b[10]+(?:_[10]+)*))\b|[-+]?\b\d+(?:_\d+)*(?:\.\d+(?:_\d+)*)?(?:[eE][+-]?\d+(?:_\d+)*)?\b|[-+]?\b(?:inf|nan)\b/, + boolean: /\b(?:false|true)\b/, + punctuation: /[.,=[\]{}]/, + }; + + registry.toml = toml; + return toml; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/typescript.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/typescript.ts new file mode 100644 index 00000000..97cf5080 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/typescript.ts @@ -0,0 +1,60 @@ +import type { Grammar, GrammarToken, LanguagesRegistry } from "../core"; +import { registerJavaScriptLanguage } from "./javascript"; +import { isRegisteredGrammar } from "./shared"; + +export function registerTypeScriptLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.typescript; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const javascript = registerJavaScriptLanguage(registry); + const javascriptKeywords = javascript.keyword; + const typescript = registry.extend("javascript", { + "class-name": { + pattern: + /(\b(?:class|extends|implements|instanceof|interface|new|type)\s+)(?!keyof\b)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?:\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?/, + lookbehind: true, + greedy: true, + }, + keyword: [ + ...(Array.isArray(javascriptKeywords) ? javascriptKeywords : [javascriptKeywords as RegExp | GrammarToken]), + /\b(?:abstract|declare|implements|interface|keyof|namespace|private|protected|public|readonly|type)\b/, + ], + builtin: + /\b(?:Array|Boolean|Function|Number|Promise|String|Symbol|any|bigint|boolean|never|number|object|string|unknown|void)\b/, + parameter: undefined, + "literal-property": undefined, + }); + registry.typescript = typescript; + const typeInside = registry.extend("typescript", {}); + delete typeInside["class-name"]; + (typescript["class-name"] as GrammarToken).inside = typeInside; + registry.insertBefore("typescript", "function", { + decorator: { + pattern: /@[$\w\xA0-\uFFFF]+/, + inside: { + at: { + pattern: /^@/, + alias: "operator", + }, + function: /^[\s\S]+/, + }, + }, + "generic-function": { + pattern: /#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>(?=\s*\()/, + greedy: true, + inside: { + function: /^#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*/, + generic: { + pattern: /<[\s\S]+/, + alias: "class-name", + inside: typeInside, + }, + }, + }, + }); + registry.ts = registry.typescript; + return registry.typescript as Grammar; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/yaml.ts b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/yaml.ts new file mode 100644 index 00000000..02807000 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/yaml.ts @@ -0,0 +1,95 @@ +import type { Grammar, LanguagesRegistry } from "../core"; +import { isRegisteredGrammar } from "./shared"; + +export function registerYamlLanguage(registry: LanguagesRegistry): Grammar { + const existing = registry.yaml; + + if (isRegisteredGrammar(existing)) { + return existing; + } + + const anchorOrAlias = /[*&][^\s[\]{},]+/; + const tag = /!(?:<[\w\-%#;/?:@&=+$,.!~*'()[\]]+>|(?:[a-zA-Z\d-]*!)?[\w\-%#;/?:@&=+$.~*'()]+)?/; + const properties = `(?:${tag.source}(?:[ \t]+${anchorOrAlias.source})?|${anchorOrAlias.source}(?:[ \t]+${tag.source})?)`; + const excludedControlRanges = "\\x00-\\x08\\x0e-\\x1f\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff"; + const plainCharacter = `[^\\s${excludedControlRanges},[\\]{}]`; + const plainKeyCharacter = `[^\\s${excludedControlRanges}!"#%&'*,\\-:>?@[\\]\`{|}]`; + const plainKey = `(?:${plainKeyCharacter}|[?:-])(?:[ \t]*(?:(?![#:])|:))*`.replace( + //g, + () => plainCharacter, + ); + const string = /"(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*'/.source; + const createValuePattern = (value: string, flags = ""): RegExp => + RegExp( + /([:\-,[{]\s*(?:\s<>[ \t]+)?)(?:<>)(?=[ \t]*(?:$|,|\]|\}|(?:[\r\n]\s*)?#))/.source + .replace(/<>/g, () => properties) + .replace(/<>/g, () => value), + `${flags.replace(/m/g, "")}m`, + ); + + const yaml: Grammar = { + scalar: { + pattern: RegExp( + /([-:]\s*(?:\s<>[ \t]+)?[|>])[ \t]*(?:((?:\r?\n|\r)[ \t]+)\S[^\r\n]*(?:\2[^\r\n]+)*)/.source.replace( + /<>/g, + () => properties, + ), + ), + lookbehind: true, + alias: "string", + }, + comment: /#.*/, + key: { + pattern: RegExp( + /((?:^|[:\-,[{\r\n?])[ \t]*(?:<>[ \t]+)?)<>(?=\s*:\s)/.source + .replace(/<>/g, () => properties) + .replace(/<>/g, () => `(?:${plainKey}|${string})`), + ), + lookbehind: true, + greedy: true, + alias: "atrule", + }, + directive: { + pattern: /(^[ \t]*)%.+/m, + lookbehind: true, + alias: "important", + }, + datetime: { + pattern: createValuePattern( + /\d{4}-\d\d?-\d\d?(?:[tT]|[ \t]+)\d\d?:\d{2}:\d{2}(?:\.\d*)?(?:[ \t]*(?:Z|[-+]\d\d?(?::\d{2})?))?|\d{4}-\d{2}-\d{2}|\d\d?:\d{2}(?::\d{2}(?:\.\d*)?)?/ + .source, + ), + lookbehind: true, + alias: "number", + }, + boolean: { + pattern: createValuePattern(/false|true/.source, "i"), + lookbehind: true, + alias: "important", + }, + null: { + pattern: createValuePattern(/null|~/.source, "i"), + lookbehind: true, + alias: "important", + }, + string: { + pattern: createValuePattern(string), + lookbehind: true, + greedy: true, + }, + number: { + pattern: createValuePattern( + /[+-]?(?:0x[\da-f]+|0o[0-7]+|(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|\.inf|\.nan)/.source, + "i", + ), + lookbehind: true, + }, + tag, + important: anchorOrAlias, + punctuation: /---|[:[\]{}\-,|>?]|\.\.\./, + }; + + registry.yaml = yaml; + registry.yml = registry.yaml; + return yaml; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/theme.css b/crates/promptforge-wb-server/ui/src/chat/highlighter/theme.css new file mode 100644 index 00000000..d9acdfba --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/highlighter/theme.css @@ -0,0 +1,171 @@ +/* Highlighter theme: one file, light + dark, scoped to Murm UI. */ + +.mur-app { + --hl-cmt: #6a737d; + --hl-cmt-style: italic; + --hl-kw: #cf222e; + --hl-str: #0a3069; + --hl-num: #0550ae; + --hl-fn: #8250df; + --hl-tag: #1a7f37; + --hl-prop: #0550ae; + --hl-op: #24292f; + --hl-var: #953800; + --hl-regex: #0a3069; + --hl-url: #0a3069; + --hl-ent: #8250df; + --hl-ins: #1a7f37; + --hl-ins-bg: #dafbe1; + --hl-del: #cf222e; + --hl-del-bg: #ffebe9; + --hl-md-h: #24292f; + --hl-md-h-w: 600; + --hl-md-i: #6a737d; +} + +.mur-app[data-theme="dark"], +.dark .mur-app:not([data-theme="light"]) { + --hl-cmt: #8b949e; + --hl-kw: #ff7b72; + --hl-str: #a5d6ff; + --hl-num: #79c0ff; + --hl-fn: #d2a8ff; + --hl-tag: #7ee787; + --hl-prop: #79c0ff; + --hl-op: #c9d1d9; + --hl-var: #ffa657; + --hl-regex: #a5d6ff; + --hl-url: #a5d6ff; + --hl-ent: #d2a8ff; + --hl-ins: #56d364; + --hl-ins-bg: rgba(63, 185, 80, 0.15); + --hl-del: #ff7b72; + --hl-del-bg: rgba(248, 81, 73, 0.1); + --hl-md-h: #c9d1d9; + --hl-md-i: #8b949e; +} + +@media (prefers-color-scheme: dark) { + .mur-app:not([data-theme]) { + --hl-cmt: #8b949e; + --hl-kw: #ff7b72; + --hl-str: #a5d6ff; + --hl-num: #79c0ff; + --hl-fn: #d2a8ff; + --hl-tag: #7ee787; + --hl-prop: #79c0ff; + --hl-op: #c9d1d9; + --hl-var: #ffa657; + --hl-regex: #a5d6ff; + --hl-url: #a5d6ff; + --hl-ent: #d2a8ff; + --hl-ins: #56d364; + --hl-ins-bg: rgba(63, 185, 80, 0.15); + --hl-del: #ff7b72; + --hl-del-bg: rgba(248, 81, 73, 0.1); + --hl-md-h: #c9d1d9; + --hl-md-i: #8b949e; + } +} + +.mur-app .token { + color: var(--hl-op); +} + +.mur-app .token.comment, +.mur-app .token.prolog, +.mur-app .token.doctype { + color: var(--hl-cmt); + font-style: var(--hl-cmt-style); +} + +.mur-app .token.keyword, +.mur-app .token.builtin, +.mur-app .token.atrule, +.mur-app .token.important { + color: var(--hl-kw); +} + +.mur-app .token.important { + font-weight: 700; +} + +.mur-app .token.string, +.mur-app .token.attr-value { + color: var(--hl-str); +} + +.mur-app .token.number, +.mur-app .token.boolean, +.mur-app .token.constant { + color: var(--hl-num); +} + +.mur-app .token.function, +.mur-app .token.class-name, +.mur-app .token.decorator, +.mur-app .token.annotation { + color: var(--hl-fn); +} + +.mur-app .token.tag, +.mur-app .token.selector { + color: var(--hl-tag); +} + +.mur-app .token.property, +.mur-app .token.attr-name { + color: var(--hl-prop); +} + +.mur-app .token.operator, +.mur-app .token.punctuation { + color: var(--hl-op); +} + +.mur-app .token.variable, +.mur-app .token.parameter { + color: var(--hl-var); +} + +.mur-app .token.regex, +.mur-app .token.interpolation { + color: var(--hl-regex); +} + +.mur-app .token.url { + color: var(--hl-url); +} + +.mur-app .token.entity { + color: var(--hl-ent); +} + +.mur-app .token.inserted { + color: var(--hl-ins); + background: var(--hl-ins-bg); +} + +.mur-app .token.deleted { + color: var(--hl-del); + background: var(--hl-del-bg); +} + +.mur-app .token.coord { + color: var(--hl-cmt); +} + +.mur-app .token.heading, +.mur-app .token.bold { + color: var(--hl-md-h); + font-weight: var(--hl-md-h-w); +} + +.mur-app .token.italic { + color: var(--hl-md-i); + font-style: italic; +} + +.mur-app .token.url .token.content { + text-decoration: underline; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/index.ts b/crates/promptforge-wb-server/ui/src/chat/index.ts new file mode 100644 index 00000000..c7fa01cf --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/index.ts @@ -0,0 +1,37 @@ +export type { DeleteConfirmation, SidebarMenuBuilder, SidebarMenuContext, SidebarMenuItem } from "./components/sidebar"; +export { ChatEngine, type ChatEngineConfig } from "./core/chat-engine"; +export { OpenAIProvider } from "./core/providers/openai"; +export type { ChatSessions } from "./core/session-manager"; +export { IndexedDBStorage } from "./core/storage/indexed-db"; +export { RemoteStorage, RemoteStorageError, type RemoteStorageOptions } from "./core/storage/remote"; +export type { + ActionButtonDef, + AgentRunCollapse, + BlockRenderContext, + ChatPlugin, + ChatProvider, + ChatRequest, + ChatRequestDefaults, + ChatRequestPatch, + ChatSession, + ChatSessionMeta, + ChatState, + ChatStorage, + CodeHighlighter, + ContentBlock, + FinishReason, + JsonValue, + Message, + MessageActionContext, + PaginatedSessions, + PluginContext, + PluginInputContext, + ReadonlyChatRequest, + RequestOptions, + Role, + StreamEvent, + TokenUsage, + ToolDefinition, +} from "./core/types"; +export { ChatUI, type ChatUIConfig } from "./main"; +export type { RouterConfig, RouterType } from "./router"; diff --git a/crates/promptforge-wb-server/ui/src/chat/main.ts b/crates/promptforge-wb-server/ui/src/chat/main.ts new file mode 100644 index 00000000..8c15af8e --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/main.ts @@ -0,0 +1,504 @@ +import { Feed } from "./components/feed"; +import { Header } from "./components/header"; +import { Input } from "./components/input"; +import { type DeleteConfirmation, Sidebar, type SidebarMenuBuilder } from "./components/sidebar"; +import { ChatEngine } from "./core/chat-engine"; +import type { + AgentRunCollapse, + ChatPlugin, + ChatProvider, + ChatStorage, + CodeHighlighter, + RequestOptions, +} from "./core/types"; +import { AppRouter, type RouterConfig } from "./router"; +import { el, queryOrThrow } from "./utils/dom"; + +const PAGE_SCROLL_CLASS = "mur-chat-page-scroll"; +let pageScrollAttachCount = 0; + +export interface ChatUIConfig { + container: HTMLElement | string; + provider: ChatProvider; + storage: ChatStorage; + routing?: RouterConfig | boolean; + titleOptions?: Partial; + titleInstructions?: string; + + /** + * Whether Murm UI owns the viewport and uses page-level scrolling on mobile. + * Defaults to true. Pass false when rendering inside a containing element. + */ + fullscreen?: boolean; + enableSidebar?: boolean; + initialSessionId?: string; + + highlighter?: CodeHighlighter; + plugins?: (chatApi: ChatEngine) => ChatPlugin[]; + agentRunCollapse?: AgentRunCollapse; + minAgentRunSteps?: number; + + /** + * Customizes sidebar item menus. Return the final item list from the provided + * defaults; keep side effects inside each item's onClick handler. + */ + sidebarMenu?: SidebarMenuBuilder; + confirmDelete?: DeleteConfirmation; + + /** + * Updates the browser window title to match the active chat. + * Pass `true` to use the chat title as-is, or a function for custom formatting. + */ + updateWindowTitle?: boolean | ((title: string) => string); +} + +export class ChatUI { + public readonly engine: ChatEngine; + private container: HTMLElement; + private config: ChatUIConfig; + private router: AppRouter; + + private inputComponent!: Input; + private feedComponent!: Feed; + private headerComponent!: Header; + private sidebarComponent?: Sidebar; + private plugins: ChatPlugin[] = []; + private inputDrafts = new Map(); + private unsubscribeWindowTitle: () => void = () => {}; + private usesFullscreenLayout = false; + + private elements!: { + mainArea: HTMLElement; + sidebarEl: HTMLElement; + globalError: HTMLElement; + globalErrorText: HTMLElement; + globalErrorCloseBtn: HTMLButtonElement; + }; + + private onMainAreaClickBound = () => this.closeSidebar(true); + private onSidebarRailClickBound = (event: MouseEvent) => this.handleSidebarRailClick(event); + private onGlobalErrorCloseBound = (e: MouseEvent) => { + e.stopPropagation(); + this.engine.clearError(); + }; + + constructor(config: ChatUIConfig) { + this.config = { enableSidebar: true, ...config }; + this.usesFullscreenLayout = this.config.fullscreen !== false; + + let routerConfig: RouterConfig = { type: "hash" }; + if (this.config.routing === false) { + routerConfig = { type: "none" }; + } else if (typeof this.config.routing === "object") { + routerConfig = this.config.routing; + } + + this.router = new AppRouter(routerConfig); + + const el = + typeof this.config.container === "string" ? document.querySelector(this.config.container) : this.config.container; + + if (!el) throw new Error(`Chat container not found: ${this.config.container}`); + this.container = el as HTMLElement; + if (this.usesFullscreenLayout) { + attachPageScrollClass(); + } + + const initialSessionId = this.config.initialSessionId || this.router.getId() || null; + + this.engine = new ChatEngine({ + provider: this.config.provider, + storage: this.config.storage, + initialSessionId, + titleOptions: this.config.titleOptions, + titleInstructions: this.config.titleInstructions, + }); + + this.initComponents(); + this.bindEvents(); + } + + public async destroy() { + this.router.destroy(); + this.unsubscribeWindowTitle(); + this.headerComponent.destroy(); + await this.engine.destroy(); + + this.elements.globalErrorCloseBtn.removeEventListener("click", this.onGlobalErrorCloseBound); + + if (this.config.enableSidebar) { + this.elements.mainArea.removeEventListener("click", this.onMainAreaClickBound); + this.elements.sidebarEl.removeEventListener("click", this.onSidebarRailClickBound); + } + + for (const plugin of this.plugins) { + if (!plugin.destroy) continue; + try { + plugin.destroy(); + } catch (error) { + console.error(`Plugin "${plugin.name}" failed during destroy`, error); + } + } + + this.sidebarComponent?.destroy(); + this.feedComponent.destroy(); + this.inputComponent.destroy(); + if (this.usesFullscreenLayout) { + detachPageScrollClass(); + this.usesFullscreenLayout = false; + } + } + + private initComponents() { + this.plugins = this.config.plugins ? this.config.plugins(this.engine) : []; + this.engine.registerPlugins(this.plugins); + + this.elements = {} as typeof this.elements; + this.elements.mainArea = queryOrThrow(this.container, ".mur-main-area"); + this.headerComponent = new Header({ + container: this.container, + engine: this.engine, + enableSidebar: Boolean(this.config.enableSidebar), + onOpenSidebar: () => this.openSidebar(), + }); + this.elements.globalErrorText = el("span", "mur-global-error-text"); + this.elements.globalErrorCloseBtn = el("button", "mur-global-error-close", { + type: "button", + textContent: "×", + title: "Dismiss error", + }); + this.elements.globalErrorCloseBtn.setAttribute("aria-label", "Dismiss error"); + this.elements.globalError = el( + "div", + "mur-global-error", + { + hidden: true, + }, + [this.elements.globalErrorText, this.elements.globalErrorCloseBtn], + ); + this.elements.globalError.setAttribute("role", "alert"); + this.elements.mainArea.appendChild(this.elements.globalError); + + const pluginCtx = { + engine: this.engine, + container: this.container, + }; + + for (const plugin of this.plugins) { + if (!plugin.onMount) continue; + try { + plugin.onMount(pluginCtx); + } catch (error) { + console.error(`Plugin "${plugin.name}" failed during onMount`, error); + } + } + + this.inputComponent = new Input( + { + container: this.container, + onSubmit: (text) => this.engine.sendMessage(text), + onStop: () => { + void this.engine.stopGeneration(); + }, + }, + this.plugins, + ); + + this.feedComponent = new Feed(this.container, { + highlighter: this.config.highlighter, + plugins: this.plugins, + fullscreen: this.usesFullscreenLayout, + agentRunCollapse: this.config.agentRunCollapse, + minAgentRunSteps: this.config.minAgentRunSteps, + onReachTop: () => { + void this.engine.sessions.loadOlderMessages(); + }, + }); + + if (this.config.enableSidebar) { + this.elements.sidebarEl = queryOrThrow(this.container, ".mur-sidebar"); + this.restoreSidebarState(); + + this.sidebarComponent = new Sidebar({ + container: this.container, + engine: this.engine, + onNewChat: () => { + void this.engine.sessions.create(); + this.closeSidebar(true); + }, + onSelectSession: (id) => { + void this.engine.sessions.switch(id); + this.closeSidebar(true); + }, + onLoadMore: () => { + void this.engine.sessions.loadMore(); + }, + onClose: () => { + this.closeSidebar(false); + }, + getSessionHref: (id) => this.router.hrefFor(id), + sidebarMenu: this.config.sidebarMenu, + confirmDelete: this.config.confirmDelete, + }); + void this.engine.sessions.loadHistory(); + } + } + + private restoreSidebarState() { + const isDesktopClosed = lsGetItem("mur_sidebar_closed") === "true"; + if (!isDesktopClosed || window.innerWidth <= 768) return; + + const hadAnimatedSidebar = this.container.classList.contains("mur-sidebar-animated"); + if (hadAnimatedSidebar) { + this.container.classList.remove("mur-sidebar-animated"); + } + + this.container.classList.add("mur-sidebar-closed"); + + if (hadAnimatedSidebar) { + // Commit the restored state before re-enabling sidebar transitions. + this.elements.sidebarEl.getBoundingClientRect(); + this.container.classList.add("mur-sidebar-animated"); + } + } + + private bindEvents() { + this.elements.globalErrorCloseBtn.addEventListener("click", this.onGlobalErrorCloseBound); + + if (this.config.enableSidebar) { + this.elements.mainArea.addEventListener("click", this.onMainAreaClickBound); + this.elements.sidebarEl.addEventListener("click", this.onSidebarRailClickBound); + } + + this.router.listen((id) => { + if (id) { + void this.engine.sessions.switch(id); + } else { + void this.engine.sessions.create(); + } + }); + + if (this.config.updateWindowTitle) { + this.unsubscribeWindowTitle = this.engine.subscribe( + (state) => state.sessions.find((session) => session.id === state.currentSessionId)?.title ?? "New Chat", + (title) => this.syncWindowTitle(title), + ); + } + + this.engine.subscribe( + (state) => state.sessions, + (sessions) => { + const state = this.engine.state; + if (this.config.enableSidebar && this.sidebarComponent) { + this.sidebarComponent.renderSessions( + sessions, + state.currentSessionId, + state.hasMoreSessions, + state.isLoadingSessions, + ); + } + }, + ); + + this.engine.subscribe( + (state) => (state.hasMoreSessions ? 1 : 0) | (state.isLoadingSessions ? 2 : 0), + () => { + const state = this.engine.state; + if (this.config.enableSidebar && this.sidebarComponent) { + this.sidebarComponent.renderSessions( + state.sessions, + state.currentSessionId, + state.hasMoreSessions, + state.isLoadingSessions, + ); + } + }, + ); + + this.engine.subscribe( + (state) => state.currentSessionId, + (currentSessionId) => { + if (this.config.enableSidebar && this.sidebarComponent) { + this.sidebarComponent.setActiveSession(currentSessionId); + } + this.syncRouterToState(); + }, + ); + + this.engine.subscribe( + (state) => + (state.isLoadingSession ? 1 : 0) | (state.error !== null ? 2 : 0) | (state.messages.length > 0 ? 4 : 0), + () => this.syncRouterToState(), + ); + + this.engine.subscribe( + (state) => (state.isLoadingSession ? null : state.messages.length === 0), + (isEmpty) => { + if (isEmpty !== null) { + this.container.classList.toggle("mur-chat-empty", isEmpty); + } + }, + ); + + let prevIsGenerating = false; + + // Feed subscribes to the hot lane because stream chunks are applied via + // in-place mutation and should not run every normal selector per token. + this.engine.subscribeHot((state) => { + const isGenerating = state.generatingMessageId !== null; + const generationStarted = !prevIsGenerating && isGenerating; + + this.feedComponent.update( + state.messages, + state.generatingMessageId, + state.isLoadingSession, + generationStarted, + state.error, + ); + prevIsGenerating = isGenerating; + }); + + // Older-messages affordance (parallel to the sidebar's load-more state). + this.engine.subscribe( + (state) => (state.hasMoreMessages ? 1 : 0) | (state.isLoadingMessages ? 2 : 0), + () => { + const state = this.engine.state; + this.feedComponent.setOlderMessagesState(state.hasMoreMessages, state.isLoadingMessages); + }, + ); + + let inputSessionId = this.engine.state.currentSessionId; + this.engine.onChange( + (state) => state.currentSessionId, + (currentSessionId) => { + const draft = this.inputComponent.getText(); + if (draft.length > 0) { + this.inputDrafts.set(inputSessionId, draft); + } else { + this.inputDrafts.delete(inputSessionId); + } + + inputSessionId = currentSessionId; + this.inputComponent.setText(this.inputDrafts.get(currentSessionId) ?? ""); + this.inputComponent.focus(); + }, + ); + + this.engine.subscribe( + (state) => (state.generatingMessageId ? 2 : 0) | (state.isLoadingSession ? 1 : 0), + (bits) => { + const isGenerating = !!(bits & 2); + const isLoadingSession = !!(bits & 1); + + this.inputComponent.setGeneratingState(isGenerating, isLoadingSession); + }, + ); + + this.engine.subscribe( + (state) => state.error, + (error) => this.renderGlobalError(error), + ); + this.renderGlobalError(this.engine.state.error); + } + + private syncWindowTitle(title: string) { + if (!this.config.updateWindowTitle) return; + + document.title = typeof this.config.updateWindowTitle === "function" ? this.config.updateWindowTitle(title) : title; + } + + private renderGlobalError(error: { message: string; id?: string } | null) { + if (!error || error.id) { + this.elements.globalError.hidden = true; + this.elements.globalErrorText.textContent = ""; + return; + } + + this.elements.globalErrorText.textContent = error.message; + this.elements.globalError.hidden = false; + } + + private syncRouterToState() { + const state = this.engine.state; + const currentUrlId = this.router.getId(); + + const isSavedSession = state.sessions.some((s) => s.id === state.currentSessionId); + const shouldHaveUrlId = + state.messages.length > 0 || + isSavedSession || + (state.isLoadingSession && currentUrlId === state.currentSessionId); + + const targetId = shouldHaveUrlId ? state.currentSessionId : null; + + if (currentUrlId === targetId) return; + + // If we fell back to an empty chat due to a loading error (e.g., broken link), + // use replace so we don't trap the user's Back button. + const isErrorFallback = !shouldHaveUrlId && state.error !== null; + this.router.setUrl(targetId, isErrorFallback); + } + + private openSidebar() { + const isMobile = window.innerWidth <= 768; + + if (isMobile) { + this.elements.sidebarEl.classList.add("mur-mobile-open"); + } else { + this.container.classList.remove("mur-sidebar-closed"); + lsSetItem("mur_sidebar_closed", "false"); + } + } + + private closeSidebar(isNavigation = false) { + const isMobile = window.innerWidth <= 768; + + if (isMobile) { + this.elements.sidebarEl.classList.remove("mur-mobile-open"); + return; + } + + if (isNavigation) return; + + this.container.classList.add("mur-sidebar-closed"); + lsSetItem("mur_sidebar_closed", "true"); + } + + private handleSidebarRailClick(event: MouseEvent) { + if (window.innerWidth <= 768) return; + if (!this.container.classList.contains("mur-sidebar-closed")) return; + + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest("button, a, input, textarea, select, [role='button']")) return; + + this.openSidebar(); + } +} + +function lsGetItem(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function lsSetItem(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Ignore + } +} + +function attachPageScrollClass(): void { + pageScrollAttachCount++; + document.documentElement.classList.add(PAGE_SCROLL_CLASS); +} + +function detachPageScrollClass(): void { + pageScrollAttachCount = Math.max(0, pageScrollAttachCount - 1); + if (pageScrollAttachCount === 0) { + document.documentElement.classList.remove(PAGE_SCROLL_CLASS); + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking-plugin.ts new file mode 100644 index 00000000..d83f4022 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking-plugin.ts @@ -0,0 +1,149 @@ +import "./agent-thinking.css"; +import type { ChatPlugin } from "../../core/types"; +import { el } from "../../utils/dom"; + +export interface AgentThinkingPluginConfig { + previewLines?: number; +} + +interface AgentThinkingState { + expanded: boolean; + expandable: boolean; + explicitExpandable: boolean; + contentCache: string; + measureFrame: number | null; + previewEl: HTMLElement; + textEl: HTMLElement; +} + +const DEFAULT_PREVIEW_LINES = 3; +const ENCRYPTED_REASONING_FALLBACK = "Thought process is hidden by the model provider."; + +export function AgentThinkingPlugin(config: AgentThinkingPluginConfig = {}): ChatPlugin { + const stateMap = new WeakMap(); + const previewLines = Math.max(1, Math.floor(config.previewLines ?? DEFAULT_PREVIEW_LINES)); + + return { + name: "agent-thinking", + onBlockRender: (block, containerEl) => { + if (block.type !== "reasoning") return false; + + const content = reasoningContent(block); + if (content.trim().length === 0) return false; + + let state = stateMap.get(containerEl); + if (!state) { + state = createState(previewLines); + containerEl.replaceChildren(state.previewEl); + stateMap.set(containerEl, state); + } + + containerEl.className = "mur-content-block mur-block-reasoning mur-agent-think"; + state.previewEl.style.setProperty("--mur-agent-think-preview-lines", String(previewLines)); + if (state.contentCache !== content) { + state.textEl.textContent = content; + state.contentCache = content; + state.explicitExpandable = countExplicitLines(content) > previewLines; + state.expandable = state.explicitExpandable; + } + syncState(state); + if (!state.explicitExpandable && !state.expanded) queueMeasure(state); + + return true; + }, + }; +} + +function createState(previewLines: number): AgentThinkingState { + const textEl = el("span", "mur-agent-think-text"); + const previewEl = el("div", "mur-agent-think-preview", null, [textEl]); + previewEl.style.setProperty("--mur-agent-think-preview-lines", String(previewLines)); + + const state: AgentThinkingState = { + expanded: false, + expandable: false, + explicitExpandable: false, + contentCache: "", + measureFrame: null, + previewEl, + textEl, + }; + + previewEl.addEventListener("click", () => toggleExpanded(state)); + previewEl.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (!state.expandable) return; + event.preventDefault(); + toggleExpanded(state); + }); + + syncState(state); + return state; +} + +function toggleExpanded(state: AgentThinkingState): void { + if (!state.expandable) return; + state.expanded = !state.expanded; + syncState(state); +} + +function syncState(state: AgentThinkingState): void { + if (!state.expandable) state.expanded = false; + + state.previewEl.dataset.expandable = String(state.expandable); + state.previewEl.dataset.expanded = String(state.expanded); + + if (state.expandable) { + state.previewEl.setAttribute("role", "button"); + state.previewEl.tabIndex = 0; + state.previewEl.setAttribute("aria-expanded", String(state.expanded)); + state.previewEl.setAttribute("aria-label", "Toggle reasoning"); + return; + } + + state.previewEl.removeAttribute("role"); + state.previewEl.removeAttribute("tabindex"); + state.previewEl.removeAttribute("aria-expanded"); + state.previewEl.removeAttribute("aria-label"); +} + +function queueMeasure(state: AgentThinkingState): void { + const win = state.previewEl.ownerDocument.defaultView; + const requestFrame = + win?.requestAnimationFrame?.bind(win) ?? + (typeof requestAnimationFrame === "function" ? requestAnimationFrame : undefined); + const cancelFrame = + win?.cancelAnimationFrame?.bind(win) ?? + (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : undefined); + + if (state.measureFrame !== null && cancelFrame) cancelFrame(state.measureFrame); + + if (!requestFrame) { + measureExpandable(state); + return; + } + + state.measureFrame = requestFrame(() => { + state.measureFrame = null; + measureExpandable(state); + }); +} + +function measureExpandable(state: AgentThinkingState): void { + if (state.expanded || state.explicitExpandable) return; + + const measuredExpandable = state.textEl.scrollHeight > state.textEl.clientHeight + 1; + if (state.expandable === measuredExpandable) return; + + state.expandable = measuredExpandable; + syncState(state); +} + +function reasoningContent(block: { text: string; encrypted?: boolean }): string { + if (block.encrypted) return ENCRYPTED_REASONING_FALLBACK; + return block.text; +} + +function countExplicitLines(text: string): number { + return text.split(/\r\n|\r|\n/).length; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking.css b/crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking.css new file mode 100644 index 00000000..f84ad11c --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking.css @@ -0,0 +1,43 @@ +.mur-agent-think { + margin: 0.18rem 0 0.35rem; + color: var(--mur-text-muted); +} + +.mur-agent-run-steps .mur-agent-think { + margin: 0; +} + +.mur-agent-think-preview { + display: block; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-style: italic; + text-align: left; +} + +.mur-agent-think-preview[data-expandable="true"] { + cursor: pointer; +} + +.mur-agent-think-preview[data-expandable="true"]:hover, +.mur-agent-think-preview[data-expandable="true"]:focus-visible { + color: var(--mur-text); +} + +.mur-agent-think-text { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--mur-agent-think-preview-lines, 3); + white-space: pre-wrap; +} + +.mur-agent-think-preview[data-expanded="true"] .mur-agent-think-text { + display: block; + overflow: visible; + -webkit-line-clamp: unset; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment-plugin.ts new file mode 100644 index 00000000..f813022a --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment-plugin.ts @@ -0,0 +1,388 @@ +import "./attachment.css"; +import type { ChatPlugin, ContentBlock, PluginInputContext } from "../../core/types"; +import { el } from "../../utils/dom"; +import { ICON_PAPERCLIP } from "../../utils/icons"; +import { uuidv7 } from "../../utils/uuid"; + +const DEFAULT_ACCEPTED_TYPES = "image/*,text/*,.csv,.json,.md"; +const TEXT_FILE_EXTENSIONS = new Set(["csv", "json", "md"]); + +type AttachmentState = "processing" | "ready" | "error"; + +interface AttachmentQueueItem { + id: string; + fileName: string; + mimeType: string; + state: AttachmentState; + statusText?: string; + block?: ContentBlock; + error?: string; +} + +export interface FileHandler { + accepts: (file: File) => boolean; + process: (file: File) => Promise; +} + +export interface AttachmentPluginConfig { + /** Maximum file size in bytes. Default: 20MB */ + maxFileSize?: number; + /** Controls the hidden file input accept attribute. */ + acceptedTypes?: string; + /** Uploads files remotely instead of using built-in local processing. */ + uploadFile?: (file: File) => Promise<{ type: string; data: string; name?: string }>; + /** Custom parsers for specific file types. First matching handler wins. */ + fileHandlers?: FileHandler[]; + /** Callback when a file exceeds the limit. Native error UI is still shown. */ + onSizeExceeded?: (file: File, maxSize: number) => void; + /** Callback when a file type is rejected. Native error UI is still shown. */ + onUnsupportedFile?: (file: File) => void; + + /** + * A CSS selector defining where the image preview tray should be mounted. + * The selector is scoped to the chat container unless previewMountSelectorScope is "document". + * If omitted, it will be inserted just before the chat form. + */ + previewMountSelector?: string; + previewMountSelectorScope?: "container" | "document"; +} + +export function AttachmentPlugin(config?: AttachmentPluginConfig): ChatPlugin { + const maxSize = config?.maxFileSize ?? 20 * 1024 * 1024; + const acceptedTypes = config?.acceptedTypes ?? DEFAULT_ACCEPTED_TYPES; + + let queue: AttachmentQueueItem[] = []; + + let fileInput: HTMLInputElement; + let previewContainer: HTMLElement; + let attachBtn: HTMLButtonElement; + let inputContext: PluginInputContext | null = null; + let dragDepth = 0; + let destroyed = false; + + const syncSubmitState = () => inputContext?.requestSubmitStateSync(); + + const renderPreviews = () => { + if (!previewContainer) return; + previewContainer.innerHTML = ""; + previewContainer.hidden = queue.length === 0; + + queue.forEach((item) => { + const previewItem = el("div", `mur-attachment-preview-item mur-attachment-${item.state}`); + previewItem.setAttribute("data-attachment-state", item.state); + + if (item.state === "processing") { + previewItem.appendChild( + el("div", "mur-file-preview", null, [ + el("span", "mur-attachment-spinner"), + el("span", "", { textContent: item.statusText ?? "Processing..." }), + ]), + ); + } else if (item.state === "error") { + previewItem.appendChild(el("div", "mur-file-preview", { textContent: item.error ?? "Unsupported type" })); + } else { + renderReadyPreview(item, previewItem); + } + + const removeBtn = el("button", "mur-attachment-remove-btn", { + innerHTML: "×", + type: "button", + onclick: () => { + queue = queue.filter((queuedItem) => queuedItem.id !== item.id); + renderPreviews(); + syncSubmitState(); + }, + }); + removeBtn.setAttribute("aria-label", `Remove ${item.fileName}`); + + previewItem.appendChild(removeBtn); + previewContainer.appendChild(previewItem); + }); + }; + + const queueFiles = (files: Iterable) => { + for (const file of files) { + void queueFile(file); + } + }; + + const queueFile = async (file: File) => { + const item: AttachmentQueueItem = { + id: uuidv7(), + fileName: file.name || "Untitled file", + mimeType: file.type || "application/octet-stream", + state: "processing", + statusText: config?.uploadFile ? "Uploading..." : "Processing...", + }; + + queue.push(item); + renderPreviews(); + syncSubmitState(); + + if (file.size > maxSize) { + updateItemError(item.id, "File too large"); + config?.onSizeExceeded?.(file, maxSize); + return; + } + + try { + const block = await processFile(file); + updateItemReady(item.id, block); + } catch (error) { + const message = error instanceof Error ? error.message : "Unsupported type"; + updateItemError(item.id, message); + + if (message === "Unsupported type") { + config?.onUnsupportedFile?.(file); + } + } + }; + + const updateItemReady = (id: string, block: ContentBlock) => { + const item = queue.find((queuedItem) => queuedItem.id === id); + if (!item || destroyed) return; + + item.state = "ready"; + item.block = block; + item.mimeType = getBlockMimeType(block, item.mimeType); + item.statusText = undefined; + item.error = undefined; + renderPreviews(); + syncSubmitState(); + }; + + const updateItemError = (id: string, error: string) => { + const item = queue.find((queuedItem) => queuedItem.id === id); + if (!item || destroyed) return; + + item.state = "error"; + item.error = error; + item.statusText = undefined; + renderPreviews(); + syncSubmitState(); + }; + + const processFile = async (file: File): Promise => { + const handler = config?.fileHandlers?.find((candidate) => candidate.accepts(file)); + if (handler) { + return handler.process(file); + } + + if (config?.uploadFile) { + const uploaded = await config.uploadFile(file); + return { + id: uuidv7(), + type: "file", + mimeType: uploaded.type, + name: uploaded.name ?? file.name, + data: uploaded.data, + }; + } + + if (file.type.startsWith("image/")) { + return { + id: uuidv7(), + type: "file", + mimeType: file.type, + name: file.name, + data: await readFile(file, "data-url"), + }; + } + + if (isTextLikeFile(file)) { + return { + id: uuidv7(), + type: "file", + mimeType: file.type || mimeTypeFromName(file.name), + name: file.name, + data: await readFile(file, "text"), + }; + } + + throw new Error("Unsupported type"); + }; + + const onFileInputChange = () => { + queueFiles(Array.from(fileInput.files || [])); + fileInput.value = ""; + }; + + const onDragEnter = (event: DragEvent) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + dragDepth++; + inputContext?.container.classList.add("mur-attachment-drag-active"); + }; + + const onDragOver = (event: DragEvent) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + }; + + const onDragLeave = (event: DragEvent) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + inputContext?.container.classList.remove("mur-attachment-drag-active"); + } + }; + + const onDrop = (event: DragEvent) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + dragDepth = 0; + inputContext?.container.classList.remove("mur-attachment-drag-active"); + queueFiles(Array.from(event.dataTransfer?.files || [])); + }; + + const onPaste = (event: ClipboardEvent) => { + const files = Array.from(event.clipboardData?.files || []); + if (files.length === 0) return; + + if (!hasClipboardText(event)) { + event.preventDefault(); + } + queueFiles(files); + }; + + return { + name: "attachments", + + onInputMount: (ctx: PluginInputContext) => { + inputContext = ctx; + destroyed = false; + previewContainer = el("div", "mur-attachment-previews"); + previewContainer.hidden = true; + + fileInput = el("input", "", { type: "file", hidden: true, multiple: true, accept: acceptedTypes }); + + attachBtn = el("button", "mur-form-icon-btn", { + type: "button", + innerHTML: ICON_PAPERCLIP, + onclick: () => fileInput.click(), + }); + attachBtn.setAttribute("aria-label", "Attach files"); + attachBtn.title = "Attach files"; + + ctx.form.prepend(attachBtn); + if (config?.previewMountSelector) { + const selectorRoot = config.previewMountSelectorScope === "document" ? document : ctx.container; + const customTarget = selectorRoot.querySelector(config.previewMountSelector); + if (customTarget) { + customTarget.appendChild(previewContainer); + } else { + console.error( + `AttachmentPlugin: Could not find element matching previewMountSelector "${config.previewMountSelector}". Image previews will not be visible.`, + ); + } + } else { + ctx.form.before(previewContainer); + } + ctx.form.appendChild(fileInput); + + fileInput.addEventListener("change", onFileInputChange); + ctx.container.addEventListener("dragenter", onDragEnter); + ctx.container.addEventListener("dragover", onDragOver); + ctx.container.addEventListener("dragleave", onDragLeave); + ctx.container.addEventListener("drop", onDrop); + ctx.input.addEventListener("paste", onPaste); + }, + + hasPendingData: () => queue.some((item) => item.state === "ready" && item.block), + + isSubmitBlocked: () => queue.some((item) => item.state === "processing"), + + onUserSubmit: (msg) => { + const readyBlocks = queue.flatMap((item) => (item.state === "ready" && item.block ? [item.block] : [])); + if (readyBlocks.length > 0) { + msg.blocks.unshift(...readyBlocks); + queue = queue.filter((item) => item.state !== "ready"); + renderPreviews(); + syncSubmitState(); + } + }, + + destroy: () => { + destroyed = true; + fileInput?.removeEventListener("change", onFileInputChange); + inputContext?.container.removeEventListener("dragenter", onDragEnter); + inputContext?.container.removeEventListener("dragover", onDragOver); + inputContext?.container.removeEventListener("dragleave", onDragLeave); + inputContext?.container.removeEventListener("drop", onDrop); + inputContext?.input.removeEventListener("paste", onPaste); + inputContext?.container.classList.remove("mur-attachment-drag-active"); + fileInput?.remove(); + attachBtn?.remove(); + previewContainer?.remove(); + queue = []; + inputContext = null; + dragDepth = 0; + }, + }; +} + +function renderReadyPreview(item: AttachmentQueueItem, previewItem: HTMLElement): void { + const block = item.block; + + if (block?.type === "file" && block.mimeType.startsWith("image/")) { + previewItem.appendChild(el("img", "", { src: block.data, alt: block.name ?? item.fileName })); + return; + } + + const label = block?.type === "file" ? (block.name ?? item.fileName) : item.fileName; + previewItem.appendChild(el("div", "mur-file-preview", { textContent: `📄 ${label}` })); +} + +function getBlockMimeType(block: ContentBlock, fallback: string): string { + return block.type === "file" ? block.mimeType : fallback; +} + +function hasDraggedFiles(event: DragEvent): boolean { + const types = event.dataTransfer?.types; + if (!types) return false; + return Array.from(types).includes("Files"); +} + +function hasClipboardText(event: ClipboardEvent): boolean { + const data = event.clipboardData; + if (!data) return false; + + const types = Array.from(data.types || []); + return ( + types.includes("text/plain") || + types.includes("text/html") || + (typeof data.getData === "function" && data.getData("text/plain").length > 0) + ); +} + +function isTextLikeFile(file: File): boolean { + if (file.type.startsWith("text/") || file.type === "application/json") return true; + + const extension = getFileExtension(file.name); + return extension !== "" && TEXT_FILE_EXTENSIONS.has(extension); +} + +function mimeTypeFromName(fileName: string): string { + return getFileExtension(fileName) === "json" ? "application/json" : "text/plain"; +} + +function getFileExtension(fileName: string): string { + const index = fileName.lastIndexOf("."); + return index === -1 ? "" : fileName.slice(index + 1).toLowerCase(); +} + +function readFile(file: File, mode: "data-url" | "text"): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => resolve(String(reader.result ?? "")); + reader.onerror = () => reject(reader.error ?? new Error("Failed to read file")); + + if (mode === "data-url") { + reader.readAsDataURL(file); + } else { + reader.readAsText(file); + } + }); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment.css b/crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment.css new file mode 100644 index 00000000..a1fd596e --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment.css @@ -0,0 +1,120 @@ +.mur-attachment-previews { + display: flex; + gap: 8px; + padding: 4px 8px 12px 8px; + overflow-x: auto; + width: 100%; + max-width: 768px; + pointer-events: auto; +} + +.mur-attachment-previews[hidden] { + display: none; +} + +.mur-attachment-preview-item { + position: relative; + display: inline-block; + flex-shrink: 0; +} + +.mur-attachment-preview-item.mur-attachment-processing { + opacity: 0.68; +} + +.mur-attachment-preview-item img { + height: 48px; + border-radius: 6px; + object-fit: cover; +} + +.mur-file-preview { + height: 48px; + padding: 0 12px; + background: var(--mur-surface); + border-radius: 6px; + display: flex; + align-items: center; + font-size: 0.85rem; + color: var(--mur-text-muted); + border: 1px solid var(--mur-border); +} + +.mur-attachment-preview-item.mur-attachment-error .mur-file-preview { + color: var(--mur-danger-text); + border-color: var(--mur-danger-border); + background: var(--mur-danger-bg); +} + +.mur-attachment-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--mur-border); + border-top-color: var(--mur-text-muted); + border-radius: 50%; + animation: mur-attachment-spin 0.8s linear infinite; + margin-right: 8px; + flex-shrink: 0; +} + +.mur-attachment-drag-active .mur-chat-form { + border-color: var(--mur-primary); + box-shadow: 0 0 0 3px var(--mur-attachment-drag-ring); +} + +@keyframes mur-attachment-spin { + to { + transform: rotate(360deg); + } +} + +.mur-attachment-remove-btn { + position: absolute; + top: -6px; + right: -6px; + background: var(--mur-text-secondary); + color: var(--mur-inverse-text); + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--mur-shadow-attachment); + opacity: 0.8; +} + +.mur-attachment-remove-btn:hover { + background: var(--mur-danger); + opacity: 1; +} + +.mur-message-attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.mur-attachment-image { + max-width: 100%; + max-height: 300px; + border-radius: 0.5rem; + object-fit: contain; + background-color: var(--mur-surface); +} + +.mur-attachment-file-pill { + display: inline-flex; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--mur-surface); + border-radius: 0.5rem; + font-size: 0.85rem; + color: var(--mur-text-muted); + border: 1px solid var(--mur-border); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/copy/copy-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/copy/copy-plugin.ts new file mode 100644 index 00000000..6382e375 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/copy/copy-plugin.ts @@ -0,0 +1,36 @@ +import { extractPlainText } from "../../core/msg-utils"; +import type { ChatPlugin } from "../../core/types"; +import { ICON_CHECK, ICON_COPY } from "../../utils/icons"; + +export function CopyPlugin(): ChatPlugin { + return { + name: "copy", + getActionButtons: (msg) => { + if (msg.role !== "assistant") return []; + if (typeof navigator === "undefined" || !navigator.clipboard) return []; + if (!extractPlainText(msg).trim()) return []; + + return [ + { + id: "copy", + title: "Copy message", + iconHtml: ICON_COPY, + onClick: async ({ message, buttonEl }) => { + try { + const textToCopy = extractPlainText(message); + await navigator.clipboard.writeText(textToCopy); + buttonEl.innerHTML = ICON_CHECK; + setTimeout(() => { + if (buttonEl.isConnected) { + buttonEl.innerHTML = ICON_COPY; + } + }, 2000); + } catch { + // Ignore + } + }, + }, + ]; + }, + }; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit-plugin.ts new file mode 100644 index 00000000..da72f6b7 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit-plugin.ts @@ -0,0 +1,121 @@ +import "./edit.css"; +import { extractPlainText } from "../../core/msg-utils"; +import type { ChatPlugin, Message } from "../../core/types"; +import { el, replaceNodes } from "../../utils/dom"; +import { ICON_EDIT } from "../../utils/icons"; + +export interface EditConfig { + onSave: (messageId: string, newText: string) => void; +} + +interface EditState { + isEditing: boolean; + editContainer: HTMLElement; + currentMsg: Message; +} + +export function EditPlugin(config: EditConfig): ChatPlugin { + const stateMap = new WeakMap(); + + const ensureState = (parentEl: HTMLElement, msg: Message): EditState => { + let state = stateMap.get(parentEl); + + if (!state) { + const editContainer = el("div", "mur-edit-container"); + parentEl.appendChild(editContainer); + + state = { + isEditing: false, + editContainer, + currentMsg: msg, + }; + stateMap.set(parentEl, state); + } + + state.currentMsg = msg; + return state; + }; + + const enterEditMode = (parentEl: HTMLElement, state: EditState) => { + const msg = state.currentMsg; + const currentText = extractPlainText(msg); + + const blocksWrapper = parentEl.querySelector(".mur-message-blocks-wrapper") as HTMLElement | null; + + let targetHeight = "auto"; + let targetMinWidth = "100%"; + + if (blocksWrapper) { + targetHeight = Math.max(blocksWrapper.offsetHeight, 24) + "px"; + targetMinWidth = blocksWrapper.offsetWidth + "px"; + } + + state.isEditing = true; + parentEl.classList.add("mur-editing"); + + const textarea = el("textarea", "mur-edit-textarea", { spellcheck: false }) as HTMLTextAreaElement; + const cancelBtn = el("button", "mur-cancel-edit-btn", { textContent: "Cancel", type: "button" }); + const saveBtn = el("button", "mur-save-edit-btn", { textContent: "Save", type: "button" }); + const controls = el("div", "mur-edit-controls", null, [cancelBtn, saveBtn]); + + replaceNodes(state.editContainer, textarea, controls); + + textarea.style.height = targetHeight; + textarea.style.minWidth = targetMinWidth; + textarea.value = currentText; + + textarea.addEventListener("input", () => { + textarea.style.height = "auto"; + textarea.style.height = textarea.scrollHeight + "px"; + }); + + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + + const exitEdit = () => { + state.isEditing = false; + parentEl.classList.remove("mur-editing"); + state.editContainer.innerHTML = ""; + }; + + cancelBtn.addEventListener("click", exitEdit); + + saveBtn.addEventListener("click", () => { + const newText = textarea.value.trim(); + if (newText && newText !== currentText) { + config.onSave(msg.id, newText); + exitEdit(); + } else { + exitEdit(); + } + }); + + textarea.addEventListener("keydown", (e) => { + if (e.key === "Escape") exitEdit(); + + if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + saveBtn.click(); + } + }); + }; + + return { + name: "edit", + getActionButtons: (msg) => { + if (msg.role !== "user") return []; + + return [ + { + id: "edit", + title: "Edit message", + iconHtml: ICON_EDIT, + onClick: (ctx) => { + const state = ensureState(ctx.messageEl, ctx.message); + enterEditMode(ctx.messageEl, state); + }, + }, + ]; + }, + }; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit.css b/crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit.css new file mode 100644 index 00000000..1e2f9682 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit.css @@ -0,0 +1,69 @@ +.mur-edit-container { + order: 2; + display: none; + width: 100%; +} + +.mur-message.mur-editing > .mur-message-blocks-wrapper, +.mur-message.mur-editing > .mur-message-actions { + display: none; +} + +.mur-message.mur-editing > .mur-edit-container { + display: block; +} + +.mur-edit-textarea { + width: 100%; + font-family: inherit; + font-size: 1rem; + line-height: 1.6; + padding: 0; + border: none; + outline: none; + resize: none; + background: transparent; + color: inherit; + overflow: hidden; +} + +.mur-edit-controls { + display: flex; + gap: 8px; + margin-top: 8px; + justify-content: flex-end; +} + +.mur-cancel-edit-btn { + background: transparent; + border: none; + color: var(--mur-text-muted); + cursor: pointer; + font-size: 0.9rem; + padding: 4px 8px; + border-radius: 4px; + transition: + background-color 0.2s, + color 0.2s; +} + +.mur-cancel-edit-btn:hover { + background-color: var(--mur-hover-bg); + color: var(--mur-text); +} + +.mur-save-edit-btn { + padding: 6px 14px; + font-size: 0.9rem; + background-color: var(--mur-primary); + color: var(--mur-bg); + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: opacity 0.2s; +} + +.mur-save-edit-btn:hover { + opacity: 0.8; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings-plugin.ts new file mode 100644 index 00000000..e4c13e94 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings-plugin.ts @@ -0,0 +1,357 @@ +import "./settings.css"; +import { OpenAIProvider } from "../../core/providers/openai"; +import type { ChatPlugin, ChatProvider, PluginContext } from "../../core/types"; +import { el } from "../../utils/dom"; +import { ICON_SETTINGS } from "../../utils/icons"; + +export interface SettingsState { + endpoint: string; + apiKey: string; + model: string; + titleModel: string; + systemPrompt: string; +} + +export interface SettingsStorage { + get: () => Promise | null>; + set: (state: SettingsState) => Promise; +} + +export interface SettingsPluginConfig { + defaultEndpoint?: string; + defaultModel?: string; + defaultTitleModel?: string; + defaultSystemPrompt?: string; + endpointPlaceholder?: string; + apiKeyPlaceholder?: string; + modelPlaceholder?: string; + titleModelPlaceholder?: string; + systemPromptPlaceholder?: string; + storage?: SettingsStorage; + + /** + * Optional. A CSS selector for an existing button in your custom HTML. + * If provided, the plugin will NOT create its own button, but will instead + * attach the settings modal click-listener to your existing element. + * The selector is scoped to the chat container unless triggerSelectorScope is "document". + */ + triggerSelector?: string; + triggerSelectorScope?: "container" | "document"; + /** + * A factory function that returns the correct provider based on the settings. + * Defaults to returning an OpenAIProvider. + */ + createProvider?: (settings: SettingsState) => ChatProvider; +} + +const STORAGE_KEY = "mur_chat_settings"; +let nextSettingsModalId = 0; + +const defaultLocalStorageSettingsStorage: SettingsStorage = { + async get() { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null") as Partial | null; + }, + async set(state) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + }, +}; + +export function SettingsPlugin(config?: SettingsPluginConfig): ChatPlugin { + // Default fallback values + const defaults: SettingsState = { + endpoint: config?.defaultEndpoint || "https://api.openai.com/v1/chat/completions", + apiKey: "", + model: config?.defaultModel || "gpt-4o-mini", + titleModel: config?.defaultTitleModel || "", + systemPrompt: config?.defaultSystemPrompt || "", + }; + + let currentSettings = { ...defaults }; + + let modalOverlay: HTMLElement | null = null; + let closeModal: (() => void) | null = null; + let mountedTriggerEl: Element | null = null; + let mountedTriggerHandler: (() => void) | null = null; + let appliedProviderSettings: Pick | null = null; + let settingsRevision = 0; + let destroyed = false; + + const storage = config?.storage ?? defaultLocalStorageSettingsStorage; + const buildProvider = config?.createProvider ?? ((s) => new OpenAIProvider(s.apiKey, s.endpoint, s.model)); + + async function loadInitialSettings(ctx: PluginContext) { + const loadRevision = settingsRevision; + let settings: SettingsState; + try { + const saved = await storage.get(); + settings = { ...defaults, ...(saved ?? {}) }; + } catch (error) { + console.warn("SettingsPlugin: Could not read settings from storage.", error); + settings = { ...defaults }; + } + if (destroyed || settingsRevision !== loadRevision) return; + await applySettings(ctx, settings, false); + } + + async function applySettings(ctx: PluginContext, settings: SettingsState, persist = true) { + if (destroyed) return; + const applyRevision = ++settingsRevision; + currentSettings = settings; + if (persist) { + try { + void Promise.resolve(storage.set(settings)).catch((error) => { + console.warn("SettingsPlugin: Could not save settings to storage.", error); + }); + } catch (error) { + console.warn("SettingsPlugin: Could not save settings to storage.", error); + } + } + + const providerSettings = { + endpoint: settings.endpoint, + apiKey: settings.apiKey, + model: settings.model, + }; + + if ( + !appliedProviderSettings || + appliedProviderSettings.endpoint !== providerSettings.endpoint || + appliedProviderSettings.apiKey !== providerSettings.apiKey || + appliedProviderSettings.model !== providerSettings.model + ) { + await ctx.engine.setProvider(buildProvider(settings)); + if (destroyed || settingsRevision !== applyRevision) return; + appliedProviderSettings = providerSettings; + } + + ctx.engine.setRequestDefaults({ + instructions: settings.systemPrompt || undefined, + }); + ctx.engine.setTitleOptions({ + model: settings.titleModel || undefined, + }); + } + + function createModal(ctx: PluginContext, triggerEl: Element | null) { + const overlay = el("div", "mur-settings-overlay"); + const idPrefix = `mur-settings-${++nextSettingsModalId}`; + const id = (suffix: string) => `${idPrefix}-${suffix}`; + const endpointPlaceholder = escapeAttr(config?.endpointPlaceholder || "https://api.openai.com/v1/chat/completions"); + const apiKeyPlaceholder = escapeAttr(config?.apiKeyPlaceholder || "sk-..."); + const modelPlaceholder = escapeAttr(config?.modelPlaceholder || "gpt-4o-mini"); + const titleModelPlaceholder = escapeAttr(config?.titleModelPlaceholder || "Use chat model"); + const systemPromptPlaceholder = escapeAttr(config?.systemPromptPlaceholder || "You are a helpful assistant..."); + + const modal = el("div", "mur-settings-modal", { + innerHTML: ` +
+

Chat Settings

+ +
+
+
+ + +
Compatible with OpenAI, OpenRouter, LMStudio, Ollama, etc.
+
+
+ + +
Stored in this browser. Shared deployments usually use a backend proxy.
+
+
+ + +
+
+ + +
+
+ + +
+
+ + `, + }); + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-labelledby", id("title")); + + overlay.appendChild(modal); + + const endpointInput = modal.querySelector(".mur-set-endpoint") as HTMLInputElement; + const apiKeyInput = modal.querySelector(".mur-set-apikey") as HTMLInputElement; + const modelInput = modal.querySelector(".mur-set-model") as HTMLInputElement; + const titleModelInput = modal.querySelector(".mur-set-title-model") as HTMLInputElement; + const systemPromptInput = modal.querySelector(".mur-set-sysprompt") as HTMLTextAreaElement; + + endpointInput.value = currentSettings.endpoint; + apiKeyInput.value = currentSettings.apiKey; + modelInput.value = currentSettings.model; + titleModelInput.value = currentSettings.titleModel; + systemPromptInput.value = currentSettings.systemPrompt; + + const restoreFocus = () => { + if (triggerEl?.isConnected && typeof (triggerEl as HTMLElement).focus === "function") { + (triggerEl as HTMLElement).focus(); + } + }; + + const getFocusableElements = () => + Array.from( + modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), + ).filter((element) => !element.hidden && !element.hasAttribute("disabled")); + + const trapFocus = (event: KeyboardEvent) => { + const focusable = getFocusableElements(); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) return; + + const activeElement = document.activeElement; + if (!modal.contains(activeElement)) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + function onKeydown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } else if (event.key === "Tab") { + trapFocus(event); + } + } + + const close = () => { + document.removeEventListener("keydown", onKeydown); + overlay.remove(); + modalOverlay = null; + closeModal = null; + restoreFocus(); + }; + + modal.querySelector(".mur-settings-close-btn")!.addEventListener("click", close); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) close(); + }); + + modal.querySelector(".mur-set-save-btn")!.addEventListener("click", () => { + const invalidInput = validateRequiredSettings(endpointInput, modelInput); + if (invalidInput) { + invalidInput.focus(); + return; + } + + const newSettings = { + endpoint: endpointInput.value.trim(), + apiKey: apiKeyInput.value.trim(), + model: modelInput.value.trim(), + titleModel: titleModelInput.value.trim(), + systemPrompt: systemPromptInput.value.trim(), + }; + void applySettings(ctx, newSettings).catch((error) => { + console.warn("SettingsPlugin: Could not apply settings.", error); + }); + close(); + }); + + document.addEventListener("keydown", onKeydown); + closeModal = close; + + return overlay; + } + + return { + name: "settings", + + onMount: (ctx) => { + destroyed = false; + void loadInitialSettings(ctx).catch((error) => { + console.warn("SettingsPlugin: Could not apply initial settings.", error); + }); + + const openModal = () => { + if (!modalOverlay) { + modalOverlay = createModal(ctx, mountedTriggerEl); + ctx.container.appendChild(modalOverlay); + (modalOverlay.querySelector(".mur-set-endpoint") as HTMLInputElement | null)?.focus(); + } + }; + + if (config?.triggerSelector) { + const selectorRoot = config.triggerSelectorScope === "document" ? document : ctx.container; + const customBtn = selectorRoot.querySelector(config.triggerSelector); + if (customBtn) { + customBtn.addEventListener("click", openModal); + mountedTriggerEl = customBtn; + mountedTriggerHandler = openModal; + } else { + console.warn(`SettingsPlugin: Could not find element matching triggerSelector "${config.triggerSelector}"`); + } + return; + } + + const footer = ctx.container.querySelector(".mur-sidebar-footer"); + if (footer) { + const btn = el("button", "mur-settings-btn mur-sidebar-nav-btn", { + title: "Settings", + innerHTML: `${ICON_SETTINGS}Settings`, + }); + + btn.addEventListener("click", openModal); + mountedTriggerEl = btn; + mountedTriggerHandler = openModal; + footer.appendChild(btn); + } + }, + + destroy: () => { + destroyed = true; + settingsRevision++; + if (mountedTriggerEl && mountedTriggerHandler) { + mountedTriggerEl.removeEventListener("click", mountedTriggerHandler); + } + closeModal?.(); + modalOverlay = null; + closeModal = null; + mountedTriggerEl = null; + mountedTriggerHandler = null; + }, + }; +} + +function validateRequiredSettings( + endpointInput: HTMLInputElement, + modelInput: HTMLInputElement, +): HTMLInputElement | null { + endpointInput.removeAttribute("aria-invalid"); + modelInput.removeAttribute("aria-invalid"); + + if (!endpointInput.value.trim()) { + endpointInput.setAttribute("aria-invalid", "true"); + return endpointInput; + } + + if (!modelInput.value.trim()) { + modelInput.setAttribute("aria-invalid", "true"); + return modelInput; + } + + return null; +} + +function escapeAttr(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings.css b/crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings.css new file mode 100644 index 00000000..131abd51 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings.css @@ -0,0 +1,140 @@ +.mur-settings-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--mur-overlay-bg); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.mur-settings-modal { + background: var(--mur-bg); + color: var(--mur-text); + width: 90%; + max-width: 450px; + border-radius: 12px; + box-shadow: var(--mur-shadow-modal); + display: flex; + flex-direction: column; + border: 1px solid var(--mur-border); + overflow: hidden; + animation: mur-modal-pop 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes mur-modal-pop { + 0% { + transform: scale(0.95); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + +.mur-settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--mur-border); + background: var(--mur-surface); +} + +.mur-settings-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.mur-settings-close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--mur-text-muted); + line-height: 1; +} + +.mur-settings-close-btn:hover { + color: var(--mur-text); +} + +.mur-settings-body { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.mur-settings-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mur-settings-group label { + font-size: 0.85rem; + font-weight: 600; + color: var(--mur-text); +} + +.mur-settings-group input, +.mur-settings-group textarea { + width: 100%; + padding: 10px 12px; + border-radius: 6px; + font-family: inherit; + font-size: 0.9rem; + border: 1px solid var(--mur-border); + background: var(--mur-bg); + color: var(--mur-text); +} + +.mur-settings-group input:focus, +.mur-settings-group textarea:focus { + outline: none; + border-color: var(--mur-primary); + box-shadow: 0 0 0 2px var(--mur-border); +} + +.mur-settings-hint { + font-size: 0.75rem; + color: var(--mur-text-muted); +} + +.mur-settings-footer { + padding: 16px 20px; + border-top: 1px solid var(--mur-border); + background: var(--mur-surface); + display: flex; + justify-content: flex-end; +} + +.mur-set-save-btn { + width: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + background-color: var(--mur-bg); + color: var(--mur-text); + border: 1px solid var(--mur-border); + border-radius: 0.5rem; + box-shadow: var(--mur-shadow-button); + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + transition: background-color 0.2s; +} + +.mur-set-save-btn:hover { + background-color: var(--mur-hover-bg); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking-plugin.ts new file mode 100644 index 00000000..c44ba0cf --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking-plugin.ts @@ -0,0 +1,86 @@ +import "./thinking.css"; +import type { ChatPlugin } from "../../core/types"; +import { el } from "../../utils/dom"; +import { renderSafeHTML } from "../../utils/html"; +import { ICON_CHEVRON } from "../../utils/icons"; + +interface ThinkingState { + isExpanded: boolean; + cacheReasoning: string; + cacheIsGenerating: boolean; + contentEl: HTMLElement; + btnSpan: HTMLElement; +} + +const ENCRYPTED_REASONING_FALLBACK = "Thought process is hidden by the model provider."; + +function getReasoningDisplayContent(block: { text: string; encrypted?: boolean }): string { + if (block.encrypted) return ENCRYPTED_REASONING_FALLBACK; + return block.text; +} + +export function ThinkingPlugin(): ChatPlugin { + const stateMap = new WeakMap(); + + return { + name: "thinking", + onBlockRender: (block, containerEl, isGenerating) => { + if (block.type !== "reasoning") return false; + + let state = stateMap.get(containerEl); + + if (!state) { + const btn = el("button", "mur-think-toggle", { + innerHTML: ICON_CHEVRON + "Thought Process", + }); + + const btnSpan = btn.querySelector("span") as HTMLElement; + btn.setAttribute("aria-expanded", "false"); + + const contentEl = el("div", "mur-think-content"); + contentEl.hidden = true; + const wrapper = el("div", "mur-think-wrapper", {}, [btn, contentEl]); + + containerEl.innerHTML = ""; + containerEl.appendChild(wrapper); + + state = { + isExpanded: false, + cacheReasoning: "", + cacheIsGenerating: false, + contentEl, + btnSpan, + }; + + btn.onclick = () => { + state!.isExpanded = !state!.isExpanded; + contentEl.hidden = !state!.isExpanded; + btn.setAttribute("aria-expanded", String(state!.isExpanded)); + + const displayContent = getReasoningDisplayContent(block); + + if (state!.isExpanded && state!.cacheReasoning !== displayContent) { + renderSafeHTML(contentEl, displayContent); + state!.cacheReasoning = displayContent; + } + }; + + stateMap.set(containerEl, state); + } + + if (state.cacheIsGenerating !== isGenerating) { + state.btnSpan.textContent = isGenerating ? "Thinking..." : "Thought Process"; + state.cacheIsGenerating = isGenerating; + } + + const displayContent = getReasoningDisplayContent(block); + + if (state.isExpanded && state.cacheReasoning !== displayContent) { + renderSafeHTML(state.contentEl, displayContent); + state.cacheReasoning = displayContent; + } + + return true; + }, + }; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking.css b/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking.css new file mode 100644 index 00000000..daa8a7c4 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking.css @@ -0,0 +1,145 @@ +.mur-think-wrapper { + margin-bottom: 0.75rem; +} + +.mur-think-toggle { + display: flex; + align-items: center; + gap: 0.35rem; + background: none; + border: none; + color: var(--mur-text-muted); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + padding: 0.25rem 0.5rem; + margin-left: -0.5rem; + border-radius: 0.25rem; + transition: + background-color 0.2s, + color 0.2s; +} + +.mur-think-toggle:hover { + background-color: var(--mur-hover-bg); + color: var(--mur-text); +} + +.mur-think-toggle svg { + transition: transform 0.2s; +} + +.mur-think-toggle[aria-expanded="true"] svg { + transform: rotate(90deg); +} + +.mur-think-content { + margin-top: 0.25rem; + max-height: min(400px, 50vh); + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.5rem 0.75rem; + border-left: 2px solid var(--mur-border); + color: var(--mur-text-muted); + font-size: 0.9rem; + line-height: 1.5; + background-color: var(--mur-surface); + border-radius: 0 0.25rem 0.25rem 0; + white-space: pre-wrap; + animation: mur-slide-down 0.2s ease-out forwards; +} + +@keyframes mur-slide-down { + from { + opacity: 0; + transform: translateY(-5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.mur-agent-run-steps .mur-block-reasoning { + color: var(--mur-text-muted); +} + +.mur-agent-run-steps .mur-think-wrapper { + margin: 0; +} + +.mur-agent-run-steps .mur-think-toggle { + display: inline-flex; + align-items: center; + width: auto; + min-height: var(--mur-agent-run-control-height, 1.5rem); + max-width: 100%; + gap: 0.35rem; + padding: 0.125rem 0.28rem; + margin-left: 0; + background: transparent; + border-radius: 4px; + color: inherit; + font: inherit; + font-size: 0.8125rem; + font-weight: 400; + line-height: 1.2; + text-align: left; +} + +.mur-agent-run-steps .mur-think-toggle::before { + content: "\2022"; + flex: 0 0 1.1em; + order: 0; + width: 1.1em; + color: var(--mur-text-muted); + font-size: 0.78rem; + line-height: 1; + text-align: center; +} + +.mur-agent-run-steps .mur-think-toggle:hover { + background: transparent; + color: var(--mur-text); +} + +.mur-agent-run-steps .mur-think-toggle span { + display: block; + flex: 1 1 auto; + order: 1; + min-width: 0; + overflow: hidden; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mur-agent-run-steps .mur-think-toggle svg { + flex: 0 0 auto; + order: 2; + color: var(--mur-text-muted); + opacity: 0.65; + transition: + opacity 0.15s ease, + transform 0.15s ease; +} + +.mur-agent-run-steps .mur-think-toggle:hover svg, +.mur-agent-run-steps .mur-think-toggle:focus-visible svg, +.mur-agent-run-steps .mur-think-toggle[aria-expanded="true"] svg { + opacity: 1; +} + +.mur-agent-run-steps .mur-think-content { + margin: 0.18rem 0 0.35rem 0.7rem; + max-height: min(400px, 50vh); + overflow-y: auto; + overscroll-behavior: auto; + padding: 0.2rem 0 0.2rem 0.65rem; + border-left: 1px solid var(--mur-border); + border-radius: 0; + background: transparent; + font-size: 0.82rem; + line-height: 1.5; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools-plugin.ts new file mode 100644 index 00000000..5d3baf2e --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools-plugin.ts @@ -0,0 +1,451 @@ +import "./tools.css"; +import type { BlockRenderContext, ChatPlugin, ContentBlock, Message } from "../../core/types"; +import { el } from "../../utils/dom"; +import { ICON_CHEVRON } from "../../utils/icons"; + +type ToolCallBlock = Extract; +type ToolResultBlock = Extract; + +export interface ToolRenderContext { + toolCall: ToolCallBlock; + toolResult?: ToolResultBlock; + message: Message; + messages: readonly Message[]; + blockIndex: number; + isGenerating: boolean; + args: unknown; + argsText: string; + result: unknown; + outputText: string; +} + +export interface ToolRenderer { + label?: string | ((ctx: ToolRenderContext) => string | undefined); + preview?: (ctx: ToolRenderContext) => string | undefined; + formatArgs?: (ctx: ToolRenderContext) => string | undefined; + formatResult?: (ctx: ToolRenderContext) => string | undefined; +} + +export interface ToolsPluginConfig { + defaultExpanded?: boolean | ((ctx: ToolRenderContext) => boolean); + maxLabelChars?: number; + maxPreviewChars?: number; + tools?: Record; +} + +interface ToolState { + expanded: boolean; + rootEl: HTMLElement; + ctx?: ToolRenderContext; + renderer?: ToolRenderer; + resultCache?: ToolResultCache; + previewText: string; + buttonEl: HTMLButtonElement; + titleEl: HTMLElement; + statusEl: HTMLElement; + previewEl?: HTMLElement; + detailsEl?: HTMLElement; + details?: ToolDetailsState; +} + +interface ToolDetailsState { + argsPre: HTMLPreElement; + resultSectionEl: HTMLElement; + resultTitleEl: HTMLElement; + resultPre: HTMLPreElement; +} + +interface ToolResultCache { + messages: readonly Message[]; + messageId: string; + blockId: string; + toolCallId: string; + result: ToolResultBlock; +} + +const DEFAULT_MAX_LABEL_CHARS = 120; +const DEFAULT_MAX_PREVIEW_CHARS = 240; +const MAX_ARG_SUMMARY_VALUE_CHARS = 40; +const EMPTY_MESSAGE: Message = { id: "", role: "assistant", blocks: [] }; + +export function ToolsPlugin(config: ToolsPluginConfig = {}): ChatPlugin { + const stateMap = new WeakMap(); + + return { + name: "tools", + onBlockRender: (block, containerEl, isGenerating, renderCtx) => { + if (block.type !== "tool_call") return false; + + let state = stateMap.get(containerEl); + const ctx = createToolContext(block, renderCtx, isGenerating, state); + const renderer = config.tools?.[block.name]; + + if (!state) { + state = createToolState(containerEl, resolveDefaultExpanded(config.defaultExpanded, ctx)); + containerEl.replaceChildren(state.buttonEl); + state.buttonEl.addEventListener("click", () => { + state!.expanded = !state!.expanded; + syncExpansion(state!); + }); + stateMap.set(containerEl, state); + } + cacheToolResult(state, block, renderCtx, ctx.toolResult); + + renderTool(containerEl, state, ctx, renderer, config); + return true; + }, + }; +} + +function createToolState(rootEl: HTMLElement, expanded: boolean): ToolState { + const chevronEl = el("span", "mur-tool-chevron", { innerHTML: ICON_CHEVRON }); + const titleEl = el("span", "mur-tool-title"); + const statusEl = el("span", "mur-tool-status"); + const buttonEl = el("button", "mur-tool-summary", { type: "button" }, [statusEl, titleEl, chevronEl]); + + const state = { + expanded, + rootEl, + previewText: "", + buttonEl, + titleEl, + statusEl, + }; + + syncExpansion(state); + return state; +} + +function renderTool( + containerEl: HTMLElement, + state: ToolState, + ctx: ToolRenderContext, + renderer: ToolRenderer | undefined, + config: ToolsPluginConfig, +): void { + const status = ctx.toolResult?.isError ? "error" : ctx.toolCall.status; + containerEl.className = `mur-content-block mur-block-tool_call mur-tool mur-tool-${status}`; + + const label = rendererLabel(renderer, ctx) ?? defaultToolLabel(ctx.toolCall, ctx.args); + const preview = renderer?.preview?.(ctx) ?? defaultPreview(ctx); + const statusText = statusLabel(status); + + state.ctx = ctx; + state.renderer = renderer; + state.titleEl.textContent = truncateText(label, config.maxLabelChars ?? DEFAULT_MAX_LABEL_CHARS); + state.statusEl.textContent = statusSymbol(status); + state.statusEl.title = statusText; + state.statusEl.setAttribute("aria-label", statusText); + state.buttonEl.setAttribute("aria-label", `${label} (${statusText})`); + + state.previewText = truncateText(preview ?? "", config.maxPreviewChars ?? DEFAULT_MAX_PREVIEW_CHARS); + + syncExpansion(state); +} + +function createToolContext( + toolCall: ToolCallBlock, + ctx: BlockRenderContext | undefined, + isGenerating: boolean, + state: ToolState | undefined, +): ToolRenderContext { + const messages = ctx?.messages ?? []; + const toolResult = resolveToolResult(toolCall, ctx, state); + const args = parseJson(toolCall.argsText); + const outputText = toolResult?.outputText ?? ""; + let resultParsed = false; + let parsedResult: unknown; + + return { + toolCall, + toolResult, + message: ctx?.message ?? EMPTY_MESSAGE, + messages, + blockIndex: ctx?.blockIndex ?? -1, + isGenerating, + args, + argsText: toolCall.argsText, + outputText, + get result() { + if (!resultParsed) { + parsedResult = parseJson(outputText); + resultParsed = true; + } + return parsedResult; + }, + }; +} + +function resolveToolResult( + toolCall: ToolCallBlock, + ctx: BlockRenderContext | undefined, + state: ToolState | undefined, +): ToolResultBlock | undefined { + const cached = state?.resultCache; + if ( + cached && + ctx && + cached.messages === ctx.messages && + cached.messageId === ctx.message.id && + cached.blockId === toolCall.id && + cached.toolCallId === toolCall.toolCallId + ) { + return cached.result; + } + + const result = findToolResult(toolCall.toolCallId, ctx); + if (state) cacheToolResult(state, toolCall, ctx, result); + return result; +} + +function cacheToolResult( + state: ToolState, + toolCall: ToolCallBlock, + ctx: BlockRenderContext | undefined, + result: ToolResultBlock | undefined, +): void { + state.resultCache = + result && ctx + ? { + messages: ctx.messages, + messageId: ctx.message.id, + blockId: toolCall.id, + toolCallId: toolCall.toolCallId, + result, + } + : undefined; +} + +function findToolResult(toolCallId: string, ctx: BlockRenderContext | undefined): ToolResultBlock | undefined { + if (!ctx) return undefined; + + const messageIndex = ctx.messages.findIndex((message) => message.id === ctx.message.id); + const startIndex = messageIndex >= 0 ? messageIndex : 0; + + for (let i = startIndex; i < ctx.messages.length; i++) { + const result = ctx.messages[i].blocks.find( + (block): block is ToolResultBlock => block.type === "tool_result" && block.toolCallId === toolCallId, + ); + if (result) return result; + } + + return undefined; +} + +function rendererLabel(renderer: ToolRenderer | undefined, ctx: ToolRenderContext): string | undefined { + if (!renderer?.label) return undefined; + return typeof renderer.label === "function" ? renderer.label(ctx) : renderer.label; +} + +function resolveDefaultExpanded( + defaultExpanded: ToolsPluginConfig["defaultExpanded"], + ctx: ToolRenderContext, +): boolean { + if (typeof defaultExpanded === "function") return defaultExpanded(ctx); + return defaultExpanded ?? false; +} + +function syncExpansion(state: ToolState): void { + state.buttonEl.setAttribute("aria-expanded", String(state.expanded)); + syncPreview(state); + + if (state.expanded && state.ctx) { + renderDetails(state); + return; + } + + clearDetails(state); +} + +function renderDetails(state: ToolState): void { + const ctx = state.ctx; + if (!ctx) return; + const detailsEl = ensureDetailsEl(state); + const details = ensureDetails(state); + + detailsEl.hidden = false; + details.argsPre.textContent = state.renderer?.formatArgs?.(ctx) ?? defaultArgsText(ctx); + details.resultTitleEl.textContent = ctx.toolResult?.isError ? "Error" : "Result"; + details.resultPre.textContent = state.renderer?.formatResult?.(ctx) ?? defaultResultText(ctx); + details.resultSectionEl.hidden = false; +} + +function clearDetails(state: ToolState): void { + if (state.detailsEl) { + state.detailsEl.remove(); + state.detailsEl = undefined; + } + state.details = undefined; +} + +function ensureDetails(state: ToolState): ToolDetailsState { + if (state.details) return state.details; + + const argsTitleEl = el("div", "mur-tool-section-title", { textContent: "Arguments" }); + const argsPre = el("pre", "mur-tool-pre"); + const argsSectionEl = el("section", "mur-tool-section", {}, [argsTitleEl, argsPre]); + + const resultTitleEl = el("div", "mur-tool-section-title", { textContent: "Result" }); + const resultPre = el("pre", "mur-tool-pre"); + const resultSectionEl = el("section", "mur-tool-section", {}, [resultTitleEl, resultPre]); + + ensureDetailsEl(state).replaceChildren(argsSectionEl, resultSectionEl); + state.details = { + argsPre, + resultSectionEl, + resultTitleEl, + resultPre, + }; + return state.details; +} + +function syncPreview(state: ToolState): void { + if (!state.previewText || state.expanded) { + state.previewEl?.remove(); + state.previewEl = undefined; + return; + } + + const previewEl = ensurePreviewEl(state); + previewEl.textContent = state.previewText; +} + +function ensurePreviewEl(state: ToolState): HTMLElement { + if (state.previewEl) return state.previewEl; + + const previewEl = el("div", "mur-tool-preview"); + state.rootEl.insertBefore(previewEl, state.detailsEl ?? null); + state.previewEl = previewEl; + return previewEl; +} + +function ensureDetailsEl(state: ToolState): HTMLElement { + if (state.detailsEl) return state.detailsEl; + + const detailsEl = el("div", "mur-tool-details"); + state.rootEl.appendChild(detailsEl); + state.detailsEl = detailsEl; + return detailsEl; +} + +function defaultToolLabel(toolCall: ToolCallBlock, args: unknown): string { + const name = toolCall.name || "tool"; + const summary = summarizeArgs(args, toolCall.argsText); + return summary ? `${name} ${summary}` : name; +} + +function summarizeArgs(args: unknown, argsText: string): string { + if (args && typeof args === "object" && !Array.isArray(args)) { + const entries = Object.entries(args as Record).filter( + ([, value]) => value !== undefined && value !== null, + ); + if (entries.length === 0) return ""; + + const preferred = [ + "command", + "cmd", + "pattern", + "query", + "path", + "dir_path", + "file", + "filePath", + "filepath", + "url", + "name", + ]; + const preferredEntries: Array<[string, unknown]> = []; + for (const key of preferred) { + const match = entries.find(([entryKey]) => entryKey === key); + if (match) preferredEntries.push(match); + if (preferredEntries.length >= 2) break; + } + + const summaryEntries = preferredEntries.length > 0 ? preferredEntries : entries.slice(0, 2); + if (summaryEntries.length > 0) { + if (summaryEntries.length === 1 && preferredEntries.length === 1) { + return compactValue(summaryEntries[0][1]); + } + return summaryEntries.map(([key, value]) => `${key}=${compactValue(value)}`).join(" "); + } + + return `${entries.length} args`; + } + + if (Array.isArray(args)) return `${args.length} items`; + if (args !== undefined) return compactValue(args); + + const raw = argsText.trim().replace(/\s+/g, " "); + return raw === "{}" ? "" : raw; +} + +function compactValue(value: unknown): string { + const text = + typeof value === "string" + ? value + : typeof value === "number" || typeof value === "boolean" || value === null + ? String(value) + : JSON.stringify(value); + return truncateText(text.replace(/\s+/g, " "), MAX_ARG_SUMMARY_VALUE_CHARS); +} + +function defaultPreview(ctx: ToolRenderContext): string | undefined { + if (!ctx.toolResult?.isError) return undefined; + return ctx.outputText || "Tool failed."; +} + +function defaultArgsText(ctx: ToolRenderContext): string { + if (ctx.args !== undefined) return JSON.stringify(ctx.args, null, 2); + return ctx.argsText.trim() || "{}"; +} + +function defaultResultText(ctx: ToolRenderContext): string { + if (!ctx.toolResult) { + if (ctx.toolCall.status === "running") return "Running..."; + if (ctx.toolCall.status === "pending") return "Waiting for result..."; + return "No result."; + } + + if (ctx.result !== undefined) return JSON.stringify(ctx.result, null, 2); + return ctx.outputText; +} + +function parseJson(text: string): unknown { + const firstChar = firstNonWhitespaceChar(text); + if (!firstChar || !'{["-0123456789tfn'.includes(firstChar)) return undefined; + + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function firstNonWhitespaceChar(text: string): string { + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char !== " " && char !== "\n" && char !== "\r" && char !== "\t") return char; + } + return ""; +} + +function statusSymbol(status: ToolCallBlock["status"] | "error"): string { + switch (status) { + case "complete": + return "✓"; + case "error": + return "×"; + default: + return "..."; + } +} + +function statusLabel(status: ToolCallBlock["status"] | "error"): string { + return status; +} + +function truncateText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + if (maxChars <= 3) return text.slice(0, maxChars); + return `${text.slice(0, maxChars - 3)}...`; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools.css b/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools.css new file mode 100644 index 00000000..f5ad880e --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools.css @@ -0,0 +1,121 @@ +.mur-tool { + margin: 0.18rem 0 0.32rem; + color: var(--mur-text-muted); +} + +.mur-tool + .mur-tool { + margin-top: 0; +} + +.mur-agent-run-steps .mur-tool { + margin: 0; +} + +.mur-tool-summary { + display: inline-grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + width: auto; + max-width: 100%; + gap: 0.35rem; + padding: 0.18rem 0.28rem; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; +} + +.mur-tool-chevron { + display: inline-flex; + color: var(--mur-text-muted); + opacity: 0; + transition: opacity 0.15s ease; +} + +.mur-tool-summary:hover .mur-tool-chevron, +.mur-tool-summary:focus-visible .mur-tool-chevron, +.mur-tool-summary[aria-expanded="true"] .mur-tool-chevron { + opacity: 1; +} + +.mur-agent-run-steps .mur-tool-summary { + min-height: var(--mur-agent-run-control-height, 1.5rem); + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.mur-agent-run-steps .mur-tool-chevron { + opacity: 0.65; +} + +.mur-tool-chevron svg { + transition: transform 0.15s ease; +} + +.mur-tool-summary[aria-expanded="true"] .mur-tool-chevron svg { + transform: rotate(90deg); +} + +.mur-tool-title { + min-width: 0; + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.8rem; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mur-tool-status { + width: 1.1em; + color: var(--mur-text-muted); + font-size: 0.78rem; + line-height: 1; + text-align: center; +} + +.mur-tool-preview { + margin-left: 1.45rem; + padding: 0.15rem 0.3rem 0.2rem; + color: var(--mur-text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.76rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.mur-tool-details { + margin: 0.18rem 0 0.35rem 0.7rem; + padding: 0.2rem 0 0.2rem 0.65rem; + border-left: 1px solid var(--mur-border); +} + +.mur-tool-section + .mur-tool-section { + margin-top: 0.45rem; +} + +.mur-tool-section-title { + margin-bottom: 0.22rem; + color: var(--mur-text-muted); + font-size: 0.68rem; + font-weight: 650; + text-transform: uppercase; +} + +.mur-tool-pre { + max-height: min(360px, 45vh); + overflow: auto; + border-radius: 6px; + background: var(--mur-bg); + color: var(--mur-text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.76rem; + line-height: 1.45; + padding: 0.45rem; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/router.ts b/crates/promptforge-wb-server/ui/src/chat/router.ts new file mode 100644 index 00000000..b7b560a1 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/router.ts @@ -0,0 +1,111 @@ +export type RouterType = "hash" | "path" | "none"; + +export interface RouterConfig { + type?: RouterType; + pathPrefix?: string; // Default: '/c/' for path, '#/chat/' for hash +} + +export class AppRouter { + private type: RouterType; + private prefix: string; + private handleNavigate?: () => void; + + constructor(config?: RouterConfig) { + this.type = config?.type || "hash"; + + if (this.type === "path") { + this.prefix = config?.pathPrefix || "/c/"; + } else { + this.prefix = config?.pathPrefix || "#/chat/"; + } + } + + public getId(): string | null { + if (this.type === "none") return null; + + if (this.type === "path") { + const path = window.location.pathname; + if (path.startsWith(this.prefix)) { + return this.decodeId(path.slice(this.prefix.length)); + } + } else if (this.type === "hash") { + const hash = window.location.hash; + if (hash.startsWith(this.prefix)) { + return this.decodeId(hash.slice(this.prefix.length)); + } + } + return null; + } + + public hrefFor(id: string): string { + if (this.type === "none") return "#"; + return `${this.prefix}${encodeURIComponent(id)}`; + } + + public setUrl(id: string | null, replace = false) { + if (this.type === "none") return; + + const currentId = this.getId(); + if (currentId === id) return; + + const newUrl = id ? this.hrefFor(id) : this.emptyUrl(); + + if (replace) { + history.replaceState(null, "", newUrl); + } else { + history.pushState(null, "", newUrl); + } + } + + public listen(onNavigate: (id: string | null) => void) { + if (this.type === "none") return; + + this.handleNavigate = () => { + onNavigate(this.getId()); + }; + + for (const eventType of this.eventTypes()) { + window.addEventListener(eventType, this.handleNavigate); + } + } + + public destroy() { + if (this.type === "none" || !this.handleNavigate) return; + for (const eventType of this.eventTypes()) { + window.removeEventListener(eventType, this.handleNavigate); + } + this.handleNavigate = undefined; + } + + private eventTypes(): ("hashchange" | "popstate")[] { + return this.type === "hash" ? ["hashchange", "popstate"] : ["popstate"]; + } + + private decodeId(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } + } + + private emptyUrl(): string { + if (this.type === "hash") return this.emptyHashUrl(); + return this.emptyPathUrl(); + } + + private emptyPathUrl(): string { + const trimmed = this.prefix.endsWith("/") ? this.prefix.slice(0, -1) : this.prefix; + const slashIndex = trimmed.lastIndexOf("/"); + if (slashIndex <= 0) return "/"; + return `${trimmed.slice(0, slashIndex)}/`; + } + + private emptyHashUrl(): string { + const hashPath = this.prefix.startsWith("#") ? this.prefix.slice(1) : this.prefix; + const trimmed = hashPath.endsWith("/") ? hashPath.slice(0, -1) : hashPath; + const slashIndex = trimmed.lastIndexOf("/"); + if (slashIndex <= 0) return "#/"; + return `#${trimmed.slice(0, slashIndex)}/`; + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/base.css b/crates/promptforge-wb-server/ui/src/chat/styles/base.css new file mode 100644 index 00000000..ed97f466 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/styles/base.css @@ -0,0 +1,419 @@ +.mur-app { + --mur-bg: #ffffff; + --mur-surface: #f9fafb; + --mur-surface-user: #e1e5ec; + --mur-hover-bg: rgba(0, 0, 0, 0.05); + + --mur-text: #111827; + --mur-text-secondary: #374151; + --mur-text-muted: #6b7280; + --mur-inverse-text: #ffffff; + + --mur-border: #e5e7eb; + --mur-primary: #000000; + --mur-danger: #ef4444; + --mur-danger-text: #991b1b; + --mur-danger-bg: #fef2f2; + --mur-danger-border: rgba(239, 68, 68, 0.3); + --mur-danger-hover-bg: rgba(239, 68, 68, 0.12); + --mur-success: #10b981; + + --mur-header-button-bg: rgba(255, 255, 255, 0.8); + --mur-header-title-bg: rgba(255, 255, 255, 0.5); + --mur-code-heading-bg: #fdf1e7; + --mur-overlay-bg: rgba(0, 0, 0, 0.4); + --mur-attachment-drag-ring: rgba(0, 0, 0, 0.12); + + --mur-shadow-popover: 0 12px 30px rgba(17, 24, 39, 0.12); + --mur-shadow-button: 0 1px 2px rgba(0, 0, 0, 0.05); + --mur-shadow-input: 0 4px 15px rgba(0, 0, 0, 0.05); + --mur-shadow-input-focus: 0 4px 12px rgba(0, 0, 0, 0.08); + --mur-shadow-sidebar: 4px 0 15px rgba(0, 0, 0, 0.1); + --mur-shadow-modal: 0 10px 25px rgba(0, 0, 0, 0.15); + --mur-shadow-attachment: 0 2px 4px rgba(0, 0, 0, 0.2); + + --mur-font: system-ui, -apple-system, sans-serif; + --mur-header-height: 50px; + --mur-input-max-height: 200px; + --mur-chat-content-width: 768px; + --mur-chat-form-width: 768px; + --mur-sidebar-width: 260px; + --mur-sidebar-rail-width: 56px; + --mur-user-message-max-width: 85%; + + display: flex; + height: 100vh; + width: 100vw; + position: relative; + overflow: hidden; + font-family: var(--mur-font); + background-color: var(--mur-bg); + color: var(--mur-text); + color-scheme: light; +} + +.mur-app[data-theme="light"] { + color-scheme: light; +} + +.mur-app[data-theme="dark"] { + --mur-bg: #111827; + --mur-surface: #1f2937; + --mur-surface-user: #263244; + --mur-hover-bg: rgba(255, 255, 255, 0.08); + + --mur-text: #f9fafb; + --mur-text-secondary: #e5e7eb; + --mur-text-muted: #9ca3af; + --mur-inverse-text: #111827; + + --mur-border: #374151; + --mur-primary: #f9fafb; + --mur-danger: #f87171; + --mur-danger-text: #fecaca; + --mur-danger-bg: rgba(127, 29, 29, 0.32); + --mur-danger-border: rgba(248, 113, 113, 0.38); + --mur-danger-hover-bg: rgba(248, 113, 113, 0.14); + --mur-success: #34d399; + + --mur-header-button-bg: rgba(17, 24, 39, 0.82); + --mur-header-title-bg: rgba(17, 24, 39, 0.62); + --mur-code-heading-bg: rgba(251, 146, 60, 0.16); + --mur-overlay-bg: rgba(0, 0, 0, 0.58); + --mur-attachment-drag-ring: rgba(255, 255, 255, 0.18); + + --mur-shadow-popover: 0 12px 30px rgba(0, 0, 0, 0.34); + --mur-shadow-button: 0 1px 2px rgba(0, 0, 0, 0.24); + --mur-shadow-input: 0 4px 15px rgba(0, 0, 0, 0.2); + --mur-shadow-input-focus: 0 4px 12px rgba(0, 0, 0, 0.28); + --mur-shadow-sidebar: 4px 0 15px rgba(0, 0, 0, 0.28); + --mur-shadow-modal: 0 10px 25px rgba(0, 0, 0, 0.36); + --mur-shadow-attachment: 0 2px 4px rgba(0, 0, 0, 0.36); + + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + .mur-app:not([data-theme]) { + --mur-bg: #111827; + --mur-surface: #1f2937; + --mur-surface-user: #263244; + --mur-hover-bg: rgba(255, 255, 255, 0.08); + + --mur-text: #f9fafb; + --mur-text-secondary: #e5e7eb; + --mur-text-muted: #9ca3af; + --mur-inverse-text: #111827; + + --mur-border: #374151; + --mur-primary: #f9fafb; + --mur-danger: #f87171; + --mur-danger-text: #fecaca; + --mur-danger-bg: rgba(127, 29, 29, 0.32); + --mur-danger-border: rgba(248, 113, 113, 0.38); + --mur-danger-hover-bg: rgba(248, 113, 113, 0.14); + --mur-success: #34d399; + + --mur-header-button-bg: rgba(17, 24, 39, 0.82); + --mur-header-title-bg: rgba(17, 24, 39, 0.62); + --mur-code-heading-bg: rgba(251, 146, 60, 0.16); + --mur-overlay-bg: rgba(0, 0, 0, 0.58); + --mur-attachment-drag-ring: rgba(255, 255, 255, 0.18); + + --mur-shadow-popover: 0 12px 30px rgba(0, 0, 0, 0.34); + --mur-shadow-button: 0 1px 2px rgba(0, 0, 0, 0.24); + --mur-shadow-input: 0 4px 15px rgba(0, 0, 0, 0.2); + --mur-shadow-input-focus: 0 4px 12px rgba(0, 0, 0, 0.28); + --mur-shadow-sidebar: 4px 0 15px rgba(0, 0, 0, 0.28); + --mur-shadow-modal: 0 10px 25px rgba(0, 0, 0, 0.36); + --mur-shadow-attachment: 0 2px 4px rgba(0, 0, 0, 0.36); + + color-scheme: dark; + } +} + +:where(.mur-app, .mur-app *), +:where(.mur-app, .mur-app *)::before, +:where(.mur-app, .mur-app *)::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +.mur-app [hidden] { + display: none; +} + +.mur-app.mur-app-embedded { + height: 100%; + width: 100%; + min-height: 0; + min-width: 0; +} + +@supports (height: 100dvh) { + .mur-app:not(.mur-app-embedded) { + height: 100dvh; + width: 100dvw; + } +} + +.mur-main-area { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + position: relative; +} + +.mur-main-header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: var(--mur-header-height); + display: flex; + align-items: center; + padding: 0 1rem; + z-index: 10; + background: transparent; + pointer-events: none; +} + +.mur-main-header > button { + pointer-events: auto; + background-color: var(--mur-header-button-bg); + backdrop-filter: blur(4px); +} + +.mur-main-header > button:hover { + background-color: var(--mur-hover-bg); +} + +.mur-header-title { + background-color: var(--mur-header-title-bg); + border-radius: 5px; + padding: 5px 5px 5px 0; + font-size: 1.15rem; +} + +.mur-global-error { + position: absolute; + top: 72px; + left: 50%; + z-index: 20; + display: flex; + align-items: center; + gap: 0.75rem; + max-width: min(520px, calc(100% - 2rem)); + padding: 0.75rem 0.875rem 0.75rem 1rem; + color: var(--mur-danger-text); + background-color: var(--mur-danger-bg); + border-radius: 8px; + box-shadow: var(--mur-shadow-popover); + transform: translateX(-50%); +} + +.mur-global-error[hidden] { + display: none; +} + +.mur-global-error-text { + min-width: 0; + overflow-wrap: anywhere; + font-size: 0.9rem; + line-height: 1.35; +} + +.mur-global-error-close { + flex: 0 0 auto; + width: 1.5rem; + height: 1.5rem; + border: none; + border-radius: 4px; + color: var(--mur-danger-text); + background: transparent; + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.mur-global-error-close:hover { + background: var(--mur-danger-hover-bg); +} + +.mur-global-error-close:focus-visible { + outline: 2px solid var(--mur-danger-text); + outline-offset: 2px; +} + +.mur-open-sidebar-btn { + background: none; + border: none; + cursor: pointer; + color: var(--mur-text); + display: none; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + padding: 0.25rem; +} + +.mur-open-sidebar-btn:hover { + background: var(--mur-hover-bg); +} + +.mur-chat-layout-wrapper { + flex: 1; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.mur-chat-scroll-area { + flex: 1; + min-height: 0; + width: 100%; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.mur-chat-history { + width: 100%; + max-width: var(--mur-chat-content-width); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.375rem; + padding: 4rem 0.5rem 7rem; +} + +.mur-chat-form-container { + --mur-chat-form-bottom-space: 1.5rem; + + position: absolute; + left: 0; + right: 0; + bottom: 0; + margin: 0 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0 var(--mur-chat-form-bottom-space); + pointer-events: none; + background: linear-gradient(to bottom, transparent 0, var(--mur-bg) 1rem, var(--mur-bg) 100%); + transition: + bottom 0.4s cubic-bezier(0.1, 0.7, 0.1, 1), + transform 0.4s ease; +} + +@media (max-width: 768px) { + html.mur-chat-page-scroll, + html.mur-chat-page-scroll body { + height: auto; + min-height: 100%; + } + + html.mur-chat-page-scroll body { + overflow-y: auto; + } + + .mur-app:not(.mur-app-embedded) { + min-height: 100vh; + height: auto; + width: 100%; + overflow: visible; + } + + @supports (min-height: 100svh) { + .mur-app:not(.mur-app-embedded) { + min-height: 100svh; + } + } + + @supports (min-height: 100dvh) { + .mur-app:not(.mur-app-embedded) { + min-height: 100dvh; + height: auto; + } + } + + .mur-app:not(.mur-app-embedded) .mur-main-area { + min-height: 100vh; + } + + @supports (min-height: 100svh) { + .mur-app:not(.mur-app-embedded) .mur-main-area { + min-height: 100svh; + } + } + + @supports (min-height: 100dvh) { + .mur-app:not(.mur-app-embedded) .mur-main-area { + min-height: 100dvh; + } + } + + .mur-app:not(.mur-app-embedded) .mur-main-header { + position: sticky; + background-color: var(--mur-bg); + border-bottom: 1px solid var(--mur-border); + pointer-events: auto; + } + + .mur-app:not(.mur-app-embedded) .mur-main-header > button { + background-color: transparent; + backdrop-filter: none; + } + + .mur-app:not(.mur-app-embedded) .mur-header-title { + display: block; + font-size: 1.1rem; + flex: 1; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .mur-app:not(.mur-app-embedded) .mur-open-sidebar-btn { + display: flex; + } + + .mur-app:not(.mur-app-embedded) .mur-chat-layout-wrapper { + min-height: calc(100vh - var(--mur-header-height)); + overflow: visible; + } + + @supports (min-height: 100svh) { + .mur-app:not(.mur-app-embedded) .mur-chat-layout-wrapper { + min-height: calc(100svh - var(--mur-header-height)); + } + } + + @supports (min-height: 100dvh) { + .mur-app:not(.mur-app-embedded) .mur-chat-layout-wrapper { + min-height: calc(100dvh - var(--mur-header-height)); + } + } + + .mur-app:not(.mur-app-embedded) .mur-chat-scroll-area { + flex: 1; + min-height: 0; + overflow: visible; + scrollbar-gutter: auto; + } + + .mur-app:not(.mur-app-embedded) .mur-chat-history { + padding: 1rem 1rem 7rem; + } + + .mur-app:not(.mur-app-embedded) .mur-chat-form-container { + --mur-chat-form-bottom-space: max(1rem, env(safe-area-inset-bottom)); + + position: sticky; + z-index: 12; + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/css.d.ts b/crates/promptforge-wb-server/ui/src/chat/styles/css.d.ts new file mode 100644 index 00000000..cbe652db --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/styles/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/dropdown.css b/crates/promptforge-wb-server/ui/src/chat/styles/dropdown.css new file mode 100644 index 00000000..40e3a71f --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/styles/dropdown.css @@ -0,0 +1,79 @@ +.mur-dropdown-menu { + position: absolute; + z-index: 9999; + background-color: var(--mur-bg); + border: 1px solid var(--mur-border); + border-radius: 8px; + box-shadow: var(--mur-shadow-popover); + min-width: 160px; + padding: 4px; + display: flex; + flex-direction: column; + animation: mur-dropdown-fade 0.15s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes mur-dropdown-fade { + 0% { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.mur-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: var(--mur-text); + font-size: 0.9rem; + text-align: left; + transition: + background-color 0.2s, + color 0.2s; +} + +.mur-dropdown-item:hover:not(:disabled) { + background-color: var(--mur-hover-bg); +} + +.mur-dropdown-item:focus-visible { + outline: none; + background-color: var(--mur-hover-bg); +} + +.mur-dropdown-item:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.mur-dropdown-item.mur-danger { + color: var(--mur-danger); +} + +.mur-dropdown-item.mur-danger:hover:not(:disabled) { + background-color: var(--mur-danger-hover-bg); + color: var(--mur-danger-text); +} + +.mur-dropdown-item.mur-danger:focus-visible { + background-color: var(--mur-danger-hover-bg); + color: var(--mur-danger-text); +} + +.mur-dropdown-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: inherit; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/feed.css b/crates/promptforge-wb-server/ui/src/chat/styles/feed.css new file mode 100644 index 00000000..423e5894 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/styles/feed.css @@ -0,0 +1,477 @@ +.mur-message { + max-width: 100%; + line-height: 1.6; + word-wrap: break-word; + overflow-wrap: break-word; + display: flex; + flex-direction: column; +} + +.mur-message.mur-message-user { + align-self: flex-end; + max-width: var(--mur-user-message-max-width); +} + +.mur-message.mur-message-assistant { + align-self: flex-start; + width: 100%; +} + +.mur-message > *:first-child { + margin-top: 0; +} + +.mur-message > *:last-child { + margin-bottom: 0; +} + +.mur-message .mur-block-text { + order: 2; + max-width: 100%; + overflow-x: auto; +} + +.mur-message.mur-message-user .mur-block-text { + align-self: flex-end; + background-color: var(--mur-surface-user); + padding: 0.75rem 1.25rem; + border-radius: 1.5rem 1.5rem 0 1.5rem; +} + +.mur-message p { + margin-bottom: 1rem; +} + +.mur-message p:last-child { + margin-bottom: 0; +} + +.mur-message ul, +.mur-message ol { + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +.mur-message li { + margin-bottom: 0.25rem; +} + +.mur-message li > ul, +.mur-message li > ol { + margin-bottom: 0; +} + +/* Grouped headers safely */ +.mur-message h1, +.mur-message h2, +.mur-message h3, +.mur-message h4, +.mur-message h5, +.mur-message h6 { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + font-weight: 600; + line-height: 1.25; + color: var(--mur-text); +} + +.mur-message h1, +.mur-message h2 { + color: var(--mur-text-secondary); +} + +/* Adjacent headers spacing */ +.mur-message :is(h1, h2, h3, h4, h5, h6) + :is(h1, h2, h3, h4, h5, h6) { + margin-top: 0.25rem; +} + +.mur-message code { + background-color: var(--mur-surface); + padding: 0.2em 0.4em; + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.9em; +} + +/* Code inside headers */ +.mur-message :is(h1, h2, h3, h4, h5, h6) code { + background-color: var(--mur-code-heading-bg); + padding: 0.2em; + color: inherit; +} + +.mur-message pre { + background-color: var(--mur-surface); + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin-bottom: 1rem; +} + +.mur-code-block { + background-color: var(--mur-surface); + border-radius: 0.5rem; + overflow: hidden; + margin-bottom: 1rem; +} + +.mur-message .mur-code-block pre { + background-color: transparent; + border-radius: 0; + margin-bottom: 0; +} + +.mur-code-header { + display: flex; + align-items: center; + min-height: 2rem; + padding: 0.25rem 0.25rem 0.25rem 1rem; + color: var(--mur-text-muted); +} + +.mur-code-language { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; + font-size: 0.75rem; + line-height: 1; +} + +.mur-code-copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 1.8rem; + height: 1.8rem; + margin-left: auto; + color: var(--mur-text-muted); + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.mur-code-copy-btn:hover { + background-color: var(--mur-hover-bg); + color: var(--mur-text); +} + +.mur-code-copy-btn svg { + flex: 0 0 auto; +} + +.mur-message pre code { + background-color: transparent; + padding: 0; +} + +.mur-message blockquote { + border-left: 4px solid var(--mur-border); + padding-left: 1rem; + margin-left: 0; + margin-bottom: 1rem; + color: var(--mur-text-muted); +} + +.mur-message table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + margin-bottom: 1rem; + font-size: 0.95em; +} + +.mur-message table:last-child { + margin-bottom: 0; +} + +.mur-message th, +.mur-message td { + border: 1px solid var(--mur-border); + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; +} + +.mur-message th { + background-color: var(--mur-surface); + color: var(--mur-text); + font-weight: 600; +} + +/* Clean hr */ +.mur-message hr { + border: none; + border-top: 1px solid var(--mur-border); + margin: 1.5rem 0; +} + +.mur-message-loading { + order: 0; + display: flex; + align-items: center; + gap: 4px; + padding: 0.5rem 0; + height: 1.5rem; + color: var(--mur-text-muted); +} + +.mur-message-loading .mur-loading-dot { + width: 6px; + height: 6px; + background-color: currentColor; + border-radius: 50%; + animation: mur-pulse 1.5s infinite cubic-bezier(0.4, 0, 0.6, 1); +} + +.mur-message-loading .mur-loading-dot:nth-child(2) { + animation-delay: 200ms; +} + +.mur-message-loading .mur-loading-dot:nth-child(3) { + animation-delay: 400ms; +} + +@keyframes mur-pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + + 50% { + opacity: 1; + transform: scale(1.1); + } +} + +.mur-message-error { + order: 30; + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + background-color: var(--mur-danger-bg); + color: var(--mur-danger-text); + border: 1px solid var(--mur-danger-border); + border-radius: 0.5rem; + font-size: 0.95rem; + margin-top: 0.5rem; +} + +.mur-message-error svg { + flex-shrink: 0; + margin-top: 2px; +} + +.mur-message-actions { + order: 20; + margin-top: 0.25rem; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.mur-message:hover .mur-message-actions, +.mur-message:focus-within .mur-message-actions { + opacity: 1; +} + +.mur-message.mur-message-user .mur-message-actions { + justify-content: flex-end; +} + +.mur-message.mur-message-assistant .mur-message-actions { + justify-content: flex-start; +} + +/* Hide actions while generating */ +.mur-message.mur-generating > .mur-message-actions { + display: none; +} + +.mur-action-icon-btn { + background: transparent; + border: none; + color: var(--mur-text-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + transition: + color 0.2s, + background-color 0.2s; +} + +.mur-action-icon-btn:hover { + color: var(--mur-text); + background-color: var(--mur-hover-bg); +} + +.mur-feed-spinner { + display: flex; + justify-content: center; + padding-top: 2rem; + width: 100%; +} + +/* Older-messages spinner sits above the transcript, not below it. */ +.mur-feed-spinner-top { + position: sticky; + top: 0; + z-index: 2; + padding-top: 0.5rem; + padding-bottom: 0.25rem; + pointer-events: none; + background: linear-gradient(to bottom, var(--mur-bg) 0%, var(--mur-bg) 70%, transparent 100%); +} + +.mur-feed-older-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.625rem; + border: 1px solid var(--mur-border); + border-radius: 6px; + background: var(--mur-surface); + color: var(--mur-text-muted); + font-size: 0.8125rem; + line-height: 1.25rem; +} + +.mur-feed-older-status .mur-message-loading { + padding: 0; + height: auto; +} + +.mur-agent-run { + --mur-agent-run-gap: 0.875rem; + --mur-agent-run-control-height: 1.5rem; + + display: flex; + flex-direction: column; + row-gap: var(--mur-agent-run-gap); + width: 100%; +} + +.mur-agent-run-work { + display: flex; + flex-direction: column; + width: 100%; +} + +.mur-agent-run-messages { + display: contents; +} + +.mur-agent-run-summary { + align-self: flex-start; + display: flex; + align-items: center; + gap: 0.35rem; + width: auto; + max-width: 100%; + margin-left: -0.5rem; + padding: 0.25rem 0.5rem; + background: none; + border: none; + border-radius: 0.25rem; + color: var(--mur-text-muted); + font: inherit; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: + background-color 0.2s, + color 0.2s; +} + +.mur-agent-run-summary:hover, +.mur-agent-run-summary:focus-visible { + background-color: var(--mur-hover-bg); + color: var(--mur-text); +} + +.mur-agent-run-summary-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.2s; +} + +.mur-agent-run-summary[aria-expanded="true"] .mur-agent-run-summary-chevron { + transform: rotate(90deg); +} + +.mur-agent-run-steps { + display: flex; + flex-direction: column; + gap: 0.375rem; + width: 100%; + margin-top: 0.35rem; +} + +.mur-message.mur-message-assistant.mur-generating + .mur-message-blocks-wrapper + > .mur-block-text:last-child + > *:last-child::after { + content: ""; + display: inline-block; + width: 6px; + height: 1.1em; + background-color: var(--mur-text-muted); + vertical-align: -0.1em; + margin-left: 4px; + animation: mur-cursor-blink 1s step-end infinite; + border-radius: 1px; +} + +@keyframes mur-cursor-blink { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +.mur-block-tool { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0.5rem 0.75rem; + background-color: var(--mur-surface); + border: 1px solid var(--mur-border); + border-radius: 0.5rem; + font-family: monospace; + font-size: 0.85rem; + color: var(--mur-text-muted); + margin-bottom: 0.5rem; + transition: + border-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease; +} + +.mur-block-tool.mur-tool-streaming { + border-color: var(--mur-text-muted); + opacity: 0.8; +} + +.mur-block-tool.mur-tool-complete { + border-left: 4px solid var(--mur-success); + color: var(--mur-text); +} + +.mur-block-tool.mur-tool-error { + border-left: 4px solid var(--mur-danger); + color: var(--mur-danger); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/input.css b/crates/promptforge-wb-server/ui/src/chat/styles/input.css new file mode 100644 index 00000000..e91da3c9 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/styles/input.css @@ -0,0 +1,131 @@ +.mur-chat-form { + width: 100%; + max-width: var(--mur-chat-form-width); + pointer-events: auto; + border: 1px solid var(--mur-border); + border-radius: 24px; + background-color: var(--mur-bg); + box-shadow: var(--mur-shadow-input); + display: flex; + flex-direction: row; + align-items: flex-end; + padding: 0.5rem; + gap: 0.5rem; + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.mur-chat-form-note { + width: 100%; + max-width: var(--mur-chat-form-width); + padding: 0 0.5rem; + color: var(--mur-text-muted); + font-size: 0.75rem; + line-height: 1.35; + text-align: center; + pointer-events: auto; +} + +.mur-chat-form-note a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.mur-chat-empty .mur-chat-form-container { + bottom: 50%; + transform: translateY(50%); +} + +.mur-chat-form:focus-within { + box-shadow: var(--mur-shadow-input-focus); + border-color: var(--mur-text-muted); +} + +.mur-chat-input { + flex: 1; + border: none; + outline: none; + resize: none; + padding: 6px 4px; + margin: 0; + font-family: inherit; + font-size: 1rem; + color: var(--mur-text); + background: transparent; + line-height: 1.5; + max-height: var(--mur-input-max-height, 200px); + height: 36px; + transition: opacity 0.2s ease; +} + +@supports (field-sizing: content) { + .mur-chat-input { + field-sizing: content; + min-height: 36px; + height: auto; + } +} + +.mur-chat-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.mur-form-icon-btn { + background: transparent; + border: none; + color: var(--mur-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: + color 0.2s ease, + background-color 0.2s ease, + opacity 0.2s ease; + height: 36px; + width: 36px; + flex-shrink: 0; +} + +.mur-form-icon-btn:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.mur-form-icon-btn:hover:not(:disabled) { + background-color: var(--mur-hover-bg); + color: var(--mur-text); +} + +.mur-action-btn { + background-color: var(--mur-primary); + color: var(--mur-bg); +} + +.mur-action-btn:disabled { + background-color: var(--mur-hover-bg); + color: var(--mur-text-muted); + opacity: 1; +} + +.mur-action-btn .mur-stop-icon { + display: none; +} + +.mur-action-btn.mur-generating .mur-send-icon { + display: none; +} + +.mur-action-btn.mur-generating .mur-stop-icon { + display: block; +} + +@media (max-width: 768px) { + .mur-chat-empty .mur-chat-form-container { + position: absolute; + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/sidebar.css b/crates/promptforge-wb-server/ui/src/chat/styles/sidebar.css new file mode 100644 index 00000000..552c0a50 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/styles/sidebar.css @@ -0,0 +1,373 @@ +.mur-sidebar { + --mur-sidebar-rail-gutter: 0.5rem; + --mur-sidebar-control-size: 2.5rem; + + width: var(--mur-sidebar-width); + min-width: 0; + background-color: var(--mur-surface); + border-right: 1px solid var(--mur-border); + display: flex; + flex-direction: column; + flex: 0 0 var(--mur-sidebar-width); + min-height: 0; + overflow: hidden; +} + +.mur-sidebar-animated .mur-sidebar { + transition: + width 0.3s ease, + flex-basis 0.3s ease; +} + +.mur-sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + position: relative; + padding: 1rem; + height: var(--mur-header-height); + flex-shrink: 0; +} + +.mur-close-sidebar-btn { + background: none; + border: none; + cursor: pointer; + color: var(--mur-text-muted); + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 50%; + right: 1rem; + border-radius: 0.25rem; + padding: 0.25rem; + opacity: 1; + transform: translateY(-50%); + visibility: visible; + transition: background-color 0.2s; +} + +.mur-close-sidebar-btn:hover { + background-color: var(--mur-hover-bg); +} + +.mur-sidebar-logo { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + font-size: 1.1rem; + color: var(--mur-text); +} + +.mur-sidebar-actions { + padding: 0 var(--mur-sidebar-rail-gutter) 1rem; + flex-shrink: 0; +} + +.mur-sidebar-footer { + padding: 1rem var(--mur-sidebar-rail-gutter); + border-top: 1px solid var(--mur-border); +} + +.mur-sidebar-nav-btn { + width: 100%; + height: var(--mur-sidebar-control-size); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + padding: 0 10px; + background: transparent; + border: none; + border-radius: 0.5rem; + color: var(--mur-text-muted); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + overflow: hidden; + white-space: nowrap; + box-shadow: none; + transition: + background-color 0.2s, + color 0.2s; +} + +.mur-sidebar-nav-btn:hover { + background-color: var(--mur-hover-bg); + color: var(--mur-text); +} + +.mur-sidebar-nav-btn > svg { + flex: 0 0 auto; +} + +.mur-sidebar-nav-btn > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transform: translateY(1px); +} + +.mur-new-chat-btn.mur-sidebar-nav-btn { + padding: 0 11px; + color: var(--mur-text); +} + +.mur-sidebar-animated .mur-sidebar-nav-btn > span { + transition: opacity 0.12s ease 0.1s; +} + +.mur-sidebar-content { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.mur-sidebar-animated .mur-sidebar-content { + transition: + opacity 0.16s ease, + transform 0.16s ease, + visibility 0s linear 0s; +} + +.mur-sidebar-status { + padding: 1rem; + color: var(--mur-text-muted); + font-size: 0.9rem; + text-align: center; +} + +.mur-sidebar-load-more-trigger { + height: 1px; +} + +.mur-sidebar-pin-divider { + height: 1px; + background-color: var(--mur-border); + margin: 0.25rem 0.5rem; + flex-shrink: 0; +} + +.mur-sidebar-item { + display: flex; + align-items: center; + gap: 0.25rem; + border-radius: 0.5rem; + transition: + background-color 0.2s, + color 0.2s; + color: var(--mur-text-muted); +} + +.mur-sidebar-item:hover { + background-color: var(--mur-hover-bg); + color: var(--mur-text); +} + +.mur-sidebar-item.mur-active { + background-color: var(--mur-hover-bg); + color: var(--mur-text); + font-weight: 500; +} + +.mur-sidebar-item-link { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.75rem; + color: inherit; + text-decoration: none; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: 0.5rem; +} + +.mur-sidebar-pin-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--mur-text-muted); +} + +.mur-sidebar-item-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.mur-sidebar-item-link:focus-visible { + outline: 2px solid var(--mur-text); + outline-offset: -2px; +} + +.mur-sidebar-rename-input { + flex: 1; + min-width: 0; + margin: 0.35rem; + padding: 0.4rem 0.45rem; + border: 1px solid var(--mur-border); + border-radius: 0.35rem; + background: var(--mur-bg); + color: var(--mur-text); + font: inherit; + font-size: 0.9rem; + outline: none; +} + +.mur-sidebar-rename-input:focus { + border-color: var(--mur-text-muted); + box-shadow: 0 0 0 2px var(--mur-hover-bg); +} + +.mur-sidebar-options-btn { + background: none; + border: none; + color: var(--mur-text-muted); + cursor: pointer; + line-height: 0; + height: 1.5rem; + width: 1.5rem; + display: none; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 4px; + margin-right: 0.25rem; + transition: + background-color 0.2s, + color 0.2s; +} + +.mur-sidebar-item:focus-within .mur-sidebar-options-btn { + display: flex; +} + +.mur-sidebar-item.mur-renaming .mur-sidebar-options-btn, +.mur-sidebar-item.mur-renaming:focus-within .mur-sidebar-options-btn { + display: none; +} + +@media (hover: hover) and (pointer: fine) { + .mur-sidebar-item:hover .mur-sidebar-options-btn { + display: flex; + } + .mur-sidebar-item.mur-renaming:hover .mur-sidebar-options-btn { + display: none; + } + .mur-sidebar-options-btn:hover { + color: var(--mur-text); + background-color: var(--mur-hover-bg); + } +} + +@media (hover: none), (pointer: coarse) { + .mur-sidebar-options-btn { + display: flex; + opacity: 0.7; + } + + .mur-sidebar-item.mur-renaming .mur-sidebar-options-btn { + display: none; + } + + .mur-sidebar-options-btn:active { + opacity: 1; + color: var(--mur-text); + background-color: var(--mur-hover-bg); + } +} + +@media (min-width: 769px) { + .mur-sidebar-closed .mur-sidebar { + width: var(--mur-sidebar-rail-width); + flex-basis: var(--mur-sidebar-rail-width); + cursor: pointer; + } + + .mur-sidebar-closed .mur-sidebar-header { + justify-content: flex-start; + } + + .mur-sidebar-closed .mur-sidebar-logo { + max-width: var(--mur-sidebar-control-size); + overflow: hidden; + white-space: nowrap; + } + + .mur-sidebar-closed .mur-close-sidebar-btn { + opacity: 0; + pointer-events: none; + visibility: hidden; + } + + .mur-sidebar-animated:not(.mur-sidebar-closed) .mur-close-sidebar-btn { + transition: + background-color 0.2s, + opacity 0.12s ease 0.16s; + } + + .mur-sidebar-animated.mur-sidebar-closed .mur-close-sidebar-btn { + transition: + background-color 0.2s, + opacity 0s linear, + visibility 0s linear; + } + + .mur-sidebar-closed .mur-sidebar-nav-btn > span { + opacity: 0; + } + + .mur-sidebar-animated.mur-sidebar-closed .mur-sidebar-nav-btn > span { + transition: opacity 0.08s ease; + } + + .mur-sidebar-closed .mur-sidebar-content { + visibility: hidden; + opacity: 0; + transform: translateX(-0.35rem); + pointer-events: none; + } + + .mur-sidebar-animated.mur-sidebar-closed .mur-sidebar-content { + transition: + opacity 0.12s ease, + transform 0.12s ease, + visibility 0s linear 0.12s; + } +} + +@media (max-width: 768px) { + .mur-sidebar { + position: fixed; + inset: 0 auto 0 0; + width: var(--mur-sidebar-width); + flex-basis: auto; + height: auto; + max-width: 86vw; + z-index: 50; + margin-left: 0; + transform: translateX(-100%); + transition: transform 0.3s ease; + box-shadow: none; + } + + .mur-sidebar.mur-mobile-open { + transform: translateX(0); + box-shadow: var(--mur-shadow-sidebar); + } + + .mur-open-sidebar-btn { + display: flex; + } +} diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/device.ts b/crates/promptforge-wb-server/ui/src/chat/utils/device.ts new file mode 100644 index 00000000..074a36b2 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/utils/device.ts @@ -0,0 +1,3 @@ +export const IS_TOUCH_DEVICE = + typeof window !== "undefined" && + (window.matchMedia("(pointer: coarse)").matches || "ontouchstart" in window || navigator.maxTouchPoints > 0); diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/dom.ts b/crates/promptforge-wb-server/ui/src/chat/utils/dom.ts new file mode 100644 index 00000000..62cea0af --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/utils/dom.ts @@ -0,0 +1,125 @@ +/** + * Finds an element inside the container and throws a clear error if it is not present. + * This ensures the Fail-Fast principle. + */ +export function queryOrThrow(context: HTMLElement, selector: string): T { + const el = context.querySelector(selector); + if (!el) { + throw new Error(`DOM Error: Required element "${selector}" not found inside the container.`); + } + return el as T; +} + +export function el( + tag: K, + className?: string, + props?: Partial | null, + children?: (HTMLElement | string | null | false | undefined)[], +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + + if (className) { + element.className = className; + } + + if (props) { + Object.assign(element, props); + } + + if (children) { + for (const child of children) { + if (child) element.append(child); + } + } + + return element; +} + +export function replaceNodes(parent: HTMLElement, ...nodes: (Node | string)[]): void { + if (typeof parent.replaceChildren === "function") { + parent.replaceChildren(...nodes); + return; + } + + parent.textContent = ""; + for (const node of nodes) { + parent.appendChild(typeof node === "string" ? document.createTextNode(node) : node); + } +} + +/** + * Super lightweight child-node diffing specifically for our sanitized HTML. + * Mutates `target` children to match `source` children without destroying untouched nodes. + */ +export function syncDOMChildren(target: Node, source: Node) { + let targetChild = target.firstChild; + let sourceChild = source.firstChild; + + while (sourceChild !== null) { + if (targetChild === null) { + // Target is missing children; append the remainder + target.appendChild(sourceChild.cloneNode(true)); + sourceChild = sourceChild.nextSibling; + } else { + // Cache next siblings before recursion in case targetChild replaces itself + const nextTargetChild = targetChild.nextSibling; + const nextSourceChild = sourceChild.nextSibling; + + syncDOMNode(targetChild, sourceChild); + + targetChild = nextTargetChild; + sourceChild = nextSourceChild; + } + } + + // Cleanup remaining obsolete target children + while (targetChild !== null) { + const nextTargetChild = targetChild.nextSibling; + target.removeChild(targetChild); + targetChild = nextTargetChild; + } +} + +function syncDOMNode(target: Node, source: Node) { + // Reconcile text nodes + if (target.nodeType === Node.TEXT_NODE && source.nodeType === Node.TEXT_NODE) { + if (target.nodeValue !== source.nodeValue) { + target.nodeValue = source.nodeValue; + } + return; + } + + // Replace entirely if node types or tags diverge + if (target.nodeType !== source.nodeType || target.nodeName !== source.nodeName) { + target.parentNode?.replaceChild(source.cloneNode(true), target); + return; + } + + // Reconcile attributes (Elements only) + if (target.nodeType === Node.ELEMENT_NODE) { + const elTarget = target as HTMLElement; + const elSource = source as HTMLElement; + + const sourceAttrs = elSource.attributes; + const targetAttrs = elTarget.attributes; + + // Remove obsolete attributes. + // Note: targetAttrs is a live NamedNodeMap, so backward iteration is required. + for (let i = targetAttrs.length - 1; i >= 0; i--) { + const attrName = targetAttrs[i].name; + if (!elSource.hasAttribute(attrName)) { + elTarget.removeAttribute(attrName); + } + } + + // Add or update existing attributes + for (let i = 0; i < sourceAttrs.length; i++) { + const attr = sourceAttrs[i]; + if (elTarget.getAttribute(attr.name) !== attr.value) { + elTarget.setAttribute(attr.name, attr.value); + } + } + } + + syncDOMChildren(target, source); +} diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/html.ts b/crates/promptforge-wb-server/ui/src/chat/utils/html.ts new file mode 100644 index 00000000..36d196b0 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/utils/html.ts @@ -0,0 +1,198 @@ +import type { CodeHighlighter } from "../core/types"; +import { ICON_COPY } from "./icons"; + +export type Highlighter = CodeHighlighter; + +let parser: DOMParser | null = null; + +function getParser(): DOMParser { + parser ??= new DOMParser(); + return parser; +} + +// biome-ignore format:. +const ALLOWED_TAGS = new Set([ + "P", "B", "I", "STRONG", "EM", "DEL", + "A", "BR", "IMG", + "H1", "H2", "H3", "H4", "H5", "H6", + "CODE", "BLOCKQUOTE", "PRE", "HR", "UL", "OL", "LI", + "TABLE", "THEAD", "TBODY", "TR", "TH", "TD", +]); + +const SAFE_ATTRS = new Set(["alt", "title", "align", "start"]); +const URL_PREFIXES = ["http://", "https://", "mailto:"]; +const IMG_PREFIXES = ["http://", "https://", "data:image/"]; + +/** + * Parses a raw HTML string, renders it into the target DOM node, + * and sanitizes the resulting elements in-place to prevent XSS. + * + * @param targetNode - The DOM element that will be mutated/updated. + * @param rawHtml - The un-sanitized HTML string (usually from marked.parse). + * @param highlighter - Optional function to apply syntax highlighting to blocks. + */ +export function renderSafeHTML( + targetNode: HTMLElement, + rawHtml: string, + highlighter?: Highlighter, +): void | Promise { + const doc = getParser().parseFromString(rawHtml, "text/html"); + const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT); + + const nodesToEscape: Element[] = []; + const blocksToHighlight: { el: Element; lang: string }[] = []; + const codeElsToDecorate: Element[] = []; + const pendingHighlights: Promise[] = []; + + let node = walker.nextNode() as Element; + while (node) { + const tagName = node.tagName.toUpperCase(); + + if (!ALLOWED_TAGS.has(tagName)) { + nodesToEscape.push(node); + } else { + const isCodeBlock = tagName === "CODE" && node.parentElement?.tagName === "PRE"; + const codeLanguage = isCodeBlock ? extractCodeLanguage(node) : null; + + if (isCodeBlock && highlighter) { + blocksToHighlight.push({ el: node, lang: codeLanguage ?? "" }); + } + + if (isCodeBlock) { + codeElsToDecorate.push(node); + } + + const attrs = node.getAttributeNames(); + for (const attr of attrs) { + const attrLower = attr.toLowerCase(); + + if (tagName === "A" && attrLower === "href") { + const href = node.getAttribute(attr) || ""; + if (!isSafeUrl(href, URL_PREFIXES)) { + node.removeAttribute(attr); + } + continue; + } + + if (tagName === "IMG" && attrLower === "src") { + const src = node.getAttribute(attr) || ""; + if (!isSafeUrl(src, IMG_PREFIXES)) { + node.removeAttribute(attr); + } + continue; + } + + if (tagName === "CODE" && attrLower === "class") { + continue; + } + + if (!SAFE_ATTRS.has(attrLower)) { + node.removeAttribute(attr); + } + } + } + node = walker.nextNode() as Element; + } + + for (const el of nodesToEscape) { + if (!el.parentNode) continue; // Skip if it was already removed by an ancestor + const textNode = document.createTextNode(el.outerHTML); + el.replaceWith(textNode); + } + + for (const { el, lang } of blocksToHighlight) { + const rawCode = el.textContent || ""; + try { + const highlightedHTML = highlighter!(rawCode, lang); + if (isPromiseLike(highlightedHTML)) { + pendingHighlights.push( + highlightedHTML + .then((html) => { + applyHighlightedHTML(el, html); + }) + .catch(() => undefined), + ); + continue; + } + applyHighlightedHTML(el, highlightedHTML); + } catch {} + } + + const commit = () => { + decorateCodeBlocks(codeElsToDecorate); + + targetNode.innerHTML = ""; + while (doc.body.firstChild) { + targetNode.appendChild(doc.body.firstChild); + } + }; + + if (pendingHighlights.length > 0) { + return Promise.all(pendingHighlights).then(commit); + } + + commit(); +} + +function applyHighlightedHTML(el: Element, highlightedHTML: string): void { + if (!highlightedHTML) return; + + // Note: We inject the highlighted HTML directly without a second sanitization + // pass for performance reasons during rapid LLM streaming. + // We operate on the assumption that the provided `highlighter` is + // trusted and does not inject malicious tags. + el.innerHTML = highlightedHTML; +} + +function isPromiseLike(value: T | Promise): value is Promise { + return !!value && typeof value === "object" && "then" in value && typeof value.then === "function"; +} + +function decorateCodeBlocks(codeEls: Element[]): void { + for (const codeEl of codeEls) { + const pre = codeEl.parentElement; + if (!pre || pre.tagName !== "PRE" || pre.parentElement?.classList.contains("mur-code-block")) continue; + + const language = extractCodeLanguage(codeEl); + + const wrapper = codeEl.ownerDocument.createElement("div"); + wrapper.className = "mur-code-block"; + + const header = codeEl.ownerDocument.createElement("div"); + header.className = "mur-code-header"; + + if (language !== null) { + const label = codeEl.ownerDocument.createElement("span"); + label.className = "mur-code-language"; + label.textContent = language; + header.appendChild(label); + } + + const button = codeEl.ownerDocument.createElement("button"); + button.className = "mur-code-copy-btn"; + button.type = "button"; + button.title = "Copy code"; + button.setAttribute("aria-label", "Copy code"); + button.innerHTML = ICON_COPY; + header.appendChild(button); + + pre.replaceWith(wrapper); + wrapper.append(header, pre); + } +} + +function extractCodeLanguage(codeEl: Element): string | null { + const match = codeEl.getAttribute("class")?.match(/(?:^|\s)language-([a-zA-Z0-9+-]+)/); + return match?.[1] ?? null; +} + +// Validates URLs against an explicit whitelist of safe prefixes. +function isSafeUrl(url: string, allowedPrefixes: string[]): boolean { + const prefix = url.substring(0, 30).trimStart().toLowerCase(); + + for (const p of allowedPrefixes) { + if (prefix.startsWith(p)) return true; + } + + return false; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/icons.ts b/crates/promptforge-wb-server/ui/src/chat/utils/icons.ts new file mode 100644 index 00000000..9d887950 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/utils/icons.ts @@ -0,0 +1,11 @@ +export const ICON_COPY = ``; +export const ICON_CHECK = ``; +export const ICON_EDIT = ``; +export const ICON_SETTINGS = ``; +export const ICON_PAPERCLIP = ``; +export const ICON_CHEVRON = ``; +export const ICON_MORE_HORIZONTAL = ``; +export const ICON_MORE_VERTICAL = ``; +export const ICON_PIN = ``; +export const ICON_PIN_OFF = ``; +export const ICON_TRASH = ``; diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/sse.ts b/crates/promptforge-wb-server/ui/src/chat/utils/sse.ts new file mode 100644 index 00000000..a20d1d5b --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/utils/sse.ts @@ -0,0 +1,122 @@ +const MAX_EVENT_SIZE = 1024 * 1024; + +/** + * Parses a Server-Sent Events (SSE) stream from a fetch Response. + * * NOTE: This is a specialized parser tailored for LLM streaming. + * It intentionally ignores standard SSE fields such as `event:`, `id:`, + * and `retry:`. It strictly extracts and concatenates `data:` fields. + * + * @param response The Response object from `fetch()` + * @param onMessage Callback fired for every payload. + * Return `true` from the callback to cancel the stream. + */ +export async function parseSSE(response: Response, onMessage: (data: string) => boolean | undefined): Promise { + if (!response.body) throw new Error("No response body"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (value) { + buffer += decoder.decode(value, { stream: true }); + } + + if (done) { + buffer += decoder.decode(); + } + + if (buffer.length > MAX_EVENT_SIZE) { + throw new Error("SSE parse error: event buffer exceeded 1MB limit."); + } + + while (true) { + const nIdx = buffer.indexOf("\n\n"); + const rIdx = buffer.indexOf("\r\n\r\n"); + + let boundaryIdx = -1; + let skipChars = 0; + + if (nIdx !== -1 && (rIdx === -1 || nIdx < rIdx)) { + boundaryIdx = nIdx; + skipChars = 2; + } else if (rIdx !== -1) { + boundaryIdx = rIdx; + skipChars = 4; + } + + if (boundaryIdx === -1) break; + + const eventStr = buffer.substring(0, boundaryIdx); + buffer = buffer.substring(boundaryIdx + skipChars); + + if (eventStr.length > 0) { + const data = parseEventData(eventStr); + // Strictly check against null; empty string is a valid event payload. + if (data !== null) { + if (onMessage(data)) { + await reader.cancel(); + return; + } + } + } + } + + if (done) break; + } + + if (buffer.length > 0) { + const data = parseEventData(buffer); + if (data !== null) onMessage(data); + } + } catch (error) { + // Tear down the connection on the error path too; releaseLock() alone + // leaves the HTTP response streaming until the server closes it. + try { + await reader.cancel(); + } catch { + // Surfacing the original error matters more. + } + throw error; + } finally { + reader.releaseLock(); + } +} + +function parseEventData(eventStr: string): string | null { + let data: string | null = null; + let start = 0; + + while (start < eventStr.length) { + let end = eventStr.indexOf("\n", start); + if (end === -1) end = eventStr.length; + + let line = eventStr.substring(start, end); + + // Handle \r\n endings safely + if (line.endsWith("\r")) { + line = line.substring(0, line.length - 1); + } + + if (line.startsWith("data:")) { + let val = line.substring(5); + // The SSE standard dictates stripping exactly ONE leading space if present. + if (val.startsWith(" ")) { + val = val.substring(1); + } + + if (data === null) { + data = val; + } else { + data += "\n" + val; + } + } + + start = end + 1; + } + + return data; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/uuid.ts b/crates/promptforge-wb-server/ui/src/chat/utils/uuid.ts new file mode 100644 index 00000000..604b9a25 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/utils/uuid.ts @@ -0,0 +1,26 @@ +/** + * Generates a UUIDv7 (Time-ordered). + */ +export function uuidv7(): string { + // 1. 48-bit timestamp in milliseconds (12 hex chars) + const timeHex = Date.now().toString(16).padStart(12, "0"); + + // 2. We need 10 more random bytes (80 bits) + const bytes = new Uint8Array(10); + crypto.getRandomValues(bytes); + + // 3. Byte 0: Version indicator (4 bits, value 7) + 4 random bits + const g3 = (0x70 | (bytes[0] & 0x0f)).toString(16).padStart(2, "0") + bytes[1].toString(16).padStart(2, "0"); + + // 4. Byte 2: Variant indicator (2 bits, value 10 binary) + 6 random bits + const g4 = (0x80 | (bytes[2] & 0x3f)).toString(16).padStart(2, "0") + bytes[3].toString(16).padStart(2, "0"); + + // 5. Bytes 4-9: 6 bytes of pure randomness (12 hex chars) + let g5 = ""; + for (let i = 4; i < 10; i++) { + g5 += bytes[i].toString(16).padStart(2, "0"); + } + + // 6. Format: 8-4-4-4-12 + return `${timeHex.substring(0, 8)}-${timeHex.substring(8)}-${g3}-${g4}-${g5}`; +} diff --git a/crates/promptforge-wb-server/ui/src/chat/with-css.ts b/crates/promptforge-wb-server/ui/src/chat/with-css.ts new file mode 100644 index 00000000..75e2e9b6 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/chat/with-css.ts @@ -0,0 +1,7 @@ +import "./styles/base.css"; +import "./styles/sidebar.css"; +import "./styles/input.css"; +import "./styles/feed.css"; +import "./styles/dropdown.css"; + +export * from "./index"; diff --git a/crates/promptforge-wb-server/ui/src/main.ts b/crates/promptforge-wb-server/ui/src/main.ts new file mode 100644 index 00000000..fa9c173e --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/main.ts @@ -0,0 +1,181 @@ +// murm-ui's own styles, bundled by esbuild into dist/app.css. Sidebar and +// dropdown styles are skipped: the workbench disables the murm sidebar and +// no plugin renders dropdowns. +import "./chat/styles/base.css"; +import "./chat/styles/feed.css"; +import "./chat/styles/input.css"; +import "dockview/dist/styles/dockview.css"; + +import { createDockview, themeDark } from "dockview"; +import type { IContentRenderer } from "dockview"; + +import type { ChatPlugin } from "./chat/core/types"; +import { ChatUI } from "./chat/main"; +import { MemoryStorage } from "./memory-storage"; +import { StatusBar } from "./status-bar"; +import { setupVoice, type VoiceHandle } from "./voice"; +import { WorkbenchProvider } from "./workbench-provider"; +import { type CatalogModel, WorkbenchSocket } from "./workbench-socket"; + +const pickerEl = document.getElementById("model-picker") as HTMLSelectElement; +const descriptionEl = document.getElementById("model-description") as HTMLDivElement; + +// One persistent socket carries chat frames upstream and every downstream +// JSON frame - chat replies and the observer's status updates, which the +// status bar renders as they arrive. +const statusBarRoot = document.querySelector(".status-bar") as HTMLElement | null; +if (!statusBarRoot) { + throw new Error("DOM Error: .status-bar not found in the page."); +} +const statusBar = new StatusBar(statusBarRoot); +const workbenchSocket = new WorkbenchSocket(); +workbenchSocket.onStatus((frame) => statusBar.render(frame)); +// A dropped socket means every in-flight status is stale; the bar returns +// to its reconnecting state until the observer speaks again. +workbenchSocket.onDisconnect(() => statusBar.reset()); +workbenchSocket.connect(); + +function selectedModel(): string { + return pickerEl.value; +} + +// The mic button joins murm-ui's composer through the plugin seam; the +// voice status message sits below the form. +let voiceHandle: VoiceHandle | null = null; +const voicePlugin: ChatPlugin = { + name: "voice", + onInputMount({ container, form, input }) { + const mic = document.createElement("button"); + mic.type = "button"; + mic.className = "voice-mic mur-form-icon-btn"; + mic.title = "Push to talk"; + mic.setAttribute("aria-label", "Push to talk"); + mic.setAttribute("aria-pressed", "false"); + mic.innerHTML = + ''; + form.insertBefore(mic, form.querySelector(".mur-form-footer-right")); + + const formContainer = container.querySelector(".mur-chat-form-container"); + if (!formContainer) { + throw new Error("DOM Error: .mur-chat-form-container not found inside the container."); + } + const status = document.createElement("div"); + status.className = "voice-status"; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + formContainer.appendChild(status); + + voiceHandle = setupVoice({ mic, status, input }, statusBar); + }, + onUserSubmit() { + voiceHandle?.discardIfRecording(); + }, + // With no model selected there is nothing to send to; the old UI disabled + // the send button in the same situation. + isSubmitBlocked: () => !selectedModel(), +}; + +// The chat lives in dockview's single panel; the panel infrastructure is +// what later stages hang the file tree and editor panes on. The tab bar is +// hidden in style.css: with exactly one panel it is chrome, not information. +class ChatPanel implements IContentRenderer { + readonly element = document.createElement("div"); + + constructor() { + this.element.className = "chat-panel"; + } + + init(): void { + const template = document.getElementById("chat-panel") as HTMLTemplateElement; + this.element.appendChild(template.content.cloneNode(true)); + } +} + +const dockEl = document.getElementById("dock") as HTMLDivElement; +const dock = createDockview(dockEl, { + createComponent: () => new ChatPanel(), + theme: themeDark, + singleTabMode: "fullwidth", + disableFloatingGroups: true, + hideBorders: true, + locked: true, + noPanelsOverlay: "emptyGroup", +}); +dock.addPanel({ id: "chat", component: "chat", title: "Chat" }); + +const chatContainer = dockEl.querySelector(".mur-app"); +if (!chatContainer) { + throw new Error("DOM Error: the chat panel did not mount its .mur-app container."); +} + +const chat = new ChatUI({ + container: chatContainer as HTMLElement, + provider: new WorkbenchProvider(workbenchSocket), + storage: new MemoryStorage(), + enableSidebar: false, + routing: false, + fullscreen: false, + plugins: () => [voicePlugin], +}); + +function applyModel(): void { + chat.engine.setRequestDefaults({ options: { model: selectedModel() } }); +} + +function showDescription(): void { + const option = pickerEl.selectedOptions[0]; + descriptionEl.textContent = (option && option.dataset.description) || ""; +} + +// Rebuilds the model picker from a catalog, keeping the user's selection +// when it survives the refresh. Used by the boot fetch and by the pushed +// catalogs the server sends when the gateway comes back. +function renderModels(entries: CatalogModel[]): void { + const previous = pickerEl.value; + pickerEl.textContent = ""; + if (entries.length === 0) { + pickerEl.appendChild(new Option("No models available", "")); + pickerEl.disabled = true; + return; + } + for (const entry of entries) { + const option = new Option(entry.id, entry.id); + option.dataset.description = entry.description || ""; + pickerEl.appendChild(option); + } + if (entries.some((entry) => entry.id === previous)) { + pickerEl.value = previous; + } + pickerEl.disabled = false; + descriptionEl.classList.remove("sidebar__model-description--error"); + showDescription(); + applyModel(); +} + +async function loadModels(): Promise { + try { + const response = await fetch("/v1/models"); + if (!response.ok) { + throw new Error(`GET /v1/models answered ${response.status}`); + } + const catalog = (await response.json()) as { data?: CatalogModel[] }; + renderModels(Array.isArray(catalog.data) ? catalog.data : []); + } catch (error) { + pickerEl.textContent = ""; + pickerEl.appendChild(new Option("Model catalog unavailable", "")); + pickerEl.disabled = true; + descriptionEl.textContent = `Could not load the model catalog: ${(error as Error).message}`; + descriptionEl.classList.add("sidebar__model-description--error"); + } +} + +// A pushed catalog means the gateway returned after an outage; refresh the +// picker in place so a boot-time "Model catalog unavailable" heals itself. +workbenchSocket.onModels(renderModels); + +pickerEl.addEventListener("change", () => { + showDescription(); + applyModel(); +}); + +void loadModels(); diff --git a/crates/promptforge-wb-server/ui/src/memory-storage.ts b/crates/promptforge-wb-server/ui/src/memory-storage.ts new file mode 100644 index 00000000..09f5e001 --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/memory-storage.ts @@ -0,0 +1,41 @@ +import type { + ChatSession, + ChatSessionMeta, + ChatStorage, + PaginatedSessions, +} from "./chat/core/types"; + +/** + * ChatStorage backed by a page-local Map: sessions work within the page's + * lifetime and vanish on reload, matching the pre-migration UI whose history + * was a page-local array. The server-side JSONL tape remains the durable + * record of every exchange. + */ +export class MemoryStorage implements ChatStorage { + private sessions = new Map(); + + loadSessions(): Promise { + const items: ChatSessionMeta[] = [...this.sessions.values()] + .map((session) => ({ + id: session.id, + title: session.title, + updatedAt: session.updatedAt, + })) + .sort((a, b) => b.updatedAt - a.updatedAt); + return Promise.resolve({ items, hasMore: false }); + } + + loadOne(id: string): Promise { + return Promise.resolve(this.sessions.get(id) ?? null); + } + + save(session: ChatSession): Promise { + this.sessions.set(session.id, session); + return Promise.resolve(); + } + + delete(id: string): Promise { + this.sessions.delete(id); + return Promise.resolve(); + } +} diff --git a/crates/promptforge-wb-server/ui/src/status-bar.ts b/crates/promptforge-wb-server/ui/src/status-bar.ts new file mode 100644 index 00000000..e3e5c61b --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/status-bar.ts @@ -0,0 +1,138 @@ +// The status bar renderer: consumes the observer's status frames off the +// persistent socket and paints them into the bar. Info and error frames set +// the text (the description rides as the tooltip) and drive the right slot, +// which holds the progress bar or the activity LED - never both. Debug +// frames are internal instrumentation: they never touch the text or the +// slot, but they do pulse the LED. + +import type { StatusFrame } from "./workbench-socket"; + +type PulseActivity = "thinking" | "generating"; + +// Used when the stylesheet's --led-pulse-ms cannot be read (jsdom, or a +// skin that dropped the variable). +const DEFAULT_LED_PULSE_MS = 250; + +export class StatusBar { + private readonly text: HTMLElement; + private readonly progress: HTMLProgressElement; + private readonly led: HTMLElement; + private readonly rec: HTMLElement; + private readonly lit = new Set(); + private sustained: PulseActivity | null = null; + private ledTimer: ReturnType | null = null; + + constructor(private readonly root: HTMLElement) { + const text = root.querySelector(".status-bar__text"); + const progress = root.querySelector(".status-bar__progress"); + const led = root.querySelector(".status-bar__led"); + const rec = root.querySelector(".status-bar__rec"); + if (!text || !progress || !led || !rec) { + throw new Error( + "DOM Error: the status bar is missing its text, progress, LED, or REC element.", + ); + } + this.text = text; + this.progress = progress; + this.led = led; + this.rec = rec; + } + + /** Paints one observer update. Debug frames pulse the LED only. */ + render(frame: StatusFrame): void { + if (frame.activity === "thinking" || frame.activity === "generating") { + this.pulse(frame.activity); + } + if (frame.severity === "debug") { + return; + } + // Info/error frames set or clear the sustained LED state. Thinking + // keeps the amber LED lit until something else takes over; any other + // activity clears it so the LED returns to idle after the pulse decays. + this.sustained = frame.activity === "thinking" ? "thinking" : null; + this.text.textContent = frame.label; + this.root.title = frame.description; + this.text.classList.toggle("status-bar__text--error", frame.severity === "error"); + this.renderSlot(frame.progress); + } + + /** + * Swaps the slot between the progress bar and the LED. Progress wins: a + * frame carrying progress shows the bar at that fraction and hides the + * LED; a null progress restores the LED. The swap rides the `hidden` + * attribute, so the slot's fixed width keeps the bar from reflowing. + */ + private renderSlot(progress: StatusFrame["progress"]): void { + if (progress) { + // A zero total is degenerate; clamp so value/max stay valid. + this.progress.max = progress.total > 0 ? progress.total : 1; + this.progress.value = progress.current; + this.progress.hidden = false; + this.led.hidden = true; + } else { + this.progress.hidden = true; + this.led.hidden = false; + } + } + + /** + * Lights the LED for one pulse window. JS only toggles a modifier class; + * the glow and its fades are pure CSS (the modifier's transition is a + * fast fade-in, the idle rule's transition is the ~--led-pulse-ms + * ease-out decay). One shared hold timer: any pulse re-arms the window, + * so a stream of pulses reads as one continuous glow that fades when the + * activity stops. + */ + private pulse(activity: PulseActivity): void { + this.lit.add(activity); + this.applyLed(); + if (this.ledTimer !== null) { + clearTimeout(this.ledTimer); + } + this.ledTimer = setTimeout(() => { + this.lit.clear(); + if (this.sustained) this.lit.add(this.sustained); + this.applyLed(); + this.ledTimer = null; + }, this.pulseMs()); + } + + /** Lights or dims the REC badge with the mic's recording state. */ + setRecording(on: boolean): void { + this.rec.classList.toggle("status-bar__rec--active", on); + } + + /** + * Returns the bar to its reconnecting state after the persistent socket + * drops: neutral text, no tooltip, no error styling, and the LED back in + * the slot. + */ + reset(): void { + this.sustained = null; + this.text.textContent = "Reconnecting..."; + this.root.title = ""; + this.text.classList.remove("status-bar__text--error"); + this.renderSlot(null); + } + + /** Applies the lit set: green wins while generating and thinking coincide. */ + private applyLed(): void { + const generating = this.lit.has("generating"); + this.led.classList.toggle("status-bar__led--generating", generating); + this.led.classList.toggle( + "status-bar__led--thinking", + !generating && this.lit.has("thinking"), + ); + } + + /** The hold window, tunable from the stylesheet as --led-pulse-ms. */ + private pulseMs(): number { + const raw = getComputedStyle(this.led).getPropertyValue("--led-pulse-ms").trim(); + const match = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(raw); + if (!match) { + return DEFAULT_LED_PULSE_MS; + } + const value = Number.parseFloat(match[1]); + return match[2] === "s" ? value * 1000 : value; + } +} diff --git a/crates/promptforge-wb-server/ui/src/voice.ts b/crates/promptforge-wb-server/ui/src/voice.ts new file mode 100644 index 00000000..487344bc --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/voice.ts @@ -0,0 +1,300 @@ +// Push-to-talk voice capture over the /voice WebSocket: binary f32 PCM at +// 16 kHz mono in, "start"/"stop" control words, and JSON text frames out. +// Dictation behaves like typing at the cursor: each take captures the +// selection at record start, splices committed+tentative into that range, +// and sets readOnly so the user cannot disturb the insertion geometry. +// A `final` frame replaces the inserted region with polished text and +// releases readOnly; consecutive takes compose because the cursor position +// is captured fresh each time. + +import type { StatusBar } from "./status-bar"; + +export interface VoiceElements { + mic: HTMLButtonElement; + status: HTMLDivElement; + input: HTMLTextAreaElement; +} + +export interface VoiceHandle { + discardIfRecording(): void; +} + +interface VoiceSession { + ws: WebSocket; + ctx: AudioContext; + source: MediaStreamAudioSourceNode; + node: AudioWorkletNode; + stream: MediaStream; +} + +interface TakeState { + prefix: string; + suffix: string; +} + +export function setupVoice(elements: VoiceElements, statusBar: StatusBar): VoiceHandle { + const { mic, status, input } = elements; + let voice: VoiceSession | null = null; + let voiceStatusTimer = 0; + let suppressReplies = false; + let take: TakeState | null = null; + + function showVoiceStatus(text: string, isError: boolean): void { + status.textContent = text; + status.classList.toggle("voice-status--error", Boolean(isError)); + status.classList.add("voice-status--visible"); + clearTimeout(voiceStatusTimer); + voiceStatusTimer = window.setTimeout(() => { + status.classList.remove("voice-status--visible"); + }, 8000); + } + + function setRecording(next: boolean): void { + mic.classList.toggle("voice-mic--recording", next); + mic.setAttribute("aria-pressed", String(next)); + mic.title = next ? "Stop recording" : "Push to talk"; + } + + // Programmatic value sets don't fire the textarea's "input" event, which + // is what murm-ui's Input listens to for growing the composer and + // re-enabling submit. Every voice-driven rewrite goes through it so the + // canonical resizer runs; a local inline-height resizer would pin an + // explicit height and disable the CSS field-sizing the app relies on. + function notifyInput(): void { + input.dispatchEvent(new Event("input", { bubbles: true })); + } + + function spliceValue(text: string): void { + if (!take) return; + input.value = take.prefix + text + take.suffix; + const cursorPos = take.prefix.length + text.length; + input.setSelectionRange(cursorPos, cursorPos); + notifyInput(); + } + + // Tears down a session's audio half. The socket half is closed by the + // caller, after any in-flight "stop" reply has had a chance to arrive. + function releaseAudio(session: VoiceSession): void { + session.node.port.onmessage = null; + session.source.disconnect(); + session.node.disconnect(); + for (const track of session.stream.getTracks()) { + track.stop(); + } + // Best effort: a failed close leaves nothing the page can still act on. + session.ctx.close().catch(() => {}); + } + + function finishTake(finalText: string): void { + if (!take) return; + input.value = take.prefix + finalText + take.suffix; + const cursorPos = take.prefix.length + finalText.length; + input.setSelectionRange(cursorPos, cursorPos); + take = null; + input.readOnly = false; + input.classList.remove("mur-chat-input--recording"); + notifyInput(); + } + + function discardTake(): void { + if (!take) return; + input.value = take.prefix + take.suffix; + const cursorPos = take.prefix.length; + input.setSelectionRange(cursorPos, cursorPos); + take = null; + input.readOnly = false; + input.classList.remove("mur-chat-input--recording"); + notifyInput(); + } + + // Handles one server text message. Returns true when the take is over and + // the socket should close. + function handleVoiceMessage(data: unknown): boolean { + if (suppressReplies) return true; + if (typeof data !== "string") { + return true; + } + let msg: { + type?: unknown; + text?: unknown; + committed?: unknown; + tentative?: unknown; + frames?: unknown; + } | null; + try { + msg = JSON.parse(data) as typeof msg; + } catch { + msg = null; + } + if (msg && msg.type === "interim") { + const committed = typeof msg.committed === "string" ? msg.committed : ""; + const tentative = typeof msg.tentative === "string" ? msg.tentative : ""; + const gap = committed !== "" && tentative !== "" && !/\s$/.test(committed) ? " " : ""; + spliceValue(committed + gap + tentative); + return false; + } + if (msg && msg.type === "final") { + const raw = typeof msg.text === "string" ? msg.text : ""; + const text = raw.trimEnd(); + if (text !== "") { + finishTake(text); + input.focus(); + showVoiceStatus("Transcript ready - edit, then send.", false); + } else { + finishTake(""); + const frames = typeof msg.frames === "number" ? msg.frames : 0; + showVoiceStatus(`No speech detected (${frames} PCM frames captured).`, false); + } + return true; + } + // Anything else is shown verbatim and ends the take. + finishTake(""); + showVoiceStatus(String(data), false); + return true; + } + + function beginTake(): void { + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + const value = input.value; + take = { + prefix: value.slice(0, start), + suffix: value.slice(end), + }; + input.readOnly = true; + input.classList.add("mur-chat-input--recording"); + } + + async function startVoice(): Promise { + if (!navigator.mediaDevices?.getUserMedia || !window.AudioContext || !window.WebSocket) { + showVoiceStatus("Voice capture is not available in this browser.", true); + return; + } + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: 16000, + echoCancellation: true, + noiseSuppression: true, + }, + }); + } catch (error) { + const detail = + error instanceof Error && error.name === "NotAllowedError" + ? "microphone permission denied" + : `microphone unavailable: ${(error as Error).message || error}`; + showVoiceStatus(detail, true); + return; + } + let ws: WebSocket | undefined; + let ctx: AudioContext | undefined; + try { + ws = new WebSocket( + `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/voice`, + ); + ws.binaryType = "arraybuffer"; + await new Promise((resolve, reject) => { + ws!.addEventListener("open", () => resolve(), { once: true }); + ws!.addEventListener("error", () => reject(new Error("the /voice socket failed to open")), { + once: true, + }); + }); + // The context resamples the mic stream to 16 kHz before the worklet + // sees it, so the wire format is 16 kHz mono f32 on any device. + ctx = new AudioContext({ sampleRate: 16000 }); + await ctx.audioWorklet.addModule("/pcm-worklet.js"); + const source = ctx.createMediaStreamSource(stream); + const node = new AudioWorkletNode(ctx, "pcm-capture"); + const session: VoiceSession = { ws, ctx, source, node, stream }; + suppressReplies = false; + node.port.onmessage = (event) => { + if (voice === session && ws!.readyState === WebSocket.OPEN) { + ws!.send(event.data); + } + }; + ws.addEventListener("message", (event) => { + if (handleVoiceMessage(event.data)) { + ws!.close(); + } + }); + ws.addEventListener("close", () => { + if (voice === session) { + voice = null; + setRecording(false); + statusBar.setRecording(false); + if (take) finishTake(""); + releaseAudio(session); + showVoiceStatus("The voice connection dropped.", true); + } + }); + source.connect(node); + // The worklet renders silence, so reaching the destination is safe and + // keeps the graph pulling on every engine. + node.connect(ctx.destination); + voice = session; + beginTake(); + ws.send("start"); + setRecording(true); + statusBar.setRecording(true); + showVoiceStatus("Recording - press the mic button again to stop.", false); + } catch (error) { + for (const track of stream.getTracks()) { + track.stop(); + } + if (ws) { + ws.close(); + } + if (ctx) { + ctx.close().catch(() => {}); + } + showVoiceStatus(`Voice capture failed: ${(error as Error).message || error}`, true); + } + } + + function stopVoice(): void { + const session = voice; + voice = null; + setRecording(false); + statusBar.setRecording(false); + if (!session) { + return; + } + releaseAudio(session); + const { ws } = session; + if (ws.readyState === WebSocket.OPEN) { + ws.send("stop"); + // The final whisper pass can take 30+ seconds on CPU; give it time. + // The message listener closes the socket when the final reply arrives. + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }, 120_000); + } + } + + function discardIfRecording(): void { + const session = voice; + if (!session) return; + suppressReplies = true; + voice = null; + releaseAudio(session); + session.ws.close(); + discardTake(); + setRecording(false); + statusBar.setRecording(false); + showVoiceStatus("Recording discarded.", false); + } + + mic.addEventListener("click", () => { + if (voice) { + stopVoice(); + } else { + void startVoice(); + } + }); + + return { discardIfRecording }; +} diff --git a/crates/promptforge-wb-server/ui/src/workbench-provider.ts b/crates/promptforge-wb-server/ui/src/workbench-provider.ts new file mode 100644 index 00000000..1e08620f --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/workbench-provider.ts @@ -0,0 +1,61 @@ +import type { ChatProvider, ChatRequest, Message, StreamEvent } from "./chat/core/types"; +import { uuidv7 } from "./chat/utils/uuid"; +import type { WorkbenchSocket } from "./workbench-socket"; + +/** + * ChatProvider against the workbench's persistent `/ws` socket: each + * generation is one id-tagged chat frame on the shared connection, answered + * by `delta`/`done`/`error` frames carrying that id, while status frames + * bypass the chat entirely. Deliberately has no `generateTitle`: titles + * cost an extra completion per chat and nothing in the workbench UI + * displays them. + */ +export class WorkbenchProvider implements ChatProvider { + constructor(private readonly socket: WorkbenchSocket) {} + + async streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise { + const messageId = uuidv7(); + const textBlockId = uuidv7(); + let started = false; + await this.socket.streamChat( + { + // Submit is blocked in the UI without a model; the empty string is + // the unreachable default for the type. + model: request.options.model ?? "", + messages: formatMessages(request.messages), + }, + (content) => { + if (!started) { + started = true; + onEvent({ + type: "message_start", + message: { id: messageId, role: "assistant", blocks: [] }, + }); + } + onEvent({ type: "text_delta", messageId, blockId: textBlockId, delta: content }); + }, + request.signal, + ); + // An aborted generation is recorded by the engine itself; the provider + // finishes only a reply that ran to its done frame. + if (started && !request.signal.aborted) { + onEvent({ type: "finish", reason: "stop" }); + } + } +} + +// Flattens each message's text blocks into the OpenAI `{role, content}` +// shape; messages with no text (the ephemeral streaming placeholder) are +// dropped. +function formatMessages(messages: readonly Message[]): Array<{ role: string; content: string }> { + const formatted: Array<{ role: string; content: string }> = []; + for (const message of messages) { + const text = message.blocks + .filter((block) => block.type === "text") + .map((block) => (block as { text: string }).text) + .join("\n\n"); + if (text === "") continue; + formatted.push({ role: message.role, content: text }); + } + return formatted; +} diff --git a/crates/promptforge-wb-server/ui/src/workbench-socket.ts b/crates/promptforge-wb-server/ui/src/workbench-socket.ts new file mode 100644 index 00000000..05f48d0c --- /dev/null +++ b/crates/promptforge-wb-server/ui/src/workbench-socket.ts @@ -0,0 +1,289 @@ +// The persistent workbench socket: one WebSocket to /ws carries every +// downstream JSON frame - chat replies for in-flight generations and +// unsolicited status updates from the server's observer. Chat requests are +// multiplexed by an incrementing id the server echoes on that chat's +// delta/done/error frames; the UI runs one chat at a time, so the pending +// map holds at most one entry in practice. + +/** One observer status update, as sent by the server. */ +export interface StatusFrame { + type: "status"; + label: string; + description: string; + severity: "info" | "debug" | "error"; + activity: "general" | "thinking" | "generating"; + progress: { current: number; total: number } | null; +} + +/** One entry of the gateway's model catalog, as fetched or pushed. */ +export interface CatalogModel { + id: string; + description?: string; +} + +/** A pushed model catalog, sent when the gateway comes back after an outage. */ +export interface ModelsFrame { + type: "models"; + models: CatalogModel[]; +} + +/** The chat payload sent upstream in one `{"type":"chat",...}` frame. */ +export interface ChatPayload { + model: string; + messages: Array<{ role: string; content: string }>; +} + +interface PendingChat { + onDelta: (content: string) => void; + resolve: () => void; + reject: (error: Error) => void; + started: boolean; + settled: boolean; +} + +interface ServerFrame { + type?: unknown; + id?: unknown; + content?: unknown; + message?: unknown; + models?: unknown; +} + +function defaultUrl(): string { + return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`; +} + +// Reconnect backoff: the first retry waits a second, each failure doubles +// it, and the cap keeps a down server from pushing the wait past 30 s. +const RECONNECT_INITIAL_MS = 1000; +const RECONNECT_MAX_MS = 30_000; + +export class WorkbenchSocket { + private socket: WebSocket | null = null; + private opening: { socket: WebSocket; promise: Promise } | null = null; + private nextId = 1; + private reconnectDelayMs = RECONNECT_INITIAL_MS; + private reconnectTimer: ReturnType | null = null; + private readonly pending = new Map(); + private readonly statusHandlers = new Set<(frame: StatusFrame) => void>(); + private readonly modelsHandlers = new Set<(models: CatalogModel[]) => void>(); + private readonly disconnectHandlers = new Set<() => void>(); + + constructor(private readonly url: string = defaultUrl()) {} + + /** Opens the socket unless it is already open or opening. */ + connect(): void { + // A failed open is ignored here: `onerror` has already reset the state, + // and the next `streamChat` retries through `ensureOpen`. + void this.ensureOpen().catch(() => {}); + } + + /** Registers a handler for unsolicited status frames. */ + onStatus(handler: (frame: StatusFrame) => void): void { + this.statusHandlers.add(handler); + } + + /** Registers a handler for pushed model catalogs. */ + onModels(handler: (models: CatalogModel[]) => void): void { + this.modelsHandlers.add(handler); + } + + /** Registers a handler fired when the socket disconnects. */ + onDisconnect(handler: () => void): void { + this.disconnectHandlers.add(handler); + } + + /** + * Sends one id-tagged chat frame and resolves when its `done` frame + * arrives. Rejects on an `error` frame, or on a socket close before any + * content streamed; a close after content started resolves, mirroring an + * SSE body that ends early. Aborting the signal detaches the chat and + * recycles the socket, which is what makes the server drop the orphaned + * gateway stream. + */ + async streamChat( + payload: ChatPayload, + onDelta: (content: string) => void, + signal: AbortSignal, + ): Promise { + await this.ensureOpen(); + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("the workbench socket is not open"); + } + const id = this.nextId++; + await new Promise((resolve, reject) => { + const onAbort = (): void => { + if (!this.pending.has(id)) return; + this.settle(id, (chat) => chat.resolve()); + this.reopen(); + }; + const finish = (): void => signal.removeEventListener("abort", onAbort); + this.pending.set(id, { + onDelta, + resolve: () => { + finish(); + resolve(); + }, + reject: (error: Error) => { + finish(); + reject(error); + }, + started: false, + settled: false, + }); + signal.addEventListener("abort", onAbort, { once: true }); + try { + socket.send(JSON.stringify({ type: "chat", id, ...payload })); + } catch (error) { + this.settle(id, (chat) => + chat.reject(error instanceof Error ? error : new Error(String(error))), + ); + } + }); + } + + private ensureOpen(): Promise { + if (this.socket?.readyState === WebSocket.OPEN) { + return Promise.resolve(); + } + if (this.opening) { + return this.opening.promise; + } + const socket = new WebSocket(this.url); + this.socket = socket; + const entry = { socket, promise: Promise.resolve() }; + entry.promise = new Promise((resolve, reject) => { + socket.onopen = () => { + if (this.opening === entry) this.opening = null; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.reconnectDelayMs = RECONNECT_INITIAL_MS; + resolve(); + }; + // A failure while opening rejects the waiters; a failure on an + // established socket is followed by close, which settles pendings. + socket.onerror = () => { + if (this.socket === socket) this.socket = null; + if (this.opening === entry) this.opening = null; + reject(new Error("the workbench socket failed to open")); + }; + }); + this.opening = entry; + socket.onmessage = (event: MessageEvent) => this.route(event); + socket.onclose = () => { + if (this.socket === socket) this.socket = null; + if (this.opening === entry) this.opening = null; + this.settleAll(); + for (const handler of this.disconnectHandlers) { + handler(); + } + this.scheduleReconnect(); + }; + return entry.promise; + } + + /** + * Schedules the next reconnect attempt with exponential backoff. One + * timer at a time: a close while an attempt is already waiting does not + * stack a second. + */ + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) { + return; + } + const delay = this.reconnectDelayMs; + this.reconnectDelayMs = Math.min(delay * 2, RECONNECT_MAX_MS); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + // A failed attempt ends in onclose, which schedules the next one. + void this.ensureOpen().catch(() => {}); + }, delay); + } + + /** Closes the current socket and opens a fresh one. */ + private reopen(): void { + const socket = this.socket; + if (socket) { + // An intentional recycle, not a dropout: skip the disconnect + // handlers and the reconnect backoff for this close. + socket.onclose = null; + socket.close(); + } + // Same contract as `connect`: a failed reopen is retried by the next + // `streamChat`. + void this.ensureOpen().catch(() => {}); + } + + private route(event: MessageEvent): void { + let frame: ServerFrame; + try { + frame = JSON.parse(String(event.data)) as ServerFrame; + } catch { + // A non-JSON frame carries no chat or status event; keep reading. + return; + } + if (frame.type === "status") { + const status = frame as unknown as StatusFrame; + for (const handler of this.statusHandlers) { + handler(status); + } + return; + } + if (frame.type === "models") { + const models = Array.isArray(frame.models) ? (frame.models as CatalogModel[]) : []; + for (const handler of this.modelsHandlers) { + handler(models); + } + return; + } + if (typeof frame.id !== "number") return; + const chat = this.pending.get(frame.id); + // A reply for a detached (aborted) chat is dropped. + if (!chat) return; + if (frame.type === "delta" && typeof frame.content === "string" && frame.content !== "") { + chat.started = true; + chat.onDelta(frame.content); + return; + } + if (frame.type === "done") { + this.settle(frame.id, (c) => c.resolve()); + return; + } + if (frame.type === "error") { + this.settle(frame.id, (c) => + c.reject( + new Error( + typeof frame.message === "string" && frame.message !== "" + ? frame.message + : "the chat stream failed", + ), + ), + ); + } + } + + /** Settles one pending chat exactly once and drops it from the map. */ + private settle(id: number, fn: (chat: PendingChat) => void): void { + const chat = this.pending.get(id); + if (!chat || chat.settled) return; + chat.settled = true; + this.pending.delete(id); + fn(chat); + } + + /** Settles every pending chat after the socket closed under it. */ + private settleAll(): void { + for (const id of [...this.pending.keys()]) { + this.settle(id, (chat) => { + if (chat.started) { + chat.resolve(); + } else { + chat.reject(new Error("the workbench socket closed before the reply completed")); + } + }); + } + } +} diff --git a/crates/promptforge-wb-server/ui/style.css b/crates/promptforge-wb-server/ui/style.css index 74a89035..2ea125b8 100644 --- a/crates/promptforge-wb-server/ui/style.css +++ b/crates/promptforge-wb-server/ui/style.css @@ -1,323 +1,527 @@ +/* ========================================================================== + PromptForge workbench skin + + Every visual value the workbench owns is a CSS custom property in the + :root block below: palette, type, spacing, radius, and the status bar's + progress and LED effect. Reskinning the UI means editing this one block + (or overriding it from an additional stylesheet loaded after this one); + no rule below the block hardcodes a color or a themed length. Every + var() use carries a fallback, so deleting a variable degrades to the + stock skin instead of breaking the property. + + The murm-ui bridge (the .mur-app block after :root) maps the vendored + chat UI's --mur-* variables onto the workbench variables, so the chat + panel skins from the same block. It cannot live inside :root: murm-ui + declares its dark-theme variables on .mur-app[data-theme="dark"] itself, + and a custom property set on the element beats anything inherited from + :root. The bridge therefore repeats that selector; style.css loads after + the bundled app.css, so these declarations win the tie. + ========================================================================== */ + :root { - --bg: #0d0e12; - --bg-raised: #14161c; - --bg-hover: #1a1d25; + /* Surfaces */ + --bg: #0d0e12; /* window background, chat background */ + --bg-raised: #14161c; /* raised surfaces: sidebar, status bar, cards */ + --bg-hover: #1a1d25; /* hover washes and user message bubbles */ + --bg-sidebar: var(--bg-raised, #14161c); + --bg-composer: var(--bg, #0d0e12); /* the chat composer form */ + + /* Text and borders */ + --text: #d6d9e0; /* 13:1 on --bg */ + --text-muted: #8b90a0; /* 6.0:1 on --bg, the dimmest legal body text */ --border: #262a33; - --text: #d6d9e0; - --text-dim: #8b90a0; - --accent: #7c7fd4; - --accent-dim: #5658a0; - --danger: #b0606a; + + /* Accent and semantics */ + --accent: #7c7fd4; /* primary action (send button) */ + --accent-dim: #5658a0; /* focus borders */ + --danger: #b0606a; /* recording background, non-text danger accents */ + --danger-text: #cf7f88; /* danger lightened past 4.5:1 for text on --bg */ + --on-danger: #ffffff; /* icon or text on a --danger fill (recording mic) */ + --hover-glow: var(--accent, #5b9cf5); /* ring and bloom on hover */ + + /* Type */ --font-prose: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - --font-mono: ui-monospace, "Cascadia Code", Consolas, "Courier New", monospace; + --code-font: ui-monospace, "Cascadia Code", Consolas, "Courier New", monospace; + + /* Spacing scale and radius (shell chrome) */ + --space-xs: 4px; + --space-sm: 6px; + --space-md: 8px; + --space-lg: 12px; + --space-xl: 16px; + --radius: 6px; + + /* Sidebar */ + --sidebar-width: 220px; + + /* Status bar */ + --status-bar-height: 28px; + --status-bar-bg: var(--bg-raised, #14161c); + --status-bar-text: var(--text-muted, #8b90a0); + --status-bar-text-error: var(--danger-text, #cf7f88); + --status-bar-padding-inline: var(--space-lg, 12px); + --status-bar-gap: var(--space-lg, 12px); + + /* Status bar progress bar */ + --progress-width: 96px; + --progress-height: 6px; + --progress-fill: #4caf7d; + --progress-track: rgba(255, 255, 255, 0.08); + --progress-glow: 4px; /* blur radius of the fill's box-shadow glow */ + + /* Status bar activity LED */ + --led-size: 10px; + --led-green: #4caf7d; /* generating activity */ + --led-amber: #d9a03f; /* thinking activity */ + --led-off: rgba(255, 255, 255, 0.08); /* the unlit lens */ + --led-core: #ffffff; /* hot center of the lit gradient */ + --led-glow-radius: 6px; /* base blur of the layered bloom */ + --led-pulse-ms: 250ms; /* hold window and ease-out decay; read by JS */ + --led-fade-in-ms: 60ms; /* fast ease-in when a pulse lights the LED */ + --led-lens-highlight: rgba(255, 255, 255, 0.18); + --led-lens-shadow: rgba(0, 0, 0, 0.45); + + /* Status bar REC badge */ + --rec-idle: #552222; + --rec-active: #ff0000; + + /* Scrollbars (applied globally below) */ + --scrollbar-width: 8px; /* thin; also the thumb's rounding diameter */ + --scrollbar-thumb: rgba(255, 255, 255, 0.16); /* translucent on any surface */ + --scrollbar-thumb-hover: rgba(255, 255, 255, 0.28); +} + +/* -------------------------------------------------------------------------- + murm-ui skinning bridge. The vendored chat UI themes itself from --mur-* + variables (ui/src/chat/styles/base.css); mapping them here keeps the + whole UI skinned from the :root block above. Workbench var on the right, + murm-ui var on the left: + + --mur-bg <- --bg chat background + --mur-surface <- --bg-raised code blocks, cards + --mur-surface-user <- --bg-hover user message bubble + --mur-hover-bg <- --bg-hover hover washes + --mur-text <- --text + --mur-text-secondary <- --text + --mur-text-muted <- --text-muted + --mur-inverse-text <- --bg icon on the accent send button + --mur-border <- --border + --mur-primary <- --accent send button background + --mur-danger{,-text,-bg,-border,-hover-bg} <- --danger / --danger-text + --mur-success <- --led-green + --mur-code-heading-bg <- --bg-hover + --mur-font <- --font-prose + + murm-ui's dark shadows and overlay scrims are palette-neutral black + alphas and are left as shipped. Only the dark theme is mapped: the + workbench's template always sets data-theme="dark" on .mur-app. + -------------------------------------------------------------------------- */ +.mur-app[data-theme="dark"] { + --mur-bg: var(--bg, #0d0e12); + --mur-surface: var(--bg-raised, #14161c); + --mur-surface-user: var(--bg-hover, #1a1d25); + --mur-hover-bg: var(--bg-hover, #1a1d25); + --mur-text: var(--text, #d6d9e0); + --mur-text-secondary: var(--text, #d6d9e0); + --mur-text-muted: var(--text-muted, #8b90a0); + --mur-inverse-text: var(--bg, #0d0e12); + --mur-border: var(--border, #262a33); + --mur-primary: var(--accent, #7c7fd4); + --mur-danger: var(--danger, #b0606a); + --mur-danger-text: var(--danger-text, #cf7f88); + --mur-danger-bg: color-mix(in oklab, var(--danger, #b0606a) 20%, transparent); + --mur-danger-border: color-mix(in oklab, var(--danger, #b0606a) 38%, transparent); + --mur-danger-hover-bg: color-mix(in oklab, var(--danger, #b0606a) 14%, transparent); + --mur-success: var(--led-green, #4caf7d); + --mur-code-heading-bg: var(--bg-hover, #1a1d25); + --mur-font: var(--font-prose, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); + --mur-header-button-bg: color-mix(in oklab, var(--bg-raised, #14161c) 82%, transparent); + --mur-header-title-bg: color-mix(in oklab, var(--bg-raised, #14161c) 62%, transparent); + + /* Composer growth cap: murm-ui defaults --mur-input-max-height to 200px, + which clips long voice transcripts; 40vh keeps them visible. It must be + declared on .mur-app, not :root, because murm-ui sets the variable on + .mur-app itself and an element-local declaration beats inheritance. */ + --mur-input-max-height: 40vh; +} + +/* murm-ui hardcodes `font-family: monospace` for code and tool chrome; + route those through --code-font so the skin owns the mono stack. These + selectors tie murm-ui's own, and this stylesheet loads later. */ +.mur-message code, +.mur-code-language, +.mur-block-tool { + font-family: var(--code-font, ui-monospace, "Cascadia Code", Consolas, "Courier New", monospace); +} + +/* The composer form floats over the chat on murm-ui's --mur-bg; give the + skin its own hook so the composer can differ from the chat background. */ +.mur-app .mur-chat-form { + background-color: var(--bg-composer, #0d0e12); +} + +/* Hover glow: icon buttons and the picker trade murm-ui's background wash + for a 1px accent ring plus a soft bloom. */ +.mur-form-icon-btn:hover:not(:disabled), +.sidebar__picker:hover, +.voice-mic:hover { + background-color: transparent; + box-shadow: + 0 0 0 1px var(--hover-glow, #5b9cf5), + 0 0 4px color-mix(in oklab, var(--hover-glow, #5b9cf5) 40%, transparent); } * { box-sizing: border-box; } +/* -------------------------------------------------------------------------- + Custom scrollbars, applied globally: a thin rounded translucent thumb on + a transparent track, so the bar reads as an overlay on whatever surface + scrolls. WebView2 is Chromium, so the ::-webkit-scrollbar pseudoelements + are the styled surface; the standard `scrollbar-width`/`scrollbar-color` + pair carries the same intent to any future non-Chromium host. Widths and + colors are variables so a skin can retune them from the :root block. + -------------------------------------------------------------------------- */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb, rgba(255, 255, 255, 0.16)) transparent; +} + +::-webkit-scrollbar { + width: var(--scrollbar-width, 8px); + height: var(--scrollbar-width, 8px); +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb, rgba(255, 255, 255, 0.16)); + border-radius: calc(var(--scrollbar-width, 8px) / 2); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover, rgba(255, 255, 255, 0.28)); +} + +::-webkit-scrollbar-corner { + background: transparent; +} + html, body { margin: 0; height: 100%; - background: var(--bg); - color: var(--text); - font-family: var(--font-prose); + background: var(--bg, #0d0e12); + color: var(--text, #d6d9e0); + font-family: var(--font-prose, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); font-size: 14px; line-height: 1.55; } -.shell { +/* The window is one column: the shell fills it above the full-width + status bar. */ +body { display: flex; + flex-direction: column; height: 100vh; + margin: 0; +} + +.shell { + display: flex; + flex: 1; + min-height: 0; } .sidebar { - width: 220px; + width: var(--sidebar-width, 220px); flex: none; display: flex; flex-direction: column; - gap: 8px; - padding: 16px 12px; - background: var(--bg-raised); - border-right: 1px solid var(--border); + gap: var(--space-md, 8px); + padding: var(--space-xl, 16px) var(--space-lg, 12px); + background: var(--bg-sidebar, #14161c); + border-right: 1px solid var(--border, #262a33); } -.brand { +.sidebar__brand { font-size: 15px; font-weight: 600; letter-spacing: 0.02em; - color: var(--text); - padding: 4px 6px 12px; - border-bottom: 1px solid var(--border); - margin-bottom: 8px; + color: var(--text, #d6d9e0); + padding: var(--space-xs, 4px) var(--space-sm, 6px) var(--space-lg, 12px); + border-bottom: 1px solid var(--border, #262a33); + margin-bottom: var(--space-md, 8px); } -.picker-label { +.sidebar__picker-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; - color: var(--text-dim); - padding: 0 6px; + color: var(--text-muted, #8b90a0); + padding: 0 var(--space-sm, 6px); } -.picker { +.sidebar__picker { width: 100%; - padding: 6px 8px; - background: var(--bg); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - font-family: var(--font-prose); + padding: var(--space-sm, 6px) var(--space-md, 8px); + background: var(--bg, #0d0e12); + color: var(--text, #d6d9e0); + border: 1px solid var(--border, #262a33); + border-radius: var(--radius, 6px); + font-family: var(--font-prose, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); font-size: 13px; outline: none; } -.picker:focus { - border-color: var(--accent-dim); +.sidebar__picker:focus { + border-color: var(--accent-dim, #5658a0); } -.picker:disabled { +.sidebar__picker:disabled { opacity: 0.5; } -.model-description { +.sidebar__model-description { font-size: 12px; - color: var(--text-dim); - padding: 2px 6px; + color: var(--text-muted, #8b90a0); + padding: 2px var(--space-sm, 6px); overflow-wrap: anywhere; } -.chat { +.sidebar__model-description--error { + color: var(--danger-text, #cf7f88); +} + +/* The dockview column beside the sidebar: the dock fills it. */ +.dock-column { flex: 1; display: flex; flex-direction: column; min-width: 0; } -.messages { +.dock { flex: 1; - overflow-y: auto; - padding: 24px 0; + min-height: 0; } -.message { - max-width: 760px; - margin: 0 auto 20px; - padding: 0 24px; +/* One panel fills the window; its tab bar is chrome, not information. */ +.dock .dv-tabs-and-actions-container { + display: none; } -.message .bubble { - padding: 10px 14px; - border-radius: 8px; - overflow-wrap: break-word; +.chat-panel { + height: 100%; } -.message.user { +/* The status bar: a permanent full-width footer below the shell. The left + text carries the observer's current label; the right group holds the REC + badge and the slot, which holds the progress bar or the activity LED + (never both - the slot's children are mutually exclusive, driven by the + hidden attribute). min-height rather than height so a descender never + clips against a fixed box. */ +.status-bar { + flex: none; + min-height: var(--status-bar-height, 24px); display: flex; - justify-content: flex-end; -} - -.message.user .bubble { - background: var(--bg-hover); - border: 1px solid var(--border); - max-width: 80%; - white-space: pre-wrap; -} - -.message.assistant .bubble { - padding: 4px 0; -} - -.message.error .bubble { - background: transparent; - border: 1px solid var(--danger); - color: var(--danger); + align-items: center; + gap: var(--status-bar-gap, 12px); + padding-inline: var(--status-bar-padding-inline, 12px); + background: var(--status-bar-bg, #14161c); + border-top: 1px solid var(--border, #262a33); font-size: 13px; + line-height: 1.4; + color: var(--status-bar-text, #8b90a0); + user-select: none; } -.message.assistant.streaming .bubble::after { - content: ""; - display: inline-block; - width: 7px; - height: 14px; - margin-left: 3px; - vertical-align: text-bottom; - background: var(--accent); - animation: blink 1s steps(2, start) infinite; -} - -@keyframes blink { - to { - visibility: hidden; - } +.status-bar__text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.bubble > :first-child { - margin-top: 0; +.status-bar__text--error { + color: var(--status-bar-text-error, #cf7f88); } -.bubble > :last-child { - margin-bottom: 0; +.status-bar__right { + display: flex; + align-items: center; + gap: 4px; } -.bubble pre { - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: 6px; - padding: 10px 12px; - overflow-x: auto; +/* The REC badge: always visible, a dim dark-red outline while idle and a + lit bright red while the mic records. */ +.status-bar__rec { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + line-height: 1; + padding: 2px 5px; + border: 1px solid var(--rec-idle, #552222); + border-radius: 2px; + color: var(--rec-idle, #552222); + transition: color 0.1s, border-color 0.1s, box-shadow 0.15s; } -.bubble code { - font-family: var(--font-mono); - font-size: 12.5px; +.status-bar__rec--active { + color: var(--rec-active, #ff0000); + border-color: var(--rec-active, #ff0000); + box-shadow: 0 0 3px var(--rec-active, #ff0000); } -.bubble :not(pre) > code { - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: 4px; - padding: 1px 5px; +.status-bar__slot { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + min-width: var(--progress-width, 96px); } -.bubble a { - color: var(--accent); +.status-bar__progress[hidden], +.status-bar__led[hidden] { + display: none; } -.bubble table { - border-collapse: collapse; +/* The progress bar: a thin rounded track with a green fill and a subtle + glow. WebView2 is Chromium, so the webkit progress pseudoelements are the + styled surface. */ +.status-bar__progress { + width: var(--progress-width, 96px); + height: var(--progress-height, 6px); + appearance: none; + border: none; + border-radius: calc(var(--progress-height, 6px) / 2); + background: var(--progress-track, rgba(255, 255, 255, 0.08)); + overflow: hidden; } -.bubble th, -.bubble td { - border: 1px solid var(--border); - padding: 4px 10px; +.status-bar__progress::-webkit-progress-bar { + background: var(--progress-track, rgba(255, 255, 255, 0.08)); + border-radius: calc(var(--progress-height, 6px) / 2); } -.composer { - flex: none; - display: flex; - gap: 8px; - max-width: 760px; - width: 100%; - margin: 0 auto; - padding: 12px 24px 20px; +.status-bar__progress::-webkit-progress-value { + background: var(--progress-fill, #4caf7d); + border-radius: calc(var(--progress-height, 6px) / 2); + box-shadow: 0 0 var(--progress-glow, 4px) var(--progress-fill, #4caf7d); } -.composer textarea { - flex: 1; - resize: none; - max-height: 200px; - padding: 10px 12px; - background: var(--bg-raised); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - font-family: var(--font-prose); - font-size: 14px; - line-height: 1.45; - outline: none; +/* The activity LED: a small circle standing in the slot whenever no frame + carries progress. Idle is an unlit lens - a dark translucent disc with a + subtle inner highlight. A pulse adds the --generating or --thinking + modifier: a bright radial-gradient core with a layered box-shadow bloom. + The idle rule's transition is the slow ease-out decay; the modifier's + own transition makes the fade-in fast. */ +.status-bar__led { + width: var(--led-size, 10px); + height: var(--led-size, 10px); + border-radius: 50%; + background: var(--led-off, rgba(255, 255, 255, 0.08)); + box-shadow: + inset 0 1px 1px var(--led-lens-highlight, rgba(255, 255, 255, 0.18)), + inset 0 -1px 2px var(--led-lens-shadow, rgba(0, 0, 0, 0.45)); + transition: + background var(--led-pulse-ms, 250ms) ease-out, + box-shadow var(--led-pulse-ms, 250ms) ease-out; } -.composer textarea:focus { - border-color: var(--accent-dim); +.status-bar__led--generating, +.status-bar__led--thinking { + transition: + background var(--led-fade-in-ms, 60ms) ease-in, + box-shadow var(--led-fade-in-ms, 60ms) ease-in; } -.composer button { - flex: none; - align-self: flex-end; - padding: 10px 18px; - background: var(--accent-dim); - color: #fff; - border: 1px solid var(--accent-dim); - border-radius: 8px; - font-family: var(--font-prose); - font-size: 14px; - cursor: pointer; +.status-bar__led--generating { + background: radial-gradient(circle, var(--led-core, #ffffff) 0%, var(--led-green, #4caf7d) 60%); + box-shadow: + 0 0 calc(var(--led-glow-radius, 6px) / 2) var(--led-green, #4caf7d), + 0 0 var(--led-glow-radius, 6px) var(--led-green, #4caf7d), + 0 0 calc(var(--led-glow-radius, 6px) * 2) color-mix(in oklab, var(--led-green, #4caf7d) 55%, transparent); } -.composer button:hover:not(:disabled) { - background: var(--accent); - border-color: var(--accent); +.status-bar__led--thinking { + background: radial-gradient(circle, var(--led-core, #ffffff) 0%, var(--led-amber, #d9a03f) 60%); + box-shadow: + 0 0 calc(var(--led-glow-radius, 6px) / 2) var(--led-amber, #d9a03f), + 0 0 var(--led-glow-radius, 6px) var(--led-amber, #d9a03f), + 0 0 calc(var(--led-glow-radius, 6px) * 2) color-mix(in oklab, var(--led-amber, #d9a03f) 55%, transparent); } -.composer button:disabled { - opacity: 0.45; - cursor: default; +.voice-status { + width: 100%; + max-width: var(--mur-chat-form-width, 768px); + font-size: 12px; + color: var(--text-muted, #8b90a0); + max-height: 0; + overflow: hidden; + transition: max-height 0.15s ease-out, padding 0.15s ease-out; } -.composer button.mic-button { - width: 40px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-raised); - border-color: var(--border); - color: var(--text-dim); +.voice-status--visible { + max-height: 40px; + padding: 0 var(--space-md, 8px); } -.composer button.mic-button:hover { - color: var(--text); - background: var(--bg-hover); - border-color: var(--accent-dim); +.voice-status--error { + color: var(--danger-text, #cf7f88); } -.composer button.mic-button.recording { - color: #fff; - background: var(--danger); - border-color: var(--danger); - animation: mic-pulse 1.2s ease-in-out infinite; +.voice-mic { + flex: none; } -@keyframes mic-pulse { - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(176, 96, 106, 0.55); - } - 50% { - box-shadow: 0 0 0 6px rgba(176, 96, 106, 0); - } +/* Recording mic: a steady danger fill with a matching ring and bloom. + The hover form needs :not(:disabled) to match the glow rule's specificity - + the mic also carries mur-form-icon-btn, whose hover would otherwise + outrank this and strip the fill. */ +.voice-mic--recording { + color: var(--on-danger, #ffffff); + background: var(--danger, #b0606a); + border-radius: 50%; + box-shadow: + 0 0 0 1px var(--danger, #b0606a), + 0 0 6px color-mix(in oklab, var(--danger, #b0606a) 55%, transparent); } -.interim { - flex: none; - max-width: 760px; - width: 100%; - margin: 0 auto; - padding: 0 24px; - font-size: 13px; - font-style: italic; - color: var(--text-dim); - max-height: 0; - overflow: hidden; - transition: max-height 0.15s ease-out, padding 0.15s ease-out; +.voice-mic--recording:hover:not(:disabled) { + color: var(--on-danger, #ffffff); + background: var(--danger, #b0606a); + border-radius: 50%; + box-shadow: + 0 0 0 1px var(--danger, #b0606a), + 0 0 8px color-mix(in oklab, var(--danger, #b0606a) 70%, transparent); } -.interim.visible { - max-height: 60px; - padding: 0 24px 4px; +/* Send button hover: accent glow in both normal and generating states. */ +.mur-action-btn:hover:not(:disabled) { + box-shadow: + 0 0 0 1px var(--hover-glow, #5b9cf5), + 0 0 4px color-mix(in oklab, var(--hover-glow, #5b9cf5) 40%, transparent); } -.voice-status { - flex: none; - max-width: 760px; - width: 100%; - margin: 0 auto; - padding: 0 24px; - font-size: 12px; - color: var(--text-dim); - max-height: 0; - overflow: hidden; - transition: max-height 0.15s ease-out, padding 0.15s ease-out; +/* Composer overlap fix: make the form container participate in the column + flex flow so the scroll area shrinks to accommodate it. The embedded + workbench mode only - murm-ui's standalone keeps absolute positioning. */ +.mur-app-embedded .mur-chat-form-container { + position: relative; } -.voice-status.visible { - max-height: 40px; - padding: 0 24px 12px; +.mur-app-embedded.mur-chat-empty .mur-chat-form-container { + bottom: auto; + transform: none; } -.voice-status.error { - color: var(--danger); +.mur-app-embedded .mur-chat-history { + padding-bottom: 1rem; } diff --git a/crates/promptforge-wb-server/ui/test/smoke.mjs b/crates/promptforge-wb-server/ui/test/smoke.mjs new file mode 100644 index 00000000..bebaa575 --- /dev/null +++ b/crates/promptforge-wb-server/ui/test/smoke.mjs @@ -0,0 +1,793 @@ +// Smoke test: loads dist/index.html into jsdom, imports the bundled +// dist/app.js, asserts the chat UI mounts without throwing, and drives one +// chat round-trip through a scripted WebSocket. Guards the DOM contract +// between index.html and the vendored murm-ui (its components throw when a +// required class is missing) and the wire contract of WorkbenchProvider +// (chat frame shape against /ws, delta frames rendered into the history). +// Run after `npm run build`: `npm test`. +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { JSDOM } from "jsdom"; + +const uiDir = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.join(uiDir, "..", "dist"); + +const html = await readFile(path.join(distDir, "index.html"), "utf8"); +const dom = new JSDOM(html, { url: "http://127.0.0.1:7910/", pretendToBeVisual: true }); + +const { window } = dom; + +// jsdom lacks layout APIs the feed touches; no-op stubs are enough because +// nothing scrolls in the test. +window.matchMedia = + window.matchMedia || + (() => ({ + matches: false, + media: "", + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + })); +window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; +window.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +}; +window.Element.prototype.scrollTo = () => {}; +window.HTMLElement.prototype.scrollIntoView = () => {}; +// jsdom has no layout engine, so scrollHeight is always 0 and murm-ui's +// adjustHeight would pin the composer at 0px. Simulate line-based metrics +// for textareas so composer auto-growth is observable as inline height. +Object.defineProperty(window.HTMLElement.prototype, "scrollHeight", { + configurable: true, + get() { + if (this instanceof window.HTMLTextAreaElement) { + return 36 + (this.value.split("\n").length - 1) * 21; + } + return 0; + }, +}); +// A scripted WebSocket stands in for the server's persistent /ws route. It +// must live on globalThis: the bundle calls the global `WebSocket`, not +// `window.WebSocket`. The app opens one socket on load; each chat frame +// sent on it is captured and answered with two delta frames and a done +// frame echoing the frame's id, scheduled in order so the provider's +// round-trip runs. The socket stays open after `done` - it is persistent. +const chatSockets = []; +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.CONNECTING; + chatSockets.push(this); + setTimeout(() => { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + }, 0); + } + // The voice path attaches with addEventListener; chain listeners onto the + // on* properties the chat path assigns directly. + addEventListener(type, listener) { + const prop = `on${type}`; + const previous = this[prop]; + this[prop] = previous ? (event) => (previous(event), listener(event)) : listener; + } + send(data) { + let frame; + try { + frame = JSON.parse(data); + } catch { + return; // voice control words ("start"/"stop") are not JSON + } + if (frame.type !== "chat") return; + this.chatFrame = frame; + const frames = [ + { type: "delta", content: "Hello", id: frame.id }, + { type: "delta", content: " back", id: frame.id }, + { type: "done", id: frame.id }, + ]; + for (const reply of frames) { + queueMicrotask(() => this.onmessage?.({ data: JSON.stringify(reply) })); + } + } + close() { + this.readyState = FakeWebSocket.CLOSED; + } +} +globalThis.WebSocket = FakeWebSocket; + +// Voice capture stubs: jsdom has no audio stack, so the mic button's +// getUserMedia/AudioContext path is scripted to succeed. The bundle reads +// the globals, so they land on both window and globalThis; `navigator` is +// Node's own global (the key-copy loop below skips keys already present), +// so mediaDevices goes on it directly. +const fakeAudioStream = { getTracks: () => [{ stop() {} }] }; +const fakeMediaDevices = { getUserMedia: () => Promise.resolve(fakeAudioStream) }; +window.navigator.mediaDevices = fakeMediaDevices; +globalThis.navigator.mediaDevices = fakeMediaDevices; +class FakeAudioContext { + constructor() { + this.destination = {}; + this.audioWorklet = { addModule: () => Promise.resolve() }; + } + createMediaStreamSource() { + return { connect() {}, disconnect() {} }; + } + close() { + return Promise.resolve(); + } +} +class FakeAudioWorkletNode { + constructor() { + this.port = { onmessage: null }; + } + connect() {} + disconnect() {} +} +window.AudioContext = FakeAudioContext; +globalThis.AudioContext = FakeAudioContext; +window.AudioWorkletNode = FakeAudioWorkletNode; +globalThis.AudioWorkletNode = FakeAudioWorkletNode; + +// Pushes one observer status frame down the persistent socket, as the +// server's /ws route would. Fields default to a plain idle update. +function emitStatus(overrides = {}) { + const socket = chatSockets[0]; + socket?.onmessage?.({ + data: JSON.stringify({ + type: "status", + label: "Ready", + description: "", + severity: "info", + activity: "general", + progress: null, + ...overrides, + }), + }); +} +// A scripted fetch stands in for the model catalog. The catalog answers with +// one model so the picker enables and submission is unblocked; any other +// fetch - including the retired POST /chat SSE path - rejects the test. +globalThis.fetch = (url) => { + if (url === "/v1/models") { + return Promise.resolve( + new Response(JSON.stringify({ data: [{ id: "test-model", description: "scripted" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch in the smoke test: ${url}`)); +}; + +for (const key of [ + "document", + "navigator", + "location", + "localStorage", + "HTMLElement", + "HTMLTextAreaElement", + "HTMLButtonElement", + "Node", + "Element", + "Event", + "CustomEvent", + "MutationObserver", + "Option", + "DOMParser", + "NodeFilter", + "ResizeObserver", + "IntersectionObserver", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", +]) { + if (!(key in globalThis) && key in window) { + globalThis[key] = window[key]; + } +} +// Node ships its own Event and CustomEvent globals, so the copy loop skips +// them - but events the bundle dispatches into the jsdom document must be +// jsdom-realm instances: jsdom's dispatchEvent rejects Node's Event with +// "parameter 1 is not of type 'Event'". +globalThis.Event = window.Event; +globalThis.CustomEvent = window.CustomEvent; +globalThis.window = window; +globalThis.document = window.document; + +await import(pathToFileURL(path.join(distDir, "app.js")).href); + +// The bundle mounts dockview on #dock with one chat panel, and ChatUI on +// the .mur-app inside it: a successful mount leaves the murm structure +// intact and renders the empty-chat state. +const dock = window.document.querySelector("#dock"); +const app = window.document.querySelector("#dock .mur-app"); +const history = window.document.querySelector(".mur-chat-history"); +const input = window.document.querySelector(".mur-chat-input"); +const send = window.document.querySelector(".mur-send-btn"); +const mic = window.document.querySelector(".voice-mic"); +const statusBar = window.document.querySelector(".status-bar"); +const statusText = window.document.querySelector(".status-bar__text"); +const statusSlot = window.document.querySelector(".status-bar__slot"); +const progressEl = window.document.querySelector(".status-bar__progress"); +const ledEl = window.document.querySelector(".status-bar__led"); + +const failures = []; +if (!dock) failures.push("#dock missing"); +if (dock && !dock.querySelector(".dv-dockview")) { + failures.push("dockview did not initialize inside #dock"); +} +if (!window.document.querySelector("#dock .dv-groupview")) { + failures.push("dockview rendered no group for the chat panel"); +} +if (!app) failures.push(".mur-app missing inside the dock"); +if (!history) failures.push(".mur-chat-history missing"); +if (!input) failures.push(".mur-chat-input missing"); +if (!send) failures.push(".mur-send-btn missing"); +if (!mic) failures.push("voice plugin did not insert the mic button"); +if (!statusBar) failures.push("status bar placeholder missing"); +if (statusBar && statusBar.tagName !== "FOOTER") { + failures.push("the status bar is not a