#![forbid(unsafe_code)]
use std::{
env,
fs::File,
io::{self, Read},
};
use async_openai::{
error::OpenAIError,
types::{ChatCompletionRequestMessage, CreateChatCompletionRequestArgs, Role},
Client,
};
use clap::Parser;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct Conversation {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
messages: Vec<Message>,
}
impl Conversation {
#[inline]
fn push(&mut self, message: Message) {
self.messages.push(message);
}
#[inline]
fn from_reader<R>(reader: R) -> Result<Self, serde_yaml::Error>
where
R: Read,
{
serde_yaml::from_reader(reader)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct Message {
#[serde(default, skip_serializing_if = "is_user")]
role: Role,
#[serde(default, skip_serializing_if = "String::is_empty")]
content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
impl Message {
#[inline]
fn from_user<C>(content: C) -> Self
where
C: Into<String>,
{
Self {
role: Role::User,
content: content.into(),
name: None,
}
}
}
impl From<Message> for ChatCompletionRequestMessage {
#[inline]
fn from(message: Message) -> Self {
Self {
role: message.role,
content: message.content,
name: message.name,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct Bot {}
#[derive(Debug, Error)]
enum BotError {
#[error("could not obtain environment variable: {0}")]
Var(#[from] env::VarError),
#[error("could not exchange data with OpenAI: {0}")]
OpenAI(#[from] OpenAIError),
#[error("could not perform an input or output operation: {0}")]
Io(#[from] io::Error),
}
impl Bot {
#[inline]
async fn reply_to_writer<W>(
&self,
conversation: &Conversation,
mut writer: W,
) -> Result<(), BotError>
where
W: AsyncWrite + Send + Unpin,
{
let mut stream = Client::default()
.with_api_key(env::var("OPENAI_API_KEY")?)
.chat()
.create_stream({
CreateChatCompletionRequestArgs::default()
.model("gpt-3.5-turbo")
.temperature(0.0)
.messages(
conversation
.messages
.iter()
.cloned()
.map(Into::into)
.collect::<Vec<_>>(),
)
.build()?
})
.await?;
while let Some(response) = stream.next().await {
for content in response?
.choices
.into_iter()
.filter_map(|choice| choice.delta.content)
{
writer.write_all(content.as_bytes()).await?;
}
writer.flush().await?;
}
Ok(())
}
}
#[derive(Debug, Parser)]
#[command(author, version, about)]
#[command(propagate_version = true)]
struct Cli {
#[arg(value_parser = parse_conversation)]
conversation: Option<Conversation>,
#[clap(flatten)]
verbosity: clap_verbosity_flag::Verbosity,
}
#[derive(Debug, Error)]
enum CliError {
#[error("could not perform a serialization or deserialization operation: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("could not perform an input or output operation: {0}")]
Io(#[from] io::Error),
}
#[inline]
fn parse_conversation(path: &str) -> Result<Conversation, CliError> {
let file = File::open(path)?;
let conversation = Conversation::from_reader(file)?;
Ok(conversation)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
human_panic::setup_panic!();
let cli = Cli::parse();
pretty_env_logger::formatted_builder()
.filter_level(cli.verbosity.log_level_filter())
.init();
log::debug!("{cli:#?}");
let mut conversation = cli.conversation.unwrap_or_default();
conversation.push({
let mut content = String::new();
tokio::io::stdin().read_to_string(&mut content).await?;
Message::from_user(content)
});
Bot::default()
.reply_to_writer(&conversation, tokio::io::stdout())
.await?;
Ok(())
}
#[inline]
const fn is_user(role: &Role) -> bool {
match role {
Role::User => true,
Role::System | Role::Assistant => false,
}
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use super::*;
#[test]
fn verify_cli() {
Cli::command().debug_assert();
}
}