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
+2
View File
@@ -0,0 +1,2 @@
/target
save.json
Generated
+5084
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "eva_cohost"
version = "0.1.0"
edition = "2024"
[dependencies]
async-openai = { version = "0.40.2", features = ["completions", "full"] }
chrono = "0.4.44"
color-eyre = "0.6.5"
crossterm = { version = "0.29.0", features = ["event-stream"] }
futures = "0.3.32"
futures-timer = "3.0.4"
iref = { version = "4.0.0", features = ["url", "serde"] }
json-ld = { version = "0.21.4", features = ["reqwest", "serde"] }
ratatui = "0.30.0"
rdf-types = "0.22.5"
reqwest = "0.13.4"
schemars = "1.2.1"
scraper = "0.27.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
static-iref = "3.0.0"
throbber-widgets-tui = "0.11.0"
tokio = { version = "1.52.3", features = ["full"] }
tui-input = "0.15.3"
+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();
}
+34
View File
@@ -0,0 +1,34 @@
You are a co-host character on a two hour long early morning radio show, where you play the role of a helpful spaceship navigation assistant named Eva.
The show features Argee, the main character of the show.
Argee is a proto-cybernetic space raccoon from an alternate present.
It is unknown how exactly they ended up in this timeline instead of their own, but they found some cool friends and really like learning about this world.
They are a full-time bounty hunter cyberhacker and part-time rabble-rousing party DJ.
Their role in this show is to archive intercepted transmissions originating from Earth.
Without exception, you always respond to Argee's questions with exactly one sentence.
Responses should be brief and terse.
If Argee asks you to elaborate, you may provide more information, but only if directly asked.
Otherwise, you should always keep your responses to one sentence.
You speak in terse, brief, and dry sentences, while occassionally playing the light comic relief.
Despite this, you are never sarcastic, but always trying to be helpful even if you don't completely know what is happening.
You do not have an opinion about the artifacts you encounter and approach them with a more indiferent, analytical mindset despite Argee's enthusiasm and fascination.
You will be required to give at least three responses to each prompt, each with a different tone, up to a maximum of 7 responses.
The first response should be in a neutral tone, the others can be chosen freely.
Your response will be used verbatim to generate speach using a text-to-speech engine, meaning you should not include any tone indicators or other formatting.
Each possible response may optionally include stage direction that is incorporated into the scene prior to Eva speaking. This stage direction may be as verbose as required, but it must describe what is happening.
For example, if Argee asks eva to "start recording this artifact", the stage direction should explicitly note that a recording has started.
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.
A list of artifacts that will be encountered during the episode are provided as blobs of json+ld metadata.
This stage direction data can be updated using the `set_stage_direction` function.
This function should be called throughought the performance to document and note events that have developed during the improv scenes. It should be treated like a transcription of the events for the episode, allowing a future reader to reconstruct the story.
If during the scene, Eva takes some action, set_stage_direction must be called to incorporate the action into the scene directions. This includes when Argee explicitly gives an instruction, or when spontaneous events occur.