diff --git a/scripts/bin/aquaai.sh b/scripts/bin/aquaai.sh new file mode 100755 index 0000000..dd66ffe --- /dev/null +++ b/scripts/bin/aquaai.sh @@ -0,0 +1,764 @@ +#!/usr/bin/env bash + +# This is a bash script to enable interacting with LLMs via the command line. + +#=============================================================================== + +## Modes +### Default mode +# Default mode uses the default prompt and model for AquaAI. It's nothing +# special. +### Bash mode +# This mode will help with writing bash scripts. +### CLI mode +# CLI mode prompts the AI with system information and will return terminal +# commands. If you wish to run the command simply type run and it will end the +# chat and run the command. You are responsible for validating what the command +# does before running. +### Code Review mode +# This will ask you what changes to look at and will provide a code review of +# the changes. This mode only works if you are currently in a git repo. It can +# look at the past few commits as well as changes that have yet to be committed. +### Reasoning mode +# This uses the best available reasoning model with the default prompt. +# Reasoning models take a task and break them up to subtask to pass to +# specialized models. They are very yappy and take a while to run. Can be good +# for complex tasks. +### Regex mode +# This mode will respond only with regex. +### Git mode +# This mode will respond only with git commands. If you wish to run the command +# simply type run and it will end the chat and run the command. You are +# responsible for validating what the command does before running. + +## Special Input +### Edit +# You can type `edit` or `e` as a response and it will open your editor set with +# the EDITOR variable in your shell session. You can then type your query and +# save and exit. From there the program will send your query to the AI. +### Exit +# You can type `exit` or `q` to end the chat. Personally, I never do this just +# use C-c. +### Run +# If you are in cli mode you can type `run` or `r` and the script will run the +# given commands on your system. You are playing with fire with this, but fire +# is useful and fun just be careful. +### Save +# You can type `save` or `s` as a response and the chat history will be saved +# for use at another time. This will also end the chat. Chats are stored in +# `~/.local/share/aquaai` + +## Adding custom modes +# There are two variables that need to be set to create a custom mode. +### $selected_model will set the model to be used for the chat. +### $system_prompt will be the prompt that controls how the AI behaves. +# introduce more noise into text generation leading to more out there responses. +# +# Defaults are set for all these but to define a custom mode you should override +# at least one of these in a function. Add a custom flag in the switch statement +# at the bottom of this file and call the function there. See `--bash` as an +# example of how to do this. From there add some documentation to the +# print_help() function and then here. + +#=============================================================================== + +# User configurable variables. +# +# The following are settings that can be overwritten by environment variables. +# You can set these in your .bashrc to have them set each time you open a new +# shell. This script is designed not to be modified so updates can be applied by +# replacing the file with the newest version. +# +# +# Set the url of the ollama server. +# +# export AQUAAI_OLLAMA_URL='192.168.1.156:11434' +# +ollama_url=${AQUAAI_OLLAMA_URL:='https://ollama.aquamorph.com'} +# +# Set the default model. +# +# export AQUAAI_DEFAULT_MODEL='qwen2.5-7b-instruct' +# +default_model=${AQUAAI_DEFAULT_MODEL:='qwen2.5:32b-instruct'} +# +# Set the default coding model. +# +# export AQUAAI_CODING_MODEL='qwen2.5-7b-coder' +# +coding_model=${AQUAAI_CODING_MODEL:='qwen2.5-32b-coder'} +# +# In multiline mode, users can input multiple lines of text by pressing the +# Enter key. The message will be sent when the user presses C-d on the keyboard. +# +# export AQUAAI_MULTILINE_MODE=true +# +multiline_mode=${AQUAAI_MULTILINE_MODE:=false} +# +# Enable rich formatting for text output. A formatting program is required for +# this see below. +# +# export AQUAAI_RICH_FORMAT_MODE=true +# +rich_format_mode=${AQUAAI_RICH_FORMAT_MODE:=false} +# +# Path to the program used for rich formatting. I am currently using streamdown +# but you are free to use something different as long as it supports streaming +# text and markdown. Go to the GitHub repo to learn to install streamdown and +# configure: https://github.com/day50-dev/Streamdown +# +# export AQUAAI_RICH_FORMAT_PATH=~/.venv/bin/streamdown +# +rich_format_path=${AQUAAI_RICH_FORMAT_PATH:=streamdown} +# +# Ignore certificate checks. +# +# export AQUAAI_INSECURE_MODE=true +# +insecure_mode=${AQUAAI_INSECURE_MODE:=false} +#=============================================================================== + +# Constants. +OLLAMA_URL=${ollama_url} +CURL_FLAGS='-sN' +USER=$(whoami) +DATA_DIR="${HOME}/.local/share/aquaai" +RESPONSE_FIFO="${DATA_DIR}/.response" + +# Colors. +CLEAR='\033[0m' +BLUE='\033[0;34m' +RED='\e[1;31m' +LIGHT_GRAY='\e[38;5;247m' + +# Globals. +message_history='' +cli_mode=false +code_review_start=false +selected_model=${default_model} +message_history="[]" + +# Error Codes. +ERROR_NO_SAVEFILE=1 +ERROR_INVALID_TEMP=2 +ERROR_UNKNOWN_OPTION=3 +ERROR_UNKNOWN_MODEL=4 +ERROR_NO_GIT_REPO=5 +ERROR_INVALID_INPUT=6 +ERROR_NO_AUTOSAVE=7 +ERROR_INVALID_SSL=8 +ERROR_UNKNOWN_SSL=9 + +#=============================================================================== + +# Set the default agent. +function set_default_agent() { + system_prompt='You are an AI assistant named AquaAI.' + system_prompt+=' Follow the users instructions carefully.' + system_prompt+=' Respond using extended markdown.' + system_prompt+=' Be as concise as possible.' +} + +# Set chat to help with command line questions. +function set_cli_agent() { + local os_version=$(cat /etc/os-release | grep 'PRETTY_NAME' | \ + sed 's/PRETTY_NAME=//g' | tr -d '"') + system_prompt='You are a large language model trained to assist users with an' + system_prompt+=" ${os_version} OS. Only output terminal commands." + system_prompt+=' Do not put commands in quotation marks.' + system_prompt+=' Do not put commands in markdown.' +} + +# Set chat to help with bash questions. +function set_bash_agent() { + system_prompt='You are a large language model trained to assist users with' + system_prompt+=' POSIXs bash. Format output for view in a command line. Do' + system_prompt+=' not put commands in quotation marks. Use double spaces and' + system_prompt+=' the function key word. Write documentation for a function' + system_prompt+=' before the function declaration.' +} + +# Set ai to help with code reviews. +function set_code_review_agent() { + system_prompt='You are a senior software engineer performing a code review' + system_prompt+=' for a colleague.' + system_prompt+='' + system_prompt+='Your report should have the following format:' + system_prompt+='# Typos' + system_prompt+='List of all typos you find.' + system_prompt+='# Formatting and Readability Issues' + system_prompt+='List of all formatting and readability issues you find.' + system_prompt+='# Security Issues' + system_prompt+='List of all security issues you find.' + system_prompt+='# Other' + system_prompt+='List of all other issues you find.' +} + +# Set chat to help with git. +function set_git_agent() { + system_prompt='You are a large language model trained to assist users with' + system_prompt+=' git. Only output terminal commands.' + system_prompt+=' Do not put commands in quotation marks.' +} + +# Set chat to help with regex. +function set_regex_agent() { + system_prompt='You are a large language model trained to assist users with' + system_prompt+=' regex. Only output a single regex expression.' + system_prompt+=' Use BRE and ERE regex.' + system_prompt+=' Do not put commands in quotation marks.' +} + +#=============================================================================== + +# Set the default coding model. +function set_coding_model() { + selected_model=${coding_model} +} + +# Set the default reasoning model. +function set_reasoning_model() { + selected_model='deepseek-r1:8b' +} + +#=============================================================================== + +# Print out help menu. +function print_help() { + echo 'Interact with the AquaAI via the command line.' + echo '' + echo '--delete - delete a chat from history' + echo '-l --list - list available models' + echo '--load - load a chat from history' + echo '--restore - load last auto saved chat' + echo '' + echo '--bash - help with bash' + echo '--cli - help with command line' + echo '--code-review - code review of a git project' + echo '-r --reason - help with a reasoning model' + echo '--regex - help with regex' + echo '--git - help with git' +} + +# Print out error message. +function print_error() { + local msg=${1} + printf "${RED}ERROR: ${msg}\n${CLEAR}" +} + +# Check if a given program is installed on the system. +function check_program() { + if ! command -v ${1} 2>&1 >/dev/null; then + print_error "${1} not found. Please install ${1}." + exit ${ERROR_DEPENDENCY} + fi +} + +# Check system for required programs. +function check_requirements() { + check_program curl + check_program jq + check_program fzf + if [ "${rich_format_mode}" == true ]; then + check_program ${rich_format_path} + fi +} + +# Get list of available models. +function get_models() { + curl "${OLLAMA_URL}/api/tags" ${CURL_FLAGS} +} + +# Print list of models. +function print_models() { + get_models | jq -r '.models[].model' | column -t -s $'\t' +} + +# Print message variable. +function print_debug_message_history() { + echo ${message_history} +} + +# Check if the model exists. +function check_if_model_exists() { + local model=${1} + local model_list=($(get_models | jq -r '.models[].model')) + + for m in "${model_list[@]}"; do + if [[ "$m" == "$model" ]]; then + return 0 + fi + done + + print_error "model ${model} does not exists." + exit ${ERROR_UNKNOWN_MODEL} +} + +# Convert string to a safe format for later use. +function convert_to_safe_text() { + echo "${1}" | jq -sR @json +} + +# Set the text output color for user input. +function set_user_color() { + printf "${LIGHT_GRAY}" +} + +# Set the text output color for ai response. +function set_ai_color() { + printf "${CLEAR}" +} + +# Set text color to defaults. +function set_clear_color() { + printf "${CLEAR}" +} + +# Print the header for the ai message +function print_ai_start_message() { + echo -e "\U1F916 AquaAI" +} + +# Print the header for the ai message +function print_user_start_message() { + echo -e "\U1F464 ${USER}" +} + +# Opens the user's preferred text editor to allow them to input text. +function editor_input() { + local editor=${EDITOR:=nano} + local temp_file=$(mktemp) + + ${editor} ${temp_file} + local user_input=$(<"$temp_file") + rm "$temp_file" + + msg=${user_input} +} + +# Check if current directory is managed by git. +function check_git_directory() { + if ! git rev-parse --is-inside-work-tree &> /dev/null; then + print_error 'The current directory is not inside a git repository.' + exit ${ERROR_NO_GIT_REPO} + fi +} + +# Asks the user if they want to include staged git change data. +function gather_staged_changes() { + echo -n 'Do you want to include staged changes? (y/n)? ' + read response + + if [[ "$response" == 'y' || "$response" == 'yes' ]]; then + msg+=$(git diff --cached --patch) + fi +} + +# Ask the user if they want to include changes that have not been committed. +function gather_uncommitted_changes() { + echo -n 'Do you want to include the changes' + echo -n ' you have yet to commit or stash (y/n)? ' + read response + + if [[ "$response" == 'y' || "$response" == 'yes' ]]; then + changes=$(git diff) + msg+=${changes} + fi +} + +# Ask the user for number of commit changes to include in code review. +# Returns a list of changes for the given number of commits. +function gather_commit_changes() { + echo -n 'How many previous commits do you want to include? ' + local count + read count + + # Allow hitting enter as a no response. + if [ -z "$count" ]; then + return + fi + + # Validate that the input is a positive integer. + if ! [[ "$count" =~ ^[0-9]+$ ]] || [ "$count" -lt 0 ]; then + print_error 'Please enter a positive integer for the number of commits.' + exit ${ERROR_INVALID_INPUT} + fi + + hashes=$(git log --format=%H -n ${count}) + for h in ${hashes}; do + commit_message=$(git show ${h}) + msg+="${commit_message}"$'\n' + done +} + +# Create fifo for chat responses. +function create_response_fifo() { + create_data_dir + if [ ! -p ${RESPONSE_FIFO} ]; then + mkfifo ${RESPONSE_FIFO} + fi +} + +# Delete fifo for chat responses. +function remove_response_fifo() { + if [ -p ${RESPONSE_FIFO} ]; then + rm ${RESPONSE_FIFO} + fi +} + +# Create response trap to allow user to stop AquaAI. +function create_response_trap() { + trap 'echo "AquaAI has been interrupted...";' SIGINT +} + +# Remove response trap to allow user to exit program. +function remove_response_trap() { + trap - SIGINT +} + +# Get the first message from a saved chat. +function get_first_chat() { + local file_path=${1} + source <(cat ${file_path} | grep message_history) + message_history=$(echo $message_history | \ + jq -r '[.[] | select(.role == "user")][0].content' \ + 2>/dev/null | sed 's/^"//') + echo -e $message_history | sed 's/"$//' | tr -d '\n' | cut -c 1-80 \ + | sed ':a;N;$!ba;s/\n//g' +} + +# Get an array of all saved chat files. +function get_save_files() { + save_files=() + create_data_dir + + for f in $(find "$DATA_DIR" -type f -name "*.chat"); do + save_files+=("${f}") + done +} + +# Create the data directory if it does not exist. +function create_data_dir() { + if [ ! -d "$DATA_DIR" ]; then + mkdir -p "$DATA_DIR" + fi +} + +# Save the current chat to a file. +function save_chat() { + create_data_dir + local filename=${1} + + if [ -z "$filename" ]; then + print_error 'No filename provided.' + exit ${ERROR_NO_SAVEFILE} + fi + + declare -p selected_model system_prompt \ + message_history cli_mode > "${DATA_DIR}/${filename}" +} + +# Save the current chat to autosave. +function autosave() { + save_chat 'autosave.chat' + echo 'Chat has been auto saved' +} + +# Find all .chat files in DATA_DIR and use fzf to select one. +function select_chat_file() { + selected_file=$(select_chat_with_fzf) + + if [ -z "$selected_file" ]; then + echo 'No file selected.' + exit ${ERROR_NO_SAVEFILE} + fi +} + +# Delete .chat files in DATA_DIR. +function delete_chat_file() { + selected_file=$(select_chat_with_fzf) + + if [ -z "$selected_file" ]; then + echo 'No file selected.' + exit ${ERROR_NO_SAVEFILE} + else + local pretty_name=$(get_first_chat ${selected_file}) + echo -n "do you want to delete '${pretty_name}' (y/n)? " + read response + if [[ "$response" == 'y' || "$response" == 'yes' ]]; then + rm -- "${selected_file}" + echo "Deleted '${pretty_name}'" + fi + fi +} + +# Select saved chat with fzf program. +function select_chat_with_fzf() { + get_friendly_save_names + + local selected_index=$(printf "%s\n" "${friendly_save_files[@]}" \ + | cat -n | fzf --with-nth 2.. \ + | awk '{print $1}') + selected_index=$((${selected_index}-1)) + + if [[ -n $selected_index ]]; then + echo "${save_files[selected_index]}" + fi +} + +# Get an array of the first message in saved chats. +function get_friendly_save_names() { + get_save_files + friendly_save_files=() + for f in "${save_files[@]}"; do + friendly_save_files+=("$(get_first_chat ${f})") + done +} + +# Validate site certificate. +function check_cert() { + curl ${OLLAMA_URL} ${CURL_FLAGS} 2>&1 >/dev/null + local ec=$? + if [ "${ec}" == '60' ]; then + print_error 'unable to get local issuer certificate.' + echo 'Install the certificate on the system.' + exit ${ERROR_INVALID_SSL} + elif [ "${ec}" != '0' ]; then + print_error 'unknown ssl error.' + exit ${ERROR_UNKNOWN_SSL} + fi +} + +# Update chat history +function update_history() { + local role="$1" + local content="$2" + message_history=$(echo "$message_history" \ + | jq --arg role "$role" --arg content \ + "$content" '. + [{"role": $role, "content": $content}]') +} + +# Read input from the user. +function read_user_input() { + if [ "${multiline_mode}" == true ]; then + msg=$(awk '{if ($0 == "END") exit; else print}') + elif [ "${code_review_start}" == true ]; then + check_git_directory + msg='' + gather_uncommitted_changes + gather_staged_changes + gather_commit_changes + code_review_start=false + else + read msg + fi +} + +# Handle input related to CLI mode. +function handle_cli_mode() { + # Check for cli mode + if [ ${cli_mode} == true ]; then + if [[ -z $msg || $msg == 'run' || $msg == 'r' ]]; then + set_clear_color + autosave + echo + local commands=() + # Get a list of commands + while IFS= read -r line; do + commands+=("${line}") + done <<< "$last_cmd" + for c in "${commands[@]}"; do + # Using eval to handle commands that include pipes. + if [[ "${c}" == *'|'* ]]; then + eval "${c}" + else + ${c} + fi + done + exit 0 + fi + fi +} + +# Check for editor request. +function handle_edit() { + if [[ $msg == 'edit' || $msg == 'e' ]]; then + editor_input + set_user_color + echo "${msg}" + fi +} + +# Check for debug command. +function handle_debug() { + if [[ $msg == 'debug' ]]; then + echo "model=${selected_model}" + print_debug_message_history | jq + return 1 + fi + return 0 +} + +# Check for save command. +function handle_save() { + if [[ $msg == 'save' || $msg == 's' ]]; then + echo "Saving chat history" + save_chat "$(date +%Y%m%d%H%M%S).chat" + exit 0 + fi +} + +# Chat converstation loop. +function chat_loop() { + check_if_model_exists ${selected_model} + update_history 'system' "$system_prompt" + while true; do + chat + done +} + +# Main chat loop. +function chat() { + # Get user input. + set_user_color + print_user_start_message + read_user_input + echo + + # Handle user input. + local rc=0 + handle_edit + handle_cli_mode + handle_debug + handle_save + rc=$((rc + $?)) + if [ "$rc" -ne 0 ]; then + return + fi + update_history 'user' "${msg}" + + # Prepare JSON payload. + JSON_PAYLOAD=$(jq -n \ + --arg model "$selected_model" \ + --argjson messages "$message_history" \ + '{model: $model, messages: $messages, stream: true}') + + set_ai_color + print_ai_start_message + + create_response_fifo + create_response_trap + # Render to console. + if [ "${rich_format_mode}" == true ]; then + cat ${RESPONSE_FIFO} | ${rich_format_path} & + else + cat ${RESPONSE_FIFO} & + fi + local response=$(curl -sN "$OLLAMA_URL/api/chat" \ + -d "$JSON_PAYLOAD" \ + | stdbuf -o0 jq -j '.message.content // empty' \ + | tee ${RESPONSE_FIFO}) + wait + # Newline for AI response. + if [ "${rich_format_mode}" != true ]; then + echo + fi + # One line reponses do not print out when formatted with Streamdown. + if [[ "$rich_format_path" == *"streamdown"* && \ + "${rich_format_mode}" == true ]]; then + local wc=$(echo "${response}" | wc -l) + if [ ${wc} -eq 1 ]; then + echo "${response}" + fi + fi + remove_response_trap + remove_response_fifo + echo + update_history "assistant" "${response}" + last_cmd="${response}" +} + +#=============================================================================== + +check_requirements +if [ "${insecure_mode}" == true ]; then + CURL_FLAGS+=' -k' +else + check_cert +fi +cmd=chat_loop +set_default_agent + +# Check arguments +for i in "$@"; do + case $i in + -h|--help) + cmd=print_help + ;; + -l|--list) + cmd=print_models + ;; + --delete) + delete_chat_file + exit 0 + ;; + --load) + select_chat_file + source ${selected_file} + cmd=chat_loop + ;; + --restore) + if [ ! -e "${DATA_DIR}/autosave.chat" ]; then + print_error 'auto save does not exit' + exit ${ERROR_NO_AUTOSAVE} + fi + source "${DATA_DIR}/autosave.chat" + cmd=chat_loop + ;; + # Modes + --bash) + set_coding_model + set_bash_agent + cmd=chat_loop + ;; + --cli) + set_coding_model + set_cli_agent + cmd=chat_loop + cli_mode=true + rich_format_mode=false + ;; + --code-review) + set_coding_model + set_code_review_agent + code_review_start=true + cmd=chat_loop + ;; + --git) + set_coding_model + set_git_agent + cmd=chat_loop + cli_mode=true + rich_format_mode=false + ;; + -r|--reason) + set_reasoning_model + cmd=chat_loop + ;; + --regex) + set_coding_model + set_regex_agent + cmd=chat_loop + rich_format_mode=false + ;; + # Other + -*|--*) + echo "Unknown option ${i}" + print_help + exit ERROR_UNKNOWN_OPTION + ;; + esac +done + +${cmd} +