Add of first attempt to get client talking to LLM

This commit is contained in:
2025-12-30 18:12:52 -05:00
parent c8fd886f32
commit f6d5dba20b
9 changed files with 1084 additions and 4 deletions

View File

@@ -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

View File

@@ -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_<name>`)
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

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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_<name>: JSON array of pending messages for agent
* - player_inbox_<guid>: JSON array of pending responses for player
*/
#include "AraxiaMCPServer.h"
#include "Log.h"
#include "GameTime.h"
#include "LuaEngine/ElunaSharedData.h"
#include <sstream>
#include <random>
#include <iomanip>
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<uint64_t> dis;
std::stringstream ss;
ss << "msg_" << std::hex << dis(gen);
return ss.str();
}
// Helper: Get current timestamp
static uint64_t GetTimestamp()
{
return static_cast<uint64_t>(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

View File

@@ -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();

View File

@@ -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

View File

@@ -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<int>(itemTemplate->GetQuality()) < qualityFilter)
continue;
// Name filter using bucket's FullName (same as server's BuildListBuckets)