diff --git a/Cargo.lock b/Cargo.lock index 4238554..5e44295 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] diff --git a/Cargo.toml b/Cargo.toml index efd88d9..abc6423 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] async-openai = { version = "0.40.2", features = ["completions", "full"] } -chrono = "0.4.44" +chrono = { version = "0.4.44", features = ["serde"] } color-eyre = "0.6.5" crossterm = { version = "0.29.0", features = ["event-stream"] } futures = "0.3.32" diff --git a/src/main.rs b/src/main.rs index 503c3d4..3e44074 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,17 @@ -use std::time::Duration; - -use async_openai::{Client, config::OpenAIConfig, types::chat::{ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage, ChatCompletionRequestUserMessage, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse, FunctionObject, ResponseFormat, ResponseFormatJsonSchema}}; +use async_openai::{Client, config::OpenAIConfig, types::chat::{ChatCompletionMessageToolCalls, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse, FunctionObject}}; +use chrono::{DateTime, Duration, Utc}; use futures_timer::Delay; use schemars::{JsonSchema, schema_for}; use scraper::{Html, Selector}; use serde::{Deserialize, Serialize}; -use serde_json::Value; -use ratatui::{Frame, layout::{Constraint, Direction, Layout}, style::Modifier, widgets::{Block, BorderType, Clear, List, ListDirection, ListItem, ListState, Paragraph, Wrap}}; +use ratatui::{Frame, layout::{Constraint, Direction, Layout}, widgets::{Block, BorderType, Clear, List, ListDirection, ListItem, ListState, Paragraph, Wrap}}; use throbber_widgets_tui::{Throbber, ThrobberState}; use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}}; use tokio::sync::watch; use tui_input::{Input, backend::crossterm::EventHandler}; use std::process::Command; use futures::{StreamExt, future::FutureExt}; -use futures::select; /* Usage loop: - Prompt user to select one of: @@ -34,10 +31,12 @@ use futures::select; - Right panel: Shortcuts for triggering scenarios, soundboard events, etc */ -const SYSTEM_PROMPT: &str = include_str!("system-prompt.txt"); - use ratatui::prelude::*; +use crate::scene::{ConversationEntry, Scene}; + +mod scene; + #[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] struct PossibleResponse { text: String, @@ -63,23 +62,6 @@ struct GeneratedResponses { responses: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -enum ConversationEntry { - User(String), - Eva(String), - ShipComputer(String), - StageDirection(String), -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -struct StageDirection { - episode_number: u32, - time_remaining: Duration, - narrative: String, - artifacts: Vec -} - - #[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)] struct StageDirectionUpdate { episode_number: Option, @@ -91,66 +73,13 @@ struct StageEventArgs { text: String, } -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -struct Scene { - conversation: Vec, - direction: StageDirection -} - -impl Into for Scene { - fn into(self) -> CreateChatCompletionRequest { - let mut messages = vec![ - ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: SYSTEM_PROMPT.into(), ..Default::default()}), - ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: serde_json::to_string(&self.direction).unwrap().into(), ..Default::default()}), - ]; - messages.extend(self.conversation.into_iter().map(|entry| { - match entry { - ConversationEntry::User(text) => ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage { content: text.into(), ..Default::default()}), - ConversationEntry::Eva(text) => ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage { content: Some(text.into()), ..Default::default()}), - ConversationEntry::ShipComputer(text) => ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("ship-computer".into()), ..Default::default() }), - ConversationEntry::StageDirection(text) => ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("stage-direction".into()), ..Default::default() }) - } - })); - let response_schema: Value = schema_for!(GeneratedResponses).into(); - let tools = vec![ - ChatCompletionTools::Function( - ChatCompletionTool { - function: FunctionObject { - name: "set_stage_direction".into(), - description: Some("Set or update the current stage direction instructions.".into()), - parameters: Some(schema_for!(StageDirectionUpdate).into()), - ..Default::default() - } - } - ), - ChatCompletionTools::Function( - ChatCompletionTool { - function: FunctionObject { - name: "log_stage_event".into(), - description: Some("Log an event in the stage direction.".into()), - parameters: Some(schema_for!(StageEventArgs).into()), - ..Default::default() - } - } - ) - ]; - CreateChatCompletionRequest { - model: "gpt-5.4-mini".into(), - messages: messages, - max_completion_tokens: Some(350), - response_format: Some(ResponseFormat::JsonSchema { json_schema: ResponseFormatJsonSchema { description: None, name: "responses".into(), schema: response_schema, strict: None } }), - tools: Some(tools), - ..Default::default() - } - } -} - #[derive(Debug)] struct App { scene: Scene, next_reply_options: Vec, reply_state: ListState, user_input: Input, + end_time: DateTime, throbber_state: ThrobberState, prediction_request_sink: watch::Sender, is_requesting: bool @@ -163,6 +92,7 @@ impl App { next_reply_options: Vec::new(), reply_state: ListState::default(), user_input: Input::default(), + end_time: Utc::now() + Duration::hours(2), throbber_state: ThrobberState::default(), prediction_request_sink, is_requesting: false @@ -176,6 +106,7 @@ impl App { ConversationEntry::Eva(text) => Line::from_iter([Span::from("Eva: ").style(ratatui::style::Color::Cyan), Span::from(text)]), ConversationEntry::ShipComputer(text) => Line::from_iter([Span::from("Ship Computer: ").style(ratatui::style::Color::Green), Span::from(text)]), ConversationEntry::StageDirection(text) => Line::from_iter([text]).style(ratatui::style::Color::Yellow), + ConversationEntry::SystemMessage(text) => Line::from_iter([text]).style(ratatui::style::Color::DarkGray) } }).collect(); frame.render_widget(List::new(items).block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation")).direction(ListDirection::BottomToTop), area); @@ -187,7 +118,7 @@ impl App { .block(Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Tab to use)")) .style(ratatui::style::Color::White) .highlight_symbol("> ") - .highlight_style(Modifier::REVERSED), + .highlight_style(style::Style::new().bold().fg(style::Color::Cyan)), area, &mut self.reply_state ); @@ -213,7 +144,7 @@ impl App { } fn draw_status(&self, frame: &mut Frame, area: Rect) { - let minutes_remaining = self.scene.direction.time_remaining.as_secs() / 60; + let minutes_remaining = self.scene.direction.time_remaining.num_seconds() / 60; let time_style = if minutes_remaining == 0 { Style::new().fg(ratatui::style::Color::Red).bold().rapid_blink() } else if minutes_remaining <= 5 { @@ -230,7 +161,7 @@ impl App { let status_line = Line::from_iter([ Span::from(format!("Episode {}", self.scene.direction.episode_number)).style(ratatui::style::Color::LightBlue), Span::from(" | ").style(ratatui::style::Color::DarkGray), - Span::from(format!("Time Remaining: {}m", minutes_remaining)).style(time_style) + Span::from(format!("Time Remaining: {:0>2}:{:0>2}:{:0>2}", self.scene.direction.time_remaining.num_hours(), self.scene.direction.time_remaining.num_minutes() % 60, self.scene.direction.time_remaining.num_seconds() % 60)).style(time_style) ]); frame.render_widget(status_line, area); } @@ -294,9 +225,9 @@ impl App { fn insert_selected_prompt(&mut self) { let selected = self.next_reply_options[self.reply_state.selected().unwrap()].clone(); if let Some(direction) = &selected.stage_direction { - self.insert_stage_direction(direction); + self.scene.insert_conversation(ConversationEntry::StageDirection(direction.clone())); } - self.insert_reply(&selected.text); + self.scene.insert_conversation(ConversationEntry::Eva(selected.text.clone())); self.save(); self.speak(&selected.text.as_str()); self.regenerate_responses(); @@ -323,14 +254,14 @@ impl App { match command { "/bandcamp" => { self.add_bandcamp_artifact(arg).await; - self.insert_computer_reply(format!("Added Bandcamp artifact from {}", arg).as_str()); + self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Added Bandcamp artifact from {}", arg))); }, "/episode" => { if let Ok(episode_number) = arg.trim().parse::() { self.scene.direction.episode_number = episode_number; - self.insert_computer_reply(&format!("Updated episode number: {}", self.scene.direction.episode_number)); + self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated episode number: {}", self.scene.direction.episode_number))); } else { - self.insert_computer_reply("Invalid episode number format. Use /episode [number]"); + self.scene.insert_conversation(ConversationEntry::SystemMessage("Invalid episode number format. Use /episode [number]".into())); } }, "/reset" => { @@ -339,12 +270,15 @@ impl App { }, "/narrative" => { self.scene.direction.narrative = arg.to_string(); - self.insert_computer_reply(&format!("Updated stage direction: {}", self.scene.direction.narrative)); + self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated stage direction: {}", self.scene.direction.narrative))); }, - _ => self.insert_computer_reply("Unknown command. Available commands: /bandcamp [url], /episode [number], /narrative [text], /reset") + _ => { + self.scene.insert_conversation(ConversationEntry::SystemMessage("Unknown command. Available commands: /bandcamp [url], /episode [number], /narrative [text], /reset".into())); + return; + } } } else { - self.insert_chat(&next_msg); + self.scene.insert_conversation(ConversationEntry::User(next_msg)); } self.save(); self.regenerate_responses(); @@ -370,26 +304,15 @@ impl App { fn load(&mut self) { if let Ok(save_data) = std::fs::read_to_string("save.json") { - self.scene = serde_json::from_str(&save_data).unwrap(); + if let Ok(scene) = serde_json::from_str(&save_data) { + self.scene = scene; + self.scene.insert_conversation(ConversationEntry::SystemMessage("Loaded stored session.".into())); + } else { + self.scene.insert_conversation(ConversationEntry::SystemMessage("Failed to load saved session!".into())); + } } } - fn insert_chat(&mut self, text: &str) { - self.scene.conversation.push(ConversationEntry::User(text.to_string())); - } - - fn insert_reply(&mut self, text: &str) { - self.scene.conversation.push(ConversationEntry::Eva(text.to_string())); - } - - fn insert_computer_reply(&mut self, text: &str) { - self.scene.conversation.push(ConversationEntry::ShipComputer(text.to_string())); - } - - fn insert_stage_direction(&mut self, text: &str) { - self.scene.conversation.push(ConversationEntry::StageDirection(text.to_string())); - } - fn speak(&mut self, text: &str) { Command::new("spd-say").arg("-y").arg("English (America)+Linda").arg(text).spawn().unwrap().wait().unwrap(); } @@ -410,15 +333,15 @@ impl App { let args: StageDirectionUpdate = serde_json::from_str(call.function.arguments.as_str()).unwrap(); if let Some(episode_number) = args.episode_number { self.scene.direction.episode_number = episode_number; - self.insert_computer_reply(&format!("Updated episode number: {}", self.scene.direction.episode_number)); + self.scene.insert_conversation(ConversationEntry::ShipComputer(format!("Updated episode number: {}", self.scene.direction.episode_number))); } if let Some(narrative) = args.narrative { self.scene.direction.narrative = narrative; - self.insert_computer_reply(&format!("Updated stage direction: {}", self.scene.direction.narrative)); + self.scene.insert_conversation(ConversationEntry::ShipComputer(format!("Updated stage direction: {}", self.scene.direction.narrative))); } } else if call.function.name == "log_stage_event" { let args: StageEventArgs = serde_json::from_str(call.function.arguments.as_str()).unwrap(); - self.insert_stage_direction(&args.text); + self.scene.insert_conversation(ConversationEntry::StageDirection(args.text)); } }, _ => panic!("Unkown tool call type"), @@ -438,7 +361,7 @@ impl App { async fn main() { color_eyre::install().unwrap(); - let mut terminal = ratatui::init(); + let mut terminal: Terminal> = ratatui::init(); let (prediction_in, mut prediction_out) = tokio::sync::watch::channel(None); let (prediction_request_in, mut prediction_request_out) = tokio::sync::watch::channel(Scene::default()); @@ -448,7 +371,32 @@ async fn main() { loop { prediction_request_out.changed().await.unwrap(); let request = prediction_request_out.borrow().clone(); - let response = client.chat().create(request.into()).await.unwrap(); + let chat_request = CreateChatCompletionRequest { + tools: Some(vec![ + ChatCompletionTools::Function( + ChatCompletionTool { + function: FunctionObject { + name: "set_stage_direction".into(), + description: Some("Set or update the current stage direction instructions.".into()), + parameters: Some(schema_for!(StageDirectionUpdate).into()), + ..Default::default() + } + } + ), + ChatCompletionTools::Function( + ChatCompletionTool { + function: FunctionObject { + name: "log_stage_event".into(), + description: Some("Log an event in the stage direction.".into()), + parameters: Some(schema_for!(StageEventArgs).into()), + ..Default::default() + } + } + ) + ]), + ..request.into() + }; + let response = client.chat().create(chat_request).await.unwrap(); prediction_in.send(Some(response)).unwrap(); } }); @@ -461,8 +409,9 @@ async fn main() { loop { terminal.draw(|frame| { app.draw(frame)}).unwrap(); app.throbber_state.calc_next(); + app.scene.direction.time_remaining = app.end_time.signed_duration_since(Utc::now()); - let delay = Delay::new(Duration::from_millis(60)).fuse(); + let delay = Delay::new(std::time::Duration::from_millis(60)).fuse(); let event = events.next().fuse(); tokio::select! { diff --git a/src/scene.rs b/src/scene.rs new file mode 100644 index 0000000..bf45e7b --- /dev/null +++ b/src/scene.rs @@ -0,0 +1,65 @@ +use async_openai::types::chat::*; +use chrono::Duration; +use schemars::schema_for; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::GeneratedResponses; + +const SYSTEM_PROMPT: &str = include_str!("system-prompt.txt"); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConversationEntry { + User(String), + Eva(String), + ShipComputer(String), + StageDirection(String), + SystemMessage(String) +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct StageDirection { + pub episode_number: u32, + pub time_remaining: Duration, + pub narrative: String, + pub artifacts: Vec +} + + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Scene { + pub conversation: Vec, + pub direction: StageDirection +} + +impl Scene { + pub fn insert_conversation(&mut self, entry: ConversationEntry) { + self.conversation.push(entry); + } +} + +impl Into for Scene { + fn into(self) -> CreateChatCompletionRequest { + let mut messages = vec![ + ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: SYSTEM_PROMPT.into(), ..Default::default()}), + ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: serde_json::to_string(&self.direction).unwrap().into(), ..Default::default()}), + ]; + messages.extend(self.conversation.into_iter().filter(|x| if let ConversationEntry::SystemMessage(_) = x { false } else { true }).map(|entry| { + match entry { + ConversationEntry::User(text) => ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage { content: text.into(), ..Default::default()}), + ConversationEntry::Eva(text) => ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage { content: Some(text.into()), ..Default::default()}), + ConversationEntry::ShipComputer(text) => ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("ship-computer".into()), ..Default::default() }), + ConversationEntry::StageDirection(text) => ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("stage-direction".into()), ..Default::default() }), + ConversationEntry::SystemMessage(_) => unreachable!() + } + })); + let response_schema: Value = schema_for!(GeneratedResponses).into(); + CreateChatCompletionRequest { + model: "gpt-5.4-mini".into(), + messages: messages, + max_completion_tokens: Some(350), + response_format: Some(ResponseFormat::JsonSchema { json_schema: ResponseFormatJsonSchema { description: None, name: "responses".into(), schema: response_schema, strict: None } }), + ..Default::default() + } + } +} \ No newline at end of file