Initial commit

This commit is contained in:
2026-05-31 13:38:09 +02:00
commit 148bd04b0b
5 changed files with 5592 additions and 0 deletions
+447
View File
@@ -0,0 +1,447 @@
use std::time::Duration;
use async_openai::{Client, config::OpenAIConfig, types::{chat::{ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage, ChatCompletionRequestUserMessage, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequest, FunctionObject, ResponseFormat, ResponseFormatJsonSchema}}};
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, List, ListDirection, ListItem, ListState, Paragraph, Wrap}};
use throbber_widgets_tui::{Throbber, ThrobberState};
use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}};
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:
- Select response 1 (1)
- Select response 2 (2)
- Select response 3 (3)
- Trigger scenario (a, b, c)
- Add additional user input (t)
- Regenerate responses (r)
- Speak selected response
- Regenerate next responses while speaking
UI layout:
- Top panel: Conversation history
- Bottom panel: User input / response options (depending on mode)
- Status bar: Shows current show time, episode, and any other relevant information. Has a throbber for network activity.
- Right panel: Shortcuts for triggering scenarios, soundboard events, etc
*/
const SYSTEM_PROMPT: &str = include_str!("system-prompt.txt");
use ratatui::prelude::*;
#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
struct PossibleResponse {
text: String,
stage_direction: Option<String>
}
impl<'a> Into<ListItem<'a>> for PossibleResponse {
fn into(self) -> ListItem<'a> {
if let Some(direction) = self.stage_direction {
Line::from_iter([
Span::from(format!("({})", direction)).style(ratatui::style::Color::Yellow),
Span::from(" "),
Span::from(self.text)
]).into()
} else {
Line::from(self.text).into()
}
}
}
#[derive(JsonSchema, Deserialize, Serialize, Debug)]
struct GeneratedResponses {
responses: Vec<PossibleResponse>,
}
#[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<String>
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
struct StageDirectionUpdate {
episode_number: Option<u32>,
narrative: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
struct StageEventArgs {
text: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct Scene {
conversation: Vec<ConversationEntry>,
direction: StageDirection
}
impl Into<CreateChatCompletionRequest> 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, Default)]
struct App {
client: Client<OpenAIConfig>,
scene: Scene,
next_reply_options: Vec<PossibleResponse>,
reply_state: ListState,
user_input: Input,
throbber_state: ThrobberState,
}
impl App {
fn draw_conversation(&self, frame: &mut Frame, area: Rect) {
let items: Vec<Line> = self.scene.conversation.iter().rev().map(|entry| {
match entry {
ConversationEntry::User(text) => Line::from_iter([Span::from("Argee: ").style(ratatui::style::Color::Magenta), Span::from(text)]),
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),
}
}).collect();
frame.render_widget(List::new(items).block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation")).direction(ListDirection::BottomToTop), area);
}
fn draw_options(&mut self, frame: &mut Frame, area: Rect) {
frame.render_stateful_widget(
List::new(self.next_reply_options.clone())
.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),
area,
&mut self.reply_state
);
}
fn draw_user_input(&mut self, frame: &mut Frame, area: Rect) {
let width = area.width.max(3) - 3;
let scroll = self.user_input.visual_scroll(width as usize);
let input = Paragraph::new(self.user_input.value()).block(Block::bordered().border_type(BorderType::LightDoubleDashed).title("User Input")).scroll((0, scroll as u16));
frame.render_widget(input, area);
let x = self.user_input.visual_cursor().max(scroll);
frame.set_cursor_position((area.x + x as u16 + 1, area.y + 1));
}
fn draw_io_throbber(&mut self, frame: &mut Frame, area: Rect) {
let throb_area = area.centered(Constraint::Max(1), Constraint::Max(1));
let throbber = Throbber::default();
frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state);
}
fn draw_status(&self, frame: &mut Frame, area: Rect) {
let minutes_remaining = self.scene.direction.time_remaining.as_secs() / 60;
let time_style = if minutes_remaining == 0 {
Style::new().fg(ratatui::style::Color::Red).bold().rapid_blink()
} else if minutes_remaining <= 5 {
Style::new().fg(ratatui::style::Color::Red).bold().slow_blink()
} else if minutes_remaining <= 10 {
ratatui::style::Color::Red.into()
} else if minutes_remaining <= 25 {
ratatui::style::Color::Yellow.into()
} else if minutes_remaining <= 60 {
ratatui::style::Color::Green.into()
} else {
ratatui::style::Color::Blue.into()
};
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)
]);
frame.render_widget(status_line, area);
}
fn draw_narration(&self, frame: &mut Frame, area: Rect) {
let narrative_desc = if self.scene.direction.narrative.is_empty() {
Span::from("No narrative available.").style(ratatui::style::Color::DarkGray)
} else {
Span::from(self.scene.direction.narrative.clone())
};
let setting = Paragraph::new(narrative_desc).block(Block::bordered().border_style(style::Color::LightMagenta).title("Stage Direction")).wrap(ratatui::widgets::Wrap { trim: false });
frame.render_widget(setting, area);
}
fn draw_event_log(&self, frame: &mut Frame, area: Rect) {
let items: Vec<Line> = self.scene.conversation.iter().filter(|entry| { if let ConversationEntry::StageDirection(_) = entry { true } else { false }}).rev().map(|entry| {
match entry {
ConversationEntry::StageDirection(text) => Line::from_iter([text]).style(ratatui::style::Color::Yellow),
_ => unreachable!()
}
}).collect();
frame.render_widget(Paragraph::new(items).block(Block::bordered().border_style(style::Color::Red).title("Event Log")).wrap(Wrap { trim: false }), area);
}
fn draw(&mut self, frame: &mut Frame) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(3),
Constraint::Min(9),
Constraint::Max(3),
Constraint::Max(1)
])
.split(frame.area());
let scene_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(4), Constraint::Fill(1)])
.split(layout[0]);
let context_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Fill(1)])
.split(scene_layout[1]);
self.draw_conversation(frame, scene_layout[0]);
self.draw_narration(frame, context_layout[0]);
self.draw_event_log(frame, context_layout[1]);
self.draw_options(frame, layout[1]);
let status_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Max(3), Constraint::Fill(1)])
.split(layout[3]);
self.draw_user_input(frame, layout[2]);
self.draw_io_throbber(frame, status_layout[0]);
self.draw_status(frame, status_layout[1]);
}
async fn on_event(&mut self, evt: event::Event) {
if let Some(key) = evt.as_key_press_event() {
match key.code {
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => while !self.update_responses().await {},
KeyCode::Down => self.reply_state.select_next(),
KeyCode::Up => self.reply_state.select_previous(),
KeyCode::Tab => {
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.insert_reply(&selected.text);
self.save();
self.speak(&selected.text.as_str());
while !self.update_responses().await {};
},
KeyCode::Enter => {
let next_msg = self.user_input.value_and_reset();
if next_msg.trim().is_empty() {
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.insert_reply(&selected.text);
self.save();
self.speak(&selected.text.as_str());
while !self.update_responses().await {};
} else {
if next_msg.starts_with("/") {
let mut parts = next_msg.splitn(2, " ");
let command = parts.next().unwrap();
let arg = parts.next().unwrap_or("");
match command {
"/bandcamp" => {
self.add_bandcamp_artifact(arg).await;
self.insert_computer_reply(format!("Added Bandcamp artifact from {}", arg).as_str());
},
"/episode" => {
if let Ok(episode_number) = arg.trim().parse::<u32>() {
self.scene.direction.episode_number = episode_number;
self.insert_computer_reply(&format!("Updated episode number: {}", self.scene.direction.episode_number));
} else {
self.insert_computer_reply("Invalid episode number format. Use /episode [number]");
}
},
_ => self.insert_chat(&next_msg),
}
} else {
self.insert_chat(&next_msg);
}
self.save();
while !self.update_responses().await {};
}
},
_ => {self.user_input.handle_event(&evt);},
}
}
}
async fn add_bandcamp_artifact(&mut self, url: &str) {
let body = reqwest::get(url).await.unwrap().text().await.unwrap();
let fragment = Html::parse_document(&body);
let selector = Selector::parse("script[type=\"application/ld+json\"]").unwrap();
let json_ld = fragment.select(&selector).next().unwrap().inner_html();
self.scene.direction.artifacts.push(json_ld);
}
fn save(&self) {
let save_data = serde_json::to_string_pretty(&self.scene).unwrap();
std::fs::write("save.json", save_data).unwrap();
}
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();
}
}
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();
}
async fn update_responses(&mut self) -> bool {
let response = self.client.chat().create(self.scene.clone().into()).await.unwrap();
if let Some(calls) = &response.choices[0].message.tool_calls {
for call in calls {
match call {
ChatCompletionMessageToolCalls::Function(call) => {
if call.function.name == "set_stage_direction" {
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));
}
if let Some(narrative) = args.narrative {
self.scene.direction.narrative = narrative;
self.insert_computer_reply(&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);
}
},
_ => panic!("Unkown tool call type"),
}
}
false
} else {
let json_resp: GeneratedResponses = serde_json::from_str(response.choices[0].message.content.as_ref().unwrap().as_str()).unwrap();
self.next_reply_options = json_resp.responses;
self.reply_state.select_first();
true
}
}
}
#[tokio::main]
async fn main() {
color_eyre::install().unwrap();
let mut terminal = ratatui::init();
let mut app = App::default();
app.load();
let mut events = EventStream::new();
loop {
terminal.draw(|frame| { app.draw(frame)}).unwrap();
app.throbber_state.calc_next();
let mut delay = Delay::new(Duration::from_millis(60)).fuse();
let mut event = events.next().fuse();
select! {
_ = delay => (),
maybe_event = event => {
match maybe_event {
Some(Ok(event)) => {
if let event::Event::Key(key) = event {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
break;
}
}
app.on_event(event).await;
},
_ => ()
}
}
};
}
ratatui::restore();
}