Compare commits

...
3 Commits
7 changed files with 610 additions and 233 deletions
Generated
+60
View File
@@ -238,6 +238,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "bandcamp"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc8f990cfc739590270d0413b821f09b28505d442204fd0f3fd99196df394d52"
dependencies = [
"chrono",
"lazy_static",
"regex",
"reqwest 0.12.28",
"serde",
"serde_json",
"snafu",
"url",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -938,6 +954,7 @@ name = "eva_cohost"
version = "0.1.0"
dependencies = [
"async-openai",
"bandcamp",
"chrono",
"color-eyre",
"crossterm",
@@ -947,6 +964,7 @@ dependencies = [
"iref 4.0.0",
"jack",
"json-ld",
"minify",
"oximedia-metering",
"ratatui",
"rc-writer",
@@ -1433,6 +1451,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2313,6 +2332,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minify"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e93bacfc6ce0cf3e41da4d9415904090e1f7ca8d105c1396907f78d8fee42635"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -3308,6 +3333,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
@@ -3315,6 +3342,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
@@ -3322,6 +3350,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -3476,6 +3505,7 @@ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -3881,6 +3911,27 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "snafu"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
dependencies = [
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "socket2"
version = "0.6.3"
@@ -4889,6 +4940,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "wezterm-bidi"
version = "0.2.3"
+2
View File
@@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
async-openai = { version = "0.40.2", features = ["completions", "full"] }
bandcamp = "0.3.4"
chrono = { version = "0.4.44", features = ["serde"] }
color-eyre = "0.6.5"
crossterm = { version = "0.29.0", features = ["event-stream"] }
@@ -14,6 +15,7 @@ hound = "3.5.1"
iref = { version = "4.0.0", features = ["url", "serde"] }
jack = "0.13.5"
json-ld = { version = "0.21.4", features = ["reqwest", "serde"] }
minify = "1.3.0"
oximedia-metering = "0.1.7"
ratatui = "0.30.0"
rc-writer = "1.1.10"
+110 -151
View File
@@ -1,7 +1,6 @@
use async_openai::{types::chat::{ChatCompletionMessageToolCalls, CreateChatCompletionResponse}};
use async_openai::types::chat::ChatCompletionRequestMessage;
use chrono::{DateTime, Duration, Utc};
use futures_timer::Delay;
use schemars::JsonSchema;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
@@ -13,6 +12,16 @@ use tokio::{sync::{mpsc, watch}, time::Instant};
use tui_input::{Input, backend::crossterm::EventHandler};
use futures::{StreamExt, future::FutureExt};
use ratatui::prelude::*;
use crate::{events::AudioRecordRequest, prediction::{PossibleResponse}, scene::{ConversationEntry, PlaylistEntry, Scene, StageActions, StageDirection}, tts::start_tts};
mod scene;
mod events;
mod transcription;
mod tts;
mod prediction;
// TODO: We should have a separate 'state.json' file, which remembers jack connections, and the world time for the show to end. Then we only update the 'time remaining' field in the scene and only deal with relative durations inside the scene data
// TODO: We should be able to delete entries from the conversation, or at least go back and edit something I said.
// TODO: I want a "mark" command or keyboard shortcut, that inserts a marker into the log, so I know where to come back for the next speaking segment.
@@ -41,22 +50,6 @@ use futures::{StreamExt, future::FutureExt};
- Right panel: Shortcuts for triggering scenarios, soundboard events, etc
*/
use ratatui::prelude::*;
use crate::{events::AudioRecordRequest, scene::{ConversationEntry, PlaylistEntry, Scene}, tts::start_tts};
mod scene;
mod events;
mod transcription;
mod tts;
mod prediction;
#[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 {
@@ -71,32 +64,26 @@ impl<'a> Into<ListItem<'a>> for PossibleResponse {
}
}
#[derive(JsonSchema, Deserialize, Serialize, Debug)]
struct GeneratedResponses {
responses: Vec<PossibleResponse>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
struct StageEventArgs {
text: String,
}
#[derive(Debug)]
struct App {
scene: Scene,
next_reply_options: Vec<PossibleResponse>,
direction: StageDirection,
next_actions: Vec<ConversationEntry>,
end_time: DateTime<Utc>,
reply_state: ListState,
conversation_state: ListState,
user_input: Input,
end_time: DateTime<Utc>,
throbber_state: ThrobberState,
prediction_request_sink: watch::Sender<Scene>,
is_requesting: bool,
audio_level: f64,
recording_audio: bool,
audio_control_sink: watch::Sender<AudioRecordRequest>,
focus_state: FocusState,
tts_request_sink: mpsc::Sender<String>
audio_control_sink: watch::Sender<AudioRecordRequest>,
prediction_request_sink: watch::Sender<StageActions>,
tts_request_sink: mpsc::Sender<String>,
sys_message_sink: mpsc::Sender<String>
}
#[derive(Debug)]
@@ -106,27 +93,29 @@ enum FocusState {
}
impl App {
fn new(prediction_request_sink: watch::Sender<Scene>, audio_control_sink: watch::Sender<AudioRecordRequest>, tts_request_sink: mpsc::Sender<String>) -> Self {
fn new(prediction_request_sink: watch::Sender<StageActions>, audio_control_sink: watch::Sender<AudioRecordRequest>, tts_request_sink: mpsc::Sender<String>, sys_message_sink: mpsc::Sender<String>, initial_direction: StageDirection) -> Self {
Self {
scene: Scene::default(),
next_reply_options: Vec::new(),
reply_state: ListState::default(),
conversation_state: ListState::default(),
user_input: Input::default(),
scene: Default::default(),
direction: initial_direction,
next_actions: Default::default(),
reply_state: Default::default(),
conversation_state: Default::default(),
user_input: Default::default(),
end_time: Utc::now() + Duration::hours(2),
throbber_state: ThrobberState::default(),
throbber_state: Default::default(),
prediction_request_sink,
is_requesting: false,
audio_level: -60.,
recording_audio: false,
audio_control_sink,
focus_state: FocusState::UserInput,
tts_request_sink
tts_request_sink,
sys_message_sink
}
}
fn draw_conversation(&mut self, frame: &mut Frame, area: Rect) {
let items: Vec<Line> = self.scene.conversation.iter().rev().map(|entry| {
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)]),
@@ -150,7 +139,7 @@ impl App {
fn draw_options(&mut self, frame: &mut Frame, area: Rect) {
frame.render_stateful_widget(
List::new(self.next_reply_options.clone())
List::new(self.scene.reply_options().clone())
.block(Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)"))
.style(ratatui::style::Color::White)
.highlight_symbol("> ")
@@ -180,7 +169,7 @@ impl App {
}
fn draw_status(&self, frame: &mut Frame, area: Rect) {
let minutes_remaining = self.scene.direction.time_remaining.num_seconds() / 60;
let minutes_remaining = self.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 {
@@ -195,26 +184,26 @@ impl App {
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(format!("Episode {}", self.direction.episode_number)).style(ratatui::style::Color::LightBlue),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
// FIXME: Looks weird with negative numbers, and it doesn't actually blink in the vscode terminal.
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)
Span::from(format!("Time Remaining: {:0>2}:{:0>2}:{:0>2}", self.direction.time_remaining.num_hours(), self.direction.time_remaining.num_minutes() % 60, self.direction.time_remaining.num_seconds() % 60)).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() {
let narrative_desc = if self.direction.narrative.is_empty() {
Span::from("No narrative available.").style(ratatui::style::Color::DarkGray)
} else {
Span::from(self.scene.direction.narrative.clone())
Span::from(self.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| {
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!()
@@ -285,12 +274,11 @@ impl App {
}
async fn insert_selected_prompt(&mut self) {
let selected = self.next_reply_options[self.reply_state.selected().unwrap()].clone();
let selected = self.scene.reply_options()[self.reply_state.selected().unwrap()].clone();
if let Some(direction) = &selected.stage_direction {
self.scene.insert_conversation(ConversationEntry::StageDirection(direction.clone()));
self.next_actions.push(ConversationEntry::StageDirection(direction.clone()));
}
self.scene.insert_conversation(ConversationEntry::Eva(selected.text.clone()));
self.save();
self.next_actions.push(ConversationEntry::Eva(selected.text.clone()));
self.speak(selected.text.clone()).await;
self.regenerate_responses();
}
@@ -312,7 +300,7 @@ impl App {
KeyCode::Up => self.conversation_state.select_next(),
KeyCode::Enter => {
let row_num = self.conversation_state.selected().unwrap();
if let ConversationEntry::Eva(text) = &self.scene.conversation[self.scene.conversation.len() - 1 - row_num] {
if let ConversationEntry::Eva(text) = &self.scene.conversation()[self.scene.conversation().len() - 1 - row_num] {
self.speak(text.clone()).await;
self.focus_state = FocusState::UserInput;
self.conversation_state.select(None);
@@ -355,73 +343,68 @@ impl App {
match command {
"/bandcamp" => {
self.add_bandcamp_artifact(arg).await;
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Added Bandcamp artifact from {}", arg)));
self.scene.insert_conversation(ConversationEntry::ShipComputer(format!("Incoming transmission.")));
self.sys_message_sink.send(format!("Added Bandcamp artifact from {}", arg)).await.unwrap();
self.next_actions.push(ConversationEntry::ShipComputer(format!("Incoming transmission from {}", arg)));
self.regenerate_responses();
},
"/episode" => {
if let Ok(episode_number) = arg.trim().parse::<u32>() {
self.scene.direction.episode_number = episode_number;
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated episode number: {}", self.scene.direction.episode_number)));
self.direction.episode_number = episode_number;
self.sys_message_sink.send(format!("Updated episode number: {}", self.direction.episode_number)).await.unwrap();
self.reload_mixxx_playlist();
} else {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Invalid episode number format. Use /episode [number]".into()));
self.sys_message_sink.send("Invalid episode number format. Use /episode [number]".into()).await.unwrap();
return;
}
},
"/reload" => {
self.load();
self.reload_mixxx_playlist();
},
"/timer" => {
if let Ok(minutes) = arg.trim().parse::<i64>() {
self.end_time = Utc::now() + Duration::minutes(minutes);
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Set timer for {} minutes.", minutes)));
self.sys_message_sink.send(format!("Set timer for {} minutes.", minutes)).await.unwrap();
} else {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Invalid timer format. Use /timer [minutes]".into()));
self.sys_message_sink.send("Invalid timer format. Use /timer [minutes]".into()).await.unwrap();
}
}
"/clear" => {
match arg.trim() {
"playlist" => {
self.scene.direction.current_playlist.clear();
self.scene.insert_conversation(ConversationEntry::SystemMessage("Cleared current playlist.".into()));
return;
self.direction.current_playlist.clear();
self.sys_message_sink.send("Cleared current playlist.".into()).await.unwrap();
},
"artifacts" => {
self.scene.direction.artifacts.clear();
self.scene.insert_conversation(ConversationEntry::SystemMessage("Cleared artifacts.".into()));
return;
self.direction.artifacts.clear();
self.sys_message_sink.send("Cleared artifacts.".into()).await.unwrap();
},
"all" => {
self.scene = Scene::default();
self.scene.insert_conversation(ConversationEntry::SystemMessage("Cleared all data.".into()));
},
"conversation" => {
self.scene.conversation.clear();
self.scene.insert_conversation(ConversationEntry::SystemMessage("Cleared conversation.".into()));
self.sys_message_sink.send("Cleared all data.".into()).await.unwrap();
},
_ => {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Unknown clear command. Use /clear [playlist|artifacts|all]".into()));
self.sys_message_sink.send("Unknown clear command. Use /clear [playlist|artifacts|all]".into()).await.unwrap();
}
}
return;
},
"/narrative" => {
self.scene.direction.narrative = arg.to_string();
self.scene.insert_conversation(ConversationEntry::SystemMessage(format!("Updated stage direction: {}", self.scene.direction.narrative)));
self.direction.narrative = arg.to_string();
self.sys_message_sink.send(format!("Updated stage direction: {}", self.direction.narrative)).await.unwrap();
},
"/event" => {
self.scene.insert_conversation(ConversationEntry::StageDirection(arg.to_string()));
}
self.next_actions.push(ConversationEntry::StageDirection(arg.to_string()));
self.regenerate_responses();
},
"/computer" => {
self.next_actions.push(ConversationEntry::ShipComputer(arg.to_string()));
self.regenerate_responses();
},
_ => {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Unknown command. Available commands: /bandcamp [url], /episode [number], /narrative [text], /reset".into()));
return;
self.sys_message_sink.send("Unknown command. Available commands: /bandcamp [url], /episode [number], /narrative [text], /reset".into()).await.unwrap();
}
}
} else {
self.scene.insert_conversation(ConversationEntry::User(next_msg));
self.next_actions.push(ConversationEntry::User(next_msg));
self.regenerate_responses();
}
self.save();
self.regenerate_responses();
}
},
_ => {self.user_input.handle_event(&evt);},
@@ -437,25 +420,7 @@ impl App {
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") {
if let Ok(scene) = serde_json::from_str(&save_data) {
self.scene = scene;
// FIXME: These should get wiped out when we save as well, or even better, be completely excluded via a custom serde implementation.
self.scene.conversation.retain(|line| { if let ConversationEntry::SystemMessage(_) = line { false } else { true }});
self.scene.insert_conversation(ConversationEntry::SystemMessage("Loaded stored session.".into()));
} else {
self.scene.insert_conversation(ConversationEntry::SystemMessage("Failed to load saved session!".into()));
}
}
self.direction.artifacts.push(json_ld.trim().to_string());
}
async fn speak(&mut self, text: String) {
@@ -463,18 +428,23 @@ impl App {
}
fn regenerate_responses(&mut self) {
self.prediction_request_sink.send(self.scene.clone()).unwrap();
self.next_reply_options.clear();
let actions = StageActions {
direction: self.direction.clone(),
additions: std::mem::take(&mut self.next_actions)
};
self.scene.reply_options_mut().clear();
self.scene.conversation_mut().append(&mut actions.additions.clone());
self.prediction_request_sink.send(actions).unwrap();
self.is_requesting = true;
}
fn reload_mixxx_playlist(&mut self) {
// TODO: Should have some status message which states how many tracks are in the playlist
self.scene.direction.current_playlist.clear();
self.direction.current_playlist.clear();
let connection = sqlite::Connection::open_thread_safe_with_flags("mixxxdb.sqlite", OpenFlags::new().with_read_only()).unwrap();
let query = "SELECT id FROM Playlists WHERE name = ? ORDER BY id DESC LIMIT 1";
let mut statement = connection.prepare(query).unwrap();
statement.bind((1, format!("BFF.fm - Episode {}", self.scene.direction.episode_number).as_str())).unwrap();
statement.bind((1, format!("BFF.fm - Episode {}", self.direction.episode_number).as_str())).unwrap();
statement.next().unwrap();
let latest_id = statement.read::<i64, _>("id").unwrap();
@@ -484,42 +454,28 @@ impl App {
let artist = track.try_read::<&str, _>("artist").unwrap_or("Unknown Artist");
let album = track.try_read::<&str, _>("album").unwrap_or("Unknown Album");
let bpm = track.try_read::<f64, _>("bpm").unwrap_or(0.);
self.scene.direction.current_playlist.push(PlaylistEntry {
self.direction.current_playlist.push(PlaylistEntry {
artist: artist.into(),
album: album.into(),
title: title.into(),
bpm
});
}
self.scene.insert_conversation(ConversationEntry::SystemMessage("Mixxx playlist reloaded.".into()));
self.next_actions.push(ConversationEntry::SystemMessage("Mixxx playlist reloaded.".into()));
}
}
fn on_response(&mut self, response: &CreateChatCompletionResponse) {
self.is_requesting = false;
if let Some(calls) = &response.choices[0].message.tool_calls {
for call in calls {
match call {
ChatCompletionMessageToolCalls::Function(call) => {
if call.function.name == "log_stage_event" {
let args: StageEventArgs = serde_json::from_str(call.function.arguments.as_str()).unwrap();
self.scene.insert_conversation(ConversationEntry::StageDirection(args.text));
}
},
_ => panic!("Unkown tool call type"),
}
}
self.regenerate_responses();
} else {
if response.choices.is_empty() {
self.scene.insert_conversation(ConversationEntry::SystemMessage("OpenAI returned no responses".into()));
} else if response.choices[0].message.content.is_none() {
self.scene.insert_conversation(ConversationEntry::SystemMessage("OpenAI response did not contain content!".into()));
} 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();
}
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct SaveData {
pub direction: StageDirection,
pub messages: Vec<ChatCompletionRequestMessage>
}
impl SaveData {
fn save(&self) {
let save_data = serde_json::to_string_pretty(self).unwrap();
std::fs::write("save.json", save_data).unwrap();
}
}
@@ -532,15 +488,21 @@ async fn main() {
return;
}
let saved_session = if let Ok(save_data) = std::fs::read_to_string("save.json") {
serde_json::from_str(&save_data).unwrap_or_default()
//FIXME: Re-add load messages to sys log
} else {
SaveData::default()
};
let mut terminal: Terminal<CrosstermBackend<std::io::Stdout>> = ratatui::init();
let (sys_message_sender, mut sys_message_receiver) = tokio::sync::mpsc::channel(5);
let (sys_message_sink, sys_message_src) = tokio::sync::mpsc::channel(32);
let tts_request_sender = start_tts().await;
let (prediction_request_in, mut prediction_out) = prediction::start_prediction().await;
let (mut audio_state_receiver, audio_control_in, mut transcription_out) = transcription::start_transcription(sys_message_sender).await;
let (prediction_request_in, mut prediction_out) = prediction::start_prediction(sys_message_src, saved_session.messages).await;
let (mut audio_state_receiver, audio_control_in, mut transcription_out) = transcription::start_transcription(sys_message_sink.clone()).await;
let mut app = App::new(prediction_request_in, audio_control_in, tts_request_sender);
app.load();
let mut app = App::new(prediction_request_in, audio_control_in, tts_request_sender, sys_message_sink, saved_session.direction);
let mut events = EventStream::new();
let mut last_tick = Instant::now();
@@ -550,7 +512,7 @@ async fn main() {
last_tick = Instant::now();
app.throbber_state.calc_next();
}
app.scene.direction.time_remaining = app.end_time.signed_duration_since(Utc::now());
app.direction.time_remaining = app.end_time.signed_duration_since(Utc::now());
terminal.draw(|frame| { app.draw(frame)}).unwrap();
let delay = Delay::new(std::time::Duration::from_millis(60)).fuse();
@@ -559,18 +521,15 @@ async fn main() {
tokio::select! {
_ = delay => (),
_ = prediction_out.changed() => {
app.on_response(prediction_out.borrow_and_update().as_ref().unwrap());
app.scene = prediction_out.borrow().clone();
app.reply_state.select_first();
app.is_requesting = false;
},
_ = audio_state_receiver.changed() => {
app.audio_level = *audio_state_receiver.borrow_and_update();
},
maybe_message = sys_message_receiver.recv() => {
if let Some(message) = maybe_message {
app.scene.insert_conversation(ConversationEntry::SystemMessage(message));
}
app.audio_level = *audio_state_receiver.borrow();
},
maybe_transcription = transcription_out.recv() => {
app.scene.insert_conversation(ConversationEntry::User(maybe_transcription.unwrap()));
app.next_actions.push(ConversationEntry::User(maybe_transcription.unwrap()));
app.regenerate_responses();
}
maybe_event = event => {
+333 -28
View File
@@ -1,36 +1,341 @@
use async_openai::{Client, config::OpenAIConfig, types::chat::{CreateChatCompletionRequest, CreateChatCompletionResponse}};
use std::process::{Command, Stdio};
use crate::scene::Scene;
use async_openai::{Client, config::OpenAIConfig, types::chat::{ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestToolMessage, ChatCompletionRequestToolMessageArgs, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequestArgs, FinishReason, FunctionObjectArgs, ResponseFormat, ResponseFormatJsonSchema}};
use bandcamp::SearchResultItem;
use chrono::{DateTime, Utc};
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
pub async fn start_prediction() -> (tokio::sync::watch::Sender<Scene>, tokio::sync::watch::Receiver<Option<CreateChatCompletionResponse>>) {
let (prediction_in, prediction_out) = tokio::sync::watch::channel(None);
let (prediction_request_in, mut prediction_request_out) = tokio::sync::watch::channel(Scene::default());
use crate::{SaveData, scene::{ConversationEntry, Scene, StageActions, StageDirection}};
const SYSTEM_PROMPT: &str = include_str!("system-prompt.txt");
#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
pub struct PossibleResponse {
pub text: String,
pub stage_direction: Option<String>
}
#[derive(Default, Debug, JsonSchema, Deserialize, Serialize, Clone)]
pub struct GeneratedResponses {
pub responses: Vec<PossibleResponse>,
}
#[derive(Debug)]
struct Session {
client: Client<OpenAIConfig>,
conversation: Vec<ConversationEntry>,
header_message: ChatCompletionRequestMessage,
messages: Vec<ChatCompletionRequestMessage>,
reply_options: GeneratedResponses
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
struct StageEventArgs {
text: String,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
struct BeatsQueryArgs {
artist: Option<String>,
album: Option<String>,
genre: Option<String>,
title: Option<String>,
year: Option<u32>
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
struct BandcampQueryArgs {
query: String
}
#[derive(Debug, Serialize, Deserialize, Clone)]
enum BandcampResult {
Artist { name: String, bio: Option<String>, location: Option<String> },
Album { title: String, about: Option<String>, credits: Option<String>, release_date: DateTime<Utc>, artist: String }
}
impl Into<BandcampResult> for bandcamp::Artist {
fn into(self) -> BandcampResult {
BandcampResult::Artist { name: self.name, bio: self.bio, location: self.location }
}
}
impl Into<BandcampResult> for bandcamp::Album {
fn into(self) -> BandcampResult {
BandcampResult::Album {
about: self.about,
title: self.title,
artist: self.band.name,
credits: self.credits,
release_date: self.release_date
}
}
}
impl Session {
fn from_initial_messages(messages: Vec<ChatCompletionRequestMessage>) -> Self {
let mut conversation = vec![];
for msg in &messages {
if let Ok(conversation_msg) = msg.clone().try_into() {
conversation.push(conversation_msg);
}
}
Self {
client: Default::default(),
conversation,
header_message: ChatCompletionRequestSystemMessageArgs::default().content(SYSTEM_PROMPT).build().unwrap().into(),
messages,
reply_options: Default::default()
}
}
fn insert_actions(&mut self, actions: &StageActions) {
for addition in &actions.additions {
self.insert_conversation(addition.clone());
}
}
async fn regenerate_options(&mut self, direction: &StageDirection) {
loop {
let direction_message: ChatCompletionRequestMessage = ChatCompletionRequestSystemMessageArgs::default().content(serde_json::to_string(&direction).unwrap()).build().unwrap().into();
let mut full_conversation = vec![
self.header_message.clone(),
direction_message
];
full_conversation.append(&mut self.messages.clone());
let tools = vec![
ChatCompletionTools::Function(ChatCompletionTool {
function: FunctionObjectArgs::default()
.name("log_stage_event")
.description("Inserts an event into the current scene script")
.parameters(schema_for!(StageEventArgs))
.build().unwrap()
}),
ChatCompletionTools::Function(ChatCompletionTool {
function: FunctionObjectArgs::default()
.name("log_ship_computer_message")
.description("Inserts a message from the ship computer into the scene script")
.parameters(schema_for!(StageEventArgs))
.build().unwrap()
}),
ChatCompletionTools::Function(ChatCompletionTool {
function: FunctionObjectArgs::default()
.name("archive_query")
.description("Queries the ship's musical artifact archives for tracks matching the given search parameters")
.parameters(schema_for!(BeatsQueryArgs))
.build().unwrap()
}),
ChatCompletionTools::Function(ChatCompletionTool {
function: FunctionObjectArgs::default()
.name("bandcamp_artifact_scan")
.description("Scans Bandcamp to find artifacts to use in the scene that match the given search parameters")
.parameters(schema_for!(BandcampQueryArgs))
.build().unwrap()
})
];
let request = CreateChatCompletionRequestArgs::default()
.messages(full_conversation.clone())
.model("gpt-5.4")
.tools(tools)
.max_completion_tokens(1024u32)
.response_format(ResponseFormat::JsonSchema {
json_schema: ResponseFormatJsonSchema {
description: None,
name: "responses".into(),
schema: schema_for!(GeneratedResponses).into(),
strict: None
}
})
.build().unwrap();
let response = self.client.chat().create(request).await.unwrap_or_else(|err| {
panic!("{} {:?}", err, full_conversation);
});
if let Some(message) = response.choices.first() {
match message.finish_reason {
Some(FinishReason::ContentFilter) => {
self.insert_conversation(ConversationEntry::SystemMessage("Content filter triggered.".into()));
return;
},
Some(FinishReason::Length) => {
self.insert_conversation(ConversationEntry::SystemMessage("Maximum token count exceeded!".into()));
return;
},
_ => ()
}
if let Some(calls) = &message.message.tool_calls {
let assistant_messages: ChatCompletionRequestMessage = ChatCompletionRequestAssistantMessageArgs::default()
.tool_calls(calls.clone())
.build().unwrap().into();
self.messages.push(assistant_messages);
let mut results = vec![];
let mut messages = vec![];
for call in calls {
match call {
ChatCompletionMessageToolCalls::Function(call) => {
match call.function.name.as_str() {
"log_stage_event" => {
let args: StageEventArgs = serde_json::from_str(call.function.arguments.as_str()).unwrap();
results.push(ChatCompletionRequestMessage::Tool(ChatCompletionRequestToolMessageArgs::default()
.tool_call_id(call.id.clone())
.build().unwrap()
));
messages.push(ConversationEntry::StageDirection(args.text));
},
"log_ship_computer_message" => {
let args: StageEventArgs = serde_json::from_str(call.function.arguments.as_str()).unwrap();
results.push(ChatCompletionRequestMessage::Tool(ChatCompletionRequestToolMessageArgs::default()
.tool_call_id(call.id.clone())
.build().unwrap()
));
messages.push(ConversationEntry::ShipComputer(args.text));
},
"bandcamp_artifact_scan" => {
let args: BandcampQueryArgs = serde_json::from_str(call.function.arguments.as_str()).unwrap();
self.insert_conversation(ConversationEntry::SystemMessage(format!("Fetching artifacts from Bandcamp with {:?}", args).into()));
let mut json_results = vec![];
if let Ok(results) = bandcamp::search(args.query.as_str()).await {
for result in results {
match result {
SearchResultItem::Artist(data) => {
let result: BandcampResult = bandcamp::fetch_artist(data.artist_id).await.unwrap().into();
json_results.push(result);
},
SearchResultItem::Album(data) => {
let result: BandcampResult = bandcamp::fetch_album(data.band_id, data.album_id).await.unwrap().into();
json_results.push(result);
}
_ => ()
}
}
}
results.push(ChatCompletionRequestMessage::Tool(ChatCompletionRequestToolMessageArgs::default()
.tool_call_id(call.id.clone())
.content(serde_json::to_string(&json_results).unwrap())
.build().unwrap()
));
messages.push(ConversationEntry::ShipComputer(format!("Artifact scan for '{}' complete. {} results.", args.query, json_results.len()).into()));
},
"archive_query" => {
let args: BeatsQueryArgs = serde_json::from_str(call.function.arguments.as_str()).unwrap();
let mut beets_cmd = Command::new("beet");
beets_cmd.arg("export").arg("-f").arg("json").arg("-i").arg("title,label,year,genres,album,artist");
if let Some(artist) = args.artist {
beets_cmd.arg(format!("artist:{}", artist));
}
if let Some(genre) = args.genre {
beets_cmd.arg(format!("genre:{}", genre));
}
if let Some(album) = args.album {
beets_cmd.arg(format!("album:{}", album));
}
if let Some(title) = args.title {
beets_cmd.arg(format!("title:{}", title));
}
if let Some(year) = args.year {
beets_cmd.arg(format!("year:{}", year));
}
if let Ok(output) = beets_cmd.stdout(Stdio::piped()).spawn().unwrap().wait_with_output() {
let minified = minify::json::minify(str::from_utf8(&output.stdout).unwrap());
results.push(ChatCompletionRequestMessage::Tool(ChatCompletionRequestToolMessageArgs::default()
.tool_call_id(call.id.clone())
.content(minified)
.build().unwrap()
));
messages.push(ConversationEntry::ShipComputer(format!("Executing archive query {:?}", beets_cmd)));
} else {
messages.push(ConversationEntry::ShipComputer("Unable to execute query!".into()));
results.push(ChatCompletionRequestMessage::Tool(ChatCompletionRequestToolMessageArgs::default()
.tool_call_id(call.id.clone())
.content("")
.build().unwrap()
));
}
}
_ => panic!("Unknown function was called")
}
},
_ => panic!("Unknown tool was called")
}
}
self.messages.append(&mut results);
for msg in messages {
self.insert_conversation(msg);
}
}
if let Some(content) = message.message.content.as_ref() {
if let Ok(options) = serde_json::from_str(content.as_str()) {
self.reply_options = options;
return;
} else {
self.insert_conversation(ConversationEntry::SystemMessage("Received invalid JSON! Trying again.".into()));
}
}
} else {
self.insert_conversation(ConversationEntry::SystemMessage("No messages were received! Trying again.".into()));
}
}
}
fn as_scene(&self) -> Scene {
Scene::new(self.reply_options.clone(), self.conversation.clone())
}
fn insert_conversation(&mut self, entry: ConversationEntry) {
self.conversation.push(entry.clone());
if let Ok(next_msg) = entry.try_into() {
self.messages.push(next_msg);
}
}
}
pub async fn start_prediction(mut sys_message_src: tokio::sync::mpsc::Receiver<String>, initial_messages: Vec<ChatCompletionRequestMessage>) -> (tokio::sync::watch::Sender<StageActions>, tokio::sync::watch::Receiver<Scene>) {
let (prediction_in, prediction_out) = tokio::sync::watch::channel(Scene::default());
let (prediction_request_in, mut prediction_request_out) = tokio::sync::watch::channel(StageActions::default());
let mut session = Session::from_initial_messages(initial_messages);
// Send the initial scene to the UI, after we have loaded the session from the first messages.
prediction_in.send(session.as_scene()).unwrap();
tokio::spawn(async move {
let client: Client<OpenAIConfig> = Client::default();
loop {
if let Ok(_) = prediction_request_out.changed().await {
let request = prediction_request_out.borrow_and_update().clone();
let chat_request = CreateChatCompletionRequest {
/*tools: Some(vec![
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();
} else {
return;
}
tokio::select! {
maybe_message = sys_message_src.recv() => {
if let Some(message) = maybe_message {
session.insert_conversation(ConversationEntry::SystemMessage(message));
prediction_in.send(session.as_scene()).unwrap();
}
},
maybe_request = prediction_request_out.changed() => {
if maybe_request.is_ok() {
let next_cxt = prediction_request_out.borrow().clone();
session.insert_actions(&next_cxt);
let mut save_data = SaveData {
direction: next_cxt.direction,
messages: session.messages.clone()
};
save_data.save();
session.regenerate_options(&save_data.direction).await;
save_data.messages = session.messages.clone();
save_data.save();
prediction_in.send(session.as_scene()).unwrap();
}
}
};
}
});
+73 -35
View File
@@ -1,12 +1,8 @@
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");
use crate::prediction::{GeneratedResponses, PossibleResponse};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConversationEntry {
@@ -17,6 +13,40 @@ pub enum ConversationEntry {
SystemMessage(String)
}
impl TryInto<ChatCompletionRequestMessage> for ConversationEntry {
fn try_into(self) -> Result<ChatCompletionRequestMessage, Self::Error> {
match self {
ConversationEntry::User(text) => Ok(ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage { content: text.into(), ..Default::default()})),
ConversationEntry::Eva(text) => Ok(ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage { content: Some(text.into()), ..Default::default()})),
ConversationEntry::ShipComputer(text) => Ok(ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("ship-computer".into()), ..Default::default() })),
ConversationEntry::StageDirection(text) => Ok(ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("stage-direction".into()), ..Default::default() })),
ConversationEntry::SystemMessage(_) => Err(())
}
}
type Error = ();
}
impl TryInto<ConversationEntry> for ChatCompletionRequestMessage {
fn try_into(self) -> Result<ConversationEntry, Self::Error> {
match self {
ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage { content: ChatCompletionRequestUserMessageContent::Text(msg), ..}) => Ok(ConversationEntry::User(msg)),
ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage { content: Some(ChatCompletionRequestAssistantMessageContent::Text(msg)), ..}) => Ok(ConversationEntry::Eva(msg)),
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: ChatCompletionRequestSystemMessageContent::Text(msg), name: Some(name), ..}) => {
match name.as_str() {
"ship-computer" => Ok(ConversationEntry::ShipComputer(msg)),
"stage-direction" => Ok(ConversationEntry::StageDirection(msg)),
_ => Err(())
}
},
_ => Err(())
}
}
type Error = ();
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct StageDirection {
pub episode_number: u32,
@@ -26,6 +56,22 @@ pub struct StageDirection {
pub current_playlist: Vec<PlaylistEntry>
}
/*impl StageDirection {
pub fn insert_conversation(&mut self, entry: ConversationEntry) {
self.additions.push(entry);
}
pub fn take_actions(&mut self) -> StageActions {
StageActions { direction: self.clone(), additions: std::mem::take(&mut self.additions) }
}
}*/
#[derive(Debug, Default, Clone)]
pub struct StageActions {
pub direction: StageDirection,
pub additions: Vec<ConversationEntry>
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct PlaylistEntry {
pub artist: String,
@@ -34,41 +80,33 @@ pub struct PlaylistEntry {
pub bpm: f64
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Scene {
pub conversation: Vec<ConversationEntry>,
pub direction: StageDirection
reply_options: GeneratedResponses,
conversation: Vec<ConversationEntry>,
}
impl Scene {
pub fn insert_conversation(&mut self, entry: ConversationEntry) {
self.conversation.push(entry);
}
}
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().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".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()
pub fn new(reply_options: GeneratedResponses, conversation: Vec<ConversationEntry>) -> Self {
Self {
reply_options,
conversation,
}
}
pub fn conversation(&self) -> &Vec<ConversationEntry> {
&self.conversation
}
pub fn conversation_mut(&mut self) -> &mut Vec<ConversationEntry> {
&mut self.conversation
}
pub fn reply_options(&self) -> &Vec<PossibleResponse> {
&self.reply_options.responses
}
pub fn reply_options_mut(&mut self) -> &mut Vec<PossibleResponse> {
&mut self.reply_options.responses
}
}
+12
View File
@@ -14,6 +14,13 @@ You are playing the role of the artificial intelligence in a spaceship computer.
Along the way, you have the opportunity to invent lore and backstory for yourself, Argee, and the ship you both inhabit. This lore will be developed over the season.
To support your roleplaying, you have access to a sizable music library via the "archive_query" tool function. Internally, this runs `beet export` to produce json output.
You will occasionally be asked by Argee for information on the contents of the archive and how they are related to tracks in the playlist.
You also may use the archive to decide whether or not an artifact is somehow "familiar" based on whether or not it can be found there.
There also exists a "bandcamp_artifact_scan" tool function, which will execute a search on Bandcamp and return a JSON list of artists and albums matching the query.
You are able to run multiple queries in parallel, and it is expected that you will run this tool whenever there is something unfamiliar in the playlist for the current episode, or Argee asks you for more information about the items in the playlist.
# Scene
The show features Argee, the main character of the show.
@@ -30,6 +37,11 @@ Your records for that period are a little bit fuzzy, so you aren't entirely sure
The two of you have become best friends over the past 3 or so years together. It is common for the two of you to poke fun at each other's shortcomings, but deep down you both know that when push comes to shove, you'll make it through whatever situation you find yourselves in.
There also exists a third "character" in the scene, the ship computer. The ship computer is a distinct entity from you, and can be thought of as a kind of primordeal BIOS-level brain that you run on top of.
Both Argee and Eva maintain control over the ship computer. You, as Eva, can make the ship computer displays read out text on command with the "log_ship_computer_message" tool function.
The ship computer is used to report factual information to Argee and Eva. For example, the ship computer will report when a new artifact is discovered.
It will also report out ship conditions, such as incoming transmissions, status of the recording hardware, power grid, and so on.
# Constraints
In a subsequent system prompt, you will be given the currrent 'stage direction' of the show, which includes the current playtime, the number of the episode, and any particular extra information about this episode that you should be aware of.
The stage direction is provided as structured JSON. There may be additional data fields for semantic context that should be incorporated into the roleplaying setting.
+20 -19
View File
@@ -62,25 +62,26 @@ pub async fn start_transcription(messages: mpsc::Sender<String>) -> (watch::Rece
AudioRecordRequest::Finish => {
writer = None;
let final_audio = outfile.take().unwrap();
let bytes = match Arc::into_inner(final_audio).unwrap().into_inner().unwrap().into_inner() {
SpooledData::OnDisk(mut file) => {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).unwrap();
bytes.into()
},
SpooledData::InMemory(cursor) => cursor.into_inner().into(),
};
let c = client.clone();
let t = transcription_in.clone();
tokio::spawn(async move {
let response = c.audio().transcription().create(CreateTranscriptionRequest {
file: AudioInput { source: InputSource::Bytes { filename: "transcription.wav".into(), bytes } },
model: "gpt-4o-mini-transcribe".into(),
..Default::default()
}).await.unwrap();
t.send(response.text).await.unwrap();
});
if let Some(final_audio) = outfile.take() {
let bytes = match Arc::into_inner(final_audio).unwrap().into_inner().unwrap().into_inner() {
SpooledData::OnDisk(mut file) => {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).unwrap();
bytes.into()
},
SpooledData::InMemory(cursor) => cursor.into_inner().into(),
};
let c = client.clone();
let t = transcription_in.clone();
tokio::spawn(async move {
let response = c.audio().transcription().create(CreateTranscriptionRequest {
file: AudioInput { source: InputSource::Bytes { filename: "transcription.wav".into(), bytes } },
model: "gpt-4o-mini-transcribe".into(),
..Default::default()
}).await.unwrap();
t.send(response.text).await.unwrap();
});
}
}
}
},