369 lines
15 KiB
Lua
369 lines
15 KiB
Lua
-- chat_gui.lua
|
|
-- LLM Chat Interface v0.8.7
|
|
-- Privilege model:
|
|
-- llm → Chat only
|
|
-- llm_dev → + IDE button
|
|
-- llm_worldedit → + WE Single/Loop + Mats + Undo
|
|
-- llm_root → Superrole: implies all of the above + Config button
|
|
|
|
local core = core
|
|
local M = {}
|
|
|
|
local mod_path = core.get_modpath("llm_connect")
|
|
|
|
local context_ok, chat_context = pcall(dofile, mod_path .. "/chat_context.lua")
|
|
if not context_ok then
|
|
core.log("error", "[chat_gui] Failed to load chat_context.lua: " .. tostring(chat_context))
|
|
chat_context = nil
|
|
end
|
|
|
|
-- material_picker: prefer already-loaded global, fallback to dofile
|
|
local material_picker = _G.material_picker
|
|
if not material_picker then
|
|
local ok, mp = pcall(dofile, mod_path .. "/material_picker.lua")
|
|
if ok and mp then
|
|
material_picker = mp
|
|
else
|
|
core.log("warning", "[chat_gui] material_picker not available: " .. tostring(mp))
|
|
end
|
|
end
|
|
|
|
local function get_llm_api()
|
|
if not _G.llm_api then error("[chat_gui] llm_api not available") end
|
|
return _G.llm_api
|
|
end
|
|
|
|
-- ============================================================
|
|
-- Privilege helpers
|
|
-- llm_root is a superrole: implies llm + llm_dev + llm_worldedit
|
|
-- ============================================================
|
|
|
|
local function raw_priv(name, priv)
|
|
local p = core.get_player_privs(name) or {}
|
|
return p[priv] == true
|
|
end
|
|
|
|
local function has_priv(name, priv)
|
|
if raw_priv(name, "llm_root") then return true end
|
|
return raw_priv(name, priv)
|
|
end
|
|
|
|
local function can_chat(name) return has_priv(name, "llm") end
|
|
local function can_ide(name) return has_priv(name, "llm_dev") end
|
|
local function can_worldedit(name) return has_priv(name, "llm_worldedit") end
|
|
local function can_config(name) return raw_priv(name, "llm_root") end -- root only, no implication upward
|
|
|
|
-- ============================================================
|
|
-- Session
|
|
-- ============================================================
|
|
|
|
local sessions = {}
|
|
|
|
local WE_MODE_LABEL = {chat="Chat", single="WE Single", loop="WE Loop"}
|
|
local WE_MODE_COLOR = {chat="#444455", single="#2a4a6a", loop="#4a2a6a"}
|
|
local WE_MODE_COLOR_UNAVAIL = "#333333"
|
|
|
|
local function get_session(name)
|
|
if not sessions[name] then
|
|
sessions[name] = {history={}, last_input="", we_mode="chat"}
|
|
end
|
|
return sessions[name]
|
|
end
|
|
|
|
local function we_available()
|
|
return type(_G.we_agency) == "table" and _G.we_agency.is_available()
|
|
end
|
|
|
|
local function cycle_we_mode(session, name)
|
|
if not we_available() then
|
|
core.chat_send_player(name, "[LLM] WorldEdit not available.")
|
|
return
|
|
end
|
|
local cur = session.we_mode
|
|
if cur == "chat" then session.we_mode = "single"
|
|
elseif cur == "single" then session.we_mode = "loop"
|
|
elseif cur == "loop" then session.we_mode = "chat"
|
|
end
|
|
core.chat_send_player(name, "[LLM] Mode: " .. WE_MODE_LABEL[session.we_mode])
|
|
end
|
|
|
|
-- ============================================================
|
|
-- Build Formspec
|
|
-- ============================================================
|
|
|
|
function M.show(name)
|
|
if not can_chat(name) then
|
|
core.chat_send_player(name, "[LLM] Missing privilege: llm")
|
|
return
|
|
end
|
|
|
|
local session = get_session(name)
|
|
local text_accum = ""
|
|
|
|
for _, msg in ipairs(session.history) do
|
|
if msg.role ~= "system" then
|
|
local content = msg.content or ""
|
|
if msg.role == "user" then
|
|
text_accum = text_accum .. "You: " .. content .. "\n\n"
|
|
else
|
|
text_accum = text_accum .. "[LLM]: " .. content .. "\n\n"
|
|
end
|
|
end
|
|
end
|
|
if text_accum == "" then
|
|
text_accum = "Welcome to LLM Chat!\nType your question below."
|
|
end
|
|
|
|
local W = 16.0
|
|
local H = 12.0
|
|
local PAD = 0.25
|
|
local HEADER_H = 1.8
|
|
local INPUT_H = 0.7
|
|
local CHAT_H = H - HEADER_H - INPUT_H - (PAD * 6)
|
|
|
|
local fs = {
|
|
"formspec_version[6]",
|
|
"size[" .. W .. "," .. H .. "]",
|
|
"bgcolor[#0f0f0f;both]",
|
|
"style_type[*;bgcolor=#1a1a1a;textcolor=#e0e0e0]",
|
|
}
|
|
|
|
-- Header box
|
|
table.insert(fs, "box[0,0;" .. W .. "," .. HEADER_H .. ";#202020]")
|
|
table.insert(fs, "label[" .. PAD .. ",0.30;LLM Chat - " .. core.formspec_escape(name) .. "]")
|
|
|
|
-- ── Header Zeile 1 rechts: Config (root) + IDE (dev) ────
|
|
local right_x = W - PAD
|
|
if can_config(name) then
|
|
right_x = right_x - 2.0
|
|
table.insert(fs, "style[open_config;bgcolor=#2a2a1a;textcolor=#ffeeaa]")
|
|
table.insert(fs, "button[" .. right_x .. ",0.08;2.0,0.65;open_config;Config]")
|
|
table.insert(fs, "tooltip[open_config;Open LLM configuration (llm_root only)]")
|
|
end
|
|
if can_ide(name) then
|
|
right_x = right_x - 2.3 - 0.15
|
|
table.insert(fs, "style[open_ide;bgcolor=#1a1a2a;textcolor=#aaaaff]")
|
|
table.insert(fs, "button[" .. right_x .. ",0.08;2.3,0.65;open_ide;IDE]")
|
|
table.insert(fs, "tooltip[open_ide;Open Smart Lua IDE (llm_dev)]")
|
|
end
|
|
|
|
-- Header Zeile 2: drei direkte WE-Mode Buttons nebeneinander
|
|
local mode = session.we_mode or "chat"
|
|
if can_worldedit(name) then
|
|
local we_ok = we_available()
|
|
local dim = "#2a2a2a"
|
|
|
|
local function we_btn(bname, bx, bw, blabel, active, color_on, color_dim, tip)
|
|
local bg = we_ok and (active and color_on or color_dim) or dim
|
|
local fg = active and "#ffffff" or (we_ok and "#889999" or "#555555")
|
|
table.insert(fs, "style[" .. bname .. ";bgcolor=" .. bg .. ";textcolor=" .. fg .. "]")
|
|
table.insert(fs, "button[" .. bx .. ",0.95;" .. bw .. ",0.65;" .. bname .. ";" .. blabel .. "]")
|
|
table.insert(fs, "tooltip[" .. bname .. ";" .. (we_ok and tip or "WorldEdit not loaded") .. "]")
|
|
end
|
|
|
|
we_btn("we_btn_chat", PAD, 2.6, "Chat", mode=="chat", "#444466", "#1e1e2e", "Normal LLM chat mode")
|
|
we_btn("we_btn_single", PAD + 2.7, 2.8, "WE Single", mode=="single", "#2a4a7a", "#151d2a", "WorldEdit: one plan per message")
|
|
we_btn("we_btn_loop", PAD + 5.6, 2.6, "WE Loop", mode=="loop", "#4a2a7a", "#1e1228", "WorldEdit: iterative build loop (up to 6 steps)")
|
|
|
|
if mode == "single" or mode == "loop" then
|
|
local mat_count = material_picker and #material_picker.get_materials(name) or 0
|
|
local mat_label = mat_count > 0 and ("Mats (" .. mat_count .. ")") or "Mats"
|
|
local mat_color = mat_count > 0 and "#1a3a1a" or "#252525"
|
|
table.insert(fs, "style[we_materials_open;bgcolor=" .. mat_color .. ";textcolor=#aaffaa]")
|
|
table.insert(fs, "button[" .. (PAD + 8.3) .. ",0.95;2.6,0.65;we_materials_open;" .. mat_label .. "]")
|
|
table.insert(fs, "tooltip[we_materials_open;Material picker: attach node names to LLM context]")
|
|
end
|
|
|
|
table.insert(fs, "style[we_undo;bgcolor=#3a2020;textcolor=#ffaaaa]")
|
|
table.insert(fs, "button[" .. (W - PAD - 2.1) .. ",0.95;2.1,0.65;we_undo;Undo]")
|
|
table.insert(fs, "tooltip[we_undo;Undo last WorldEdit agency operation]")
|
|
end
|
|
|
|
-- Chat history
|
|
table.insert(fs, "textarea[" .. PAD .. "," .. (HEADER_H + PAD) .. ";"
|
|
.. (W - PAD*2) .. "," .. CHAT_H
|
|
.. ";history_display;;" .. core.formspec_escape(text_accum) .. "]")
|
|
table.insert(fs, "style[history_display;textcolor=#e0e0e0;bgcolor=#1a1a1a;border=false]")
|
|
|
|
-- Input
|
|
local input_y = HEADER_H + PAD + CHAT_H + PAD
|
|
table.insert(fs, "field[" .. PAD .. "," .. input_y .. ";"
|
|
.. (W - PAD*2 - 2.5) .. "," .. INPUT_H
|
|
.. ";input;;" .. core.formspec_escape(session.last_input) .. "]")
|
|
table.insert(fs, "button[" .. (W - PAD - 2.2) .. "," .. input_y
|
|
.. ";2.2," .. INPUT_H .. ";send;Send]")
|
|
table.insert(fs, "field_close_on_enter[input;false]")
|
|
|
|
-- Toolbar
|
|
local tb_y = input_y + INPUT_H + PAD
|
|
table.insert(fs, "button[" .. PAD .. "," .. tb_y .. ";2.8,0.75;clear;Clear Chat]")
|
|
|
|
core.show_formspec(name, "llm_connect:chat", table.concat(fs))
|
|
end
|
|
|
|
-- ============================================================
|
|
-- Formspec Handler
|
|
-- ============================================================
|
|
|
|
function M.handle_fields(name, formname, fields)
|
|
-- Material Picker weiterleiten
|
|
if formname:match("^llm_connect:material_picker") then
|
|
if material_picker then
|
|
local result = material_picker.handle_fields(name, formname, fields)
|
|
if fields.close_picker or fields.close_and_back or fields.quit then
|
|
M.show(name)
|
|
end
|
|
return result
|
|
end
|
|
return false
|
|
end
|
|
|
|
if not formname:match("^llm_connect:chat") then return false end
|
|
|
|
local session = get_session(name)
|
|
local updated = false
|
|
|
|
-- ── WE-Buttons (privilege-geprüft) ──────────────────────
|
|
|
|
if fields.we_btn_chat then
|
|
if can_worldedit(name) then session.we_mode = "chat"; updated = true end
|
|
elseif fields.we_btn_single then
|
|
if can_worldedit(name) and we_available() then session.we_mode = "single"; updated = true end
|
|
elseif fields.we_btn_loop then
|
|
if can_worldedit(name) and we_available() then session.we_mode = "loop"; updated = true end
|
|
|
|
elseif fields.we_materials_open then
|
|
if can_worldedit(name) and material_picker then
|
|
material_picker.show(name)
|
|
end
|
|
return true
|
|
|
|
elseif fields.we_undo then
|
|
if can_worldedit(name) and _G.we_agency then
|
|
local res = _G.we_agency.undo(name)
|
|
table.insert(session.history, {role="assistant",
|
|
content=(res.ok and "Undo: " or "Error: ") .. res.message})
|
|
updated = true
|
|
end
|
|
|
|
-- ── IDE / Config (privilege-geprüft) ────────────────────
|
|
|
|
elseif fields.open_ide then
|
|
if can_ide(name) and _G.ide_gui then
|
|
_G.ide_gui.show(name)
|
|
end
|
|
return true
|
|
|
|
elseif fields.open_config then
|
|
if can_config(name) and _G.config_gui then
|
|
_G.config_gui.show(name)
|
|
end
|
|
return true
|
|
|
|
-- ── Send ────────────────────────────────────────────────
|
|
|
|
elseif fields.send or fields.key_enter_field == "input" then
|
|
local input = (fields.input or ""):trim()
|
|
|
|
if input ~= "" then
|
|
table.insert(session.history, {role="user", content=input})
|
|
session.last_input = ""
|
|
|
|
-- WE Loop (nur llm_worldedit)
|
|
if session.we_mode == "loop" and can_worldedit(name) and we_available() then
|
|
table.insert(session.history, {role="assistant", content="(starting WE loop...)"})
|
|
updated = true
|
|
local mat_ctx = material_picker and material_picker.build_material_context(name)
|
|
local loop_input = mat_ctx and (input .. "\n\n" .. mat_ctx) or input
|
|
_G.we_agency.run_loop(name, loop_input, {
|
|
max_iterations = (_G.llm_api and _G.llm_api.config.we_max_iterations or 6),
|
|
timeout = (_G.llm_api and _G.llm_api.get_timeout("we") or 90),
|
|
on_step = function(i, plan, results)
|
|
local lines = {"[WE Loop] Step " .. i .. ": " .. plan}
|
|
for _, r in ipairs(results) do
|
|
table.insert(lines, " " .. (r.ok and "v" or "x") .. " " .. r.tool .. ": " .. r.message)
|
|
end
|
|
core.chat_send_player(name, table.concat(lines, "\n"))
|
|
end,
|
|
}, function(res)
|
|
local reply = _G.we_agency.format_loop_results(res)
|
|
for i = #session.history, 1, -1 do
|
|
if session.history[i].content == "(starting WE loop...)" then
|
|
session.history[i].content = reply; break
|
|
end
|
|
end
|
|
M.show(name)
|
|
end)
|
|
|
|
-- WE Single (nur llm_worldedit)
|
|
elseif session.we_mode == "single" and can_worldedit(name) and we_available() then
|
|
table.insert(session.history, {role="assistant", content="(planning WE operations...)"})
|
|
updated = true
|
|
local mat_ctx = material_picker and material_picker.build_material_context(name)
|
|
local single_input = mat_ctx and (input .. "\n\n" .. mat_ctx) or input
|
|
_G.we_agency.request(name, single_input, function(res)
|
|
local reply = not res.ok
|
|
and ("Error: " .. (res.error or "unknown"))
|
|
or _G.we_agency.format_results(res.plan, res.results)
|
|
for i = #session.history, 1, -1 do
|
|
if session.history[i].content == "(planning WE operations...)" then
|
|
session.history[i].content = reply; break
|
|
end
|
|
end
|
|
M.show(name)
|
|
end)
|
|
|
|
-- Normal Chat (immer erlaubt wenn llm)
|
|
else
|
|
-- WE-Mode zurücksetzen wenn kein Privileg
|
|
if session.we_mode ~= "chat" and not can_worldedit(name) then
|
|
session.we_mode = "chat"
|
|
end
|
|
local messages = {}
|
|
local context_added = false
|
|
if chat_context then
|
|
messages = chat_context.append_context(messages, name)
|
|
if #messages > 0 and messages[1].role == "system" then context_added = true end
|
|
end
|
|
if not context_added then
|
|
table.insert(messages, 1, {role="system",
|
|
content="You are a helpful assistant in the Luanti/Minetest game."})
|
|
end
|
|
for _, msg in ipairs(session.history) do table.insert(messages, msg) end
|
|
table.insert(session.history, {role="assistant", content="(thinking...)"})
|
|
updated = true
|
|
local llm_api = get_llm_api()
|
|
llm_api.request(messages, function(result)
|
|
local content = result.success and result.content
|
|
or "Error: " .. (result.error or "Unknown error")
|
|
for i = #session.history, 1, -1 do
|
|
if session.history[i].content == "(thinking...)" then
|
|
session.history[i].content = content; break
|
|
end
|
|
end
|
|
M.show(name)
|
|
end, {timeout = (_G.llm_api and _G.llm_api.get_timeout("chat") or 180)})
|
|
end
|
|
end
|
|
|
|
-- ── Clear ───────────────────────────────────────────────
|
|
|
|
elseif fields.clear then
|
|
session.history = {}
|
|
session.last_input = ""
|
|
updated = true
|
|
|
|
elseif fields.quit then
|
|
return true
|
|
end
|
|
|
|
if updated then M.show(name) end
|
|
return true
|
|
end
|
|
|
|
core.register_on_leaveplayer(function(player)
|
|
sessions[player:get_player_name()] = nil
|
|
end)
|
|
|
|
return M
|