aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOxbian <oxbian@mailbox.org>2025-02-17 22:14:48 -0500
committerOxbian <oxbian@mailbox.org>2025-02-17 22:14:48 -0500
commitb6d93c110c8a14f738d897c0012a2a7d61044f67 (patch)
tree6f6a83d8414c70bcadc19c5f9c2dd82266be39fa
downloadNAI-b6d93c110c8a14f738d897c0012a2a7d61044f67.tar.gz
NAI-b6d93c110c8a14f738d897c0012a2a7d61044f67.zip
feat: basic working interface + LLM API call
-rw-r--r--.gitignore1
-rw-r--r--Cargo.toml11
-rw-r--r--README.md9
-rw-r--r--src/app.rs68
-rw-r--r--src/main.rs22
-rw-r--r--src/ui.rs195
6 files changed, 306 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..3d4c780
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "nai"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+color-eyre = "0.6.3"
+ratatui = "0.29.0"
+reqwest = { version = "0.12.12", features = ["blocking", "json"] }
+serde = { version = "1.0.217", features = ["derive"] }
+serde_json = "1.0.138"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e314766
--- /dev/null
+++ b/README.md
@@ -0,0 +1,9 @@
+# NAI
+---
+
+Néo AI, a personnal assistant using LLM.
+
+## TODO
+
+- Gérer la sortie de l'interface, async...
+- Interface gauche <-> droite selon si c'est un bot ou un user
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..efc90ca
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,68 @@
+use reqwest;
+use serde_json::{Value};
+use color_eyre::Result;
+use std::{fmt, collections::HashMap};
+
+#[derive(Debug)]
+pub struct App {
+ pub messages: Vec<Message>, // History of recorded messages
+}
+
+#[derive(Debug)]
+pub struct Message {
+ content: String,
+ msg_type: MessageType,
+}
+
+impl fmt::Display for Message {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.content)
+ }
+}
+
+#[derive(Debug)]
+pub enum MessageType {
+ Human,
+ LLM,
+}
+
+impl App {
+ pub fn new() -> App {
+ App {
+ messages: Vec::new(),
+ }
+ }
+
+ pub fn send_message(&mut self, content: String) -> Result<()> {
+ // POST: http://localhost:8080/completion {"prompt": "lorem ipsum"}
+ self.messages.push(Message{
+ content: content.clone(),
+ msg_type: MessageType::Human
+ });
+
+ let client = reqwest::blocking::Client::new();
+ let response = client.post("http://localhost:8080/completion").json(&serde_json::json!({
+ "prompt": &content,
+ "n_predict": 128,
+ })).send()?;
+
+ if response.status().is_success() {
+ // Désérialiser la réponse JSON
+ let json_response: Value = response.json()?;
+
+ // Accéder à la partie spécifique du JSON
+ if let Some(msg) = json_response["content"].as_str() {
+ self.messages.push(Message{
+ content: msg.to_string().clone(),
+ msg_type: MessageType::LLM,
+ });
+ } else {
+ println!("Le champ 'data.id' est absent ou mal formaté.");
+ }
+ } else {
+ eprintln!("La requête a échoué avec le statut : {}", response.status());
+ }
+
+ Ok(())
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..b1e52d6
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,22 @@
+use crate::{
+ app::App,
+ ui::Ui
+};
+use ratatui;
+use color_eyre::Result;
+//use reqwest;
+mod app;
+mod ui;
+
+fn main() -> Result<()> {
+ // Setup terminal
+ let mut terminal = ratatui::init();
+
+ // Run the app
+ let mut app = App::new();
+ let res = Ui::new(app).run(terminal);
+
+ // Clean
+ ratatui::restore();
+ res
+}
diff --git a/src/ui.rs b/src/ui.rs
new file mode 100644
index 0000000..6726e47
--- /dev/null
+++ b/src/ui.rs
@@ -0,0 +1,195 @@
+use crate::app::App;
+use color_eyre::Result;
+use ratatui::{
+ crossterm::event::{self, Event, KeyCode, KeyEventKind},
+ layout::{Constraint, Layout, Position},
+ style::{Color, Modifier, Style, Stylize},
+ text::{Line, Span, Text},
+ widgets::{Block, List, ListItem, Paragraph},
+ DefaultTerminal, Frame,
+};
+
+pub struct Ui {
+ input: String,
+ input_mode: InputMode,
+ character_index: usize,
+ app: App,
+}
+
+pub enum InputMode {
+ Normal,
+ Editing,
+}
+
+impl Ui {
+ pub fn new(app: App) -> Self {
+ Self {
+ input: String::new(),
+ input_mode: InputMode::Normal,
+ character_index: 0,
+ app,
+ }
+ }
+
+ fn move_cursor_left(&mut self) {
+ let cursor_moved_left = self.character_index.saturating_sub(1);
+ self.character_index = self.clamp_cursor(cursor_moved_left);
+ }
+
+ fn move_cursor_right(&mut self) {
+ let cursor_moved_right = self.character_index.saturating_add(1);
+ self.character_index = self.clamp_cursor(cursor_moved_right);
+ }
+
+ fn enter_char(&mut self, new_char: char) {
+ let index = self.byte_index();
+ self.input.insert(index, new_char);
+ self.move_cursor_right();
+ }
+
+ fn byte_index(&self) -> usize {
+ self.input
+ .char_indices()
+ .map(|(i, _)| i)
+ .nth(self.character_index)
+ .unwrap_or(self.input.len())
+ }
+
+ fn delete_char(&mut self) {
+ let is_not_cursor_leftmost = self.character_index != 0;
+ if is_not_cursor_leftmost {
+ // Method "remove" is not used on the saved text for deleting the selected char.
+ // Reason: Using remove on String works on bytes instead of the chars.
+ // Using remove would require special care because of char boundaries.
+
+ let current_index = self.character_index;
+ let from_left_to_current_index = current_index - 1;
+
+ // Getting all characters before the selected character.
+ let before_char_to_delete = self.input.chars().take(from_left_to_current_index);
+ // Getting all characters after selected character.
+ let after_char_to_delete = self.input.chars().skip(current_index);
+
+ // Put all characters together except the selected one.
+ // By leaving the selected one out, it is forgotten and therefore deleted.
+ self.input = before_char_to_delete.chain(after_char_to_delete).collect();
+ self.move_cursor_left();
+ }
+ }
+
+ fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
+ new_cursor_pos.clamp(0, self.input.chars().count())
+ }
+
+ fn reset_cursor(&mut self) {
+ self.character_index = 0;
+ }
+
+ // Send the message to the LLM API when "enter" pressed
+ fn submit_message(&mut self) {
+ self.input_mode = InputMode::Normal;
+ self.app.send_message(self.input.clone());
+ self.input.clear();
+ self.reset_cursor();
+
+ }
+
+ pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
+ loop {
+ terminal.draw(|frame| self.draw(frame))?;
+
+ if let Event::Key(key) = event::read()? {
+ match self.input_mode {
+ InputMode::Normal => match key.code {
+ KeyCode::Char('e') => {
+ self.input_mode = InputMode::Editing;
+ }
+ KeyCode::Char('q') => {
+ return Ok(())
+ }
+ _ => {}
+ },
+ InputMode::Editing if key.kind == KeyEventKind::Press => match key.code {
+ KeyCode::Enter => self.submit_message(),
+ KeyCode::Char(to_insert) => self.enter_char(to_insert),
+ KeyCode::Backspace => self.delete_char(),
+ KeyCode::Left => self.move_cursor_left(),
+ KeyCode::Right => self.move_cursor_right(),
+ KeyCode::Esc => self.input_mode = InputMode::Normal,
+ _ => {}
+ },
+ InputMode::Editing => {}
+ }
+ }
+ }
+ }
+
+ fn draw(&self, frame: &mut Frame) {
+ let vertical = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Min(1),
+ Constraint::Length(3),
+ ]);
+ let [help_area, messages_area, input_area] = vertical.areas(frame.area());
+
+ let (msg, style) = match self.input_mode {
+ InputMode::Normal => (
+ vec![
+ "Press ".into(),
+ "q".bold(),
+ " to exit, ".into(),
+ "e".bold(),
+ " to start editing.".bold(),
+ ],
+ Style::default(),
+ ),
+ InputMode::Editing => (
+ vec![
+ "Press ".into(),
+ "Esc".bold(),
+ " to stop editing, ".into(),
+ "Enter".bold(),
+ " to record the message".into(),
+ ],
+ Style::default(),
+ ),
+ };
+ let text = Text::from(Line::from(msg)).patch_style(style);
+ let help_message = Paragraph::new(text);
+ frame.render_widget(help_message, help_area);
+
+ let input = Paragraph::new(self.input.as_str())
+ .style(match self.input_mode {
+ InputMode::Normal => Style::default(),
+ InputMode::Editing => Style::default().fg(Color::Yellow),
+ })
+ .block(Block::bordered().title("Input"));
+ frame.render_widget(input, input_area);
+ match self.input_mode {
+ // Hide the cursor. `Frame` does this by default, so we don't need to do anything here
+ InputMode::Normal => {}
+
+ // Make the cursor visible and ask ratatui to put it at the specified coordinates after
+ // rendering
+ #[allow(clippy::cast_possible_truncation)]
+ InputMode::Editing => frame.set_cursor_position(Position::new(
+ // Draw the cursor at the current position in the input field.
+ // This position is can be controlled via the left and right arrow key
+ input_area.x + self.character_index as u16 + 1,
+ // Move one line down, from the border to the input line
+ input_area.y + 1,
+ )),
+ }
+
+ let messages: Vec<ListItem> = self.app.messages
+ .iter()
+ .enumerate()
+ .map(|(i, m)| {
+ let content = Line::from(Span::raw(format!("{i}: {m}")));
+ ListItem::new(content)
+ })
+ .collect();
+ let messages = List::new(messages).block(Block::bordered().title("Messages"));
+ frame.render_widget(messages, messages_area);
+ }
+}
ArKa projects. All rights to me, and your next child right arm.