UI is async and event driven now, backend code needs to be linked up to the message queues correctly next.

This commit is contained in:
2025-04-30 17:43:43 -04:00
parent 18019488ec
commit c1df6cea19
4 changed files with 205 additions and 147 deletions

View File

@@ -5,28 +5,17 @@ use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::{Display, Formatter, Result as FmtResult}; use std::fmt::{Display, Formatter, Result as FmtResult};
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct StreamChunk { pub struct Prompt {
pub message: StreamMessage,
}
#[derive(Deserialize, Debug)]
pub struct StreamMessage {
pub role: String, pub role: String,
pub content: String, pub content: String,
} }
#[derive(Serialize, Deserialize, Debug)] impl<'a> From<Message> for Prompt {
pub struct Prompt<'a> {
pub role: Cow<'a, str>,
pub content: Cow<'a, str>,
}
impl<'a> From<Message> for Prompt<'a> {
fn from(message: Message) -> Self { fn from(message: Message) -> Self {
Prompt { Prompt {
role: Cow::Owned(message.role), role: message.role,
content: Cow::Owned(message.content.to_string()), content: message.content.to_string(),
} }
} }
} }
@@ -41,12 +30,12 @@ pub struct ChatOptions {
} }
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct ChatRequest<'a> { pub struct ChatRequest {
pub model: &'a str, pub model: String,
pub messages: Vec<Prompt<'a>>, pub messages: Vec<Prompt>,
pub stream: bool, pub stream: bool,
pub format: &'a str, pub format: String,
pub stop: Vec<&'a str>, pub stop: Vec<String>,
pub options: Option<ChatOptions>, pub options: Option<ChatOptions>,
} }

View File

