├── README.md └── ai /README.md: -------------------------------------------------------------------------------- 1 | # AI - a commandline LLM chat client in BASH with conversation/completion and image generation support 2 | 3 | ## Features: 4 | 5 | * **Supports any OpenAI-compatible API endpoint, both local and remote** 6 | 7 | * _[OpenAI](https://platform.openai.com/docs/)_ 8 | * _[Gemini](https://aistudio.google.com/)_ 9 | * _[Grok](https://x.ai/api)_ 10 | * _[HuggingFace](https://huggingface.co/docs/api-inference/index)_ 11 | * _[OpenRouter](https://openrouter.ai/docs/quick-start)_ 12 | * _[LM Studio](https://lmstudio.ai/docs/api/openai-api)_ 13 | * _[Ollama](https://ollama.com/blog/openai-compatibility)_ 14 | * _and many more_ 15 | 16 | * **Interactive chat sessions** 17 | 18 | * **Multiline input** 19 | 20 | * **Image generation support:** *(currently limited to DALL·E 2)* 21 | 22 | * _image preview grid rendered directly in the terminal_ 23 | * _generate up to 10 images at a time_ 24 | * _automatic URL shortening_ 25 | * _optionally save images and prompt to local disk_ 26 | 27 | * **Markdown support for replies** (requires [glow](https://github.com/charmbracelet/glow#installation)) 28 | 29 | * **Single turn Q&A** with optional follow-up conversation 30 | 31 | * **Data piping support** (sending file contents to the LLM) 32 | 33 | * **Full conversation support**: 34 | 35 | * _locally store unlimited conversations (in JSON format)_ 36 | * _quick resume last conversation_ 37 | * _delete/resume any stored conversation_ 38 | * _conversation messages replay on resume_ 39 | * _store current and start new conversation (reset history) during interactive sessions_ 40 | * _Automatic conversation topic identification and update_ 41 | 42 | * **Multiple chat models support**: 43 | 44 | * _switch to a different model mid-conversation_ 45 | * _can freely combine local and remote models_ 46 | * _a single tool to query any model served through an OpenAI-compatible API endpoint_ 47 | 48 | ## Full command line options (`ai -h`): 49 | 50 | ###### Manage models (add/delete/set default): 51 | 52 | `ai -m` 53 | 54 | ###### Start a new interactive conversation: 55 | 56 | `ai [-m ]` 57 | 58 | ###### Single turn Q&A (will ask you to continue interacting, otherwise quit after answer): 59 | 60 | `ai [-m ] "how many planets are there in the solar system?"` 61 | 62 | ###### Generate one or more images (default 1, max 10): 63 | 64 | `ai -i [num] "a cute cat"` 65 | 66 | ###### Submit data as part of the question: 67 | 68 | `cat file.txt | ai [-m ] can you summarize the contents of this file?` 69 | 70 | ###### List saved conversations: 71 | 72 | `ai -l` 73 | 74 | ###### Continue last conversation: 75 | 76 | `ai -c` 77 | 78 | ###### Continue specific conversation: 79 | 80 | `ai -c ` 81 | 82 | ###### Delete a specific conversation: 83 | 84 | `ai -d ` 85 | 86 | ###### Delete selected conversations: 87 | 88 | `ai -d -` 89 | 90 | ###### Delete all conversations: 91 | 92 | `rm "$HOME/.config/ai-bash/conversations.json"` 93 | 94 | ## Usage examples: 95 | 96 | ##### (Adding a model) 97 | 98 | ![image](https://github.com/user-attachments/assets/acd404d6-1766-4764-a590-bceb04bb3696) 99 | 100 | ##### (Listing added models) 101 | 102 | ![image](https://github.com/user-attachments/assets/feace719-0308-4e6a-8a03-f1f21d941378) 103 | 104 | ##### (Interaction and conversation resuming) 105 | 106 | [![asciicast](https://asciinema.org/a/572784.svg)](https://asciinema.org/a/572784) 107 | 108 | ##### (Image generation) 109 | 110 | [![asciicast](https://asciinema.org/a/572785.svg)](https://asciinema.org/a/572785) 111 | 112 | ##### (Input piping to stdin) 113 | 114 | [![asciicast](https://asciinema.org/a/572786.svg)](https://asciinema.org/a/572786) 115 | 116 | ## Installation: 117 | 118 | ###### Prerequisites: 119 | 120 | * Install jq, curl, imagemagick, catimg 121 | 122 | * for e.g. Ubuntu: `apt -y install jq curl imagemagick catimg` 123 | 124 | * Install [glow](https://github.com/charmbracelet/glow#installation) for Markdown rendering support in your terminal 125 | 126 | ###### Script download: 127 | 128 | Install the script by either cloning this repository or directly downloading to your `$PATH`, e.g.: 129 | 130 | ```shell 131 | curl "https://raw.githubusercontent.com/nitefood/ai-bash/master/ai" > /usr/bin/ai && chmod 0755 /usr/bin/ai 132 | ``` 133 | -------------------------------------------------------------------------------- /ai: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # ╭──────────────────────────────────────────────────────────────────────────────────────╮ 4 | # │ AI - a commandline LLM chat client in BASH with conversation/completion/image │ 5 | # │ generation support │ 6 | # │ │ 7 | # │ Project homepage: │ 8 | # │ │ 9 | # │ https://github.com/nitefood/ai-bash │ 10 | # │ │ 11 | # │ Usage: │ 12 | # │ │ 13 | # │ (Launch the script with the -h parameter or visit the project's homepage) │ 14 | # ╰──────────────────────────────────────────────────────────────────────────────────────╯ 15 | 16 | # TODO: customizable system prompting (to "prime" the assistant) 17 | # TODO: temperature setting and presets 18 | AI_VERSION="1.0-beta+5" 19 | 20 | # Color scheme 21 | green=$'\e[38;5;035m' 22 | yellow=$'\e[38;5;142m' 23 | white=$'\e[38;5;007m' 24 | blue=$'\e[38;5;038m' 25 | red=$'\e[38;5;203m' 26 | black=$'\e[38;5;016m' 27 | magenta=$'\e[38;5;161m' 28 | lightgreybg=$'\e[48;5;252m'${black} 29 | bluebg=$'\e[48;5;038m'${black} 30 | redbg=$'\e[48;5;210m'${black} 31 | greenbg=$'\e[48;5;035m'${black} 32 | yellowbg=$'\e[48;5;142m'${black} 33 | bold=$'\033[1m' 34 | underline=$'\e[4m' 35 | dim=$'\e[2m' 36 | default=$'\e[0m' 37 | IFS=$'\n' 38 | 39 | SAVE_CONVERSATION=true # Should we save conversations? 40 | SAVE_CONVERSATION_MIN_SIZE=2 # What should be the minimum conversation size to save? 41 | WORKING_DIR="${HOME}/.config/ai-bash" 42 | CONFIG_FILENAME="config.json" # Configuration file 43 | CONVERSATIONS_FILENAME="conversations.json" # Stored conversations file 44 | TMP_CONVERSATIONS_FILENAME=".${CONVERSATIONS_FILENAME}.tmp" # swap conversation filename 45 | CONVERSATION_TOPIC="\"new conversation\"" # Default name for a new conversation 46 | CONVERSATION_BUFFER_LEN=0 # Conversation buffer length (number of messages the AI will try to remember) - default: 0 (unlimited and handled on server side) 47 | RETRY_WAIT_TIME=10 # Retry wait time in case of server error 48 | IMAGE_GENERATION_MODE=false 49 | IMAGES_TO_GENERATE=1 50 | CONFIG_FILENAME="${WORKING_DIR}/${CONFIG_FILENAME}" 51 | CONVERSATIONS_FILENAME="${WORKING_DIR}/${CONVERSATIONS_FILENAME}" 52 | TMP_CONVERSATIONS_FILENAME="${WORKING_DIR}/${TMP_CONVERSATIONS_FILENAME}" 53 | CURRENT_MODEL="" 54 | CURSOR_POS=1 55 | 56 | StatusbarMessage() { # invoke without parameters to delete the status bar message 57 | if [ -n "$statusbar_message" ]; then 58 | # delete previous status bar message 59 | blank_line=$(printf "%.0s " $(seq "$terminal_width")) 60 | printf "\r%s\r" "$blank_line" >&2 61 | fi 62 | if [ -n "$1" ]; then 63 | statusbar_message="$1" 64 | msg_chars=$(echo -e "$1") 65 | max_msg_size=$((terminal_width - 5)) 66 | if [ "${#msg_chars}" -gt "${max_msg_size}" ]; then 67 | statusbar_message="${lightgreybg}${statusbar_message:0:$max_msg_size}${lightgreybg}..." 68 | else 69 | statusbar_message="${lightgreybg}${statusbar_message}" 70 | fi 71 | statusbar_message+="${lightgreybg} (press CTRL-C to cancel)...${default}" 72 | echo -en "$statusbar_message" >&2 73 | fi 74 | } 75 | 76 | ShowCommandsRecap() { 77 | echo -en "\n${dim}${green}Enter an empty line to send your query, ${default}${green}?${dim} to view previous conversations or ${default}${green}!${dim} to change model\n${default}${green}" 78 | } 79 | 80 | ChatBubble() { 81 | # Param ("$1"): speaker's name 82 | # Param ("$2"): speaker's name background color 83 | # Param ("$3"): chat bubble color 84 | # Param ("$4"): total conversation tokens 85 | # Param ("$5"): model name 86 | # [optional] (rest of the input): conversation topic 87 | local speaker="$1"; shift 88 | local bgcolor="$1"; shift 89 | local bubblecolor="$1"; shift 90 | local total_tokens="$1"; shift 91 | local model_name="$1"; shift 92 | # shellcheck disable=SC2124 93 | conversation_topic="$@" 94 | if [ -n "${total_tokens}" ] && [ "${total_tokens}" != "null" ]; then 95 | tokencount_text=" ${dim}(total conversation tokens: ${total_tokens})${default}${bubblecolor}" 96 | else 97 | tokencount_text="" 98 | fi 99 | 100 | conversation_topic_colorsstripped="" 101 | if [ -n "${conversation_topic}" ]; then 102 | conversation_topic=" ${dim}[Topic: ${default}${bubblecolor}${conversation_topic}${dim}" 103 | [[ "$speaker" != "$model_name" ]] && conversation_topic+=", Model: ${default}${bubblecolor}${model_name}${dim}" 104 | conversation_topic+="]" 105 | # strip colors from $conversation_topic for bubble border length calculation 106 | conversation_topic_colorsstripped=$(sed -r 's/\x1B\[[0-9;]*[JKmsu]//g' <<<"$conversation_topic") 107 | fi 108 | output="╞ ${bgcolor} ${speaker} ${default}${bubblecolor}${conversation_topic}${default}${bubblecolor} ╡${tokencount_text}" 109 | output_len=$(( ${#speaker} + ${#conversation_topic_colorsstripped} + 2 )) 110 | echo -en "${bubblecolor}" 111 | printf '\n╭' 112 | printf '%.s─' $(seq 1 $(( output_len + 2 )) ) 113 | printf '╮\n' 114 | echo -e "$output" 115 | printf '╰' 116 | printf '%.s─' $(seq 1 $(( output_len + 2 )) ) 117 | printf '╯\n' 118 | } 119 | 120 | SendChat() { 121 | # Param ("$@"): the full conversation buffer in JSON format 122 | # trim input to only contain the most recent CONVERSATION_BUFFER_LEN elements 123 | if [ "$CONVERSATION_BUFFER_LEN" -gt 0 ]; then 124 | input=$(jq ".[-$CONVERSATION_BUFFER_LEN:]" <<<"$@") 125 | else 126 | input=$(jq '.' <<<"$@") 127 | fi 128 | local model_endpoint api_key 129 | model_endpoint=$(jq -r ".models.\"$CURRENT_MODEL\".endpoint" "$CONFIG_FILENAME") 130 | api_key=$(jq -r ".models.\"$CURRENT_MODEL\".api_key" "$CONFIG_FILENAME") 131 | while true; do 132 | msg_buf_size=$(wc -c <<<"$input" | numfmt --to=iec) 133 | StatusbarMessage "Request sent (size: ${bluebg}$msg_buf_size${lightgreybg} bytes) Waiting for AI to reply" 134 | response_json=$( 135 | curl -s "$model_endpoint" \ 136 | -H "Content-Type: application/json" \ 137 | -H "Authorization: Bearer $api_key" \ 138 | -d '{ "model": "'"$CURRENT_MODEL"'", "messages": '"$input"' }' 139 | ) 140 | # DBG: save API call output to logfile 141 | # echo -e "\n_____\nRequest: $input\nResponse: $response_json\n_____" >>"${WORKING_DIR}/ai.log" 142 | ai_output=$(jq -c '.choices[0].message' <<<"$response_json") 143 | # fetch total conversation token count from the response JSON 144 | total_tokens=$(jq -r '.usage.totalTokens' <<<"$response_json") 145 | if [ "$total_tokens" = "null" ]; then 146 | # handle xAI API responses where the "totalTokens" key is called "total_tokens" 147 | total_tokens=$(jq -r '.usage.total_tokens' <<<"$response_json") 148 | fi 149 | 150 | if [ "$ai_output" = "null" ]; then 151 | StatusbarMessage 152 | error_type=$(jq -r '.error.type' <<<"$response_json" 2>/dev/null) 153 | error_code=$(jq -r '.error.code' <<<"$response_json" 2>/dev/null) 154 | error_msg=$(jq -r '.error.message' <<<"$response_json" 2>/dev/null) 155 | if [ -z "$error_code" ]; then 156 | error_type="-" 157 | error_code="-" 158 | # try fetching the 'error' key from the output (e.g. HF API) 159 | error_msg=$(jq -r '.error' <<<"$response_json" 2>/dev/null) 160 | fi 161 | # check if the metadata field exists (OpenRouter API) 162 | if [ -n "$(jq -r '.error.metadata' <<<"$response_json")" ]; then 163 | openrouter_provider=$(jq -r '.error.metadata.provider_name' <<<"$response_json") 164 | raw_error=$(jq -r '.error.metadata.raw' <<<"$response_json") 165 | error_msg+=". Raw reply from provider (${yellow}${openrouter_provider}${default}): ${red}${dim}$raw_error${default}" 166 | fi 167 | echo -e "\n${red}- API ERROR -${default}" >&2 168 | echo -e "${redbg} TYPE ${default} $error_type" >&2 169 | echo -e "${redbg} CODE ${default} $error_code" >&2 170 | echo -e "${redbg} MESG ${default} $error_msg" >&2 171 | retryafter="$RETRY_WAIT_TIME" 172 | while [[ ${retryafter} -gt 0 ]]; do 173 | StatusbarMessage "AI error. Retrying in ${redbg}${retryafter}${lightgreybg} seconds" 174 | sleep 1 175 | (( retryafter-- )) 176 | done 177 | else 178 | break 179 | fi 180 | done 181 | StatusbarMessage 182 | # Combine JSON data and token count into a single JSON object for the response 183 | jq -n --argjson ai_output "$ai_output" --arg total_tokens "$total_tokens" '$ai_output + {total_tokens: $total_tokens}' 184 | } 185 | 186 | GenerateImage() { 187 | local model_endpoint api_key 188 | # TODO: support more endpoints 189 | model_endpoint="https://api.openai.com/v1/images/generations" 190 | api_key=$(jq -r '.models.'"$CURRENT_MODEL"'.api_key' "$CONFIG_FILENAME") 191 | while true; do 192 | msg_buf_size=$(wc -c <<<"$input" | numfmt --to=iec) 193 | [[ "$IMAGES_TO_GENERATE" -gt 1 ]] && plural="s" || plural="" 194 | StatusbarMessage "Generating ${bluebg}$IMAGES_TO_GENERATE${lightgreybg} 1024x1024 image${plural} using DALL·E 2" 195 | response_json=$( 196 | curl -s "$model_endpoint" \ 197 | -H "Content-Type: application/json" \ 198 | -H "Authorization: Bearer $api_key" \ 199 | -d '{"prompt": "'"$userinput"'", "n": '"$IMAGES_TO_GENERATE"', "size": "1024x1024"}' 200 | ) 201 | ai_output=$(jq -r 'select (.data != null) | .data[].url' <<<"$response_json") 202 | if [ -z "$ai_output" ]; then 203 | StatusbarMessage 204 | error_type=$(jq -r '.error.type' <<<"$response_json") 205 | error_code=$(jq -r '.error.code' <<<"$response_json") 206 | error_msg=$(jq -r '.error.message' <<<"$response_json") 207 | echo -e "\n${red}- API ERROR -${default}" >&2 208 | echo -e "${redbg} TYPE ${default} $error_type" >&2 209 | echo -e "${redbg} CODE ${default} $error_code" >&2 210 | echo -e "${redbg} MESG ${default} $error_msg" >&2 211 | retryafter="$RETRY_WAIT_TIME" 212 | while [[ ${retryafter} -gt 0 ]]; do 213 | StatusbarMessage "AI error. Retrying in ${redbg}${retryafter}${lightgreybg} seconds" 214 | sleep 1 215 | (( retryafter-- )) 216 | done 217 | else 218 | createdate=$(jq -r '.created' <<<"$response_json") 219 | break 220 | fi 221 | done 222 | StatusbarMessage 223 | term_cols=$(tput cols) 224 | tmpfile=$(mktemp) 225 | imgcounter=1 226 | arr_imgfiles=() 227 | arr_imgurls=() 228 | for imageurl in $ai_output; do 229 | StatusbarMessage "Parsing image n.$imgcounter" 230 | imgfile="${tmpfile}_${imgcounter}" 231 | arr_imgfiles+=("$imgfile") 232 | curl -s "$imageurl" > "$imgfile" 233 | shortened_imgurl="$(curl -s "http://tinyurl.com/api-create.php?url=$imageurl")" 234 | arr_imgurls+=("$shortened_imgurl") 235 | StatusbarMessage 236 | (( imgcounter++ )) 237 | done 238 | i=1 239 | ( for imgfile in "${arr_imgfiles[@]}"; do 240 | convert "$imgfile" -font DejaVu-Sans -pointsize 250 -fill magenta -gravity south -annotate +0+5 "$i" miff:- 241 | (( i++ )) 242 | done ) | montage - -background '#000000' -geometry +0+0 "$tmpfile.png" 243 | 244 | echo -e "\n${default}\"${bold}${underline}${userinput}${default}\"\n" 245 | catimg -w "$term_cols" "$tmpfile.png" 246 | i=1 247 | for imgurl in "${arr_imgurls[@]}"; do 248 | echo -e "${blue}[$i]${default} - $imgurl" 249 | (( i++ )) 250 | done 251 | 252 | savedir=$(date +"%Y_%m_%d-%H_%M_%S" -d @"$createdate") 253 | savedir="${WORKING_DIR}/${savedir}_imgs" 254 | echo "" 255 | read -erp "${bold}Do you want to save the image(s) and prompt locally to ${blue}${savedir}${default} ? [Y/n]" wannasave 256 | if [ -z "$wannasave" ] || [ "$wannasave" = "Y" ] || [ "$wannasave" = "y" ]; then 257 | # save the images locally 258 | mkdir -p "$savedir" 259 | i=1 260 | for imgfile in "${arr_imgfiles[@]}"; do 261 | # move the individual image files to the savedir, renamed as 1.png, 2.png and so on 262 | mv "$imgfile" "$savedir/$i.png" 263 | (( i++ )) 264 | done 265 | # save the grid summary 266 | mv "$tmpfile.png" "$savedir/preview_grid.png" 267 | # and the prompt 268 | echo "$userinput" > "$savedir/prompt.txt" 269 | else 270 | echo -e "\nNote: the image links will be valid for ${blue}2 hours${default}, after which the provider will delete them!\n" 271 | fi 272 | # delete any leftover temporary files 273 | rm "${tmpfile}"* 274 | } 275 | 276 | MarkdownEcho() { 277 | # parse markdown with glow if installed 278 | if command -v glow &>/dev/null; then 279 | echo -e "$@" | glow 280 | else 281 | # glow is not installed, just trim leading and trailing newlines from the response 282 | output=$(sed -e :a -e '/./,$!d;/^\n*$/{$d;N;};/\n$/ba' <<<"$@") 283 | echo -e "$output" 284 | fi 285 | } 286 | 287 | UpdateConversationTopic() { 288 | # $1 = full conversation history 289 | temp_conversation_history=$(jq -c ". += [{\"role\": \"user\", \"content\": \"give me a 4-5 words title for this conversation. Don't list possible titles, come up with ONLY ONE and give it back. No other output must be given, beside the title itself.\"}]" <<<"$conversation_history") 290 | CONVERSATION_TOPIC=$(SendChat "$temp_conversation_history" | jq -r '.content' | tr -d '\n') 291 | # sometimes the AI replies with conversation names enclosed in double quotes, other times it has double quotes inside the topic description. Handle it 292 | if [ "${CONVERSATION_TOPIC::1}" = '"' ] && [ "${CONVERSATION_TOPIC: -1}" = '"' ]; then 293 | CONVERSATION_TOPIC=$(sed -e 's/^"//g' -e 's/"$//g' <<<"$CONVERSATION_TOPIC") 294 | fi 295 | CONVERSATION_TOPIC=$(echo -n "$CONVERSATION_TOPIC" | jq -Rs '.') 296 | } 297 | 298 | SaveConversation() { 299 | [[ "$SAVE_CONVERSATION" = false ]] && return 300 | current_conversation_size=$(jq -r '. | length' <<<"$conversation_history") 301 | if [ "$current_conversation_size" -lt "$SAVE_CONVERSATION_MIN_SIZE" ]; then 302 | return 303 | fi 304 | if [ -w "$CONVERSATIONS_FILENAME" ]; then 305 | # conversations file is writeable 306 | json_conversation=$(jq --arg model "$CURRENT_MODEL" '{"model": $model, "conversation_name": '"$CONVERSATION_TOPIC"', "conversation": .}' <<<"$conversation_history") 307 | jq --argjson conversation "$json_conversation" '. += [$conversation]' "${CONVERSATIONS_FILENAME}" > "${TMP_CONVERSATIONS_FILENAME}" && \ 308 | mv "${TMP_CONVERSATIONS_FILENAME}" "${CONVERSATIONS_FILENAME}" 309 | fi 310 | } 311 | 312 | ReplayConversationMessages() { 313 | # displays the messages in the $conversation_history buffer 314 | # $1 = model name for this conversation 315 | conversation_model="$1" 316 | for row in $(jq -rc '.[]' <<<"$conversation_history"); do 317 | speaker=$(jq -r '.role' <<<"$row") 318 | rowcontent=$(jq -r '.content' <<<"$row") 319 | case "${speaker}" in 320 | "user") 321 | speaker="YOU" 322 | color="$green" 323 | colorbg="$greenbg" 324 | ;; 325 | "assistant") 326 | speaker="$conversation_model" 327 | color="$white" 328 | colorbg="$lightgreybg" 329 | ;; 330 | esac 331 | ChatBubble "$speaker" "$colorbg" "$color" "null" "$speaker" 332 | if [ "$speaker" = "$conversation_model" ]; then 333 | # preserve markdown formatting in LLM replies 334 | MarkdownEcho "$rowcontent" 335 | else 336 | for line in $(echo -e "$rowcontent"); do 337 | echo -e "» $line" 338 | done 339 | fi 340 | done 341 | } 342 | 343 | Ctrl_C() { 344 | SaveConversation 345 | StatusbarMessage 346 | if [ "$IMAGE_GENERATION_MODE" = true ] && [ -n "$tmpfile" ]; then 347 | # clean up temp files 348 | rm "${tmpfile}"* 349 | fi 350 | tput sgr0 351 | tput cnorm 352 | exit 0 353 | } 354 | 355 | ParseConversationsFile() { 356 | # check if conversation storing is enabled 357 | if [ "$SAVE_CONVERSATION" = true ]; then 358 | # load conversations file 359 | if [ ! -f "$CONVERSATIONS_FILENAME" ]; then 360 | if [ "$CONTINUE_CONVERSATION" = true ]; then 361 | echo "Error: conversations file does not exist, cannot continue last conversation" 362 | exit 1 363 | elif [ "$DELETE_CONVERSATION" = true ]; then 364 | echo "Error: conversations file does not exist, cannot delete conversation" 365 | exit 1 366 | fi 367 | # conversations file does not exist, initialize it 368 | echo "[]" > "$CONVERSATIONS_FILENAME" 369 | num_previous_conversations=0 370 | else 371 | num_previous_conversations=$(jq '. | length' "${CONVERSATIONS_FILENAME}" 2>/dev/null) 372 | if [ -z "$num_previous_conversations" ]; then 373 | if [ "$CONTINUE_CONVERSATION" = true ]; then 374 | echo "Error: conversations file is corrupted, cannot continue last conversation" 375 | exit 1 376 | elif [ "$DELETE_CONVERSATION" = true ]; then 377 | echo "Error: conversations file is corrupted, cannot delete conversation" 378 | exit 1 379 | fi 380 | # conversations file is invalid, reinitialize it 381 | echo "[]" > "$CONVERSATIONS_FILENAME" 382 | num_previous_conversations=0 383 | fi 384 | fi 385 | fi 386 | } 387 | 388 | SwitchConversation() { 389 | # 390 | # @param $1 => conversation id (JSON array index) to switch to 391 | # 392 | # extract the requested conversation JSON 393 | conversation_json=$(jq '.['"$1"']' "$CONVERSATIONS_FILENAME") 394 | # load the new conversation into the current session 395 | conversation_history=$(jq '.conversation' <<<"$conversation_json") 396 | # update the conversation messages number 397 | conversation_messages=$(jq 'length' <<<"$conversation_history") 398 | # and switch the topic and model 399 | CONVERSATION_TOPIC=$(jq '.conversation_name' <<<"$conversation_json") 400 | conversation_model=$(jq -r '.model' <<<"$conversation_json") 401 | # show the old conversation to the user 402 | ReplayConversationMessages "$conversation_model" 403 | if ! SetCurrentModel "$conversation_model"; then 404 | # the model for this conversation is not found in the configuration file, try to switch to the default model 405 | if ! SetCurrentModel; then 406 | # the default model is not found in the configuration file, cannot continue 407 | exit 1 408 | fi 409 | fi 410 | # delete the old conversation from the conversations file. 411 | # an updated version of this conversation will be saved at the end of this session 412 | jq 'del (.['"$1"'])' "${CONVERSATIONS_FILENAME}" > "${TMP_CONVERSATIONS_FILENAME}" && \ 413 | mv "${TMP_CONVERSATIONS_FILENAME}" "${CONVERSATIONS_FILENAME}" 414 | } 415 | 416 | ShowConversationsMenu() { 417 | if [ "$SAVE_CONVERSATION" = true ]; then 418 | ParseConversationsFile 419 | echo -e "\n\n${yellowbg} Current conversation ${default}${yellow}" 420 | printf "\n %-50s %-30s" "$CONVERSATION_TOPIC" "${blue}${dim}[$CURRENT_MODEL]${default}${yellow}" 421 | echo -en "\n\n${yellowbg} Previous conversations ${default}${yellow}" 422 | if [ "$num_previous_conversations" = "0" ]; then 423 | echo -en "\n ${yellow}No previous/other conversations found!" 424 | else 425 | title_list=$(jq '.[].conversation_name' "$CONVERSATIONS_FILENAME") 426 | model_list=$(jq -r '.[].model' "$CONVERSATIONS_FILENAME") 427 | i=1 428 | paste <(echo "$title_list") <(echo "$model_list") | while IFS=$'\t' read -r title model; do 429 | printf "\n%3d) %-50s %-30s" "$i" "$title" "${blue}${dim}[$model]${default}${yellow}" 430 | (( i++ )) 431 | done 432 | fi 433 | echo -e "\n\n ${bold}0) START A NEW CONVERSATION${default}${yellow}" 434 | echo -e "\n\n${dim}Choose a conversation to continue, or\n${default}${yellow}[ 0 ]${default}${yellow}${dim} to start a new conversation\n${default}${yellow}[ ENTER ]${default}${yellow}${dim} to return to the current one\n" 435 | while true; do 436 | read -erp "${default}${yellow}» " convmenuinput 437 | if [ -n "$convmenuinput" ]; then 438 | if [[ $convmenuinput =~ ^[0-9]+$ ]]; then 439 | # user entered a valid integer 440 | if [ "$convmenuinput" -gt "$num_previous_conversations" ] || [ "$convmenuinput" -lt 0 ]; then 441 | echo "Please enter a number between 0 and $num_previous_conversations" 442 | continue 443 | else 444 | (( convmenuinput-- )) # convmenuinput now points to the appropriate conversations JSON array index 445 | break 446 | fi 447 | else 448 | echo "Please enter a number between 0 and $num_previous_conversations" 449 | fi 450 | else 451 | # user pressed enter, abort 452 | echo 453 | break 454 | fi 455 | done 456 | if [ -n "$convmenuinput" ]; then 457 | # user chose to continue a conversation or start a new one, first of all save the current one to disk 458 | SaveConversation 459 | if [ "$convmenuinput" = "-1" ]; then 460 | # the user entered '0' to start a new conversation 461 | CONVERSATION_TOPIC="\"new conversation\"" 462 | conversation_history="[]" 463 | conversation_messages=0 464 | echo -e "\n${greenbg} NEW CONVERSATION STARTED ${default}\n" 465 | return 466 | else 467 | # the users wants to continue a previous conversation 468 | SwitchConversation "$convmenuinput" 469 | fi 470 | fi 471 | else 472 | echo -e "\n\n${redbg} ERROR ${default}${red} conversation saving is not enabled!\n" 473 | fi 474 | } 475 | 476 | # Function to set a model as default in the configuration file 477 | SetDefaultModel(){ 478 | # $1 = model name 479 | local model_name="$1" 480 | if ! jq -e --arg model_name "$model_name" '.models[$model_name]' "$CONFIG_FILENAME" >/dev/null; then 481 | echo -e "\n${redbg} ERROR ${default} Model ${red}$model_name${default} not found.\n" 482 | return 1 483 | fi 484 | # First, set all models to default: false 485 | config_content=$(jq '.models |= map_values(.default = false)' "$CONFIG_FILENAME") 486 | # Then, set the specified model to default: true 487 | config_content=$(jq --arg model_name "$model_name" '.models[$model_name].default = true' <<<"$config_content") 488 | echo "$config_content" > "$CONFIG_FILENAME" 489 | echo -e "${greenbg} SUCCESS ${default} Model ${green}$model_name${default} set as default." 490 | } 491 | 492 | # Function to draw the model selection menu with arrow 493 | DrawModelMenu() { 494 | # $1 = total number of models 495 | # $2 = fullredraw (true -> reload models and redraw menu | false -> just move the cursor for speed) [DEFAULT: true] 496 | # the global CURSOR_POS contains the current position in the menu 497 | local model_count=$1 498 | local fullredraw=$2 499 | local i=1 500 | 501 | if [ "$fullredraw" = false ]; then 502 | # We're here because the user moved the arrow cursor while navigating the menu 503 | # We don't need to reload the models and redraw the whole menu, just move the cursor 504 | # save cursor pos 505 | tput sc 506 | # blank out the cursor in every line except for CURSOR_POS 507 | for ((i=1; i<=model_count; i++)); do 508 | [[ $i -eq "$CURSOR_POS" ]] && arrow=" ${blue}→${default}" || arrow=" " 509 | tput cup $(( i+4 )) 0 510 | echo -n "$arrow" 511 | done 512 | # restore cursor pos 513 | tput rc 514 | return 515 | fi 516 | 517 | # Full redraw mode. Hide cursor and clear screen 518 | tput civis 519 | tput clear 520 | echo -e "\n${lightgreybg}Available models:${default}\n" 521 | config_content=$(jq -r '.models' "$CONFIG_FILENAME") 522 | # get terminal width to decide wether to show the full menu or just the model names and the default checkmark 523 | SHOW_COMPACT_MENU=false 524 | termwidth=$(tput cols) 525 | if [ "$termwidth" -gt 88 ]; then 526 | # show full menu 527 | printf " %-40s %-20s %-10s %-1s\n" "Model Name" "Provider" "API Key" "Is Default?" 528 | printf " %-40s %-20s %-10s %-1s\n" "----------" "--------" "-------" "-----------" 529 | else 530 | # show compact menu, only arrow, model name and Is Default? columns 531 | SHOW_COMPACT_MENU=true 532 | printf " %-40s %-1s\n" "Model Name" "Is Default?" 533 | printf " %-40s %-1s\n" "----------" "-----------" 534 | fi 535 | 536 | while IFS= read -r model_name; do 537 | is_default=$(jq -r --arg model_name "$model_name" '.[$model_name].default' <<<"$config_content") 538 | endpoint=$(jq -r --arg model_name "$model_name" '.[$model_name].endpoint' <<<"$config_content") 539 | provider=$(jq -r --arg model_name "$model_name" '.[$model_name].provider' <<<"$config_content") 540 | api_key=$(jq -r --arg model_name "$model_name" '.[$model_name].api_key' <<<"$config_content") 541 | # if CURRENT_MODEL is not set, use CURSOR_POS to set the arrow 542 | # otherwise match the model name with CURRENT_MODEL and set the arrow accordingly 543 | if [ -z "$CURRENT_MODEL" ]; then 544 | # no current model set, position the arrow on the model at CURSOR_POS 545 | [[ $i -eq "$CURSOR_POS" ]] && arrow="${blue}→${default}" || arrow=" " 546 | elif [ "$model_name" = "$CURRENT_MODEL" ]; then 547 | # current model is set, and it matches the current model in the loop. show the arrow and update CURSOR_POS 548 | CURSOR_POS="$i" 549 | arrow="${blue}→${default}" 550 | # also unset current model so that the arrow is now the only indicator of the current model as the user moves around in the menu 551 | CURRENT_MODEL="" 552 | else 553 | # current model is set, but it doesn't match the current model in the loop 554 | arrow=" " 555 | fi 556 | if [ "$is_default" = true ]; then 557 | default_model_color="${green}" 558 | default_checkmark="✓" 559 | else 560 | default_model_color="${default}" 561 | default_checkmark="" 562 | fi 563 | if [ "$SHOW_COMPACT_MENU" = true ]; then 564 | printf " ${arrow} ${default_model_color}%-40s${default} ${green}%8s${default}\n" "$model_name" "${default_checkmark}" 565 | else 566 | printf " ${arrow} ${default_model_color}%-40s${default} %-20s %-10s${default} ${green}%8s${default}\n" "$model_name" "$provider" "${api_key:0:5}***" "${default_checkmark}" 567 | fi 568 | ((i++)) 569 | done < <(jq -r 'keys[]' <<<"$config_content") 570 | 571 | echo -e "\n${dim}Use the arrow keys to navigate.\n\nPress ENTER to select a model\n${default}${blue}a${default}${dim} to add a new model" 572 | echo -e "${default}${blue}d${default}${dim} to delete a model" 573 | echo -e "${default}${blue}s${default}${dim} to set a model as default${default}" 574 | echo -e "${default}${blue}q${default}${dim} to quit${default}" 575 | } 576 | 577 | # Main model management function 578 | ManageModels() { 579 | if [ -z "$1" ]; then 580 | # if the user invoked "ai -m" without any parameters, pop up the model selection menu 581 | 582 | # Create a trap to handle window resizing 583 | trap 'tput clear; DrawModelMenu "$model_count" true' WINCH 584 | 585 | # count models 586 | model_count=$(jq -r '.models | keys | length' "$CONFIG_FILENAME") 587 | 588 | while [ "$model_count" -eq 0 ]; do 589 | tput clear 590 | echo "${dim}No models found in the configuration file. Please add one below.${default}" 591 | AddNewModel true 592 | sleep 3s 593 | model_count=$(jq -r '.models | keys | length' "$CONFIG_FILENAME") 594 | done 595 | # Draw initial menu 596 | DrawModelMenu "$model_count" 597 | 598 | # Handle key input 599 | while true; do 600 | read -rsn1 key 601 | case "$key" in 602 | $'\x1B') # ESC sequence 603 | read -rsn1 -t 0.1 key 604 | if [ "$key" = "[" ]; then 605 | read -rsn1 -t 0.1 key 606 | case "$key" in 607 | "A") # Up arrow 608 | ((CURSOR_POS--)) 609 | [ $CURSOR_POS -lt 1 ] && CURSOR_POS="$model_count" 610 | DrawModelMenu "$model_count" false 611 | ;; 612 | "B") # Down arrow 613 | ((CURSOR_POS++)) 614 | [ "$CURSOR_POS" -gt "$model_count" ] && CURSOR_POS=1 615 | DrawModelMenu "$model_count" false 616 | ;; 617 | esac 618 | fi 619 | ;; 620 | "") # Enter key, Select model 621 | tput cnorm # show cursor 622 | model_name=$(jq -r ".models | keys[$((CURSOR_POS-1))]" "$CONFIG_FILENAME") 623 | SetCurrentModel "$model_name" 624 | break 625 | ;; 626 | "a"|"A") # Add model 627 | tput cnorm # show cursor 628 | AddNewModel false 629 | sleep 3s 630 | model_count=$(jq -r '.models | keys | length' "$CONFIG_FILENAME") 631 | CURSOR_POS=1 632 | DrawModelMenu "$model_count" 633 | ;; 634 | "d"|"D") # Delete model 635 | tput cnorm # show cursor 636 | model_name=$(jq -r ".models | keys[$((CURSOR_POS-1))]" "$CONFIG_FILENAME") 637 | if ! jq -e --arg model_name "$model_name" '.models[$model_name]' "$CONFIG_FILENAME" >/dev/null; then 638 | echo -e "\n${redbg} ERROR ${default} Model ${red}$model_name${default} not found.\n" 639 | return 1 640 | fi 641 | # Prompt the user for confirmation 642 | echo -en "\n${yellowbg} WARNING ${default} Are you sure you want to delete model ${yellow}$model_name${default}? [y/N]: " 643 | read -r confirm_delete 644 | if [[ "$confirm_delete" =~ ^[Yy]$ ]]; then 645 | is_default=$(jq -r --arg model_name "$model_name" '.models[$model_name].default' "$CONFIG_FILENAME") 646 | jq --arg model_name "$model_name" 'del(.models[$model_name])' "$CONFIG_FILENAME" >temp.json && mv temp.json "$CONFIG_FILENAME" 647 | if [ "$is_default" = "true" ]; then 648 | first_model=$(jq -r '.models | keys[0]' "$CONFIG_FILENAME") 649 | if [ "$first_model" = "null" ]; then 650 | echo -e "${yellowbg} WARNING ${default} Default model deleted" 651 | else 652 | jq --arg first_model "$first_model" '.models[$first_model].default = true' "$CONFIG_FILENAME" >temp.json && mv temp.json "$CONFIG_FILENAME" 653 | echo -e "${yellowbg} WARNING ${default} Default model deleted. ${yellow}$first_model${default} set as new default." 654 | fi 655 | fi 656 | echo -e "${greenbg} SUCCESS ${default} Model ${green}$model_name${default} deleted successfully." 657 | (( model_count-- )) 658 | CURSOR_POS=1 659 | sleep 3s 660 | fi 661 | while [ "$model_count" -eq 0 ]; do 662 | tput clear 663 | echo "${dim}No models found in the configuration file. Please add one below.${default}" 664 | AddNewModel true 665 | sleep 3s 666 | model_count=$(jq -r '.models | keys | length' "$CONFIG_FILENAME") 667 | done 668 | DrawModelMenu "$model_count" 669 | ;; 670 | "s"|"S") # Set default model 671 | model_name=$(jq -r ".models | keys[$((CURSOR_POS-1))]" "$CONFIG_FILENAME") 672 | echo 673 | SetDefaultModel "$model_name" 674 | sleep 1s 675 | DrawModelMenu "$model_count" true 676 | ;; 677 | "q"|"Q") # Quit 678 | tput cnorm # show cursor 679 | exit 0 680 | ;; 681 | esac 682 | done 683 | else 684 | # treat $1 as a model name and switch to it (e.g. "ai -m gpt4o"). Quit if model is not found 685 | if ! SetCurrentModel "$1"; then 686 | exit 1 687 | fi 688 | # shift the arguments and let the script handle the rest of the input (if any) 689 | shift 690 | fi 691 | } 692 | 693 | AddNewModel() { 694 | # $1 = force default model (true/false) 695 | local forcedefault="$1" 696 | local provider="" 697 | local api_key="" 698 | echo -e "\n${dim}Select the provider to list currently available models:${default}\n" 699 | echo -e "1) ${magenta}OpenAI ${dim}(GPT4o, o1, etc.)${default}" 700 | echo -e "2) ${blue}Google ${dim}(Gemini, Learn LM, etc.)${default}" 701 | echo -e "3) ${yellow}HuggingFace ${dim}(lists the top trending models on HF)${default}" 702 | echo -e "4) ${red}xAI ${dim}(Grok)${default}" 703 | echo -e "5) ${white}OpenRouter ${dim}(https://openrouter.ai/models)${default}" 704 | echo -e "6) ${green}Custom ${dim}(e.g. Ollama/LM Studio/llama.cpp)${default}" 705 | echo 706 | 707 | read -rp "Enter your choice: " provider_choice 708 | case "$provider_choice" in 709 | 1) 710 | provider="OpenAI" 711 | model_list_endpoint="https://api.openai.com/v1/models" 712 | endpoint="https://api.openai.com/v1/chat/completions" 713 | ;; 714 | 2) 715 | provider="Google" 716 | model_list_endpoint="https://generativelanguage.googleapis.com/v1beta/models" 717 | endpoint="https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" 718 | ;; 719 | 3) 720 | provider="HuggingFace" 721 | model_list_endpoint="https://huggingface.co/api/models?other=conversational&filter=text-generation&inference=warm" 722 | endpoint="https://api-inference.huggingface.co/v1/chat/completions" 723 | ;; 724 | 4) 725 | provider="xAI" 726 | model_list_endpoint="https://api.x.ai/v1/models" 727 | endpoint="https://api.x.ai/v1/chat/completions" 728 | ;; 729 | 5) 730 | provider="OpenRouter" 731 | model_list_endpoint="https://openrouter.ai/api/v1/models" 732 | endpoint="https://openrouter.ai/api/v1/chat/completions" 733 | ;; 734 | 6) 735 | provider="Custom" 736 | read -rp "Enter API endpoint ${underline}BASE URL${default} ${dim}- e.g. http://127.0.0.1:11434/v1 (Ollama) or http://127.0.0.1:1234/v1 (LM Studio)${default}: " endpoint 737 | model_list_endpoint="${endpoint}/models" 738 | endpoint="${endpoint}/chat/completions" 739 | ;; 740 | *) 741 | echo -e "${red}Invalid choice. Exiting.${default}" 742 | exit 1 743 | ;; 744 | esac 745 | 746 | # Check if the provider's API key is already in the config file, unless it's a custom provider (in which case, always ask for a key) 747 | if [ "$provider" != "Custom" ]; then 748 | lowercase_provider=$(tr '[:upper:]' '[:lower:]' <<<"$provider") 749 | api_key=$(jq -r --arg provider "$lowercase_provider" '.api_keys[$provider] | select(. != null)' "$CONFIG_FILENAME") 750 | fi 751 | # Prompt for API key if not found, display asterisks for each character entered 752 | if [ -z "$api_key" ]; then 753 | exec < /dev/tty # Ensure input comes from terminal 754 | stty -echo # Disable terminal echo 755 | echo -n "Enter your $provider API Key (press ENTER to skip): " 756 | while IFS= read -r -n1 char; do 757 | [[ -z "$char" ]] && { echo; break; } # Exit on Enter key 758 | [[ "$char" == $'\177' ]] && # Handle backspace 759 | [[ ${#api_key} -gt 0 ]] && { 760 | api_key=${api_key%?} 761 | echo -en "\b \b" 762 | } && continue 763 | api_key+="$char" 764 | echo -n "*" 765 | done 766 | stty echo # Restore terminal echo 767 | # save the API key in the config file, unless it's empty (or for a custom provider) 768 | if [ -n "$api_key" ] && [ "$provider" != "Custom" ]; then 769 | jq --arg provider "$lowercase_provider" --arg api_key "$api_key" '.api_keys[$provider] = $api_key' "$CONFIG_FILENAME" >temp.json && mv temp.json "$CONFIG_FILENAME" 770 | else 771 | # if it was empty, set it to "XXX" to pass a fake key (e.g. when using a local/custom provider) and don't save it 772 | api_key="XXX" 773 | fi 774 | fi 775 | echo 776 | StatusbarMessage "Fetching available models for $provider" 777 | # Get list of available models 778 | case "$provider" in 779 | "OpenAI") 780 | model_list=$(curl -s "${model_list_endpoint}" -H "Authorization: Bearer ${api_key}" | jq -r '.data[].id' 2>/dev/null | sort) 781 | model_display_color="${magenta}" 782 | ;; 783 | "Custom") 784 | model_list=$(curl -s "${model_list_endpoint}" -H "Authorization: Bearer ${api_key}" | jq -r '.data[].id' 2>/dev/null | sort) 785 | model_display_color="${green}" 786 | ;; 787 | "Google") 788 | model_list=$(curl -s "${model_list_endpoint}?key=${api_key}" | jq -r '.models[].name | split("/") | last' 2>/dev/null | sort) 789 | model_display_color="${blue}" 790 | ;; 791 | "HuggingFace") 792 | model_list=$(curl -s "${model_list_endpoint}" -H "Authorization: Bearer ${api_key}" | jq -r '.[] | select(.trendingScore > 0) | .id' 2>/dev/null | head -n 50 | sort) 793 | model_display_color="${yellow}" 794 | ;; 795 | "xAI") 796 | model_list=$(curl -s "${model_list_endpoint}" -H "Authorization: Bearer ${api_key}" | jq -r '.data[].id' 2>/dev/null | sort) 797 | model_display_color="${red}" 798 | ;; 799 | "OpenRouter") 800 | model_list=$(curl -s "${model_list_endpoint}" -H "Authorization: Bearer ${api_key}" | jq -r '.data[].id' 2>/dev/null | sort) 801 | model_display_color="${white}" 802 | ;; 803 | esac 804 | 805 | # Convert model list to array 806 | if [ -z "$model_list" ]; then 807 | StatusbarMessage 808 | echo -e "${red}No models found or API error. Check your API key.${default}" 809 | return 1 810 | else 811 | mapfile -t arr_model_list <<<"$model_list" 812 | fi 813 | 814 | StatusbarMessage 815 | 816 | # Display model selection menu 817 | echo -e "${dim}Available models for provider ${lightgreybg}${provider}${default}${dim}:${default}\n" 818 | for i in "${!arr_model_list[@]}"; do 819 | printf "%3d) %s\n" "$((i+1))" "${model_display_color}${arr_model_list[$i]}${default}" 820 | done 821 | echo 822 | 823 | # Get user selection 824 | while true; do 825 | read -rp "Enter your choice: " model_choice 826 | if [[ "$model_choice" =~ ^[0-9]+$ ]] && \ 827 | (( model_choice > 0 )) && \ 828 | (( model_choice <= ${#arr_model_list[@]} )); then 829 | model_name="${arr_model_list[$((model_choice-1))]}" 830 | break 831 | else 832 | echo -e "${red}Invalid selection. Please choose a number between 1 and ${#arr_model_list[@]}.${default}" 833 | fi 834 | done 835 | 836 | if [ "$forcedefault" = true ]; then 837 | default_value=true 838 | else 839 | read -rp "Set as default model [y/N]: " set_default 840 | [[ "$set_default" =~ ^[Yy]$ ]] && default_value=true || default_value=false 841 | fi 842 | jq --arg model_name "$model_name" \ 843 | --arg endpoint "$endpoint" \ 844 | --arg api_key "$api_key" \ 845 | --arg provider "$provider" \ 846 | --argjson default "$default_value" \ 847 | '.models += {($model_name): {provider: $provider, endpoint: $endpoint, api_key: $api_key, default: $default}}' "$CONFIG_FILENAME" >temp.json && mv temp.json "$CONFIG_FILENAME" 848 | 849 | echo -e "${greenbg} SUCCESS ${default} Model ${green}$model_name${default} added successfully." 850 | if [ "$default_value" = true ]; then 851 | SetDefaultModel "$model_name" 852 | fi 853 | } 854 | 855 | InitializeConfig() { 856 | empty_config_template='{"models": {}, "api_keys": {}}' 857 | # Create config directory if it doesn't exist 858 | mkdir -p "$WORKING_DIR" 859 | # Check if config file exists 860 | if [ ! -f "$CONFIG_FILENAME" ]; then 861 | echo -e "\n${yellowbg} Configuration file not found ${default}" 862 | # Create a new config file, with an empty models block 863 | echo -e "$empty_config_template" > "$CONFIG_FILENAME" 864 | echo -e "${bluebg} INFO ${default} Configuration file created at ${blue}\"$CONFIG_FILENAME\"${default}" 865 | # open the configuration wizard 866 | echo -e "${yellow}Please add at least one model to continue.${default}" 867 | AddNewModel true # add a new model, and set it as default 868 | fi 869 | # Check if the config file is valid JSON 870 | if ! jq empty "$CONFIG_FILENAME" >/dev/null 2>&1; then 871 | echo -e "${redbg} ERROR ${default} Invalid JSON in configuration file: ${red}\"$CONFIG_FILENAME\", recreating it${default}" 872 | echo -e "$empty_config_template" > "$CONFIG_FILENAME" 873 | AddNewModel true # add a new model, and set it as default 874 | fi 875 | # Check if the 'models' and 'api_keys' keys exist in the config file 876 | if ! jq -e '.models' "$CONFIG_FILENAME" >/dev/null 2>&1 || ! jq -e '.api_keys' "$CONFIG_FILENAME" >/dev/null 2>&1; then 877 | # the config file is corrupted, recreate it 878 | echo -e "$empty_config_template" > "$CONFIG_FILENAME" 879 | AddNewModel true # add a new model, and set it as default 880 | fi 881 | } 882 | 883 | SetCurrentModel() { 884 | if [ -n "$1" ]; then 885 | # Model specified via command line 886 | if jq -e --arg model "$1" '.models[$model] == null' "$CONFIG_FILENAME" &>/dev/null; then 887 | echo -e "${yellowbg} WARNING ${default}${yellow}${dim} Model ${default}${yellow}\"$1\"${dim} not found in configuration.${default}" 888 | return 1 889 | fi 890 | CURRENT_MODEL="$1" 891 | else 892 | # Determine default model 893 | default_model=$(jq -r '.models | to_entries[] | select(.value.default == true) | .key' "$CONFIG_FILENAME") 894 | if [ -z "$default_model" ]; then 895 | echo -e "${redbg} ERROR ${default}${red} No default model set in configuration.${default}" 896 | return 1 897 | fi 898 | CURRENT_MODEL="$default_model" 899 | fi 900 | echo -e "\n${blue}${dim}[Model set to ${default}${blue}${CURRENT_MODEL}${dim}]${default}" 901 | } 902 | 903 | CoreutilsFixup() { 904 | # check for GNU coreutils alternatives (improve command predictability on FreeBSD/MacOS systems) 905 | if [ -x "$(command -v gsed)" ]; then 906 | sed() { gsed "$@"; } 907 | export -f sed 908 | fi 909 | } 910 | 911 | #* 912 | #* Main script start 913 | #* 914 | 915 | terminal_width=$(tput cols) 916 | trap 'terminal_width=$(tput cols)' SIGWINCH 917 | conversation_history="[]" 918 | userinput="" 919 | PIPED_SCRIPT=false 920 | INTERACTIVE_CONVERSATION=false 921 | DELETE_CONVERSATION=false 922 | CONTINUE_CONVERSATION=false 923 | CONVERSATION_ID_TO_CONTINUE="" 924 | tmpfile="" 925 | 926 | trap Ctrl_C INT 927 | CoreutilsFixup 928 | InitializeConfig 929 | 930 | if [ "$#" -ge 1 ] || [ ! -t 0 ]; then 931 | # script was called with a command line parameter, or was invoked through a pipe 932 | if [ "$#" -ge 1 ]; then 933 | # parse command line options 934 | # 935 | # optspec contains: 936 | # - options followed by a colon: parameter is mandatory 937 | # - first colon: disable getopts' own error reporting 938 | # in this mode, getopts sets optchar to: 939 | # '?' -> unknown option 940 | # ':' -> missing mandatory parameter to the option 941 | optspec=":hcd:lim" 942 | while getopts "$optspec" optchar; do { 943 | GetFullParamsFromCurrentPosition() { 944 | # 945 | # Helper function that retrieves all the command line 946 | # parameters starting from position $OPTIND (current 947 | # option's argument as being parsed by getopts) 948 | # 949 | # 1) first param is set to current option's param (space-separated) 950 | # 2) then append (if any exist) the following command line params. 951 | # 952 | # this allows for invocations such as 'ai -i SENTENCE WITH SPACES' 953 | # without having to quote SENTENCE WITH SPACES 954 | # The function requires passing the original $@ as parameter 955 | # so as to not confuse it with the function's own $@. 956 | # 957 | # in the above example, $OPTARG="SENTENCE", $OPTIND="3", ${@:$OPTIND}=array("WITH" "SPACES") 958 | # 959 | userinput="$OPTARG" 960 | for option in "${@:$OPTIND}"; do 961 | userinput+=" $option" 962 | done 963 | } 964 | 965 | case "${optchar}" in 966 | "h") 967 | # display usage help 968 | echo -e "\n${bold}ai-bash v${AI_VERSION} Usage:${default}\n" 969 | echo -e "${dim}Show this help:${default}\n ${blue}ai -h${default}" 970 | echo -e "${dim}Manage models (add/delete/set default):${default}\n ${blue}ai -m${default}" 971 | echo -e "${dim}Start a new interactive conversation:${default}\n ${blue}ai [-m ]${default}" 972 | echo -e "${dim}Single turn Q&A:${default}\n ${blue}ai [-m ] \"how many planets are there in the solar system?\"${default}" 973 | echo -e "${dim}Generate one or more images (default: 1):${default}\n ${blue}ai -i [num] \"a cute cat\"${default}" 974 | echo -e "${dim}Submit data as part of the question:${default}\n ${blue}cat file.txt | ai [-m ] can you summarize the contents of this file?${default}" 975 | echo -e "${dim}List saved conversations:${default}\n ${blue}ai -l${default}" 976 | echo -e "${dim}Continue last conversation:${default}\n ${blue}ai -c${default}" 977 | echo -e "${dim}Continue specific conversation:${default}\n ${blue}ai -c ${default}" 978 | echo -e "${dim}Delete a conversation:${default}\n ${blue}ai -d ${default}" 979 | echo -e "${dim}Delete multiple conversations:${default}\n ${blue}ai -d -${default}" 980 | echo -e "${dim}Delete all conversations:${default}\n ${blue}rm \"${CONVERSATIONS_FILENAME}\"${default}" 981 | exit 0 982 | ;; 983 | "c") 984 | # user wants to continue a conversation 985 | INTERACTIVE_CONVERSATION=true 986 | CONTINUE_CONVERSATION=true 987 | GetFullParamsFromCurrentPosition "$@" 988 | CONVERSATION_ID_TO_CONTINUE=${userinput//[^0-9]/} # remove all non-digits (e.g. the user passed "-c3" instead of "-c 3") 989 | break 990 | ;; 991 | "d") 992 | # user wants to delete a previous conversation 993 | GetFullParamsFromCurrentPosition "$@" 994 | DELETE_CONVERSATION=true 995 | CONVERSATION_ID_TO_DELETE_UPTO="" 996 | ParseConversationsFile 997 | CONVERSATION_ID_TO_DELETE=${userinput//[^0-9\-]/} # remove all non-digits (excluding dash) (e.g. the user passed "-d3" instead of "-d 3") 998 | if [ -z "${CONVERSATION_ID_TO_DELETE##*-*}" ]; then 999 | # user wants to delete a range of conversations 1000 | CONVERSATION_ID_TO_DELETE_UPTO=$(cut -d '-' -f 2 <<<"$CONVERSATION_ID_TO_DELETE") 1001 | CONVERSATION_ID_TO_DELETE=$(cut -d '-' -f 1 <<<"$CONVERSATION_ID_TO_DELETE") 1002 | if (( CONVERSATION_ID_TO_DELETE_UPTO < CONVERSATION_ID_TO_DELETE )); then 1003 | echo -e "\nError: wrong conversation range, cannot delete it!" 1004 | exit 1 1005 | fi 1006 | else 1007 | CONVERSATION_ID_TO_DELETE_UPTO="$CONVERSATION_ID_TO_DELETE" 1008 | fi 1009 | if (( CONVERSATION_ID_TO_DELETE > num_previous_conversations )) || \ 1010 | (( CONVERSATION_ID_TO_DELETE_UPTO > num_previous_conversations )) || \ 1011 | (( CONVERSATION_ID_TO_DELETE == 0 )) || \ 1012 | (( CONVERSATION_ID_TO_DELETE_UPTO == 0 )); then 1013 | echo -e "\nError: conversation not found (or wrong conversation range), cannot delete it!" 1014 | exit 1 1015 | fi 1016 | if [ "$CONVERSATION_ID_TO_DELETE_UPTO" != "$CONVERSATION_ID_TO_DELETE" ]; then 1017 | delete_prompt="${yellow}Delete conversations from ${bold}$CONVERSATION_ID_TO_DELETE${default}${yellow} to ${bold}$CONVERSATION_ID_TO_DELETE_UPTO${default}${yellow}? [y/N]" 1018 | else 1019 | delete_prompt="${yellow}Delete conversation ${bold}$CONVERSATION_ID_TO_DELETE${default}${yellow}? [y/N]" 1020 | fi 1021 | read -erp "$delete_prompt" wannadelete 1022 | if [ "$wannadelete" = "Y" ] || [ "$wannadelete" = "y" ]; then 1023 | (( CONVERSATION_ID_TO_DELETE-- )) 1024 | (( CONVERSATION_ID_TO_DELETE_UPTO-- )) 1025 | for (( convid = CONVERSATION_ID_TO_DELETE_UPTO; convid >= CONVERSATION_ID_TO_DELETE; convid-- )); do 1026 | convname=$(jq '.['"${convid}"'].conversation_name' "$CONVERSATIONS_FILENAME") 1027 | jq 'del (.['"$convid"'])' "${CONVERSATIONS_FILENAME}" > "${TMP_CONVERSATIONS_FILENAME}" && \ 1028 | mv "${TMP_CONVERSATIONS_FILENAME}" "${CONVERSATIONS_FILENAME}" 1029 | echo -e "\n${default}Conversation ${yellow}$convname${default} deleted" 1030 | done 1031 | fi 1032 | exit 0 1033 | ;; 1034 | "l") 1035 | # user wants to list previous conversations 1036 | ParseConversationsFile 1037 | echo -en "\n${yellowbg} Previous conversations ${default}${yellow}" 1038 | if [ "$num_previous_conversations" = "0" ]; then 1039 | echo -e "\n\nNo previous conversation found.${default}" 1040 | exit 0 1041 | fi 1042 | title_list=$(jq '.[].conversation_name' "$CONVERSATIONS_FILENAME") 1043 | model_list=$(jq -r '.[].model' "$CONVERSATIONS_FILENAME") 1044 | i=1 1045 | paste <(echo "$title_list") <(echo "$model_list") | while IFS=$'\t' read -r title model; do 1046 | printf "\n%3d) %-50s %-30s" "$i" "$title" "${blue}${dim}[$model]${default}${yellow}" 1047 | (( i++ )) 1048 | done 1049 | echo -e "\n\n${default}Use ${blue}${bold}ai -c${default} to continue last conversation, ${blue}${bold}ai -c ${default} to continue a specific one, or ${blue}${bold}ai -d ${default} to delete it" 1050 | exit 1051 | ;; 1052 | "i") 1053 | # user wants to generate 1 or more images 1054 | IMAGE_GENERATION_MODE=true 1055 | GetFullParamsFromCurrentPosition "$@" 1056 | # check if the user specified a number of images 1057 | regexp_number_and_space="^ (10|[1-9]) (.*)" 1058 | if [[ $userinput =~ $regexp_number_and_space ]]; then 1059 | IMAGES_TO_GENERATE="${BASH_REMATCH[1]}" 1060 | userinput="${BASH_REMATCH[2]}" 1061 | fi 1062 | break 1063 | ;; 1064 | "m") 1065 | # user wants to manage models or select a specific model for the current session 1066 | shift 1067 | ManageModels "$@" 1068 | shift 1069 | ;; 1070 | *) 1071 | if [ "$OPTERR" = 1 ] && [ -t 0 ]; then 1072 | [[ "$optchar" = "?" ]] && echo -e "Error: unknown option '-$OPTARG'" 1073 | [[ "$optchar" = ":" ]] && echo -e "Error: option '-$OPTARG' requires an argument" 1074 | exit 1 1075 | fi 1076 | ;; 1077 | esac 1078 | } 1079 | done 1080 | userinput=$(awk '{ sub(/^[ \t]+/, ""); print }' <<<"$userinput") 1081 | fi 1082 | 1083 | ParseConversationsFile 1084 | 1085 | if [ "$CONTINUE_CONVERSATION" = true ]; then 1086 | if [ "$num_previous_conversations" = "0" ]; then 1087 | echo "Error: No previous conversations found, cannot continue!" 1088 | exit 1 1089 | fi 1090 | if [ -z "$CONVERSATION_ID_TO_CONTINUE" ]; then 1091 | # continue last conversation 1092 | conversation_id=$(jq 'length-1' "$CONVERSATIONS_FILENAME") 1093 | else 1094 | # continue a specific conversation 1095 | if [ "$CONVERSATION_ID_TO_CONTINUE" -gt "$num_previous_conversations" ]; then 1096 | echo "Error: invalid conversation id" 1097 | exit 1 1098 | fi 1099 | conversation_id="$(( CONVERSATION_ID_TO_CONTINUE - 1 ))" 1100 | fi 1101 | if [ ! -t 0 ]; then 1102 | # we have input from stdin (script was invoked through a pipe) 1103 | echo "Error: cannot continue last conversation and also accept new data" 1104 | exit 1 1105 | fi 1106 | SwitchConversation "$conversation_id" 1107 | elif [ "$IMAGE_GENERATION_MODE" = false ]; then 1108 | # we're not in image generation mode 1109 | # if the user has not passed a model name, set the default model 1110 | [[ -z "$CURRENT_MODEL" ]] && SetCurrentModel 1111 | if [ ! -t 0 ]; then 1112 | # we have input from stdin (script was invoked through a pipe) 1113 | PIPED_SCRIPT=true 1114 | if [ -z "$userinput" ]; then 1115 | # shellcheck disable=SC2124 1116 | userinput="$@" 1117 | fi 1118 | # parse the input passed and treat the stdin input (sanitized into a safe JSON string and formatted as a markdown code block) as "attachment" data 1119 | # resulting $userinput will be "