use eframe::egui::{self, RichText, ScrollArea};
use super::facett_theme::{Theme, RED};
use crate::warehouse::generator::{generator, GenRequest, Generator};
pub const DEFAULT_MODEL_SPEC: &str = "candle:qwen2.5-7b";
const DEFAULT_MAX_TOKENS: usize = 256;
struct ChatMsg {
role: &'static str,
text: String,
}
pub struct ChatTabState {
spec: String,
input: String,
system: String,
max_tokens: usize,
messages: Vec<ChatMsg>,
last_response: Option<String>,
error: Option<String>,
r#gen: Option<Result<Box<dyn Generator>, String>>,
theme: Theme,
}
impl Default for ChatTabState {
fn default() -> Self {
Self::new()
}
}
impl ChatTabState {
pub fn new() -> Self {
let spec = crate::warehouse::generator::spec_from_env()
.unwrap_or_else(|| DEFAULT_MODEL_SPEC.to_string());
Self::with_spec(spec)
}
pub fn with_spec(spec: impl Into<String>) -> Self {
Self {
spec: spec.into(),
input: String::new(),
system: String::new(),
max_tokens: DEFAULT_MAX_TOKENS,
messages: Vec::new(),
last_response: None,
error: None,
r#gen: None,
theme: Theme::default(),
}
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
pub fn spec(&self) -> &str {
&self.spec
}
pub fn send(&mut self) -> Result<String, String> {
let prompt = self.input.trim().to_string();
if prompt.is_empty() {
return Err("empty prompt (nothing to send)".into());
}
if self.r#gen.is_none() {
self.r#gen = Some(generator(&self.spec).map_err(|e| e.to_string()));
}
let result: Result<String, String> = match self.r#gen.as_ref().expect("set above") {
Err(e) => Err(e.clone()),
Ok(g) => {
let mut req = GenRequest::new(prompt.clone()).with_max_tokens(self.max_tokens);
if !self.system.trim().is_empty() {
req = req.with_system(self.system.clone());
}
g.complete(&req)
.map(|a| a.text.trim().to_string())
.map_err(|e| format!("{e:#}"))
}
};
self.messages.push(ChatMsg { role: "user", text: prompt });
match result {
Ok(text) => {
self.messages.push(ChatMsg { role: "assistant", text: text.clone() });
self.last_response = Some(text.clone());
self.error = None;
self.input.clear();
Ok(text)
}
Err(e) => {
self.error = Some(e.clone());
Err(e)
}
}
}
pub(crate) fn set_field_for_drive(&mut self, field: &str, value: &str) -> Result<(), String> {
match field {
"input" => {
self.input = value.to_string();
Ok(())
}
"system" => {
self.system = value.to_string();
Ok(())
}
"model" => {
self.spec = value.trim().to_string();
self.r#gen = None;
Ok(())
}
"max_tokens" => {
let n: usize = value
.trim()
.parse()
.map_err(|_| format!("max_tokens must be a positive integer, got {value:?}"))?;
self.max_tokens = n.clamp(1, 4096);
Ok(())
}
other => Err(format!("unknown chat field {other:?}")),
}
}
pub(crate) fn click_for_drive(
&mut self,
button: &str,
log: &super::action_log::ActionLog,
) -> Result<(), String> {
match button {
"send" => {
let r = self.send();
match &r {
Ok(text) => log.push(
super::action_log::Kind::Click,
format!("chat send (drive): {} char reply", text.len()),
),
Err(e) => log.push(
super::action_log::Kind::Error,
format!("chat send (drive) failed: {e}"),
),
}
r.map(|_| ())
}
"clear" => {
self.messages.clear();
self.last_response = None;
self.error = None;
Ok(())
}
other => Err(format!("unknown chat button {other:?}")),
}
}
fn model_detail(&self) -> serde_json::Value {
#[cfg(feature = "gen-candle")]
if let Some(model) = self.spec.strip_prefix("candle:") {
return crate::warehouse::generator::candle::spec_detail(model);
}
match generator(&self.spec) {
Ok(g) => serde_json::json!({ "id": g.id(), "available": g.available() }),
Err(e) => serde_json::json!({ "id": self.spec, "error": e.to_string() }),
}
}
pub fn state_json(&self) -> serde_json::Value {
let messages: Vec<serde_json::Value> = self
.messages
.iter()
.map(|m| serde_json::json!({ "role": m.role, "text": m.text }))
.collect();
let turn_count = self.messages.iter().filter(|m| m.role == "user").count();
serde_json::json!({
"model_spec": self.spec,
"model": self.model_detail(),
"system": self.system,
"max_tokens": self.max_tokens,
"input": self.input,
"turn_count": turn_count,
"message_count": self.messages.len(),
"messages": messages,
"last_response": self.last_response,
"responded": self.last_response.as_deref().map(|s| !s.is_empty()).unwrap_or(false),
"error": self.error,
"mcp": mcp_state_json(),
"palette": self.theme.name,
})
}
pub fn draw(&mut self, ui: &mut egui::Ui, log: &super::action_log::ActionLog) {
let theme = self.theme;
ui.horizontal(|ui| {
ui.heading("💬 Local chat");
ui.separator();
ui.label(RichText::new(&self.spec).monospace().color(theme.accent));
});
ui.label(
"an agent-agnostic chat wired to the ACTIVE local model — a quantized GGUF loaded \
in-process (candle), cached under /mnt/work4t. No ollama, no cloud.",
);
egui::CollapsingHeader::new("🔌 MCP → nornir (agent wiring)").show(ui, |ui| {
ui.label(
"nornir-mcp is a STDIO relay (no network endpoint): an agent spawns it locally; \
it relays to nornir-server over gRPC. Wire it in with:",
);
ui.monospace(mcp_add_command());
ui.add_space(4.0);
ui.label("or the mcpServers block:");
ui.monospace(
serde_json::to_string_pretty(&mcp_client_config()).unwrap_or_default(),
);
});
ui.separator();
ScrollArea::vertical()
.auto_shrink([false, false])
.max_height((ui.available_height() - 90.0).max(60.0))
.stick_to_bottom(true)
.show(ui, |ui| {
if self.messages.is_empty() {
ui.weak("(no messages yet — type a prompt below and hit Send)");
}
for m in &self.messages {
let (who, col) = if m.role == "user" {
("you", theme.text)
} else {
("model", theme.accent)
};
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(format!("{who}: ")).strong().color(col));
ui.label(&m.text);
});
ui.add_space(4.0);
}
if let Some(err) = &self.error {
ui.colored_label(RED, format!("error: {err}"));
}
});
ui.separator();
ui.horizontal(|ui| {
let hint = "ask the local model…";
let edit = egui::TextEdit::singleline(&mut self.input)
.hint_text(hint)
.desired_width(ui.available_width() - 90.0);
let resp = ui.add(edit);
let submit = (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|| ui.button("➤ Send").clicked();
if submit && !self.input.trim().is_empty() {
let _ = self.click_for_drive("send", log);
}
});
}
}
fn mcp_server_target() -> String {
std::env::var("NORNIR_SERVER").unwrap_or_else(|_| "http://localhost:7878".to_string())
}
pub fn mcp_client_config() -> serde_json::Value {
let token = std::env::var("NORNIR_SERVER_TOKEN").unwrap_or_else(|_| "<token>".to_string());
serde_json::json!({
"mcpServers": {
"nornir": {
"command": "nornir-mcp",
"args": [],
"env": {
"NORNIR_SERVER": mcp_server_target(),
"NORNIR_SERVER_TOKEN": token,
}
}
}
})
}
pub fn mcp_add_command() -> String {
format!(
"claude mcp add nornir --env NORNIR_SERVER={} \
--env NORNIR_SERVER_TOKEN=<token> -- nornir-mcp",
mcp_server_target()
)
}
pub fn mcp_state_json() -> serde_json::Value {
serde_json::json!({
"transport": "stdio",
"command": "nornir-mcp",
"server": mcp_server_target(),
"config": mcp_client_config(),
"add_command": mcp_add_command(),
"note": "nornir-mcp is a local stdio relay; it is NOT a network MCP endpoint. \
Spawn it beside the agent; it relays to nornir-server over gRPC.",
})
}
pub fn write_mcp_config_file(dir: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
let path = dir.join("nornir-mcp.json");
let body = serde_json::to_string_pretty(&mcp_client_config()).unwrap_or_default();
std::fs::write(&path, body)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcp_config_is_stdio_relay_not_a_url() {
let cfg = mcp_client_config();
assert_eq!(cfg["mcpServers"]["nornir"]["command"], "nornir-mcp");
assert!(
cfg["mcpServers"]["nornir"]["env"]["NORNIR_SERVER"]
.as_str()
.unwrap()
.starts_with("http"),
"relay points at the nornir-server gRPC target"
);
assert!(mcp_add_command().contains("claude mcp add nornir"));
assert_eq!(mcp_state_json()["transport"], "stdio");
}
#[test]
fn empty_input_send_errors_not_panics() {
let mut chat = ChatTabState::with_spec("mock");
let err = chat.send().unwrap_err();
assert!(err.contains("empty prompt"), "{err}");
assert!(!chat.state_json()["responded"].as_bool().unwrap());
}
#[test]
fn mock_backend_round_trips_a_reply_into_state_json() {
let log = super::super::action_log::ActionLog::new();
let mut chat = ChatTabState::with_spec("mock");
chat.set_field_for_drive("input", "capital of France?").unwrap();
chat.click_for_drive("send", &log).unwrap();
let st = chat.state_json();
assert_eq!(st["turn_count"], 1);
assert_eq!(st["message_count"], 2, "one user + one assistant turn");
assert!(st["responded"].as_bool().unwrap(), "a non-empty reply came back");
let reply = st["last_response"].as_str().unwrap();
assert!(reply.contains("capital of France?"), "mock echoes the prompt: {reply}");
assert_eq!(st["input"], "");
}
#[test]
fn set_model_field_rebuilds_generator() {
let mut chat = ChatTabState::with_spec("mock:one");
chat.set_field_for_drive("model", "mock:two").unwrap();
assert_eq!(chat.spec(), "mock:two");
}
}