From c1df6cea1935f32a55708d83f172b535464705c5 Mon Sep 17 00:00:00 2001 From: Elaina Claus Date: Wed, 30 Apr 2025 17:43:43 -0400 Subject: [PATCH] UI is async and event driven now, backend code needs to be linked up to the message queues correctly next. --- src/chat/mod.rs | 31 +++---- src/data/tools_list.json | 2 +- src/main.rs | 171 +++++++++++++++++++++------------------ src/ui/mod.rs | 148 ++++++++++++++++++++++----------- 4 files changed, 205 insertions(+), 147 deletions(-) diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 5a47543..2d3999a 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -5,28 +5,17 @@ use std::borrow::Cow; use std::collections::HashMap; use std::fmt::{Display, Formatter, Result as FmtResult}; -#[derive(Deserialize, Debug)] -pub struct StreamChunk { - pub message: StreamMessage, -} - -#[derive(Deserialize, Debug)] -pub struct StreamMessage { +#[derive(Serialize, Deserialize, Debug)] +pub struct Prompt { pub role: String, pub content: String, } -#[derive(Serialize, Deserialize, Debug)] -pub struct Prompt<'a> { - pub role: Cow<'a, str>, - pub content: Cow<'a, str>, -} - -impl<'a> From for Prompt<'a> { +impl<'a> From for Prompt { fn from(message: Message) -> Self { Prompt { - role: Cow::Owned(message.role), - content: Cow::Owned(message.content.to_string()), + role: message.role, + content: message.content.to_string(), } } } @@ -41,12 +30,12 @@ pub struct ChatOptions { } #[derive(Serialize, Debug)] -pub struct ChatRequest<'a> { - pub model: &'a str, - pub messages: Vec>, +pub struct ChatRequest { + pub model: String, + pub messages: Vec, pub stream: bool, - pub format: &'a str, - pub stop: Vec<&'a str>, + pub format: String, + pub stop: Vec, pub options: Option, } diff --git a/src/data/tools_list.json b/src/data/tools_list.json index ab1993f..eae4b8b 100644 --- a/src/data/tools_list.json +++ b/src/data/tools_list.json @@ -21,7 +21,7 @@ "type": "function", "function": { "name": "web_search", - "description": "Search DuckDuckGo (a web search engine)", + "description": "Search the web using DuckDuckGo (a web search engine)", "parameters": { "type": "object", "properties": { diff --git a/src/main.rs b/src/main.rs index 2d2b1aa..f66a52e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use crossterm::terminal; +use ratatui::CompletedFrame; use tokio::sync::mpsc; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; @@ -5,11 +7,9 @@ use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, Event as CEvent, EventStream, KeyCode, MouseButton, }; -use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, -}; use ratatui::{Frame, Terminal, backend::CrosstermBackend}; +use ui::OxiTerminal; use std::borrow::Cow; @@ -41,23 +41,30 @@ struct Args { nerd_stats: bool, } -pub struct AppStateQueue { - tx: UnboundedSender, - rx: UnboundedReceiver, +pub struct Queues { + pub tx_msg: mpsc::UnboundedSender, // worker → UI (already exists) + pub rx_msg: mpsc::UnboundedReceiver, + + pub tx_cmd: mpsc::UnboundedSender, // UI → worker (NEW) + pub rx_cmd: mpsc::UnboundedReceiver, } -impl From<(UnboundedSender, UnboundedReceiver)> for AppStateQueue { - fn from(value: (UnboundedSender, UnboundedReceiver)) -> Self { - AppStateQueue { - tx: value.0, - rx: value.1, +impl Queues { + pub fn new() -> Self { + let (tx_msg, rx_msg) = mpsc::unbounded_channel(); + let (tx_cmd, rx_cmd) = mpsc::unbounded_channel(); + Queues { + tx_msg, + rx_msg, + tx_cmd, + rx_cmd, } } } struct AppState { args: Args, - event_queue: AppStateQueue, + queues: Queues, prompt: String, messages: Vec, waiting: bool, @@ -65,7 +72,7 @@ struct AppState { } impl AppState { - const HEADER_PROMPT: &str = r#"SYSTEM: You are "OxiAI", a logical, personal assistant that answers *only* via valid, minified, UTF-8 JSON."#; + const HEADER_PROMPT: &str = r#"SYSTEM: You are "OxiAI", A personal assistant with access to tools. You answer *only* via valid, UTF-8 JSON."#; const TOOLS_LIST: &str = include_str!("data/tools_list.json"); @@ -74,14 +81,14 @@ impl AppState { 2. To use a tool: {"action":"","arguments":{...}} 3. To reply directly: {"action":"chat","arguments":{"response":"..."} 4. If a question is vague, comparative, descriptive, or about ideas rather than specifics: use the web_search tool. -5. If a question clearly names a specific object, animal, person, place: use the wiki_search tool. +5. If a question clearly names a specific entity, place, or period of time: use the wiki_search tool. 6. Base claims strictly on provided data or tool results. If unsure, say so. 7. Check your output; If you reach four consecutive newlines: *stop*"#; pub fn default(args: Args) -> AppState { AppState { args, - event_queue: AppStateQueue::from(mpsc::unbounded_channel::()), + queues: Queues::new(), prompt: String::new(), messages: vec![], waiting: false, @@ -105,7 +112,7 @@ impl AppState { Ok(()) } - pub fn handle_input(&mut self, ev: Event) -> anyhow::Result<()> { + pub fn handle_input(&mut self, ev: Event) -> anyhow::Result> { match ev { Event::FocusGained => { /* do nothing */ } Event::FocusLost => { /* do nothing */ } @@ -129,8 +136,8 @@ impl AppState { )); let mut prompts = vec![chat::Prompt { - role: Cow::Borrowed("system"), - content: Cow::Borrowed(&self.system_prompt), + role: "system".to_string(), + content: self.system_prompt.clone(), }]; prompts.extend( self.messages @@ -139,10 +146,10 @@ impl AppState { ); let req = chat::ChatRequest { - model: &self.args.model.clone(), + model: self.args.model.clone(), stream: self.args.stream, - format: "json", - stop: vec!["\n\n\n\n"], + format: "json".to_string(), + stop: vec!["\n\n\n\n".to_string()], options: Some(chat::ChatOptions { temperature: Some(0.3), top_p: Some(0.92), @@ -154,43 +161,30 @@ impl AppState { }; self.waiting = true; - match self.args.stream { - true => { - todo!(); - } - false => { - todo!(); - } - } + return Ok(Some(Cmd::RunChat { req })); } _ => { /* ignore all other keys */ } } } - Event::Mouse(mouse_event) => { - match mouse_event.kind { - event::MouseEventKind::Up(MouseButton::Left) => { - // --- Kick off an HTTP worker as a proof-of-concept ---- - let tx = self.event_queue.tx.clone(); - tokio::spawn(async move { - let res: Result = async { - let resp = reqwest::get("https://ifconfig.me/all").await?; - resp.text().await - } - .await; - let _ = tx.send(Msg::HttpDone(res)); - }); - } - _ => {} - } - } + Event::Mouse(mouse_event) => match mouse_event.kind { + event::MouseEventKind::Up(MouseButton::Left) => {} + _ => {} + }, Event::Paste(_) => { /* do nothing */ } Event::Resize(_, _) => { /* do nothing */ } } - Ok(()) + Ok(None) } } +/// Cmds that can arrive in the command event queue +enum Cmd { + RunChat { req: chat::ChatRequest }, + GetAddr, + Quit, +} + /// Messages that can arrive in the UI loop enum Msg { Input(CEvent), @@ -199,7 +193,7 @@ enum Msg { #[tokio::main] async fn main() -> anyhow::Result<()> { - // parse arguments + // parse arguments from Clap let args = match Args::try_parse() { Ok(args) => args, Err(e) => { @@ -208,47 +202,42 @@ async fn main() -> anyhow::Result<()> { } }; - // ---- UI LOOP ---------------------------------------------------------- - enable_raw_mode()?; // crossterm - let mut stdout_handle = std::io::stdout(); - crossterm::execute!(stdout_handle, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout_handle); - let mut terminal = Terminal::new(backend)?; - terminal.clear()?; + // UI Event Loop let mut events = EventStream::new(); let mut ticker = tokio::time::interval(std::time::Duration::from_millis(33)); + let mut terminal = OxiTerminal::setup(); let mut state = AppState::default(args); 'uiloop: loop { // first – non-blocking drain of all pending messages - 'drain_event_loop: while let Ok(msg) = state.event_queue.rx.try_recv() { + while let Ok(msg) = state.queues.rx_msg.try_recv() { match msg { Msg::Input(ev) => match ev.as_key_event() { Some(ke) => { if ke.code == KeyCode::Esc { - term_cleanup(&mut terminal)?; - return Ok(()); + return terminal.term_cleanup(); } else { - state.handle_input(ev)? + if let Some(cmd) = state.handle_input(ev)? { + if state.queues.tx_cmd.send(cmd).is_err() { + break; + } + } } } - None => break 'drain_event_loop, + None => {} }, Msg::HttpDone(r) => state.handle_http_done(r)?, }; } - // draw a new frame - terminal.draw(|f| ui::chat_ui(f, &state))?; - // block until either next tick or next user input tokio::select! { - _ = ticker.tick() => { /* redraw ui per tick rate */}, + _ = ticker.tick() => { terminal.do_draw(&state); }, maybe_ev = events.next() => { if let Some(Ok(ev)) = maybe_ev { - if state.event_queue.tx.send(Msg::Input(ev)).is_err() { break 'uiloop } + if state.queues.tx_msg.send(Msg::Input(ev)).is_err() { break 'uiloop } } } } @@ -257,16 +246,44 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -fn term_cleanup( - terminal: &mut Terminal, -) -> anyhow::Result<()> { - disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - Ok(()) +async fn run_workers( + mut rx_cmd: mpsc::UnboundedReceiver, + tx_msg: mpsc::UnboundedSender, + model: String, +) { + while let Some(cmd) = rx_cmd.recv().await { + match cmd { + Cmd::RunChat { req } => { + let tx_msg = tx_msg.clone(); + tokio::spawn(async move { + let res = ollama_call(req).await; // see next section + let _ = tx_msg.send(Msg::HttpDone(res)); + }); + } + Cmd::GetAddr => { + // --- Kick off an HTTP worker as a proof-of-concept ---- + let tx_msg = tx_msg.clone(); + tokio::spawn(async move { + let res: Result = async { + let resp = reqwest::get("https://ifconfig.me/all").await?; + resp.text().await + } + .await; + let _ = tx_msg.send(Msg::HttpDone(res)); + }); + } + Cmd::Quit => break, + } + } +} + +async fn ollama_call(req: chat::ChatRequest) -> Result { + let client = reqwest::Client::new(); + client + .post("http://localhost:11434/api/chat") + .json(&req) + .send() + .await? + .text() + .await } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2d14b14..6541ecd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,17 @@ +use std::io::IsTerminal; + +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; + +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, Event as CEvent, EventStream, KeyCode, + MouseButton, +}; + +use ratatui::CompletedFrame; +use ratatui::prelude::Backend; +use ratatui::{Frame, Terminal, backend::CrosstermBackend}; use ratatui::{ layout::{Constraint, Direction, Layout}, style::{Color, Style}, @@ -5,53 +19,91 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; -pub fn chat_ui(f: &mut ratatui::Frame, app: &crate::AppState) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([Constraint::Min(1), Constraint::Length(3)].as_ref()) - .split(f.area()); +use crate::AppState; - let chat_messages: Vec = app - .messages - .iter() - .map(|m| { - Line::from(Span::raw(format!( - "{}: {}", - m.role.to_string(), - m.to_string() - ))) - }) - .collect(); - - let messages_block = Paragraph::new(ratatui::text::Text::from(chat_messages)) - .block(Block::default().borders(Borders::ALL).title("Chat")) - .wrap(ratatui::widgets::Wrap { trim: true }) - .scroll(( - app.messages - .len() - .saturating_sub((chunks[0].height - 2) as usize) as u16, - 0, - )); - - f.render_widget(messages_block, chunks[0]); - - let input_text = if app.waiting { - format!("> {} (waiting...)", &app.prompt) - } else { - format!("> {}", app.prompt) - }; - - let input = Paragraph::new(input_text) - .style(Style::default().fg(Color::Yellow)) - .block(Block::default().borders(Borders::ALL).title("Input")); - f.render_widget(input, chunks[1]); - - use ratatui::layout::Position; - f.set_cursor_position(Position::new( - // the +3 comes from the 3 'characters' of space between the terminal edge and the text location - // this places the text cursor after the last entered character - chunks[1].x + app.prompt.len() as u16 + 3, - chunks[1].y + 1, - )); +pub struct OxiTerminal { + handle: Terminal>, +} + +impl OxiTerminal { + pub fn setup() -> Self { + enable_raw_mode(); // crossterm + let mut stdout_handle = std::io::stdout(); + crossterm::execute!(stdout_handle, EnterAlternateScreen, EnableMouseCapture); + let backend = CrosstermBackend::new(stdout_handle); + let mut handle = Terminal::new(backend).expect("unable to open a terminal"); + handle.clear(); + + OxiTerminal { handle } + } + + pub fn do_draw(&mut self, app: &AppState) -> CompletedFrame { + self.handle + .draw(|f| OxiTerminal::chat_ui(f, app)) + .expect("failed to draw to framebuffer") + } + + pub fn term_cleanup(&mut self) -> anyhow::Result<()> { + disable_raw_mode()?; + crossterm::execute!( + self.handle.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + self.handle.show_cursor()?; + + Ok(()) + } + + //FIXME: awaiting refactor + pub fn chat_ui(f: &mut ratatui::Frame, app: &crate::AppState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(1), Constraint::Length(3)].as_ref()) + .split(f.area()); + + let chat_messages: Vec = app + .messages + .iter() + .map(|m| { + Line::from(Span::raw(format!( + "{}: {}", + m.role.to_string(), + m.to_string() + ))) + }) + .collect(); + + let messages_block = Paragraph::new(ratatui::text::Text::from(chat_messages)) + .block(Block::default().borders(Borders::ALL).title("Chat")) + .wrap(ratatui::widgets::Wrap { trim: true }) + .scroll(( + app.messages + .len() + .saturating_sub((chunks[0].height - 2) as usize) as u16, + 0, + )); + + f.render_widget(messages_block, chunks[0]); + + let input_text = if app.waiting { + format!("> {} (waiting...)", &app.prompt) + } else { + format!("> {}", app.prompt) + }; + + let input = Paragraph::new(input_text) + .style(Style::default().fg(Color::Yellow)) + .block(Block::default().borders(Borders::ALL).title("Input")); + f.render_widget(input, chunks[1]); + + use ratatui::layout::Position; + f.set_cursor_position(Position::new( + // the +3 comes from the 3 'characters' of space between the terminal edge and the text location + // this places the text cursor after the last entered character + chunks[1].x + app.prompt.len() as u16 + 3, + chunks[1].y + 1, + )); + } }