trendy-cli 0.1.0

A CLI tool for fetching posts from Reddit and Hacker News with AI chat capabilities
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::io;
use tokio_stream::StreamExt;

type AppError = Box<dyn Error + Send + Sync>;

const AI_CHAT_URL: &str = "https://ai.hackclub.com/proxy/v1/chat/completions";

#[derive(Serialize)]
pub struct ChatRequest {
    pub model: String,
    pub messages: Vec<Message>,
    pub stream: bool,
}

#[derive(Serialize)]
pub struct Message {
    pub role: String,
    pub content: String,
}

#[derive(Deserialize)]
pub struct GetModels {
    pub data: Vec<ModelsData>,
}

#[derive(Deserialize)]
pub struct ModelsData {
    pub id: String,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub created: Option<u64>,
    #[serde(default)]
    pub description: Option<String>,
}

#[derive(Deserialize)]
struct ChatResponse {
    choices: Vec<Choice>,
}

#[derive(Deserialize)]
struct Choice {
    message: ResponseMessage,
}

#[derive(Deserialize)]
struct ResponseMessage {
    content: String,
}

pub async fn fetch_ai_response(
    client: &Client,
    api_key: Option<String>,
    model: String,
    prompt: String,
) -> Result<String, AppError> {
    let api_key = api_key.ok_or_else(|| -> AppError {
        io::Error::new(
            io::ErrorKind::NotFound,
            "No API key configured. Run `trendy --api-key YOUR_KEY` or set HACKCLUB_API_KEY env var.",
        )
        .into()
    })?;

    let request = ChatRequest {
        model: model,
        messages: vec![Message {
            role: String::from("user"),
            content: prompt,
        }],
        stream: false,
    };

    let response = client
        .post(AI_CHAT_URL)
        .header("Authorization", format!("Bearer {}", api_key))
        .header("Content-Type", "application/json")
        .json(&request)
        .send()
        .await?
        .error_for_status()?
        .json::<ChatResponse>()
        .await?;

    Ok(response.choices[0].message.content.clone())
}

pub async fn fetch_ai_response_stream<F>(
    client: &Client,
    api_key: Option<String>,
    model: String,
    prompt: String,
    on_token: F,
) -> Result<(), AppError>
where
    F: Fn(String) + Send + Sync,
{
    let api_key = api_key.ok_or_else(|| -> AppError {
        io::Error::new(
            io::ErrorKind::NotFound,
            "No API key configured. Run `trendy --api-key YOUR_KEY` or set HACKCLUB_API_KEY env var.",
        )
        .into()
    })?;

    let request = ChatRequest {
        model,
        messages: vec![Message {
            role: String::from("user"),
            content: prompt,
        }],
        stream: true,
    };

    let mut stream = client
        .post(AI_CHAT_URL)
        .header("Authorization", format!("Bearer {}", api_key))
        .header("Content-Type", "application/json")
        .json(&request)
        .send()
        .await?
        .error_for_status()?
        .bytes_stream();

    let mut buffer = Vec::new();

    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        buffer.extend_from_slice(&chunk);

        while let Some(newline_pos) = buffer.iter().position(|byte| *byte == b'\n') {
            let line_bytes: Vec<u8> = buffer.drain(..=newline_pos).collect();
            if let Ok(line) = std::str::from_utf8(&line_bytes) {
                if parse_stream_line(line, &on_token) {
                    return Ok(());
                }
            }
        }
    }

    if !buffer.is_empty() {
        if let Ok(line) = std::str::from_utf8(&buffer) {
            parse_stream_line(line, &on_token);
        }
    }

    Ok(())
}


fn parse_stream_line<F>(line: &str, on_token: &F) -> bool
where
    F: Fn(String) + Send + Sync,
{
    let line = line.trim();

    let Some(data) = line.strip_prefix("data: ") else {
        return false;
    };

    if data == "[DONE]" {
        return true;
    }

    if let Ok(stream_resp) = serde_json::from_str::<serde_json::Value>(data) {
        if let Some(content) = stream_resp["choices"][0]["delta"]["content"].as_str() {
            on_token(content.to_string());
        }
    }

    false
}

pub async fn fetch_ai_models(client: &Client) -> Result<GetModels, AppError> {
    let response = client
        .get("https://ai.hackclub.com/proxy/v1/models")
        .send()
        .await?
        .error_for_status()?
        .json::<GetModels>()
        .await?;

    Ok(response)
}