going to be adapting the Elm MVU model for event applications here, there are some good examples around.

This commit is contained in:
2025-04-29 17:27:57 -04:00
parent a8f264bbbc
commit e8d146b596
2 changed files with 77 additions and 86 deletions

View File

@@ -1,5 +1,5 @@
use serde::de::{self, Deserializer as DeDeserializer, IntoDeserializer, Visitor}; use serde::de::{self, Deserializer as DeDeserializer, IntoDeserializer, Visitor};
use serde::{Deserialize, Serialize, Serializer, Deserializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;

View File

@@ -1,3 +1,14 @@
use tokio::{sync::mpsc, task};
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, EventStream, Event as CEvent, KeyCode,
};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::{Frame, Terminal, backend::CrosstermBackend};
use std::borrow::Cow; use std::borrow::Cow;
use std::pin::Pin; use std::pin::Pin;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -7,12 +18,7 @@ use clap::Parser;
use futures_util::StreamExt; use futures_util::StreamExt;
use reqwest::Client; use reqwest::Client;
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}; use futures_util::stream::StreamExt;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::{Terminal, backend::CrosstermBackend};
mod chat; mod chat;
mod ui; mod ui;
@@ -38,13 +44,23 @@ struct Args {
nerd_stats: bool, nerd_stats: bool,
} }
struct App { struct AppState {
args: Args, args: Args,
prompt: String, prompt: String,
messages: Vec<Message>, messages: Vec<Message>,
waiting: bool, waiting: bool,
} }
impl AppState {
}
/// Messages that can arrive in the UI loop
enum Msg {
Input(CEvent),
HttpDone(Result<String, reqwest::Error>),
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// parse arguments // parse arguments
@@ -56,21 +72,35 @@ async fn main() -> anyhow::Result<()> {
} }
}; };
// setup crossterm // channel capacity 100 is plenty for a TUI
enable_raw_mode()?; let (tx, mut rx) = mpsc::unbounded_channel::<Msg>();
// --- Kick off an HTTP worker as a proof-of-concept ----
{
let tx = tx.clone();
tokio::spawn(async move {
let res: Result<String, reqwest::Error> = async {
let resp = reqwest::get("https://ifconfig.me/all.json").await?;
resp.text().await
}.await;
let _ = tx.send(Msg::HttpDone(res));
});
}
// ---- UI LOOP ----------------------------------------------------------
enable_raw_mode()?; // crossterm
let mut stdout_handle = std::io::stdout(); let mut stdout_handle = std::io::stdout();
crossterm::execute!(stdout_handle, EnterAlternateScreen, EnableMouseCapture)?; crossterm::execute!(stdout_handle, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout_handle); let backend = CrosstermBackend::new(stdout_handle);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let mut app = App { let mut events = EventStream::new();
args, // fixed-rate tick for animations
prompt: String::new(), let mut ticker = tokio::time::interval(std::time::Duration::from_millis(33));
messages: vec![],
waiting: false,
};
let client = Client::new();
let header_prompt = r#"SYSTEM: You are "OxiAI", a logical, personal assistant that answers *only* via valid, minified, UTF-8 JSON."#; let header_prompt = r#"SYSTEM: You are "OxiAI", a logical, personal assistant that answers *only* via valid, minified, UTF-8 JSON."#;
@@ -87,89 +117,50 @@ async fn main() -> anyhow::Result<()> {
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*"#;
//let user_info_prompt = r#""#;
let system_prompt = format!( let system_prompt = format!(
"{header_prompt}\n "{header_prompt}\n
{tools_list}\n\n {tools_list}\n\n
{rules_prompt}\n" {rules_prompt}\n"
); );
let mut state = AppState {
args,
prompt: String::new(),
messages: vec![],
waiting: false,
};
loop { loop {
terminal.draw(|f| ui::chat_ui(f, &app))?; // first non-blocking drain of all pending messages
while let Ok(Some(msg)) = rx.try_recv() {
if event::poll(Duration::from_millis(100))? { match msg {
if let Event::Key(key) = event::read()? { Msg::Input(ev) => {
match key.code { if matches!(ev, CEvent::Key(k) if k.code == KeyCode::Char('q')) {
KeyCode::Char(c) => app.prompt.push(c), cleanup(&mut terminal)?;
KeyCode::Backspace => { return Ok(());
app.prompt.pop();
} }
KeyCode::Enter => { state.handle_input(ev);
//TODO: refactor to a parser function to take the contents of the app.prompt vec and do fancy stuff with it (like commands) }
let message_args = args_builder! { Msg::HttpDone(r) => state.handle_http(r),
"response" => app.prompt.clone(), }
}; }
app.prompt.clear();
app.messages.push(chat::Message::new( // draw a new frame
chat::MessageRoles::User, terminal.draw(|f| ui::chat_ui(f, &state))?;
chat::Action::Chat,
message_args,
));
let mut prompts = vec![chat::Prompt { // block until either next tick or next user input
role: Cow::Borrowed("system"), tokio::select! {
content: Cow::Borrowed(&system_prompt), _ = ticker.tick() => { /* redraw ui per tick rate */},
}];
prompts.extend(
app.messages
.iter()
.map(|msg| chat::Prompt::from(msg.clone())),
);
let req = chat::ChatRequest { maybe_ev = events.next() => {
model: &app.args.model.clone(), if let Some(Ok(ev)) = maybe_ev {
stream: app.args.stream, if tx.send(Msg::Input(ev)).is_err() { break }
format: "json",
stop: vec!["\n\n\n\n"],
options: Some(chat::ChatOptions {
temperature: Some(0.3),
top_p: Some(0.92),
top_k: Some(50),
repeat_penalty: Some(1.1),
seed: None,
}),
messages: prompts,
};
app.waiting = true;
match app.args.stream {
true => {
todo!();
stream_ollama_response(&mut app, client.clone(), req).await?;
}
false => {
batch_ollama_response(&mut app, client.clone(), req).await?;
}
}
}
KeyCode::Esc => {
break;
}
_ => {}
} }
} }
} }
} }
disable_raw_mode()?;
crossterm::execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
} }
//FIXME: streaming replies are harder to work with for now, save this for the future //FIXME: streaming replies are harder to work with for now, save this for the future
@@ -305,4 +296,4 @@ fn batch_ollama_response_inner<'a>(
app.waiting = false; app.waiting = false;
Ok(()) Ok(())
}) })
} }