feat: rewrite to accept json data from cli args other than env var (#7)

* update readme
This commit is contained in:
sigoden
2024-05-21 08:26:47 +08:00
committed by GitHub
parent e6642b5898
commit ef43b22a8e
8 changed files with 266 additions and 183 deletions
+45 -117
View File
@@ -13,20 +13,13 @@ LANG_CMDS=( \
)
# @cmd Call the function
# @arg func![`_choice_func`] The function name
# @arg args~[?`_choice_func_args`] The function args
# @arg cmd![`_choice_cmd`] The function command
# @arg json The json data
call() {
basename="${argc_func%.*}"
lang="${argc_func##*.}"
func_path="./$lang/$basename.$lang"
if [[ ! -e "$func_path" ]]; then
_die "error: not found $argc_func"
fi
if [[ "$lang" == "sh" ]]; then
"$func_path" "${argc_args[@]}"
else
"$(_lang_to_cmd "$lang")" "./cmd/cmd.$lang" "$argc_func"
if _is_win; then
ext=".cmd"
fi
"$BIN_DIR/$argc_cmd$ext" "$argc_json"
}
# @cmd Build the project
@@ -49,7 +42,7 @@ build-bin() {
mkdir -p "$BIN_DIR"
rm -rf "$BIN_DIR"/*
names=($(cat "$argc_names_file"))
invalid_names=()
not_found_funcs=()
for name in "${names[@]}"; do
basename="${name%.*}"
lang="${name##*.}"
@@ -57,27 +50,21 @@ build-bin() {
if [[ -f "$func_file" ]]; then
if _is_win; then
bin_file="$BIN_DIR/$basename.cmd"
if [[ "$lang" == sh ]]; then
_build_win_sh > "$bin_file"
else
_build_win_lang $lang "$(_lang_to_cmd "$lang")" > "$bin_file"
fi
_build_win_shim $lang > "$bin_file"
else
bin_file="$BIN_DIR/$basename"
if [[ "$lang" == sh ]]; then
ln -s "$PWD/$func_file" "$bin_file"
else
ln -s "$PWD/cmd/cmd.$lang" "$bin_file"
fi
fi
else
invalid_names+=("$name")
not_found_funcs+=("$name")
fi
done
if [[ -n "$invalid_names" ]]; then
_die "error: missing following functions: ${invalid_names[*]}"
if [[ -n "$not_found_funcs" ]]; then
_die "error: not founds functions: ${not_found_funcs[*]}"
fi
echo "Build bin"
for name in "$BIN_DIR"/*; do
echo "Build $name"
done
}
# @cmd Build declarations.json
@@ -125,11 +112,7 @@ build-single-declaration() {
func="$1"
lang="${func##*.}"
cmd="$(_lang_to_cmd "$lang")"
if [[ "$lang" == sh ]]; then
argc --argc-export "$lang/$func" | _parse_argc_declaration
else
LLM_FUNCTION_DECLARATE=1 "$cmd" "cmd/cmd.$lang" "$func"
fi
LLM_FUNCTION_ACTION=declarate "$cmd" "cmd/cmd.$lang" "$func"
}
# @cmd List functions that can be put into functions.txt
@@ -146,36 +129,37 @@ test() {
func_names_file=functions.txt.test
argc list-functions > "$func_names_file"
argc build --names-file "$func_names_file"
argc test-call-functions
argc test-functions
rm -rf "$func_names_file"
}
# @cmd Test call functions
test-call-functions() {
test-functions() {
if _is_win; then
ext=".cmd"
fi
"./bin/may_execute_command$ext" --command 'echo "bash works"'
argc call may_execute_command.sh --command 'echo "bash works"'
test_cases=( \
'sh#may_execute_command#{"command":"echo \"✓\""}' \
'js#may_execute_js_code#{"code":"console.log(\"✓\")"}' \
'py#may_execute_py_code#{"code":"print(\"✓\")"}' \
'rb#may_execute_rb_code#{"code":"puts \"✓\""}' \
)
if command -v node &> /dev/null; then
export LLM_FUNCTION_DATA='{"code":"console.log(\"javascript works\")"}'
"./bin/may_execute_js_code$ext"
argc call may_execute_js_code.js
for test_case in "${test_cases[@]}"; do
IFS='#' read -r lang func data <<<"${test_case}"
cmd="$(_lang_to_cmd "$lang")"
cmd_path="$BIN_DIR/$func$ext"
if command -v "$cmd" &> /dev/null; then
"$cmd_path" "$data" | {
echo "Test $cmd_path: $(cat)"
}
if ! _is_win; then
"$cmd" "cmd/cmd.$lang" "$func" "$data" | {
echo "Test $cmd cmd/cmd.$lang $func: $(cat)"
}
fi
if command -v python &> /dev/null; then
export LLM_FUNCTION_DATA='{"code":"print(\"python works\")"}'
"./bin/may_execute_py_code$ext"
argc call may_execute_py_code.py
fi
if command -v ruby &> /dev/null; then
export LLM_FUNCTION_DATA='{"code":"puts \"ruby works\""}'
"./bin/may_execute_rb_code$ext"
argc call may_execute_rb_code.rb
fi
done
}
# @cmd Install this repo to aichat functions_dir
@@ -199,49 +183,6 @@ version() {
curl --version | head -n 1
}
_parse_argc_declaration() {
jq -r '
def parse_description(flag_option):
if flag_option.describe == "" then
{}
else
{ "description": flag_option.describe }
end;
def parse_enum(flag_option):
if flag_option.choice.type == "Values" then
{ "enum": flag_option.choice.data }
else
{}
end;
def parse_property(flag_option):
[
{ condition: (flag_option.flag == true), result: { type: "boolean" } },
{ condition: (flag_option.multiple_occurs == true), result: { type: "array", items: { type: "string" } } },
{ condition: (flag_option.notations[0] == "INT"), result: { type: "integer" } },
{ condition: (flag_option.notations[0] == "NUM"), result: { type: "number" } },
{ condition: true, result: { type: "string" } } ]
| map(select(.condition) | .result) | first
| (. + parse_description(flag_option))
| (. + parse_enum(flag_option))
;
def parse_parameter(flag_options):
{
type: "object",
properties: (reduce flag_options[] as $item ({}; . + { ($item.id | sub("-"; "_"; "g")): parse_property($item) })),
required: [flag_options[] | select(.required == true) | .id],
};
{
name: (.name | sub("-"; "_"; "g")),
description: .describe,
parameters: parse_parameter([.flag_options[] | select(.id != "help" and .id != "version")])
}'
}
_lang_to_cmd() {
match_lang="$1"
for item in "${LANG_CMDS[@]}"; do
@@ -252,24 +193,14 @@ _lang_to_cmd() {
done
}
_build_win_sh() {
cat <<-'EOF'
@echo off
setlocal
set "bin_dir=%~dp0"
for %%i in ("%bin_dir:~0,-1%") do set "script_dir=%%~dpi"
set "script_name=%~n0"
set "script_name=%script_name%.sh"
for /f "delims=" %%a in ('argc --argc-shell-path') do set "_bash_prog=%%a"
"%_bash_prog%" --noprofile --norc "%script_dir%sh\%script_name%" %*
EOF
}
_build_win_lang() {
_build_win_shim() {
lang="$1"
cmd="$2"
cmd="$(_lang_to_cmd "$lang")"
if [[ "$lang" == "sh" ]]; then
run="\"$(cygpath -w "$(which $cmd)")\" --noprofile --norc"
else
run="\"$(cygpath -w "$(which $cmd)")\""
fi
cat <<-EOF
@echo off
setlocal
@@ -278,7 +209,7 @@ set "bin_dir=%~dp0"
for %%i in ("%bin_dir:~0,-1%") do set "script_dir=%%~dpi"
set "script_name=%~n0"
$cmd "%script_dir%cmd\cmd.$lang" "%script_name%.$lang" %*
$run "%script_dir%cmd\cmd.$lang" "%script_name%.$lang" %*
EOF
}
@@ -300,11 +231,8 @@ _choice_func() {
done
}
_choice_func_args() {
args=( "${argc__positionals[@]}" )
if [[ "${args[0]}" == *.sh ]]; then
argc --argc-compgen generic "sh/${args[0]}" "${args[@]}"
fi
_choice_cmd() {
ls -1 "$BIN_DIR" | sed -e 's/\.cmd$//'
}
_die() {
+9 -4
View File
@@ -1,6 +1,6 @@
# LLM Functions
This project allows you to enhance large language models (LLMs) with custom functions written in Bash/Js/Python/Ruby. Imagine your LLM being able to execute system commands, access web APIs, or perform other complex tasks all triggered by simple, natural language prompts.
This project allows you to enhance large language models (LLMs) with custom functions written in bash/js/python/ruby. Imagine your LLM being able to execute system commands, access web APIs, or perform other complex tasks all triggered by simple, natural language prompts.
## Prerequisites
@@ -46,7 +46,8 @@ AIChat will automatically load `functions.json` and execute functions located in
Now you can interact with your LLM using natural language prompts that trigger your defined functions.
![image](https://github.com/sigoden/llm-functions/assets/4012553/867b7b2a-25fb-4c74-9b66-3701eaabbd1f)
![function-showcase](https://github.com/sigoden/llm-functions/assets/4012553/391867dd-577c-4aaa-9ff2-c9e67fb0f3a3)
## Function Types
@@ -56,13 +57,17 @@ The function returns JSON data to LLM for further processing.
AIChat does not ask permission to run the function or print the output.
![retrieve-type-showcase](https://github.com/sigoden/llm-functions/assets/4012553/7e628834-9863-444a-bad8-7b51bfb18dff)
### Execute Type
The function does not return data to LLM. Instead, they enable more complex actions, such as showing a progress bar or running a TUI application.
The function does not have to return JSON data.
The function can perform dangerous tasks like creating/deleting files, changing network adapter, and setting a scheduled task...
AIChat will ask permission before running the function.
![image](https://github.com/sigoden/aichat/assets/4012553/711067b8-dd23-443d-840a-5556697ab075)
![execute-type-showcase](https://github.com/sigoden/llm-functions/assets/4012553/1dbc345f-daf9-4d65-a49f-3df8c7df1727)
**AIChat categorizes functions starting with `may_` as `execute type` and all others as `retrieve type`.**
+37 -18
View File
@@ -1,36 +1,55 @@
#!/usr/bin/env node
function loadModule() {
const path = require("path");
let func_name = process.argv[1];
if (func_name.endsWith("cmd.js")) {
func_name = process.argv[2]
function parseArgv() {
let func_file = process.argv[1];
let func_data = null;
if (func_file.endsWith("cmd.js")) {
func_file = process.argv[2]
func_data = process.argv[3]
} else {
func_name = path.basename(func_name)
func_file = path.basename(func_file)
func_data = process.argv[2];
}
if (!func_name.endsWith(".js")) {
func_name += '.js'
if (!func_file.endsWith(".js")) {
func_file += '.js'
}
const func_path = path.resolve(__dirname, `../js/${func_name}`)
return [func_file, func_data]
}
function loadFunc(func_file) {
const func_path = path.resolve(__dirname, `../js/${func_file}`)
try {
return require(func_path);
} catch {
console.log(`Invalid js function: ${func_name}`)
console.log(`Invalid function: ${func_file}`)
process.exit(1)
}
}
if (process.env["LLM_FUNCTION_DECLARATE"]) {
const { declarate } = loadModule();
const [func_file, func_data] = parseArgv();
if (process.env["LLM_FUNCTION_ACTION"] == "declarate") {
const { declarate } = loadFunc(func_file);
console.log(JSON.stringify(declarate(), null, 2))
} else {
let data = null;
try {
data = JSON.parse(process.env["LLM_FUNCTION_DATA"])
} catch {
console.log("Invalid LLM_FUNCTION_DATA")
if (!func_data) {
console.log("No json data");
process.exit(1)
}
const { execute } = loadModule();
execute(data)
let args;
try {
args = JSON.parse(func_data)
} catch {
console.log("Invalid json data")
process.exit(1)
}
const { execute } = loadFunc(func_file);
execute(args)
}
+33 -22
View File
@@ -5,39 +5,50 @@ import json
import sys
import importlib.util
def load_module(func_name):
def parse_argv():
func_file = sys.argv[0]
func_data = None
if func_file.endswith("cmd.py"):
func_file = sys.argv[1] if len(sys.argv) > 1 else None
func_data = sys.argv[2] if len(sys.argv) > 2 else None
else:
func_file = os.path.basename(func_file)
func_data = sys.argv[1] if len(sys.argv) > 1 else None
if not func_file.endswith(".py"):
func_file += ".py"
return func_file, func_data
def load_func(func_file):
base_dir = os.path.dirname(os.path.abspath(__file__))
func_path = os.path.join(base_dir, f"../py/{func_name}")
func_path = os.path.join(base_dir, f"../py/{func_file}")
if os.path.exists(func_path):
spec = importlib.util.spec_from_file_location(func_name, func_path)
spec = importlib.util.spec_from_file_location(func_file, func_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
else:
print(f"Invalid py function: {func_name}")
print(f"Invalid function: {func_file}")
sys.exit(1)
func_name = sys.argv[0]
if func_name.endswith("cmd.py"):
func_name = sys.argv[1]
else:
func_name = os.path.basename(func_name)
func_file, func_data = parse_argv()
if not func_name.endswith(".py"):
func_name += ".py"
if os.getenv("LLM_FUNCTION_DECLARATE"):
module = load_module(func_name)
declarate = getattr(module, 'declarate')
print(json.dumps(declarate(), indent=2))
if os.getenv("LLM_FUNCTION_ACTION") == "declarate":
module = load_func(func_file)
print(json.dumps(module.declarate(), indent=2))
else:
data = None
if not func_data:
print("No json data")
sys.exit(1)
args = None
try:
data = json.loads(os.getenv("LLM_FUNCTION_DATA"))
args = json.loads(func_data)
except (json.JSONDecodeError, TypeError):
print("Invalid LLM_FUNCTION_DATA")
print("Invalid json data")
sys.exit(1)
module = load_module(func_name)
execute = getattr(module, 'execute')
execute(data)
module = load_func(func_file)
module.execute(args)
+35 -17
View File
@@ -3,34 +3,52 @@
require 'json'
require 'pathname'
def load_module
if __FILE__.end_with?("cmd.rb")
func_name = ARGV[0]
def parse_argv
func_file = __FILE__
func_data = nil
if func_file.end_with?("cmd.rb")
func_file = ARGV[0]
func_data = ARGV[1]
else
func_name = Pathname.new(__FILE__).basename.to_s
func_file = File.basename(func_file)
func_data = ARGV[0]
end
func_name += '.rb' unless func_name.end_with?('.rb')
func_path = File.expand_path("../rb/#{func_name}", __dir__)
func_file += '.rb' unless func_file.end_with?(".rb")
[func_file, func_data]
end
def load_func(func_file)
func_path = File.expand_path("../rb/#{func_file}", __dir__)
begin
return require_relative func_path
require func_path
rescue LoadError
puts "Invalid ruby function: #{func_name}"
puts "Invalid function: #{func_file}"
exit 1
end
end
if ENV["LLM_FUNCTION_DECLARATE"]
declarate = load_module.method(:declarate)
puts JSON.pretty_generate(declarate.call)
func_file, func_data = parse_argv
if ENV["LLM_FUNCTION_ACTION"] == "declarate"
load_func(func_file)
puts JSON.pretty_generate(declarate)
else
begin
data = JSON.parse(ENV["LLM_FUNCTION_DATA"])
rescue JSON::ParserError
puts "Invalid LLM_FUNCTION_DATA"
if func_data.nil?
puts "No json data"
exit 1
end
execute = load_module.method(:execute)
execute.call(data)
begin
args = JSON.parse(func_data)
rescue JSON::ParserError
puts "Invalid json data"
exit 1
end
load_func(func_file)
execute(args)
end
Executable
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -e
if [[ "$0" == *cmd.sh ]]; then
FUNC_FILE="$1"
FUNC_DATA="$2"
else
FUNC_FILE="$(basename "$0")"
FUNC_DATA="$1"
fi
if [[ "$FUNC_FILE" != *'.sh' ]]; then
FUNC_FILE="$FUNC_FILE.sh"
fi
PROJECT_DIR="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/.." &> /dev/null && pwd)"
FUNC_FILE="$PROJECT_DIR/sh/$FUNC_FILE"
if [[ "$OS" == "Windows_NT" ]]; then
FUNC_FILE="$(cygpath -w "$FUNC_FILE")"
fi
if [[ "$LLM_FUNCTION_ACTION" == "declarate" ]]; then
argc --argc-export "$FUNC_FILE" | \
jq -r '
def parse_description(flag_option):
if flag_option.describe == "" then
{}
else
{ "description": flag_option.describe }
end;
def parse_enum(flag_option):
if flag_option.choice.type == "Values" then
{ "enum": flag_option.choice.data }
else
{}
end;
def parse_property(flag_option):
[
{ condition: (flag_option.flag == true), result: { type: "boolean" } },
{ condition: (flag_option.multiple_occurs == true), result: { type: "array", items: { type: "string" } } },
{ condition: (flag_option.notations[0] == "INT"), result: { type: "integer" } },
{ condition: (flag_option.notations[0] == "NUM"), result: { type: "number" } },
{ condition: true, result: { type: "string" } } ]
| map(select(.condition) | .result) | first
| (. + parse_description(flag_option))
| (. + parse_enum(flag_option))
;
def parse_parameter(flag_options):
{
type: "object",
properties: (reduce flag_options[] as $item ({}; . + { ($item.id | sub("-"; "_"; "g")): parse_property($item) })),
required: [flag_options[] | select(.required == true) | .id],
};
{
name: (.name | sub("-"; "_"; "g")),
description: .describe,
parameters: parse_parameter([.flag_options[] | select(.id != "help" and .id != "version")])
}'
else
if [[ -z "$FUNC_DATA" ]]; then
echo "No json data"
exit 1
fi
data="$(
echo "$FUNC_DATA" | \
jq -r '
to_entries | .[] |
(.key | split("_") | join("-")) as $key |
if .value | type == "array" then
.value | .[] | "--\($key)\n\(.)"
elif .value | type == "boolean" then
if .value then "--\($key)" else "" end
else
"--\($key)\n\(.value)"
end' | \
tr -d '\r'
)" || {
echo "Invalid json data"
exit 1
}
while IFS= read -r line; do
ARGS+=("$line")
done <<< "$data"
"$FUNC_FILE" "${ARGS[@]}"
fi
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
# @describe Get the current time.
main() {
date
}
eval "$(argc --argc-eval "$0" "$@")"
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
# @describe Executes a shell command.
# @option --command~ Command to execute, such as `ls -la`
# @option --command! Command to execute, such as `ls -la`
main() {
eval "$argc_command"