Compare commits

..

No commits in common. "main" and "0.1.2" have entirely different histories.
main ... 0.1.2

11 changed files with 522 additions and 907 deletions

854
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@ clap = { version = "4.5.4", features = ["derive"] }
femme = "2.2.1"
home = "0.5.9"
log = "0.4.21"
openssl = { version = "0.10.68", features = ["vendored"] }
reqwest = "0.12.3"
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.115"

View File

@ -45,7 +45,7 @@ you can set their paths and filenames via `HEY_CONFIG_PATH`, `HEY_CONFIG_FILENAM
## config file reference
```toml
model = "Claude" # or "GPT4OMini"
model = "Claude" # or "GPT3"
tos = false # whether if you agree to ddg chat tos
```

View File

@ -1,92 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"naersk": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1717067539,
"narHash": "sha256-oIs5EF+6VpHJRvvpVWuqCYJMMVW/6h59aYUv9lABLtY=",
"owner": "nix-community",
"repo": "naersk",
"rev": "fa19d8c135e776dc97f4dcca08656a0eeb28d5c0",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "naersk",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 0,
"narHash": "sha256-pL9jeus5QpX5R+9rsp3hhZ+uplVHscNJh8n8VpqscM0=",
"path": "/nix/store/8s55w0927lh3mdbkxf434zb0c5hqsz8z-source",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1717646450,
"narHash": "sha256-KE+UmfSVk5PG8jdKdclPVcMrUB8yVZHbsjo7ZT1Bm3c=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "818dbe2f96df233d2041739d6079bb616d3e5597",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"naersk": "naersk",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,34 +0,0 @@
{
inputs = {
flake-utils.url = "github:numtide/flake-utils";
naersk.url = "github:nix-community/naersk";
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs = { self, flake-utils, naersk, nixpkgs }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = (import nixpkgs) {
inherit system;
};
naersk' = pkgs.callPackage naersk { };
in
rec {
defaultPackage = naersk'.buildPackage
{
src = ./.;
buildInputs = with pkgs; [ openssl pkg-config ];
};
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
openssl
pkg-config
clippy
rust-analyzer
];
nativeBuildInputs = with pkgs; [ rustc cargo ];
};
}
);
}

View File

@ -5,38 +5,23 @@ use std::process::exit;
use reqwest::{header::{HeaderMap, HeaderValue}, Client};
use serde::{Deserialize, Serialize};
use crate::{config::Config, history::HistoryObject, session::Session};
use crate::{cache::Cache, config::Config};
use crate::{WARN, RED, RESET};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessagePayload {
struct ChatMessagePayload {
pub role: String,
pub content: String,
}
impl ChatMessagePayload {
pub fn empty(role: String) -> Self {
Self {
role,
content: String::new()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatPayload {
struct ChatPayload {
pub model: String,
pub messages: Vec<ChatMessagePayload>
}
impl ChatMessagePayload {
pub fn is_ai(&self) -> bool {
self.role == "assistant"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatChunk {
struct ChatChunk {
pub role: Option<String>,
pub message: String,
pub created: u64,
@ -45,17 +30,8 @@ pub struct ChatChunk {
pub model: Option<String>
}
impl Into<ChatMessagePayload> for ChatChunk {
fn into(self) -> ChatMessagePayload {
ChatMessagePayload {
role: "assistant".to_string(),
content: self.message
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrChatChunk {
struct ErrChatChunk {
pub action: String,
pub status: u64,
#[serde(rename = "type")]
@ -111,20 +87,13 @@ pub async fn get_vqd(cli: &Client) -> Result<String, Box<dyn Error>> {
}
}
pub async fn get_res<'a>(cli: &Client, query: String, vqd: String, config: &Config) {
let init_msg = ChatMessagePayload { role: "user".into(), content: query };
let mut session = Session::create_or_restore(&vqd);
session.push_message(&init_msg).unwrap();
pub async fn get_res<'a>(cli: &Client, query: String, vqd: String, cache: &'a mut Cache, config: &Config) {
let payload = ChatPayload {
model: config.model.to_string(),
messages: session.get_messages()
messages: vec![ ChatMessagePayload { role: "user".into(), content: query } ]
};
let payload = serde_json::to_string(&payload).unwrap();
println!("{}", init_msg.display_as_history());
let req = cli.post("https://duckduckgo.com/duckchat/v1/chat")
.header("Content-Type", "application/json")
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0")
@ -136,17 +105,15 @@ pub async fn get_res<'a>(cli: &Client, query: String, vqd: String, config: &Conf
let new_vqd = res.headers().iter().find(|x| x.0 == "x-vqd-4");
let vqd_set_res =
if let Some(new_vqd) = new_vqd {
session.set_last_vqd(new_vqd.1.as_bytes().iter().map(|x| char::from(*x)).collect::<String>())
cache.set_last_vqd(new_vqd.1.as_bytes().iter().map(|x| char::from(*x)).collect::<String>())
} else {
eprintln!("{WARN}Warn: DuckDuckGo did not return new VQD. Ignore this if everything else is ok.{RESET}");
session.set_last_vqd(vqd.clone())
cache.set_last_vqd(vqd.clone())
};
if let Err(err) = vqd_set_res {
eprintln!("{WARN}Warn: Could not save VQD to cache: {err}{RESET}");
}
let mut error = None;
println!("{}", ChatMessagePayload::empty("assistant".into()).display_as_history());
while let Some(chunk) = res.chunk().await.unwrap() {
if let Ok(obj) = serde_json::from_slice::<ErrChatChunk>(&chunk) {
@ -160,17 +127,9 @@ pub async fn get_res<'a>(cli: &Client, query: String, vqd: String, config: &Conf
let chunk = chunk.replace("data: ", "");
for line in chunk.lines() {
if let Ok(obj) = serde_json::from_str::<ChatChunk>(line) {
if let Err(err) = session.push_ai_message_chunk(&obj) {
error = Some(err);
}
print!("{}", obj.message);
}
}
}
println!("\n");
if let Some(err) = error {
eprintln!("Error while writing to session: {err:#?}");
eprintln!("Session may be broken.");
}
}

85
src/cache.rs Normal file
View File

@ -0,0 +1,85 @@
use std::{env, error::Error, fs, io, path::PathBuf};
use home::home_dir;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cache {
pub last_vqd: String,
pub last_vqd_time: u64
}
impl Default for Cache {
fn default() -> Self {
Self {
last_vqd: "".into(),
last_vqd_time: 0
}
}
}
impl Cache {
pub fn get_path<T: From<String>>() -> T {
match env::var("HEY_CACHE_PATH") {
Ok(v) => v,
Err(_) =>
match home_dir() {
Some(home) => home.join(".cache/hey").as_os_str().as_encoded_bytes().iter().map(|x| char::from(*x)).collect::<String>(),
None => panic!("Cannot detect your home directory!")
}
}.into()
}
pub fn get_file_name<T: From<String>>() -> T {
match env::var("HEY_CACHE_FILENAME") {
Ok(v) => v,
Err(_) => "cache.json".into()
}.into()
}
fn ensure_dir_exists() -> io::Result<()> {
let path: PathBuf = Self::get_path();
if ! path.is_dir() { fs::create_dir_all(path)? }
Ok(())
}
pub fn load() -> Result<Self, Box<dyn Error>> {
let path: PathBuf = Self::get_path();
Self::ensure_dir_exists()?;
let file_path = path.join(Self::get_file_name::<PathBuf>());
if ! file_path.is_file() {
let def = Self::default();
def.save()?;
Ok(def)
} else {
let file = fs::read_to_string(file_path)?;
Ok(serde_json::from_str(&file)?)
}
}
pub fn save(self: &Self) -> Result<(), Box<dyn Error>> {
let path: PathBuf = Self::get_path();
Self::ensure_dir_exists()?;
let file_path = path.join(Self::get_file_name::<PathBuf>());
fs::write(file_path, serde_json::to_string_pretty(self)?)?;
Ok(())
}
pub fn set_last_vqd<T: Into<String>>(self: &mut Self, vqd: T) -> Result<(), Box<dyn Error>> {
self.last_vqd = vqd.into();
self.last_vqd_time = chrono::Local::now().timestamp_millis() as u64;
self.save()?;
Ok(())
}
pub fn get_last_vqd<'a, T: From<&'a String>>(self: &'a Self) -> Option<T> {
if self.last_vqd_time - (chrono::Local::now().timestamp_millis() as u64) < 60000 {
Some((&self.last_vqd).into())
} else {
None
}
}
}

View File

@ -1,6 +1,4 @@
use crate::{RED, BLUE, WARN, RESET, GRAY};
use std::{env, error::Error, fs, io, path::PathBuf, process::exit};
use std::{env, error::Error, fs, io, path::PathBuf};
use home::home_dir;
use serde::{Deserialize, Serialize};
@ -10,11 +8,10 @@ pub enum Model {
// outdated
Claude12,
GPT35,
GPT3,
// current
Claude,
GPT4OMini,
GPT3,
Llama,
Mixtral
}
@ -27,9 +24,8 @@ impl ToString for Model {
Self::Claude => String::from("claude-3-haiku-20240307"),
Self::GPT3 => String::from("gpt-3.5-turbo-0125"),
Self::Llama => String::from("meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"),
Self::Mixtral => String::from("mistralai/Mixtral-8x7B-Instruct-v0.1"),
Self::GPT4OMini => String::from("gpt-4o-mini")
Self::Llama => String::from("meta-llama/Llama-3-70b-chat-hf"),
Self::Mixtral => String::from("mistralai/Mixtral-8x7B-Instruct-v0.1")
}
}
}
@ -50,27 +46,6 @@ impl Default for Config {
}
impl Config {
pub fn set_tos(&mut self, tos: bool) -> Result<(), Box<dyn Error>> {
self.tos = tos;
self.save()?;
Ok(())
}
pub fn check_tos(&self) -> () {
if ! self.tos {
eprintln!("{RED}You need to agree to DuckDuckgo AI Chat TOS to continue.{RESET}");
eprintln!("{GRAY}Visit it on this URL: {RESET}{BLUE}https://duck.ai{RESET}");
eprintln!();
eprintln!("{GRAY}If it doesnt pop up, open the URL in a private window.{RESET}");
eprintln!("{GRAY}Running this command is equivalent to clicking \"I Agree\" in the ToS window:{RESET}");
eprintln!("{BLUE} {} --agree-tos{RESET}", std::env::current_exe().unwrap().display());
eprintln!();
eprintln!("{WARN}Note: if you want to, modify `tos` parameter in {}{RESET}", Config::get_path::<PathBuf>().join(Config::get_file_name::<String>()).display());
exit(3);
}
}
pub fn get_path<T: From<String>>() -> T {
match env::var("HEY_CONFIG_PATH") {
Ok(v) => v,

View File

@ -1,27 +0,0 @@
use crate::api::ChatMessagePayload;
use crate::{RESET, GREEN, GRAY, BLUE};
pub trait HistoryObject {
fn display_as_history(&self) -> String;
}
impl HistoryObject for ChatMessagePayload {
fn display_as_history(&self) -> String {
let user = &self.role;
let msg = &self.content;
let role_color = {
if user == "user" { GREEN }
else if user == "assistant" { BLUE }
else { GRAY }
};
let short_user = {
if user == "user" { "you" }
else if user == "assistant" { "ai" }
else { user }
}.to_string();
format!("{role_color}{short_user}{RESET}\t: {msg}{RESET}")
}
}

View File

@ -1,33 +1,28 @@
use std::process::exit;
use std::{path::PathBuf, process::exit};
use reqwest::Client;
use clap::Parser;
use session::Session;
use std::io::{stdout, IsTerminal};
use crate::config::Config;
use crate::{cache::Cache, config::Config};
use crate::api::{get_res, get_vqd, simulate_browser_reqs};
mod cache;
mod config;
mod api;
mod session;
mod history;
pub const GREEN: &str = "\x1b[1;32m";
pub const RED: &str = "\x1b[1;31m";
pub const BLUE: &str = "\x1b[34m";
pub const WARN: &str = "\x1b[33m";
pub const RESET: &str = "\x1b[0m";
pub const GRAY: &str = "\x1b[90m";
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(long, default_value = "false", required = false, help = "If you want to agree to the DuckDuckGo TOS")]
pub agree_tos: bool,
#[arg(long, short, default_value = "false", required = false, help = "Clear current session")]
pub clear: bool,
#[arg()]
pub query: Vec<String>,
}
@ -43,44 +38,38 @@ async fn main() {
}
let args = Args::parse();
let query = args.query.join(" ").trim().to_string();
if args.clear {
Session::clear();
println!("Cleared session");
exit(0);
}
{
let session = Session::create_or_restore("");
if session.is_restored() {
session.print_history();
}
}
if query.len() == 0 {
exit(0);
}
let query = args.query.join(" ");
let mut cache = Cache::load().unwrap();
let mut config = Config::load().unwrap();
if args.agree_tos {
if ! config.tos {
println!("{GREEN}TOS accepted{RESET}");
}
config.set_tos(true).expect("Error saving config");
config.tos = true;
config.save().expect("Error saving config");
exit(0);
}
config.check_tos();
if ! config.tos {
eprintln!("{RED}You need to agree to duckduckgo AI chat TOS to continue.{RESET}");
eprintln!("{RED}Visit it on this URL: {RESET}{BLUE}https://duckduckgo.com/?q=duckduckgo+ai+chat&ia=chat{RESET}");
eprintln!("Once you read it, pass --agree-tos parameter to agree.");
eprintln!("{WARN}Note: if you want to, modify `tos` parameter in {}{RESET}", Config::get_path::<PathBuf>().join(Config::get_file_name::<String>()).display());
exit(3);
}
println!("{GREEN}Contacting DuckDuckGo chat AI...{RESET}");
let cli = Client::new();
simulate_browser_reqs(&cli).await.unwrap();
let vqd = match Session::restore_vqd() {
Some(v) => { v },
let vqd = match cache.get_last_vqd() {
Some(v) => { println!("using cached vqd"); v},
None => get_vqd(&cli).await.unwrap()
};
get_res(&cli, query, vqd, &config).await;
get_res(&cli, query, vqd, &mut cache, &config).await;
}

View File

@ -1,189 +0,0 @@
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::Duration;
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use crate::api::{ChatChunk, ChatMessagePayload};
use crate::{GRAY, RESET};
use crate::history::HistoryObject;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
session_id: String,
ttl: DateTime<Local>,
messages: Vec<ChatMessagePayload>,
vqd: String,
#[serde(skip)]
restored: bool
}
impl Session {
pub fn terminal_session_id<T: From<String>>() -> T {
#[cfg(unix)]
fn inner() -> Result<String, Box<dyn std::error::Error>> {
let child = Command::new("tty")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let child = child.wait_with_output()?;
if ! child.status.success() {
Err("")? // value ignored
}
Ok(String::from_utf8(child.stdout)?)
}
#[cfg(not(unix))]
fn inner() -> Result<String, ()> {
Err(())
}
inner()
.unwrap_or("default".into())
.replace(['/', '\\', ':'], "-")
.trim()
.trim_start_matches('-')
.to_string()
.into()
}
fn path_for_id<T: Into<String>>(id: T) -> PathBuf {
let id: String = id.into();
std::env::temp_dir().join(format!("hey-duck-session-{id}"))
}
fn path(&self) -> PathBuf {
Self::path_for_id(&self.session_id)
}
fn restore_with_id<T: Into<String>>(id: T) -> Option<Self> {
fn inner(path: PathBuf) -> Result<Session, Box<dyn std::error::Error>> {
if path.is_file() {
let data = fs::read_to_string(path)?;
Ok(serde_json::from_str(&data)?)
} else {
Err("")? // value ignored
}
}
match inner(Self::path_for_id(id)) {
Ok(mut session) => {
session.restored = true;
if session.is_expired() {
session.destroy().expect("Couldn't destroy expired session");
None
} else {
session.increase_ttl().expect("Couldn't increase TTL");
Some(session)
}
},
Err(_) => None
}
}
fn get_ttl() -> DateTime<Local> {
Local::now() + Duration::from_secs(60 * 5)
}
pub fn increase_ttl(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.ttl = Self::get_ttl();
Ok(self.save()?)
}
fn new<I: Into<String>, V: Into<String>>(id: I, vqd: V) -> Self {
Self {
session_id: id.into(),
ttl: Self::get_ttl(),
messages: vec![],
vqd: vqd.into(),
restored: false
}
}
fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
if self.messages.len() == 0 {
return Ok(());
}
fs::write(self.path(), serde_json::to_string_pretty(self)?)?;
Ok(())
}
fn is_expired(&self) -> bool {
self.ttl < Local::now()
}
pub fn clear() {
let session = Session::create_or_restore("");
if session.is_restored() {
session.destroy().unwrap();
}
}
pub fn is_restored(&self) -> bool {
self.restored
}
pub fn print_history(&self) {
println!("{GRAY}* start restored conversation *{RESET}");
println!();
for message in self.messages.iter() {
println!("{}", message.display_as_history());
if message.role == "assistant" {
println!("\n");
}
}
println!("{GRAY}* end restored conversation *{RESET}");
}
pub fn destroy(&self) -> Result<(), Box<dyn std::error::Error>> {
Ok(fs::remove_file(self.path())?)
}
pub fn create_or_restore<T: Into<String>>(vqd: T) -> Self {
let session_id: String = Self::terminal_session_id();
match Self::restore_with_id(&session_id) {
Some(session) => { session },
None => {
let session = Self::new(&session_id, vqd);
session.save().expect("Couldn't save new session");
session
}
}
}
pub fn push_message(&mut self, msg: &ChatMessagePayload) -> Result<(), Box<dyn std::error::Error>> {
self.messages.push(msg.clone());
Ok(self.save()?)
}
pub fn push_ai_message_chunk(&mut self, chunk: &ChatChunk) -> Result<(), Box<dyn std::error::Error>> {
if self.messages.last().unwrap().is_ai() {
self.messages.last_mut().unwrap().content += chunk.message.as_str();
} else {
self.messages.push(chunk.clone().into());
}
Ok(self.save()?)
}
pub fn get_messages(&self) -> Vec<ChatMessagePayload> {
self.messages.clone()
}
pub fn restore_vqd() -> Option<String> {
Self::restore_with_id(Self::terminal_session_id::<String>()).map(|x| x.vqd)
}
pub fn set_last_vqd<T: Into<String>>(&mut self, vqd: T) -> Result<(), Box<dyn std::error::Error>> {
self.vqd = vqd.into();
Ok(self.save()?)
}
}