@@ -21,7 +21,7 @@
"type": "function", "type": "function",
"function": { "function": {
"name": "web_search", "name": "web_search",
"description": "Search DuckDuckGo (a web search engine)", "description": "Search the web using DuckDuckGo (a web search engine)",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -1,3 +1,5 @@
use crossterm::terminal;
use ratatui::CompletedFrame;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
@@ -5,11 +7,9 @@ use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, Event as CEvent, EventStream, KeyCode, self, DisableMouseCapture, EnableMouseCapture, Event, Event as CEvent, EventStream, KeyCode,
MouseButton, MouseButton,
}; };
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::{Frame, Terminal, backend::CrosstermBackend}; use ratatui::{Frame, Terminal, backend::CrosstermBackend};
use ui::OxiTerminal;
use std::borrow::Cow; use std::borrow::Cow;
@@ -41,23 +41,30 @@ struct Args {
nerd_stats: bool, nerd_stats: bool,
} }
pub struct AppStateQueue { pub struct Queues {
tx: UnboundedSender<Msg>, pub tx_msg: mpsc::UnboundedSender<Msg>, // worker → UI (already exists)
rx: UnboundedReceiver<Msg>, pub rx_msg: mpsc::UnboundedReceiver<Msg>,
pub tx_cmd: mpsc::UnboundedSender<Cmd>, // UI → worker (NEW)
pub rx_cmd: mpsc::UnboundedReceiver<Cmd>,
} }
impl From<(UnboundedSender<Msg>, UnboundedReceiver<Msg>)> for AppStateQueue { impl Queues {
fn from(value: (UnboundedSender<Msg>, UnboundedReceiver<Msg>)) -> Self { pub fn new() -> Self {
AppStateQueue { let (tx_msg, rx_msg) = mpsc::unbounded_channel();
tx: value.0, let (tx_cmd, rx_cmd) = mpsc::unbounded_channel();
rx: value.1, Queues {
tx_msg,
rx_msg,
tx_cmd,
rx_cmd,
} }
} }
} }
struct AppState { struct AppState {
args: Args, args: Args,
event_queue: AppStateQueue, queues: Queues,
prompt: String, prompt: String,
messages: Vec<Message>, messages: Vec<Message>,
waiting: bool, waiting: bool,
@@ -65,7 +72,7 @@ struct AppState {
} }
impl 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"); const TOOLS_LIST: &str = include_str!("data/tools_list.json");
@@ -74,14 +81,14 @@ impl AppState {
2. To use a tool: {"action":"<tool>","arguments":{...}} 2. To use a tool: {"action":"<tool>","arguments":{...}}
3. To reply directly: {"action":"chat","arguments":{"response":"..."} 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. 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. 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*"#; 7. Check your output; If you reach four consecutive newlines: *stop*"#;
pub fn default(args: Args) -> AppState { pub fn default(args: Args) -> AppState {
AppState { AppState {
args, args,
event_queue: AppStateQueue::from(mpsc::unbounded_channel::<Msg>()), queues: Queues::new(),
prompt: String::new(), prompt: String::new(),
messages: vec![], messages: vec![],
waiting: false, waiting: false,
@@ -105,7 +112,7 @@ impl AppState {
Ok(()) Ok(())
} }
pub fn handle_input(&mut self, ev: Event) -> anyhow::Result<()> { pub fn handle_input(&mut self, ev: Event) -> anyhow::Result<Option<Cmd>> {
match ev { match ev {
Event::FocusGained => { /* do nothing */ } Event::FocusGained => { /* do nothing */ }
Event::FocusLost => { /* do nothing */ } Event::FocusLost => { /* do nothing */ }
@@ -129,8 +136,8 @@ impl AppState {
)); ));
let mut prompts = vec![chat::Prompt { let mut prompts = vec![chat::Prompt {
role: Cow::Borrowed("system"), role: "system".to_string(),
content: Cow::Borrowed(&self.system_prompt), content: self.system_prompt.clone(),
}]; }];
prompts.extend( prompts.extend(
self.messages self.messages
@@ -139,10 +146,10 @@ impl AppState {
); );
let req = chat::ChatRequest { let req = chat::ChatRequest {
model: &self.args.model.clone(), model: self.args.model.clone(),
stream: self.args.stream, stream: self.args.stream,
format: "json", format: "json".to_string(),
stop: vec!["\n\n\n\n"], stop: vec!["\n\n\n\n".to_string()],
options: Some(chat::ChatOptions { options: Some(chat::ChatOptions {
temperature: Some(0.3), temperature: Some(0.3),
top_p: Some(0.92), top_p: Some(0.92),
@@ -154,43 +161,30 @@ impl AppState {
}; };
self.waiting = true; self.waiting = true;
match self.args.stream { return Ok(Some(Cmd::RunChat { req }));
true => {
todo!();
}
false => {
todo!();
}
}
} }
_ => { /* ignore all other keys */ } _ => { /* ignore all other keys */ }
} }
} }
Event::Mouse(mouse_event) => { Event::Mouse(mouse_event) => match mouse_event.kind {
match mouse_event.kind { event::MouseEventKind::Up(MouseButton::Left) => {}
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<String, reqwest::Error> = async {
let resp = reqwest::get("https://ifconfig.me/all").await?;
resp.text().await
}
.await;
let _ = tx.send(Msg::HttpDone(res));
});
}
_ => {} _ => {}
} },
}
Event::Paste(_) => { /* do nothing */ } Event::Paste(_) => { /* do nothing */ }
Event::Resize(_, _) => { /* 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 /// Messages that can arrive in the UI loop
enum Msg { enum Msg {
Input(CEvent), Input(CEvent),
@@ -199,7 +193,7 @@ enum Msg {
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// parse arguments // parse arguments from Clap
let args = match Args::try_parse() { let args = match Args::try_parse() {
Ok(args) => args, Ok(args) => args,
Err(e) => { Err(e) => {
@@ -208,47 +202,42 @@ async fn main() -> anyhow::Result<()> {
} }
}; };
// ---- UI LOOP ---------------------------------------------------------- // UI Event 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()?;
let mut events = EventStream::new(); let mut events = EventStream::new();
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(33)); let mut ticker = tokio::time::interval(std::time::Duration::from_millis(33));
let mut terminal = OxiTerminal::setup();
let mut state = AppState::default(args); let mut state = AppState::default(args);
'uiloop: loop { 'uiloop: loop {
// first non-blocking drain of all pending messages // 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 { match msg {
Msg::Input(ev) => match ev.as_key_event() { Msg::Input(ev) => match ev.as_key_event() {
Some(ke) => { Some(ke) => {
if ke.code == KeyCode::Esc { if ke.code == KeyCode::Esc {
term_cleanup(&mut terminal)?; return terminal.term_cleanup();
return Ok(());
} else { } 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)?, 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 // block until either next tick or next user input
tokio::select! { tokio::select! {
_ = ticker.tick() => { /* redraw ui per tick rate */}, _ = ticker.tick() => { terminal.do_draw(&state); },
maybe_ev = events.next() => { maybe_ev = events.next() => {
if let Some(Ok(ev)) = maybe_ev { 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(()) Ok(())
} }
fn term_cleanup<B: ratatui::backend::Backend + std::io::Write>( async fn run_workers(
terminal: &mut Terminal<B>, mut rx_cmd: mpsc::UnboundedReceiver<Cmd>,
) -> anyhow::Result<()> { tx_msg: mpsc::UnboundedSender<Msg>,
disable_raw_mode()?; model: String,
crossterm::execute!( ) {
terminal.backend_mut(), while let Some(cmd) = rx_cmd.recv().await {
LeaveAlternateScreen, match cmd {
DisableMouseCapture Cmd::RunChat { req } => {
)?; let tx_msg = tx_msg.clone();
terminal.show_cursor()?; tokio::spawn(async move {
let res = ollama_call(req).await; // see next section
Ok(()) 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<String, reqwest::Error> = 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<String, reqwest::Error> {
let client = reqwest::Client::new();
client
.post("http://localhost:11434/api/chat")
.json(&req)
.send()
.await?
.text()
.await
} }

View File

@@ -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::{ use ratatui::{
layout::{Constraint, Direction, Layout}, layout::{Constraint, Direction, Layout},
style::{Color, Style}, style::{Color, Style},
@@ -5,7 +19,44 @@ use ratatui::{
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph},
}; };
pub fn chat_ui(f: &mut ratatui::Frame, app: &crate::AppState) { use crate::AppState;
pub struct OxiTerminal {
handle: Terminal<CrosstermBackend<std::io::Stdout>>,
}
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() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
@@ -54,4 +105,5 @@ pub fn chat_ui(f: &mut ratatui::Frame, app: &crate::AppState) {
chunks[1].x + app.prompt.len() as u16 + 3, chunks[1].x + app.prompt.len() as u16 + 3,
chunks[1].y + 1, chunks[1].y + 1,
)); ));
}
} }