From f6d5dba20b334c748d652e40a5d49c0d136ab93f Mon Sep 17 00:00:00 2001 From: James Huston Date: Tue, 30 Dec 2025 18:12:52 -0500 Subject: [PATCH] Add of first attempt to get client talking to LLM --- .../AraxiaTrinityAdmin/AraxiaTrinityAdmin.toc | 2 + .../AraxiaTrinityAdmin/CASCADE.md | 31 ++ .../AraxiaTrinityAdmin/UI/MainWindow.lua | 14 + .../UI/Panels/AgentChatPanel.lua | 434 +++++++++++++++ .../UI/Panels/NPCInfoPanel.lua | 103 +++- src/araxiaonline/mcp/AgentTools.cpp | 500 ++++++++++++++++++ src/araxiaonline/mcp/AraxiaMCPServer.cpp | 1 + src/araxiaonline/mcp/AraxiaMCPServer.h | 1 + src/araxiaonline/mcp/ServerTools.cpp | 2 +- 9 files changed, 1084 insertions(+), 4 deletions(-) create mode 100644 araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/AgentChatPanel.lua create mode 100644 src/araxiaonline/mcp/AgentTools.cpp diff --git a/araxiaonline/client_addons/AraxiaTrinityAdmin/AraxiaTrinityAdmin.toc b/araxiaonline/client_addons/AraxiaTrinityAdmin/AraxiaTrinityAdmin.toc index 17ac45aeb6..ecc3bcbc27 100644 --- a/araxiaonline/client_addons/AraxiaTrinityAdmin/AraxiaTrinityAdmin.toc +++ b/araxiaonline/client_addons/AraxiaTrinityAdmin/AraxiaTrinityAdmin.toc @@ -9,7 +9,9 @@ Core.lua ServerDataModule.lua AMSTestClient.lua +MCPBridge.lua UI/MainWindow.lua UI/MinimapButton.lua UI/Panels/NPCInfoPanel.lua UI/Panels/AddNPCPanel.lua +UI/Panels/AgentChatPanel.lua diff --git a/araxiaonline/client_addons/AraxiaTrinityAdmin/CASCADE.md b/araxiaonline/client_addons/AraxiaTrinityAdmin/CASCADE.md index 1ab62404da..99ee4cccf8 100644 --- a/araxiaonline/client_addons/AraxiaTrinityAdmin/CASCADE.md +++ b/araxiaonline/client_addons/AraxiaTrinityAdmin/CASCADE.md @@ -222,6 +222,36 @@ UIParent - `|cFF0070DD` - Blue (rare) - `|cFFFF00FF` - Purple (rare elite) +### Agent Chat Panel (`UI/Panels/AgentChatPanel.lua`) +Bidirectional chat interface for communicating with AI agents (like Cascade). + +**Features:** +- Agent selector dropdown with online/offline status +- Chat history scrollframe with timestamps +- Message input with Enter-to-send +- Auto-subscribe to push updates when panel opens +- Context attachment (target info) with messages + +**Data Flow:** +1. Player sends message via AMS `AGENT_SEND_MESSAGE` +2. Server queues in ElunaSharedData (`agent_inbox_`) +3. AI agent polls via MCP `mcp_agent_poll_messages` +4. AI agent responds via MCP `mcp_agent_send_message` +5. Server pushes to player via AMS `AGENT_MESSAGE_RESPONSE` + +**AMS Handlers (Client):** +- `AGENT_LIST_RESPONSE` - Updates agent dropdown +- `AGENT_SEND_MESSAGE_RESPONSE` - Confirms message sent +- `AGENT_MESSAGE_RESPONSE` - Receives agent responses + +**For AI Agents (MCP Tools):** +``` +1. mcp_agent_register({name: "Scarlet", owner: "Cascade"}) +2. mcp_agent_poll_messages({name: "Scarlet"}) - returns pending messages +3. mcp_agent_send_message({name: "Scarlet", to_player_guid: X, content: "..."}) +4. mcp_agent_unregister({name: "Scarlet"}) - when done +``` + ## Future Expansion Ideas - Item Info Panel - View item details, stats, sources - Quest Info Panel - Quest chains, requirements, rewards @@ -232,4 +262,5 @@ UIParent - Macro Builder - Generate TrinityCore command macros ## Version History +- v1.1.0 - Added Agent Chat panel for AI assistant communication - v1.0.0 - Initial release with NPC Info panel, tab navigation, 3D model viewer diff --git a/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/MainWindow.lua b/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/MainWindow.lua index 7f56898395..ca9380897f 100644 --- a/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/MainWindow.lua +++ b/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/MainWindow.lua @@ -97,6 +97,20 @@ mainWindow:SetScript("OnHide", function(self) AraxiaTrinityAdminDB.windowShown = false end) +-- Movement-based opacity: 70% opaque when moving, 100% when stopped +local MOVING_ALPHA = 0.3 -- 70% opaque = 30% alpha +local STOPPED_ALPHA = 1.0 +local movementFrame = CreateFrame("Frame") +movementFrame:RegisterEvent("PLAYER_STARTED_MOVING") +movementFrame:RegisterEvent("PLAYER_STOPPED_MOVING") +movementFrame:SetScript("OnEvent", function(self, event) + if event == "PLAYER_STARTED_MOVING" then + mainWindow:SetAlpha(MOVING_ALPHA) + elseif event == "PLAYER_STOPPED_MOVING" then + mainWindow:SetAlpha(STOPPED_ALPHA) + end +end) + -- Determine content area (Inset or fallback to mainWindow with margins) local contentArea = mainWindow.Inset or mainWindow local topOffset = mainWindow.Inset and -4 or -30 -- Account for title bar if no Inset diff --git a/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/AgentChatPanel.lua b/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/AgentChatPanel.lua new file mode 100644 index 0000000000..e34ca9b642 --- /dev/null +++ b/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/AgentChatPanel.lua @@ -0,0 +1,434 @@ +-- AraxiaTrinityAdmin Agent Chat Panel +-- Chat interface for communicating with AI agents + +local addonName = "AraxiaTrinityAdmin" + +-- Wait for addon to load +local initFrame = CreateFrame("Frame") +initFrame:RegisterEvent("ADDON_LOADED") +initFrame:SetScript("OnEvent", function(self, event, loadedAddon) + if loadedAddon ~= addonName then return end + self:UnregisterEvent("ADDON_LOADED") + + local ATA = AraxiaTrinityAdmin + if not ATA then return end + +-- Create panel frame +local chatPanel = CreateFrame("Frame", "AraxiaTrinityAdminAgentChatPanel", UIParent) +chatPanel:Hide() + +-- Chat history storage +local chatHistory = {} -- { {from="player"|"agent", agent_name, content, timestamp, message_id}, ... } +local MAX_HISTORY = 200 + +-- Current selected agent +local selectedAgent = nil +local availableAgents = {} + +-- Polling state +local isSubscribed = false + +-- ============================================================================ +-- Header Section +-- ============================================================================ + +local title = chatPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") +title:SetPoint("TOPLEFT", chatPanel, "TOPLEFT", 10, -10) +title:SetText("Agent Chat") + +-- Agent selector dropdown +local agentDropdown = CreateFrame("Frame", "AgentChatAgentDropdown", chatPanel, "UIDropDownMenuTemplate") +agentDropdown:SetPoint("LEFT", title, "RIGHT", 10, -2) + +local function AgentDropdown_OnClick(self, arg1, arg2, checked) + selectedAgent = arg1 + UIDropDownMenu_SetText(agentDropdown, arg1 or "Select Agent") +end + +local function AgentDropdown_Initialize(self, level) + local info = UIDropDownMenu_CreateInfo() + + if #availableAgents == 0 then + info.text = "No agents available" + info.disabled = true + info.notCheckable = true + UIDropDownMenu_AddButton(info) + return + end + + for _, agent in ipairs(availableAgents) do + info.text = agent.name .. (agent.online and " |cFF00FF00(online)|r" or " |cFF888888(offline)|r") + info.arg1 = agent.name + info.func = AgentDropdown_OnClick + info.checked = (selectedAgent == agent.name) + info.notCheckable = false + UIDropDownMenu_AddButton(info) + end +end + +UIDropDownMenu_SetWidth(agentDropdown, 150) +UIDropDownMenu_SetText(agentDropdown, "Select Agent") +UIDropDownMenu_Initialize(agentDropdown, AgentDropdown_Initialize) + +-- Refresh agents button +local refreshAgentsBtn = CreateFrame("Button", nil, chatPanel, "UIPanelButtonTemplate") +refreshAgentsBtn:SetSize(24, 24) +refreshAgentsBtn:SetPoint("LEFT", agentDropdown, "RIGHT", -10, 2) +refreshAgentsBtn:SetText("R") +refreshAgentsBtn:SetScript("OnClick", function() + if AMS then + AMS.Send("AGENT_LIST_REQUEST", {}) + end +end) +refreshAgentsBtn:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:AddLine("Refresh agent list", 1, 1, 1) + GameTooltip:Show() +end) +refreshAgentsBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) + +-- Status indicator +local statusText = chatPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") +statusText:SetPoint("TOPRIGHT", chatPanel, "TOPRIGHT", -10, -14) +statusText:SetText("|cFF888888Disconnected|r") + +-- ============================================================================ +-- Chat History Display +-- ============================================================================ + +local chatContainer = CreateFrame("Frame", nil, chatPanel, "BackdropTemplate") +chatContainer:SetPoint("TOPLEFT", chatPanel, "TOPLEFT", 10, -45) +chatContainer:SetPoint("BOTTOMRIGHT", chatPanel, "BOTTOMRIGHT", -10, 50) +chatContainer:SetBackdrop({ + bgFile = "Interface/Tooltips/UI-Tooltip-Background", + edgeFile = "Interface/Tooltips/UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 } +}) +chatContainer:SetBackdropColor(0.05, 0.05, 0.05, 0.9) +chatContainer:SetBackdropBorderColor(0.4, 0.4, 0.4, 1) + +local chatScroll = CreateFrame("ScrollFrame", "AgentChatScrollFrame", chatContainer, "UIPanelScrollFrameTemplate") +chatScroll:SetPoint("TOPLEFT", chatContainer, "TOPLEFT", 8, -8) +chatScroll:SetPoint("BOTTOMRIGHT", chatContainer, "BOTTOMRIGHT", -28, 8) + +local chatScrollChild = CreateFrame("Frame", nil, chatScroll) +chatScrollChild:SetWidth(chatScroll:GetWidth() - 10) +chatScrollChild:SetHeight(1) +chatScroll:SetScrollChild(chatScrollChild) + +-- Message display elements +local messageFrames = {} + +local function FormatTimestamp(ts) + if not ts then return "" end + return date("%H:%M", ts) +end + +local function AddMessageToDisplay(from, agentName, content, timestamp, isFromPlayer) + local frame = CreateFrame("Frame", nil, chatScrollChild) + frame:SetWidth(chatScrollChild:GetWidth() - 10) + + -- Header line (name + timestamp) + local header = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + header:SetPoint("TOPLEFT", frame, "TOPLEFT", 5, -5) + + if isFromPlayer then + header:SetText("|cFF00CCFF" .. UnitName("player") .. "|r |cFF888888" .. FormatTimestamp(timestamp) .. "|r") + else + header:SetText("|cFF00FF00" .. (agentName or "Agent") .. "|r |cFF888888" .. FormatTimestamp(timestamp) .. "|r") + end + + -- Content + local contentText = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + contentText:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -3) + contentText:SetPoint("RIGHT", frame, "RIGHT", -5, 0) + contentText:SetJustifyH("LEFT") + contentText:SetText(content) + contentText:SetWordWrap(true) + + -- Calculate height + local contentHeight = contentText:GetStringHeight() + frame:SetHeight(header:GetStringHeight() + contentHeight + 15) + + -- Position frame + local yOffset = 0 + for _, f in ipairs(messageFrames) do + yOffset = yOffset + f:GetHeight() + 5 + end + frame:SetPoint("TOPLEFT", chatScrollChild, "TOPLEFT", 0, -yOffset) + + table.insert(messageFrames, frame) + + -- Update scroll child height + chatScrollChild:SetHeight(yOffset + frame:GetHeight() + 10) + + -- Scroll to bottom + C_Timer.After(0.01, function() + chatScroll:SetVerticalScroll(chatScroll:GetVerticalScrollRange()) + end) + + return frame +end + +local function ClearMessageDisplay() + for _, frame in ipairs(messageFrames) do + frame:Hide() + frame:SetParent(nil) + end + messageFrames = {} + chatScrollChild:SetHeight(1) +end + +local function RefreshChatDisplay() + ClearMessageDisplay() + + for _, msg in ipairs(chatHistory) do + local isFromPlayer = (msg.from == "player") + AddMessageToDisplay(msg.from, msg.agent_name, msg.content, msg.timestamp, isFromPlayer) + end +end + +-- ============================================================================ +-- Message Input +-- ============================================================================ + +local inputContainer = CreateFrame("Frame", nil, chatPanel, "BackdropTemplate") +inputContainer:SetPoint("BOTTOMLEFT", chatPanel, "BOTTOMLEFT", 10, 10) +inputContainer:SetPoint("BOTTOMRIGHT", chatPanel, "BOTTOMRIGHT", -80, 10) +inputContainer:SetHeight(30) +inputContainer:SetBackdrop({ + bgFile = "Interface/Tooltips/UI-Tooltip-Background", + edgeFile = "Interface/Tooltips/UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 } +}) +inputContainer:SetBackdropColor(0.1, 0.1, 0.1, 0.8) +inputContainer:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + +local inputBox = CreateFrame("EditBox", "AgentChatInputBox", inputContainer) +inputBox:SetPoint("TOPLEFT", inputContainer, "TOPLEFT", 8, -6) +inputBox:SetPoint("BOTTOMRIGHT", inputContainer, "BOTTOMRIGHT", -8, 6) +inputBox:SetFontObject("ChatFontNormal") +inputBox:SetAutoFocus(false) +inputBox:SetMaxLetters(1000) + +local function SendMessage() + local text = inputBox:GetText() + if not text or text == "" then return end + + if not selectedAgent then + print("|cFFFF0000[Agent Chat]|r Please select an agent first") + return + end + + if not AMS then + print("|cFFFF0000[Agent Chat]|r AMS not available") + return + end + + -- Get context (current target info) + local context = nil + if UnitExists("target") then + local guid = UnitGUID("target") + context = { + target_guid = guid, + target_name = UnitName("target"), + target_level = UnitLevel("target") + } + end + + -- Send message + AMS.Send("AGENT_SEND_MESSAGE", { + agent_name = selectedAgent, + content = text, + context = context + }) + + -- Add to local history immediately + local msg = { + from = "player", + agent_name = selectedAgent, + content = text, + timestamp = time(), + message_id = "local_" .. time() + } + table.insert(chatHistory, msg) + + -- Cap history + while #chatHistory > MAX_HISTORY do + table.remove(chatHistory, 1) + end + + -- Refresh display + AddMessageToDisplay("player", selectedAgent, text, msg.timestamp, true) + + -- Clear input + inputBox:SetText("") +end + +inputBox:SetScript("OnEnterPressed", function() + SendMessage() +end) + +inputBox:SetScript("OnEscapePressed", function(self) + self:ClearFocus() +end) + +-- Send button +local sendBtn = CreateFrame("Button", nil, chatPanel, "UIPanelButtonTemplate") +sendBtn:SetSize(60, 30) +sendBtn:SetPoint("LEFT", inputContainer, "RIGHT", 5, 0) +sendBtn:SetText("Send") +sendBtn:SetScript("OnClick", SendMessage) + +-- ============================================================================ +-- AMS Response Handlers +-- ============================================================================ + +local function InitAMSHandlers() + if not AMS then + C_Timer.After(0.5, InitAMSHandlers) + return + end + + -- Handle agent list response + AMS.RegisterHandler("AGENT_LIST_RESPONSE", function(data) + if data.success then + availableAgents = data.agents or {} + UIDropDownMenu_Initialize(agentDropdown, AgentDropdown_Initialize) + + -- Auto-select first agent if none selected + if not selectedAgent and #availableAgents > 0 then + selectedAgent = availableAgents[1].name + UIDropDownMenu_SetText(agentDropdown, selectedAgent) + end + + print("|cFF00FF00[Agent Chat]|r Found " .. #availableAgents .. " agent(s)") + end + end) + + -- Handle send confirmation + AMS.RegisterHandler("AGENT_SEND_MESSAGE_RESPONSE", function(data) + if not data.success then + print("|cFFFF0000[Agent Chat]|r Failed to send: " .. (data.error or "Unknown error")) + end + end) + + -- Handle agent responses (push delivery) + AMS.RegisterHandler("AGENT_MESSAGE_RESPONSE", function(data) + if data.success and data.messages then + for _, msg in ipairs(data.messages) do + -- Add to history + local historyEntry = { + from = "agent", + agent_name = msg.from_agent, + content = msg.content, + timestamp = msg.timestamp, + message_id = msg.message_id, + reply_to_id = msg.reply_to_id + } + table.insert(chatHistory, historyEntry) + + -- Add to display + AddMessageToDisplay("agent", msg.from_agent, msg.content, msg.timestamp, false) + + -- Show notification if panel not visible + if not chatPanel:IsVisible() then + print("|cFF00FF00[" .. msg.from_agent .. "]|r " .. msg.content:sub(1, 100) .. (msg.content:len() > 100 and "..." or "")) + end + end + + -- Cap history + while #chatHistory > MAX_HISTORY do + table.remove(chatHistory, 1) + end + end + end) + + -- Handle poll responses (manual polling fallback) + AMS.RegisterHandler("AGENT_POLL_RESPONSES_RESULT", function(data) + if data.success and data.messages then + for _, msg in ipairs(data.messages) do + local historyEntry = { + from = "agent", + agent_name = msg.from_agent, + content = msg.content, + timestamp = msg.timestamp, + message_id = msg.message_id + } + table.insert(chatHistory, historyEntry) + AddMessageToDisplay("agent", msg.from_agent, msg.content, msg.timestamp, false) + end + end + end) + + print("[Agent Chat] AMS handlers registered") +end + +-- ============================================================================ +-- Panel Lifecycle +-- ============================================================================ + +-- Subscribe/unsubscribe when panel shows/hides +chatPanel:SetScript("OnShow", function() + if AMS then + AMS.Send("AGENT_CHAT_SUBSCRIBE", {}) + AMS.Send("AGENT_LIST_REQUEST", {}) + isSubscribed = true + statusText:SetText("|cFF00FF00Connected|r") + end + inputBox:SetFocus() +end) + +chatPanel:SetScript("OnHide", function() + if AMS and isSubscribed then + AMS.Send("AGENT_CHAT_UNSUBSCRIBE", {}) + isSubscribed = false + statusText:SetText("|cFF888888Disconnected|r") + end + inputBox:ClearFocus() +end) + +-- Manual poll button (hidden, for debugging) +local pollBtn = CreateFrame("Button", nil, chatPanel, "UIPanelButtonTemplate") +pollBtn:SetSize(60, 22) +pollBtn:SetPoint("BOTTOMRIGHT", chatPanel, "BOTTOMRIGHT", -10, 45) +pollBtn:SetText("Poll") +pollBtn:Hide() -- Hidden by default +pollBtn:SetScript("OnClick", function() + if AMS then + AMS.Send("AGENT_POLL_RESPONSES", {}) + end +end) + +-- Clear chat button +local clearBtn = CreateFrame("Button", nil, chatPanel, "UIPanelButtonTemplate") +clearBtn:SetSize(50, 22) +clearBtn:SetPoint("TOPRIGHT", chatPanel, "TOPRIGHT", -10, -35) +clearBtn:SetText("Clear") +clearBtn:SetScript("OnClick", function() + chatHistory = {} + ClearMessageDisplay() +end) + +-- ============================================================================ +-- Register with MainWindow +-- ============================================================================ + +local function InitPanel() + if ATA.MainWindow then + ATA.MainWindow:RegisterPanel("AgentChat", "Agent Chat", chatPanel) + InitAMSHandlers() + else + C_Timer.After(0.1, InitPanel) + end +end + +C_Timer.After(0.1, InitPanel) + +-- Make available globally for debugging +ATA.AgentChatPanel = chatPanel +ATA.AgentChatHistory = chatHistory + +end) -- End ADDON_LOADED handler diff --git a/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/NPCInfoPanel.lua b/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/NPCInfoPanel.lua index 1bb1901c5d..4c90e82280 100644 --- a/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/NPCInfoPanel.lua +++ b/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/NPCInfoPanel.lua @@ -46,9 +46,46 @@ deleteButton:SetSize(80, 22) deleteButton:SetPoint("LEFT", refreshButton, "RIGHT", 5, 0) deleteButton:SetText("Delete") +local respawnButton = CreateFrame("Button", nil, npcPanel, "UIPanelButtonTemplate") +respawnButton:SetSize(80, 22) +respawnButton:SetPoint("LEFT", deleteButton, "RIGHT", 5, 0) +respawnButton:SetText("Respawn") + +respawnButton:SetScript("OnClick", function() + if AMS then + print("|cFF00FF00[ATA]|r Requesting respawn...") + AMS.Send("RESPAWN_TARGET", {}) + else + print("|cFFFF0000[ATA]|r AMS not available") + end +end) + +respawnButton:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") + GameTooltip:AddLine("Respawn Target", 1, 1, 1) + GameTooltip:AddLine("Force despawn and respawn the targeted creature", 0.7, 0.7, 0.7) + GameTooltip:AddLine("Useful after changing creature template data", 0.7, 0.7, 0.7) + GameTooltip:Show() +end) + +respawnButton:SetScript("OnLeave", function() + GameTooltip:Hide() +end) + +-- Register response handler for respawn +if AMS then + AMS.RegisterHandler("RESPAWN_TARGET_RESPONSE", function(data) + if data.success then + print("|cFF00FF00[ATA]|r " .. data.message .. " (" .. data.creature .. ")") + else + print("|cFFFF0000[ATA]|r Respawn failed: " .. (data.error or "Unknown error")) + end + end) +end + local waypointButton = CreateFrame("Button", nil, npcPanel, "UIPanelButtonTemplate") waypointButton:SetSize(110, 22) -waypointButton:SetPoint("LEFT", deleteButton, "RIGHT", 5, 0) +waypointButton:SetPoint("LEFT", respawnButton, "RIGHT", 5, 0) waypointButton:SetText("Show Waypoints") waypointButton:Disable() -- Disabled until we have a creature with waypoints @@ -1296,7 +1333,7 @@ end -- Content Formatters -- ============================================================================ -local function FormatBasicTab(npcData) +local function FormatBasicTab(npcData, sData) if not npcData then return "No valid NPC target found.\n\nPlease target a creature or NPC." end @@ -1306,6 +1343,9 @@ local function FormatBasicTab(npcData) table.insert(lines, string.format(" |cFF00FF00Name:|r %s", npcData.name or "Unknown")) table.insert(lines, string.format(" |cFF00FF00Entry ID:|r %s", npcData.npcID or "Unknown")) table.insert(lines, string.format(" |cFF00FF00GUID:|r %s", npcData.guid or "Unknown")) + -- Spawn ID from server data (database guid) + local spawnId = (sData and sData.success and sData.basic and sData.basic.spawnId) or "Unknown" + table.insert(lines, string.format(" |cFF00FF00Spawn ID:|r %s", spawnId)) table.insert(lines, string.format(" |cFF00FF00Level:|r %s", npcData.level == -1 and "??" or npcData.level)) table.insert(lines, "") @@ -1334,6 +1374,62 @@ local function FormatBasicTab(npcData) table.insert(lines, string.format(" |cFF00FF00Faction:|r %s", npcData.faction or "Unknown")) table.insert(lines, "") + -- Position data (from server) + if sData and sData.success and sData.position then + table.insert(lines, "|cFFFFD700Location|r") + + -- Spawn/Home position + if sData.position.spawn then + local sp = sData.position.spawn + table.insert(lines, string.format(" |cFF00FF00Spawn:|r %.1f, %.1f, %.1f", sp.x or 0, sp.y or 0, sp.z or 0)) + end + + -- Current position + if sData.position.current then + local cp = sData.position.current + table.insert(lines, string.format(" |cFF00FF00Current:|r %.1f, %.1f, %.1f", cp.x or 0, cp.y or 0, cp.z or 0)) + if cp.mapId then + table.insert(lines, string.format(" |cFF00FF00Map/Zone/Area:|r %d / %d / %d", cp.mapId or 0, cp.zoneId or 0, cp.areaId or 0)) + end + end + table.insert(lines, "") + end + + -- Behavior flags (from server) + if sData and sData.success and sData.flags then + table.insert(lines, "|cFFFFD700Behavior Flags|r") + + -- NPC Flags + if sData.flags.npcFlags and sData.flags.npcFlags > 0 then + local flagStr = "" + if sData.flags.npcFlagNames and #sData.flags.npcFlagNames > 0 then + flagStr = table.concat(sData.flags.npcFlagNames, ", ") + else + flagStr = tostring(sData.flags.npcFlags) + end + table.insert(lines, string.format(" |cFF00FF00NPC Flags:|r %s", flagStr)) + else + table.insert(lines, " |cFF00FF00NPC Flags:|r None") + end + + -- Unit Flags + if sData.flags.unitFlags and sData.flags.unitFlags > 0 then + local flagStr = "" + if sData.flags.unitFlagNames and #sData.flags.unitFlagNames > 0 then + flagStr = table.concat(sData.flags.unitFlagNames, ", ") + else + flagStr = tostring(sData.flags.unitFlags) + end + table.insert(lines, string.format(" |cFF00FF00Unit Flags:|r %s", flagStr)) + else + table.insert(lines, " |cFF00FF00Unit Flags:|r None") + end + table.insert(lines, "") + elseif not sData or not sData.success then + table.insert(lines, "|cFF888888Click Refresh to load location/flags data|r") + table.insert(lines, "") + end + table.insert(lines, "|cFFFFD700GM Commands|r") table.insert(lines, string.format(" |cFFFFFF00.npc info|r")) table.insert(lines, string.format(" |cFFFFFF00.lookup creature %s|r", npcData.name or "")) @@ -1661,7 +1757,7 @@ function npcPanel:Update(requestServerData, forceRefresh) local npcData = ATA:GetTargetNPCInfo() -- Update Basic tab - contentFrames["Basic"].text:SetText(FormatBasicTab(npcData)) + contentFrames["Basic"].text:SetText(FormatBasicTab(npcData, serverData)) UpdateContentSize(contentFrames["Basic"]) -- Update Stats tab @@ -1682,6 +1778,7 @@ function npcPanel:Update(requestServerData, forceRefresh) serverData = nil -- Update displays to show loading + contentFrames["Basic"].text:SetText(FormatBasicTab(npcData, nil)) contentFrames["Stats"].text:SetText(FormatStatsTab(npcData, nil)) contentFrames["AI"].text:SetText(FormatAITab(npcData, nil)) contentFrames["Raw"].text:SetText(FormatRawTab(npcData, nil)) diff --git a/src/araxiaonline/mcp/AgentTools.cpp b/src/araxiaonline/mcp/AgentTools.cpp new file mode 100644 index 0000000000..cb641834da --- /dev/null +++ b/src/araxiaonline/mcp/AgentTools.cpp @@ -0,0 +1,500 @@ +/* + * Araxia MCP Server - Agent Tools + * + * Tools for AI agent registration and bidirectional chat with players. + * Agents register with friendly names (e.g., "Scarlet") and can receive + * messages from players and send responses. + * + * Message flow: + * Player (WoW) -> AMS -> ElunaSharedData -> MCP poll -> AI Agent + * AI Agent -> MCP send -> ElunaSharedData -> AMS push -> Player (WoW) + * + * Data stored in ElunaSharedData: + * - agent_registry: JSON object mapping agent names to info + * - agent_inbox_: JSON array of pending messages for agent + * - player_inbox_: JSON array of pending responses for player + */ + +#include "AraxiaMCPServer.h" +#include "Log.h" +#include "GameTime.h" +#include "LuaEngine/ElunaSharedData.h" +#include +#include +#include + +namespace Araxia +{ + +// Helper: Generate a unique message ID +static std::string GenerateMessageId() +{ + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution dis; + + std::stringstream ss; + ss << "msg_" << std::hex << dis(gen); + return ss.str(); +} + +// Helper: Get current timestamp +static uint64_t GetTimestamp() +{ + return static_cast(GameTime::GetGameTime()); +} + +// Helper: Get agent registry from shared data +static json GetAgentRegistry() +{ + std::string registryStr; + if (!sElunaSharedData->Get("agent_registry", registryStr) || registryStr.empty()) + return json::object(); + + try { + return json::parse(registryStr); + } catch (...) { + return json::object(); + } +} + +// Helper: Save agent registry to shared data +static void SaveAgentRegistry(const json& registry) +{ + sElunaSharedData->Set("agent_registry", registry.dump()); +} + +// Helper: Get agent inbox +static json GetAgentInbox(const std::string& agentName) +{ + std::string key = "agent_inbox_" + agentName; + std::string inboxStr; + if (!sElunaSharedData->Get(key, inboxStr) || inboxStr.empty()) + return json::array(); + + try { + return json::parse(inboxStr); + } catch (...) { + return json::array(); + } +} + +// Helper: Save agent inbox +static void SaveAgentInbox(const std::string& agentName, const json& inbox) +{ + std::string key = "agent_inbox_" + agentName; + sElunaSharedData->Set(key, inbox.dump()); +} + +// Helper: Get player inbox +static json GetPlayerInbox(uint64_t playerGuid) +{ + std::string key = "player_inbox_" + std::to_string(playerGuid); + std::string inboxStr; + if (!sElunaSharedData->Get(key, inboxStr) || inboxStr.empty()) + return json::array(); + + try { + return json::parse(inboxStr); + } catch (...) { + return json::array(); + } +} + +// Helper: Save player inbox +static void SavePlayerInbox(uint64_t playerGuid, const json& inbox) +{ + std::string key = "player_inbox_" + std::to_string(playerGuid); + sElunaSharedData->Set(key, inbox.dump()); +} + +// Helper: Normalize agent name (lowercase for comparison) +static std::string NormalizeAgentName(const std::string& name) +{ + std::string normalized = name; + std::transform(normalized.begin(), normalized.end(), normalized.begin(), ::tolower); + return normalized; +} + +void RegisterAgentTools() +{ + TC_LOG_INFO("araxia.mcp", "[MCP] Registering Agent Chat tools..."); + + // ======================================================================== + // mcp_agent_register - Register an AI agent with a friendly name + // ======================================================================== + sMCPServer->RegisterTool( + "mcp_agent_register", + "Register an AI agent with a friendly name. Players can then send messages to this agent. " + "Agent names are case-insensitive and must be unique.", + { + {"type", "object"}, + {"properties", { + {"name", { + {"type", "string"}, + {"description", "Friendly name for the agent (e.g., 'Scarlet', 'Helper')"} + }}, + {"owner", { + {"type", "string"}, + {"description", "Identifier for the LLM/system (e.g., 'Cascade', 'Claude')"} + }}, + {"description", { + {"type", "string"}, + {"description", "Optional description of what this agent does"} + }} + }}, + {"required", {"name", "owner"}} + }, + [](const json& params) -> json { + std::string name = params["name"]; + std::string owner = params["owner"]; + std::string description = params.value("description", ""); + + if (name.empty()) { + return {{"success", false}, {"error", "Agent name cannot be empty"}}; + } + + std::string normalizedName = NormalizeAgentName(name); + + // Check if name already taken + json registry = GetAgentRegistry(); + for (auto& [key, value] : registry.items()) { + if (NormalizeAgentName(key) == normalizedName) { + return { + {"success", false}, + {"error", "Agent name already registered"}, + {"existing_owner", value.value("owner", "")} + }; + } + } + + // Register the agent + registry[name] = { + {"owner", owner}, + {"description", description}, + {"registered_at", GetTimestamp()}, + {"last_poll", GetTimestamp()}, + {"status", "online"} + }; + + SaveAgentRegistry(registry); + + TC_LOG_INFO("araxia.mcp", "[MCP] Agent '{}' registered by {}", name, owner); + + return { + {"success", true}, + {"agent_name", name}, + {"owner", owner}, + {"message", "Agent registered successfully. Use mcp_agent_poll_messages to receive player messages."} + }; + } + ); + + // ======================================================================== + // mcp_agent_unregister - Unregister an agent + // ======================================================================== + sMCPServer->RegisterTool( + "mcp_agent_unregister", + "Unregister an AI agent. Pending messages will be discarded.", + { + {"type", "object"}, + {"properties", { + {"name", { + {"type", "string"}, + {"description", "Name of the agent to unregister"} + }} + }}, + {"required", {"name"}} + }, + [](const json& params) -> json { + std::string name = params["name"]; + std::string normalizedName = NormalizeAgentName(name); + + json registry = GetAgentRegistry(); + std::string foundKey; + + for (auto& [key, value] : registry.items()) { + if (NormalizeAgentName(key) == normalizedName) { + foundKey = key; + break; + } + } + + if (foundKey.empty()) { + return {{"success", false}, {"error", "Agent not found"}}; + } + + // Remove from registry + registry.erase(foundKey); + SaveAgentRegistry(registry); + + // Clear inbox + std::string inboxKey = "agent_inbox_" + foundKey; + sElunaSharedData->Clear(inboxKey); + + TC_LOG_INFO("araxia.mcp", "[MCP] Agent '{}' unregistered", foundKey); + + return { + {"success", true}, + {"agent_name", foundKey}, + {"message", "Agent unregistered"} + }; + } + ); + + // ======================================================================== + // mcp_agent_list - List all registered agents + // ======================================================================== + sMCPServer->RegisterTool( + "mcp_agent_list", + "List all registered AI agents with their status.", + { + {"type", "object"}, + {"properties", json::object()} + }, + [](const json& /*params*/) -> json { + json registry = GetAgentRegistry(); + json agents = json::array(); + + uint64_t now = GetTimestamp(); + + for (auto& [name, info] : registry.items()) { + // Mark as offline if no poll in 60 seconds + uint64_t lastPoll = info.value("last_poll", 0ULL); + bool isOnline = (now - lastPoll) < 60; + + // Get pending message count + json inbox = GetAgentInbox(name); + + agents.push_back({ + {"name", name}, + {"owner", info.value("owner", "")}, + {"description", info.value("description", "")}, + {"online", isOnline}, + {"last_poll", lastPoll}, + {"pending_messages", inbox.size()} + }); + } + + return { + {"success", true}, + {"agent_count", agents.size()}, + {"agents", agents} + }; + } + ); + + // ======================================================================== + // mcp_agent_poll_messages - Get pending messages for an agent + // ======================================================================== + sMCPServer->RegisterTool( + "mcp_agent_poll_messages", + "Poll for pending messages sent to this agent by players. " + "By default, messages are acknowledged (removed from queue) after retrieval.", + { + {"type", "object"}, + {"properties", { + {"name", { + {"type", "string"}, + {"description", "Agent name to poll messages for"} + }}, + {"limit", { + {"type", "integer"}, + {"description", "Maximum messages to return (default: 10)"} + }}, + {"acknowledge", { + {"type", "boolean"}, + {"description", "Remove messages from queue after returning (default: true)"} + }} + }}, + {"required", {"name"}} + }, + [](const json& params) -> json { + std::string name = params["name"]; + int limit = params.value("limit", 10); + bool acknowledge = params.value("acknowledge", true); + + std::string normalizedName = NormalizeAgentName(name); + + // Find agent in registry + json registry = GetAgentRegistry(); + std::string foundKey; + + for (auto& [key, value] : registry.items()) { + if (NormalizeAgentName(key) == normalizedName) { + foundKey = key; + break; + } + } + + if (foundKey.empty()) { + return {{"success", false}, {"error", "Agent not registered"}}; + } + + // Update last poll time + registry[foundKey]["last_poll"] = GetTimestamp(); + registry[foundKey]["status"] = "online"; + SaveAgentRegistry(registry); + + // Get messages from inbox + json inbox = GetAgentInbox(foundKey); + json messages = json::array(); + + int count = 0; + for (auto& msg : inbox) { + if (count >= limit) break; + messages.push_back(msg); + count++; + } + + // Remove acknowledged messages + if (acknowledge && count > 0) { + json remainingInbox = json::array(); + for (size_t i = count; i < inbox.size(); i++) { + remainingInbox.push_back(inbox[i]); + } + SaveAgentInbox(foundKey, remainingInbox); + } + + return { + {"success", true}, + {"agent_name", foundKey}, + {"message_count", messages.size()}, + {"messages", messages}, + {"remaining", inbox.size() - count} + }; + } + ); + + // ======================================================================== + // mcp_agent_send_message - Send a response to a player + // ======================================================================== + sMCPServer->RegisterTool( + "mcp_agent_send_message", + "Send a message/response from this agent to a player. " + "The message will be queued and delivered to the player via AMS.", + { + {"type", "object"}, + {"properties", { + {"name", { + {"type", "string"}, + {"description", "Agent name sending the message"} + }}, + {"to_player_guid", { + {"type", "integer"}, + {"description", "Target player GUID (from the original message)"} + }}, + {"content", { + {"type", "string"}, + {"description", "Message content to send"} + }}, + {"reply_to_id", { + {"type", "string"}, + {"description", "ID of the message being replied to (optional)"} + }} + }}, + {"required", {"name", "to_player_guid", "content"}} + }, + [](const json& params) -> json { + std::string name = params["name"]; + uint64_t toPlayerGuid = params["to_player_guid"]; + std::string content = params["content"]; + std::string replyToId = params.value("reply_to_id", ""); + + std::string normalizedName = NormalizeAgentName(name); + + // Verify agent is registered + json registry = GetAgentRegistry(); + std::string foundKey; + + for (auto& [key, value] : registry.items()) { + if (NormalizeAgentName(key) == normalizedName) { + foundKey = key; + break; + } + } + + if (foundKey.empty()) { + return {{"success", false}, {"error", "Agent not registered"}}; + } + + // Create the response message + std::string messageId = GenerateMessageId(); + json message = { + {"message_id", messageId}, + {"from_agent", foundKey}, + {"content", content}, + {"timestamp", GetTimestamp()} + }; + + if (!replyToId.empty()) { + message["reply_to_id"] = replyToId; + } + + // Add to player's inbox + json playerInbox = GetPlayerInbox(toPlayerGuid); + playerInbox.push_back(message); + + // Cap inbox size at 100 messages + while (playerInbox.size() > 100) { + playerInbox.erase(playerInbox.begin()); + } + + SavePlayerInbox(toPlayerGuid, playerInbox); + + TC_LOG_DEBUG("araxia.mcp", "[MCP] Agent '{}' sent message to player {}", foundKey, toPlayerGuid); + + return { + {"success", true}, + {"message_id", messageId}, + {"to_player_guid", toPlayerGuid}, + {"queued", true}, + {"note", "Message queued. Will be delivered when player's client polls for responses."} + }; + } + ); + + // ======================================================================== + // mcp_agent_get_player_responses - Get pending responses for a player (used by Lua) + // ======================================================================== + sMCPServer->RegisterTool( + "mcp_agent_get_player_responses", + "Get pending agent responses for a specific player. Used internally by server scripts.", + { + {"type", "object"}, + {"properties", { + {"player_guid", { + {"type", "integer"}, + {"description", "Player GUID to get responses for"} + }}, + {"acknowledge", { + {"type", "boolean"}, + {"description", "Remove messages from queue after returning (default: true)"} + }} + }}, + {"required", {"player_guid"}} + }, + [](const json& params) -> json { + uint64_t playerGuid = params["player_guid"]; + bool acknowledge = params.value("acknowledge", true); + + json inbox = GetPlayerInbox(playerGuid); + + if (acknowledge && !inbox.empty()) { + // Clear the inbox + SavePlayerInbox(playerGuid, json::array()); + } + + return { + {"success", true}, + {"player_guid", playerGuid}, + {"message_count", inbox.size()}, + {"messages", inbox} + }; + } + ); + + TC_LOG_INFO("araxia.mcp", "[MCP] Registered 6 Agent Chat tools"); +} + +} // namespace Araxia diff --git a/src/araxiaonline/mcp/AraxiaMCPServer.cpp b/src/araxiaonline/mcp/AraxiaMCPServer.cpp index 5add8e3aee..240adaebc9 100644 --- a/src/araxiaonline/mcp/AraxiaMCPServer.cpp +++ b/src/araxiaonline/mcp/AraxiaMCPServer.cpp @@ -77,6 +77,7 @@ bool MCPServer::Initialize() RegisterDatabaseTools(); RegisterWorldScanTools(); // LIDAR-style spatial awareness RegisterSpawnTools(); // Headless spawn management + RegisterAgentTools(); // Agent chat - bidirectional player↔AI messaging // Initialize AraxiaCore (provides World::Update hook for all Araxia systems) sAraxiaCore->Initialize(); diff --git a/src/araxiaonline/mcp/AraxiaMCPServer.h b/src/araxiaonline/mcp/AraxiaMCPServer.h index 9db222e270..2516e109f8 100644 --- a/src/araxiaonline/mcp/AraxiaMCPServer.h +++ b/src/araxiaonline/mcp/AraxiaMCPServer.h @@ -108,6 +108,7 @@ void RegisterWorldTools(); void RegisterWorldScanTools(); // LIDAR-style spatial awareness void RegisterSpawnTools(); // Headless spawn management (no player required) void RegisterMCPPlayerTools(); // AI player session management +void RegisterAgentTools(); // Agent chat - bidirectional player↔AI messaging } // namespace Araxia diff --git a/src/araxiaonline/mcp/ServerTools.cpp b/src/araxiaonline/mcp/ServerTools.cpp index 5e20ddb633..f85dfc36be 100644 --- a/src/araxiaonline/mcp/ServerTools.cpp +++ b/src/araxiaonline/mcp/ServerTools.cpp @@ -874,7 +874,7 @@ void RegisterServerTools() if (!itemTemplate) continue; // Quality filter - check bucket's quality mask - if (qualityFilter >= 0 && itemTemplate->GetQuality() < qualityFilter) + if (qualityFilter >= 0 && static_cast(itemTemplate->GetQuality()) < qualityFilter) continue; // Name filter using bucket's FullName (same as server's BuildListBuckets)