Skip to main content

openkind_api/
playground.rs

1//! Embedded web playground served at `GET /playground` when the daemon runs
2//! with `--playground on`.
3//!
4//! The page is embedded in the binary. Evaluation uses the standard Jev API;
5//! opt-in `/playground/api/models` controls delegate to the daemon's local
6//! loader and stay outside the public TypeSafe contract.
7
8use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
9use axum::http::HeaderValue;
10use axum::response::IntoResponse;
11
12/// The playground app, embedded at compile time.
13const PLAYGROUND_HTML: &str = include_str!("../assets/playground.html");
14
15/// Marker the router tests pin so an accidentally empty asset fails loudly.
16const PLAYGROUND_MARKER: &str = "openkind playground";
17
18/// Handler for `GET /playground`.
19///
20/// The body is a compile-time constant, so it is served as-is instead of
21/// being rebuilt per request. `no-store` keeps browser caches from pinning a
22/// stale UI across daemon upgrades.
23pub async fn playground_page() -> impl IntoResponse {
24    debug_assert!(PLAYGROUND_HTML.contains(PLAYGROUND_MARKER));
25    (
26        [
27            (
28                CONTENT_TYPE,
29                HeaderValue::from_static("text/html; charset=utf-8"),
30            ),
31            (CACHE_CONTROL, HeaderValue::from_static("no-store")),
32            (axum::http::header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")),
33            (axum::http::header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")),
34        ],
35        PLAYGROUND_HTML,
36    )
37}
38
39/// A locally available model and its serving state.
40#[derive(serde::Serialize)]
41pub struct PlaygroundModel {
42    /// Alias used in evaluation requests.
43    pub name: String,
44    /// Human-readable source or profile description.
45    pub description: String,
46    /// `mock`, `installed`, or `startup`.
47    pub source: String,
48    /// Whether new requests can use this model.
49    pub loaded: bool,
50    /// Whether this daemon can explicitly load and unload it.
51    pub manageable: bool,
52}
53
54/// Daemon-owned artifact loading boundary. The API never opens model files.
55#[async_trait::async_trait]
56pub trait PlaygroundModels: Send + Sync {
57    /// List local installations and configured aliases without downloading.
58    async fn list(&self) -> Result<Vec<PlaygroundModel>, crate::ApiError>;
59    /// Explicitly load or unload one known local model.
60    async fn set_loaded(&self, name: String, loaded: bool) -> Result<(), crate::ApiError>;
61}
62
63#[derive(serde::Deserialize)]
64#[serde(deny_unknown_fields)]
65pub(crate) struct ModelAction {
66    name: String,
67    loaded: bool,
68}
69
70pub(crate) async fn list_models(
71    axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::AppState>>,
72) -> Result<impl IntoResponse, crate::ApiError> {
73    let manager = state.playground_models.as_ref().ok_or_else(|| {
74        crate::ApiError::InvalidBody("Model controls are unavailable on this daemon".into())
75    })?;
76    Ok((
77        [(CACHE_CONTROL, "no-store")],
78        axum::Json(serde_json::json!({"models": manager.list().await?})),
79    ))
80}
81
82pub(crate) async fn change_model(
83    axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::AppState>>,
84    headers: axum::http::HeaderMap,
85    axum::Json(action): axum::Json<ModelAction>,
86) -> Result<impl IntoResponse, crate::ApiError> {
87    // Requiring a non-simple header prevents another origin from submitting
88    // a resource-changing form. This route deliberately has no CORS support.
89    if headers
90        .get("x-openkind-playground")
91        .and_then(|v| v.to_str().ok())
92        != Some("1")
93        || headers
94            .get("sec-fetch-site")
95            .is_some_and(|v| v == "cross-site")
96    {
97        return Err(crate::ApiError::Unauthorized);
98    }
99    let manager = state.playground_models.as_ref().ok_or_else(|| {
100        crate::ApiError::InvalidBody("Model controls are unavailable on this daemon".into())
101    })?;
102    manager.set_loaded(action.name, action.loaded).await?;
103    Ok((
104        [(CACHE_CONTROL, "no-store")],
105        axum::Json(serde_json::json!({"ok": true})),
106    ))
107}