nexus-chat-core 0.1.19

A local-first terminal chat app for deep research and multi-agent work
Documentation
//! The serde-shaped view of app state the Phase 4 nexus host API will
//! consume: sessions, models, settings, tasks. Designed now so the wire
//! shape is stable before the API lands; the TUI does not consume this
//! (it reads fields directly until 2e).

use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};

use super::App;

/// One snapshot of the app's domain state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoreSnapshot {
    pub sessions: Vec<SessionSnapshot>,
    pub models: Vec<ModelSnapshot>,
    pub settings: SettingsSnapshot,
    pub tasks: Vec<TaskSnapshot>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSnapshot {
    pub id: String,
    pub title: String,
    pub slug: Option<String>,
    pub model: String,
    pub kind: String,
    pub web_mode: bool,
    pub created_at: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelSnapshot {
    /// Composite id (backend prefix + wire id) — what `current_model`,
    /// favorites, and last-used store.
    pub id: String,
    pub name: String,
    pub context_length: Option<u64>,
    pub favorite: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsSnapshot {
    pub model: Option<String>,
    pub verbosity: String,
    pub web_mode: bool,
    pub incognito: bool,
    /// Sanitized `SearXNG` endpoint (credentials/query strings are removed).
    pub searxng_url: String,
    /// Whether the local `LangSearch` credential is configured. The key never
    /// crosses the host API boundary.
    #[serde(default)]
    pub langsearch_configured: bool,
    pub search_provider: String,
    pub temperature: Option<f32>,
    pub top_p: Option<f32>,
    pub max_tokens: Option<u32>,
    pub compact_threshold: u8,
    pub memory_model: String,
    pub transcriber_model: String,
    pub ocr_model: String,
    pub ocr_engine: String,
    pub embedding_model: String,
    pub image_gen_model: String,
    pub video_gen_model: String,
    pub blocked_domains: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSnapshot {
    pub id: u64,
    pub session_id: String,
    pub session_title: String,
    pub model: String,
    pub backend: String,
    /// "tool" while a tool runs, "streaming" otherwise. Tasks only live in
    /// the map while their loop is active, so there is no idle state.
    pub status: String,
    pub buffer_chars: usize,
}

/// Remove URL userinfo, query, and fragment components before an endpoint is
/// exposed in a remote snapshot. Search endpoints are local configuration, not
/// credentials storage, but URLs commonly grow accidental `?token=` values.
fn sanitized_endpoint(value: &str) -> String {
    let value = value.trim();
    let value = value.split_once('#').map_or(value, |(prefix, _)| prefix);
    let value = value.split_once('?').map_or(value, |(prefix, _)| prefix);
    if let Some((scheme, authority)) = value.split_once("://")
        && let Some((_, host)) = authority.rsplit_once('@')
    {
        return format!("{scheme}://{host}");
    }
    value.to_string()
}

impl App {
    /// Serde-shaped state for API consumers (the Phase 4 host). Sessions
    /// come from the picker cache when loaded, else a fresh db read — a
    /// failed read is an error, never a silently-empty session list.
    pub fn snapshot(&self) -> Result<CoreSnapshot> {
        let sessions = if self.sessions_cache.is_empty() {
            self.db
                .list_sessions(&self.active_space.id)
                .context("reading sessions for snapshot")?
        } else {
            self.sessions_cache.clone()
        };
        let favorite_ids = &self.favorites;
        Ok(CoreSnapshot {
            sessions: sessions
                .into_iter()
                .map(|s| SessionSnapshot {
                    id: s.id.clone(),
                    title: s.title.clone(),
                    slug: s.slug.clone(),
                    model: s.model.clone(),
                    kind: s.kind.clone(),
                    web_mode: s.web_mode,
                    created_at: s.created_at.clone(),
                })
                .collect(),
            models: self
                .models
                .iter()
                .map(|m| ModelSnapshot {
                    id: super::composite_id(m),
                    name: m.name.clone(),
                    context_length: m.context_length,
                    favorite: favorite_ids.contains(&super::composite_id(m)),
                })
                .collect(),
            settings: SettingsSnapshot {
                model: self.current_model.clone(),
                verbosity: self.verbosity.clone(),
                web_mode: self.web_mode,
                incognito: self.incognito,
                searxng_url: sanitized_endpoint(&self.searxng_url),
                langsearch_configured: !self.langsearch_key.trim().is_empty(),
                search_provider: self.search_provider.clone(),
                temperature: self.settings.temperature,
                top_p: self.settings.top_p,
                max_tokens: self.settings.max_tokens,
                compact_threshold: self.settings.compact_threshold,
                memory_model: self.memory_model.clone(),
                transcriber_model: self.transcriber_model.clone(),
                ocr_model: self.ocr_model.clone(),
                ocr_engine: self.ocr_engine.clone(),
                embedding_model: self.embedding_model.clone(),
                image_gen_model: self.image_gen_model.clone(),
                video_gen_model: self.video_gen_model.clone(),
                blocked_domains: self.blocked_domains(),
            },
            tasks: self
                .chat_tasks
                .values()
                .map(|t| TaskSnapshot {
                    id: t.id,
                    session_id: t.session_id.clone(),
                    session_title: t.session_title.clone(),
                    model: t.model.clone(),
                    backend: t.backend.name().to_string(),
                    status: if t.tool_status.is_some() {
                        "tool".to_string()
                    } else {
                        "streaming".to_string()
                    },
                    buffer_chars: t.buffer.chars().count(),
                })
                .collect(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Phase 5 clients (web/mobile/`--remote`) parse `CoreSnapshot` `JSON`
    /// — this golden string locks the wire shape so a field rename or
    /// reorder can never silently break a client. Update it deliberately
    /// when the shape changes.
    #[test]
    fn golden_json_locks_wire_shape() {
        let snap = CoreSnapshot {
            sessions: vec![SessionSnapshot {
                id: "s1".into(),
                title: "hello".into(),
                slug: Some("hello".into()),
                model: "openrouter:anthropic/claude-sonnet-4".into(),
                kind: "chat".into(),
                web_mode: false,
                created_at: "2025-01-01T00:00:00Z".into(),
            }],
            models: vec![ModelSnapshot {
                id: "openrouter:anthropic/claude-sonnet-4".into(),
                name: "Claude Sonnet 4".into(),
                context_length: Some(200_000),
                favorite: true,
            }],
            settings: SettingsSnapshot {
                model: Some("openrouter:anthropic/claude-sonnet-4".into()),
                verbosity: "high".into(),
                web_mode: false,
                incognito: false,
                searxng_url: String::new(),
                langsearch_configured: false,
                search_provider: "searxng".into(),
                temperature: Some(0.7),
                top_p: None,
                max_tokens: None,
                compact_threshold: 60,
                memory_model: String::new(),
                transcriber_model: String::new(),
                ocr_model: String::new(),
                ocr_engine: "router".into(),
                embedding_model: String::new(),
                image_gen_model: String::new(),
                video_gen_model: String::new(),
                blocked_domains: Vec::new(),
            },
            tasks: vec![TaskSnapshot {
                id: 1,
                session_id: "s1".into(),
                session_title: "hello".into(),
                model: "openrouter:anthropic/claude-sonnet-4".into(),
                backend: "OpenRouter".into(),
                status: "streaming".into(),
                buffer_chars: 12,
            }],
        };
        let json = serde_json::to_string(&snap).expect("snapshot serializes");
        assert_eq!(
            json,
            r#"{"sessions":[{"id":"s1","title":"hello","slug":"hello","model":"openrouter:anthropic/claude-sonnet-4","kind":"chat","web_mode":false,"created_at":"2025-01-01T00:00:00Z"}],"models":[{"id":"openrouter:anthropic/claude-sonnet-4","name":"Claude Sonnet 4","context_length":200000,"favorite":true}],"settings":{"model":"openrouter:anthropic/claude-sonnet-4","verbosity":"high","web_mode":false,"incognito":false,"searxng_url":"","langsearch_configured":false,"search_provider":"searxng","temperature":0.7,"top_p":null,"max_tokens":null,"compact_threshold":60,"memory_model":"","transcriber_model":"","ocr_model":"","ocr_engine":"router","embedding_model":"","image_gen_model":"","video_gen_model":"","blocked_domains":[]},"tasks":[{"id":1,"session_id":"s1","session_title":"hello","model":"openrouter:anthropic/claude-sonnet-4","backend":"OpenRouter","status":"streaming","buffer_chars":12}]}"#
        );
        // The golden string must also parse back into the same shape.
        let back: CoreSnapshot = serde_json::from_str(&json).expect("golden parses");
        assert_eq!(back.sessions[0].title, "hello");
        assert_eq!(back.sessions[0].slug.as_deref(), Some("hello"));
        assert_eq!(back.models[0].context_length, Some(200_000));
        assert_eq!(back.settings.temperature, Some(0.7));
        assert!(!back.settings.langsearch_configured);
        assert_eq!(back.settings.blocked_domains, Vec::<String>::new());
        assert_eq!(back.tasks[0].status, "streaming");
        assert_eq!(back.tasks[0].buffer_chars, 12);
    }

    #[test]
    fn sanitized_endpoint_drops_credentials_and_query() {
        assert_eq!(
            sanitized_endpoint("https://user:pass@example.test/search?token=secret#frag"),
            "https://example.test/search"
        );
        assert_eq!(
            sanitized_endpoint("http://localhost:8080"),
            "http://localhost:8080"
        );
    }
}