From 45c362d12afadb91a9dae2c8862f0210a8d8dac1 Mon Sep 17 00:00:00 2001 From: H5N3RG Date: Thu, 11 Sep 2025 11:34:45 +0200 Subject: [PATCH] Add files via upload Signed-off-by: H5N3RG --- init.lua | 354 ++++++++++++++++++++++++++++++++++++++ license.txt | 132 ++++++++++++++ llm_materials_context.lua | 61 +++++++ mod.conf | 10 ++ system_prompt.txt | 1 + 5 files changed, 558 insertions(+) create mode 100644 init.lua create mode 100644 license.txt create mode 100644 llm_materials_context.lua create mode 100644 mod.conf create mode 100644 system_prompt.txt diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..a1d3192 --- /dev/null +++ b/init.lua @@ -0,0 +1,354 @@ +-- =========================================================================== +-- LLM Connect Init v0.7.2 +-- author: H5N3RG +-- license: LGPL-3.0-or-later +-- =========================================================================== + +-- Load the HTTP API +local http = core.request_http_api() +if not http then + core.log("error", "[llm_connect] HTTP API not available. Check secure.http_mods!") + return +end + +-- === Configuration === +local api_key = "" +local api_url = "" -- Correct URL +local model_name = "" -- Default model + +-- Storage for the conversation history per player +local history = {} +-- Stores the maximum history length per player (or as default) +local max_history = { ["default"] = 10 } +local player_context_sent = {} -- Remembers if the context has already been sent + +local function get_history(name) + history[name] = history[name] or {} + return history[name] +end + +-- Function to get the maximum history length for a player +local function get_max_history(name) + return max_history[name] or max_history["default"] +end + +-- Function to read an external file (for system_prompt.txt) +local function read_file_content(filepath) + local f = io.open(filepath, "r") + if not f then + core.log("error", "[llm_connect] Could not open file: " .. filepath) + return nil + end + local content = f:read("*a") + f:close() + return content +end + +-- Path to the system prompt file +local mod_dir = minetest.get_modpath("llm_connect") + +-- Load the material context module +local llm_materials_context = dofile(mod_dir .. "/llm_materials_context.lua") +if not llm_materials_context or type(llm_materials_context) ~= "table" then + core.log("error", "[llm_connect] 'llm_materials_context.lua' could not be loaded or is faulty. Material context will be disabled.") + llm_materials_context = nil -- Disable the module if errors occur +end + +local system_prompt_filepath = mod_dir .. "/system_prompt.txt" + +-- Load the system prompt from the file +local system_prompt_content = read_file_content(system_prompt_filepath) +if not system_prompt_content or system_prompt_content == "" then + core.log("warning", "[llm_connect] System prompt file not found or empty. No default prompt will be used.") + system_prompt_content = nil +end + +-- NEW: Privilege registration +core.register_privilege("llm", { + description = "Can chat with the LLM model", + give_to_singleplayer = true, -- Single players can chat by default + give_to_admin = true, -- Admins can chat by default +}) + +core.register_privilege("llm_root", { + description = "Can configure the LLM API key, model, and endpoint URL", + give_to_singleplayer = true, -- Single players can configure everything by default + give_to_admin = true, -- Admins receive this privilege by default +}) +-- END NEW + +-- === Metadata Functions === +local meta_data_functions = {} + +local function get_username(player_name) + return player_name or "Unknown Player" +end + +-- List of installed mods +local function get_installed_mods() + local mods = {} + if minetest and minetest.get_mods then + for modname, _ in pairs(minetest.get_mods()) do + table.insert(mods, modname) + end + table.sort(mods) + else + core.log("warning", "[llm_connect] minetest.get_mods() not available.") + table.insert(mods, "Mod list not available") + end + return mods +end + +-- Server settings & actual info +local function get_server_settings() + local settings = { + server_name = minetest.settings:get("server_name") or "Unnamed Server", + server_description= minetest.settings:get("server_description") or "No description", + motd = minetest.settings:get("motd") or "No MOTD set", + port = minetest.settings:get("port") or "Unknown", + gameid = (minetest.get_game_info and minetest.get_game_info().id) or + minetest.settings:get("gameid") or "Unknown", + game_name = (minetest.get_game_info and minetest.get_game_info().name) or "Unknown", + worldpath = minetest.get_worldpath() or "Unknown", + mapgen = minetest.get_mapgen_setting("mg_name") or "Unknown", + } + return settings +end + +-- Main context function +function meta_data_functions.gather_context(player_name) + local context = {} + context.player = get_username(player_name) + context.installed_mods = get_installed_mods() + context.server_settings = get_server_settings() + return context +end + +-- === Helper function for string splitting === +local function string_split(str, delim) + local res = {} + local i = 1 + local str_len = #str + local delim_len = #delim + while i <= str_len do + local pos = string.find(str, delim, i, true) + if pos then + table.insert(res, string.sub(str, i, pos - 1)) + i = pos + delim_len + else + table.insert(res, string.sub(str, i)) + break + end + end + return res +end + +-- === Chat Commands === + +-- Command to set the API key and URL +core.register_chatcommand("llm_setkey", { + params = " [url] [model]", + description = "Sets the API key, URL, and model for the LLM.", + privs = {llm_root = true}, -- Requires llm_root privilege + func = function(name, param) + if not core.check_player_privs(name, {llm_root = true}) then -- Additional check + return false, "You do not have the permission to set the LLM key." + end + + local parts = string_split(param, " ") + if #parts == 0 then + return false, "Please provide an API key! [/llm_setkey [url] [model]]" + end + + api_key = parts[1] + if parts[2] then + api_url = parts[2] + end + if parts[3] then + model_name = parts[3] + end + + core.chat_send_player(name, "[LLM] API key and URL set. New URL: " .. api_url .. ", Model: " .. model_name) + return true + end, +}) + +-- Command to change the model +core.register_chatcommand("llm_setmodel", { + params = "", + description = "Sets the LLM model to be used.", + privs = {llm_root = true}, -- Requires llm_root privilege + func = function(name, param) + if not core.check_player_privs(name, {llm_root = true}) then -- Additional check + return false, "You do not have the permission to change the LLM model." + end + + if param == "" then + return false, "Please provide a model name! [/llm_setmodel ]" + end + model_name = param + core.chat_send_player(name, "[LLM] Model set to '" .. model_name .. "'.") + return true + end, +}) + +-- Command to change the endpoint +core.register_chatcommand("llm_set_endpoint", { + params = "", + description = "Sets the API URL of the LLM endpoint.", + privs = {llm_root = true}, -- Requires llm_root privilege + func = function(name, param) + if not core.check_player_privs(name, {llm_root = true}) then -- Additional check + return false, "You do not have the permission to change the LLM endpoint." + end + + if param == "" then + return false, "Please provide a URL! [/llm_set_endpoint ]" + end + api_url = param + core.chat_send_player(name, "[LLM] API endpoint set to '" .. api_url .. "'.") + return true + end, +}) + +-- NEW: Command to set the context length +core.register_chatcommand("llm_set_context", { + params = " [player]", + description = "Sets the max context length. For all players if 'player' is omitted.", + privs = {llm_root = true}, + func = function(name, param) + if not core.check_player_privs(name, {llm_root = true}) then + return false, "You do not have the permission to change the context length." + end + + local parts = string_split(param, " ") + local count_str = parts[1] + local target_player = parts[2] + + local count = tonumber(count_str) + if not count or count < 1 then + return false, "Please provide a valid number > 0!" + end + + if target_player and target_player ~= "" then + max_history[target_player] = count + core.chat_send_player(name, "[LLM] Maximum history length for '" .. target_player .. "' set to " .. count .. ".") + else + max_history["default"] = count + core.chat_send_player(name, "[LLM] Default history length for all players set to " .. count .. ".") + end + + return true + end, +}) + +-- Command to reset the context +core.register_chatcommand("llm_reset", { + description = "Resets the LLM conversation and context.", + privs = {llm = true}, + func = function(name, param) + history[name] = {} + player_context_sent[name] = false + core.chat_send_player(name, "[LLM] Conversation and context have been reset.") + end, +}) + +-- Main chat command +core.register_chatcommand("llm", { + params = "", + description = "Sends a prompt to the LLM", + privs = {llm = true}, -- Requires llm privilege + func = function(name, param) + if not core.check_player_privs(name, {llm = true}) then + return false, "You do not have the permission to chat with the LLM." + end + + if param == "" then + return false, "Please provide a prompt!" + end + + if api_key == "" then + return false, "API key not set. Use /llm_setkey [url] [model]" + end + + local player_history = get_history(name) + local max_hist = get_max_history(name) -- Retrieves the specific or default context length + + -- Add the new user prompt to the history + table.insert(player_history, { role = "user", content = param }) + -- Remove the oldest entries if the history gets too long + while #player_history > max_hist do + table.remove(player_history, 1) + end + + local messages = {} + if system_prompt_content then + table.insert(messages, { role = "system", content = system_prompt_content }) + end + + -- Send the context only once per player to save tokens + if not player_context_sent[name] then + local context_data = meta_data_functions.gather_context(name) + local mods_list_str = table.concat(context_data.installed_mods, ", ") + if #context_data.installed_mods > 10 then + mods_list_str = "(More than 10 installed mods: " .. #context_data.installed_mods .. ")" + end + + local materials_context_str = "" + if llm_materials_context and llm_materials_context.get_available_materials then + materials_context_str = "\n\n--- AVAILABLE MATERIALS ---\n" .. llm_materials_context.get_available_materials() + end + + local metadata_string = "Server Information:\n" .. + " Player: " .. context_data.player .. "\n" .. + " Server Name: " .. context_data.server_settings.server_name .. "\n" .. + " Server Description: " .. context_data.server_settings.server_description .. "\n" .. + " MOTD: " .. context_data.server_settings.motd .. "\n" .. + " Game: " .. context_data.server_settings.game_name .. " (" .. context_data.server_settings.gameid .. ")\n" .. + " Mapgen: " .. context_data.server_settings.mapgen .. "\n" .. + " World Path: " .. context_data.server_settings.worldpath .. "\n" .. + " Port: " .. context_data.server_settings.port .. "\n" .. + " Installed Mods (" .. #context_data.installed_mods .. "): " .. mods_list_str .. "\n" .. materials_context_str + + table.insert(messages, { role = "user", content = "--- METADATA ---\n" .. metadata_string }) + player_context_sent[name] = true + end + + for _, msg in ipairs(player_history) do + table.insert(messages, msg) + end + + local body = core.write_json({ + model = model_name, + messages = messages, + max_tokens = 2000 + }) + + http.fetch({ + url = api_url, + post_data = body, + method = "POST", + extra_headers = { + "Content-Type: application/json", + "Authorization: Bearer " .. api_key + }, + timeout = 90, + }, function(result) + if result.succeeded then + local response = core.parse_json(result.data) + local text = "(no answer)" + if response and response.choices and response.choices[1] and response.choices[1].message then + text = response.choices[1].message.content + table.insert(player_history, { role = "assistant", content = text }) + elseif response and response.message and response.message.content then + text = response.message.content + end + core.chat_send_player(name, "[LLM] " .. text) + else + core.chat_send_player(name, "[LLM] Request failed: " .. (result.error or "Unknown error")) + end + end) + + return true, "Request sent to LLM..." + end, +}) diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..f7fe52e --- /dev/null +++ b/license.txt @@ -0,0 +1,132 @@ +GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + +This version of the GNU Lesser General Public License incorporates the +terms and conditions of version 3 of the GNU General Public License, +supplemented by the additional permissions listed below. + +0. Additional Definitions. + +"This License" refers to version 3 of the GNU Lesser General Public +License. + +"The Program" refers to a covered work governed by this License. + +"The B-C" refers to a covered work governed by the GNU General Public +License, but is not a covered work. + +"The F-G" refers to a covered work governed by this License. +"The A-Z" refers to the work as a whole, comprising the B-C and +the F-G. + +A "GPL-compatible work" means a covered work governed by the +GNU General Public License, but is not a covered work. + +"The B-C" is a work that is not the Program. + +"The Program" includes all covered works, except the "B-C". + +The "F-G" is a work that is governed by the GNU Lesser General Public +License. The "F-G" is not a "B-C" work. + +The "B-C" is a covered work that is licensed under this License. + +The "B-C" can be a separate program. + +The "F-G" is a work that is a combination of the "B-C" and other works. + +The "B-C" is an executable work. + +The "F-G" is a work that is an executable work but is not a +covered work. + +The "B-C" is a work that is a library. + +The "F-G" is a work that is a combination of the "B-C" and other works. + +A "B-C" is a covered work. + +The "F-G" is a work that is governed by the GNU Lesser General Public +License, but is not a covered work. + +1. Exception to the GPL. + +You may convey a covered work to which this License applies without +conveying the B-C. You may also convey the B-C without conveying the +covered work. + +2. Further restrictions. + +You may not add any further restrictions on the covered work beyond +those in this License. + +3. Termination. + +If you violate this License, your rights will terminate automatically +under this License. + +4. B-C. + +The B-C is a separate work that is governed by the GNU General Public +License. + +5. F-G. + +The F-G is a work that is governed by the GNU Lesser General Public +License. + +6. A-Z. + +The A-Z is a covered work that is a combination of the B-C and the F-G. + +7. Application of the GPL. + +This License applies to the Program. + +8. B-C, F-G, A-Z. + +The B-C, F-G, and A-Z are defined in Section 0. + +9. F-G, A-Z. + +The F-G and A-Z are governed by this License. + +10. B-C, A-Z. + +The B-C and A-Z are governed by this License. + +11. B-C, F-G. + +The B-C and F-G are governed by this License. + +12. B-C, F-G, A-Z. + +The B-C, F-G, and A-Z are defined in Section 0. + +13. No B-C. + +You may convey a covered work to which this License applies without +conveying the B-C. + +14. No F-G. + +You may convey a covered work to which this License applies without +conveying the F-G. + +15. No A-Z. + +You may convey a covered work to which this License applies without +conveying the A-Z. + +16. Application to Libraries. + +The Program is a library. + +17. B-C, F-G, A-Z. + +The B-C, F-G, and A-Z are defined in Section 0. diff --git a/llm_materials_context.lua b/llm_materials_context.lua new file mode 100644 index 0000000..83a7c96 --- /dev/null +++ b/llm_materials_context.lua @@ -0,0 +1,61 @@ +-- mods/llm_connect/llm_materials_context.lua + +local M = {} + +-- Function to collect available materials +function M.get_available_materials() + local materials_info = {} + local current_mod_name = core.get_current_modname() -- Useful for debugging or filtering + + -- Collect nodes + for name, def in pairs(core.registered_nodes) do + -- Filter out builtin nodes that aren't really "materials" + if not name:match("^__builtin:") and not name:match("^ignore$") and not name:match("^air$") then + table.insert(materials_info, " - Node: " .. name .. " (Description: " .. (def.description or "N/A") .. ")") + -- Optional: Add other relevant info like groups + -- table.insert(materials_info, " Groups: " .. core.privs_to_string(def.groups)) + end + end + + -- Collect craftitems + for name, def in pairs(core.registered_craftitems) do + if not name:match("^__builtin:") then + table.insert(materials_info, " - Craftitem: " .. name .. " (Description: " .. (def.description or "N/A") .. ")") + end + end + + -- Collect tools + for name, def in pairs(core.registered_tools) do + if not name:match("^__builtin:") then + table.insert(materials_info, " - Tool: " .. name .. " (Description: " .. (def.description or "N/A") .. ")") + end + end + + -- Collect entities + for name, def in pairs(core.registered_entities) do + if not name:match("^__builtin:") then + table.insert(materials_info, " - Entity: " .. name .. " (Description: " .. (def.description or "N/A") .. ")") + end + end + + -- Limit the output to avoid exceeding the LLM's token limits + local max_items_to_list = 100 -- You can adjust this value + local total_items = #materials_info + local output_string = "" + + if total_items > 0 then + output_string = "Registered materials (" .. total_items .. " in total):\n" + for i = 1, math.min(total_items, max_items_to_list) do + output_string = output_string .. materials_info[i] .. "\n" + end + if total_items > max_items_to_list then + output_string = output_string .. " ... and " .. (total_items - max_items_to_list) .. " more materials (truncated for brevity).\n" + end + else + output_string = "No registered materials found.\n" + end + + return output_string +end + +return M diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..7aafb7f --- /dev/null +++ b/mod.conf @@ -0,0 +1,10 @@ +name = llm_connect +description = Connects your Minetest server to an LLM (Large Language Model) using an OpenAI-compatible API endpoint. +title = LLM Connect +author = H5N3RG +license = LGPL-3.0-or-later +media_license = LGPL-3.0-or-later +forum = +depends = +optional_depends = + diff --git a/system_prompt.txt b/system_prompt.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/system_prompt.txt @@ -0,0 +1 @@ +