Import 0.9.0 development baseline
This commit is contained in:
368
chat_gui.lua
Normal file
368
chat_gui.lua
Normal file
@@ -0,0 +1,368 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user