Enhance agent chat functionality with ROM context support and structured message rendering

This commit is contained in:
scawful
2025-10-03 14:46:22 -04:00
parent dc6040551e
commit 3715ae98eb
11 changed files with 341 additions and 29 deletions

View File

@@ -933,6 +933,10 @@ absl::Status EditorManager::Update() {
}
#ifdef YAZE_WITH_GRPC
if (show_agent_chat_widget_) {
Rom* rom_context =
(current_rom_ != nullptr && current_rom_->is_loaded()) ? current_rom_
: nullptr;
agent_chat_widget_.SetRomContext(rom_context);
agent_chat_widget_.Draw();
}
#endif

View File

@@ -1,6 +1,49 @@
#include "app/editor/system/agent_chat_widget.h"
#include <cstring>
#include <string>
#include <vector>
#include "imgui.h"
namespace {
const ImVec4 kUserColor = ImVec4(0.88f, 0.76f, 0.36f, 1.0f);
const ImVec4 kAgentColor = ImVec4(0.56f, 0.82f, 0.62f, 1.0f);
const ImVec4 kJsonTextColor = ImVec4(0.78f, 0.83f, 0.90f, 1.0f);
void RenderTable(const yaze::cli::agent::ChatMessage::TableData& table_data) {
const int column_count = static_cast<int>(table_data.headers.size());
if (column_count <= 0) {
ImGui::TextDisabled("(empty)");
return;
}
if (ImGui::BeginTable("structured_table", column_count,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_SizingStretchProp)) {
for (const auto& header : table_data.headers) {
ImGui::TableSetupColumn(header.c_str());
}
ImGui::TableHeadersRow();
for (const auto& row : table_data.rows) {
ImGui::TableNextRow();
for (int col = 0; col < column_count; ++col) {
ImGui::TableSetColumnIndex(col);
if (col < static_cast<int>(row.size())) {
ImGui::TextWrapped("%s", row[col].c_str());
} else {
ImGui::TextUnformatted("-");
}
}
}
ImGui::EndTable();
}
}
} // namespace
namespace yaze {
namespace editor {
@@ -9,6 +52,10 @@ AgentChatWidget::AgentChatWidget() {
memset(input_buffer_, 0, sizeof(input_buffer_));
}
void AgentChatWidget::SetRomContext(Rom* rom) {
agent_service_.SetRomContext(rom);
}
void AgentChatWidget::Draw() {
if (!active_) {
return;
@@ -17,13 +64,54 @@ void AgentChatWidget::Draw() {
ImGui::Begin(title_.c_str(), &active_);
// Display message history
ImGui::BeginChild("History", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()));
for (const auto& msg : agent_service_.GetHistory()) {
std::string prefix =
msg.sender == cli::agent::ChatMessage::Sender::kUser ? "You: " : "Agent: ";
ImGui::TextWrapped((prefix + msg.message).c_str());
const auto& history = agent_service_.GetHistory();
if (ImGui::BeginChild("History", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()),
false,
ImGuiWindowFlags_AlwaysVerticalScrollbar |
ImGuiWindowFlags_HorizontalScrollbar)) {
for (size_t index = 0; index < history.size(); ++index) {
const auto& msg = history[index];
ImGui::PushID(static_cast<int>(index));
const bool from_user =
msg.sender == cli::agent::ChatMessage::Sender::kUser;
const ImVec4 header_color = from_user ? kUserColor : kAgentColor;
const char* header_label = from_user ? "You" : "Agent";
ImGui::TextColored(header_color, "%s", header_label);
ImGui::Indent();
if (msg.json_pretty.has_value()) {
if (ImGui::SmallButton("Copy JSON")) {
ImGui::SetClipboardText(msg.json_pretty->c_str());
}
ImGui::SameLine();
ImGui::TextDisabled("Structured response");
}
if (msg.table_data.has_value()) {
RenderTable(*msg.table_data);
} else if (msg.json_pretty.has_value()) {
ImGui::PushStyleColor(ImGuiCol_Text, kJsonTextColor);
ImGui::TextUnformatted(msg.json_pretty->c_str());
ImGui::PopStyleColor();
} else {
ImGui::TextWrapped("%s", msg.message.c_str());
}
ImGui::Unindent();
ImGui::Spacing();
ImGui::Separator();
ImGui::PopID();
}
if (history.size() > last_history_size_) {
ImGui::SetScrollHereY(1.0f);
}
}
ImGui::EndChild();
last_history_size_ = history.size();
// Display input text box
if (ImGui::InputText("Input", input_buffer_, sizeof(input_buffer_),

View File

@@ -6,6 +6,9 @@
#include "cli/service/agent/conversational_agent_service.h"
namespace yaze {
class Rom;
namespace editor {
class AgentChatWidget {
@@ -14,6 +17,8 @@ class AgentChatWidget {
void Draw();
void SetRomContext(Rom* rom);
bool* active() { return &active_; }
void set_active(bool active) { active_ = active; }
@@ -22,6 +27,7 @@ class AgentChatWidget {
char input_buffer_[1024];
bool active_ = false;
std::string title_;
size_t last_history_size_ = 0;
};
} // namespace editor

View File

@@ -31,6 +31,7 @@ target_include_directories(yaze_agent
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/incl
${CMAKE_SOURCE_DIR}/third_party/httplib
${CMAKE_SOURCE_DIR}/third_party/json/include
${CMAKE_SOURCE_DIR}/src/lib
)

View File

@@ -25,9 +25,12 @@ absl::Status HandleListCommand();
absl::Status HandleCommitCommand(Rom& rom);
absl::Status HandleRevertCommand(Rom& rom);
absl::Status HandleDescribeCommand(const std::vector<std::string>& arg_vec);
absl::Status HandleResourceListCommand(const std::vector<std::string>& arg_vec);
absl::Status HandleResourceListCommand(
const std::vector<std::string>& arg_vec,
Rom* rom_context = nullptr);
absl::Status HandleDungeonListSpritesCommand(
const std::vector<std::string>& arg_vec);
const std::vector<std::string>& arg_vec,
Rom* rom_context = nullptr);
absl::Status HandleChatCommand();
} // namespace agent

View File

@@ -45,7 +45,7 @@ absl::StatusOr<Rom> LoadRomFromFlag() {
} // namespace
absl::Status HandleResourceListCommand(
const std::vector<std::string>& arg_vec) {
const std::vector<std::string>& arg_vec, Rom* rom_context) {
std::string type;
std::string format = "table";
@@ -73,13 +73,20 @@ absl::Status HandleResourceListCommand(
"Usage: agent resource-list --type <type> [--format <table|json>]");
}
auto rom_or = LoadRomFromFlag();
if (!rom_or.ok()) {
return rom_or.status();
Rom rom_storage;
Rom* rom = nullptr;
if (rom_context != nullptr && rom_context->is_loaded()) {
rom = rom_context;
} else {
auto rom_or = LoadRomFromFlag();
if (!rom_or.ok()) {
return rom_or.status();
}
rom_storage = std::move(rom_or.value());
rom = &rom_storage;
}
Rom rom = std::move(rom_or.value());
ResourceContextBuilder context_builder(&rom);
ResourceContextBuilder context_builder(rom);
auto labels_or = context_builder.GetLabels(type);
if (!labels_or.ok()) {
return labels_or.status();
@@ -108,7 +115,7 @@ absl::Status HandleResourceListCommand(
}
absl::Status HandleDungeonListSpritesCommand(
const std::vector<std::string>& arg_vec) {
const std::vector<std::string>& arg_vec, Rom* rom_context) {
std::string room_id_str;
std::string format = "table";
@@ -142,13 +149,20 @@ absl::Status HandleDungeonListSpritesCommand(
"Invalid room ID format. Must be hex.");
}
auto rom_or = LoadRomFromFlag();
if (!rom_or.ok()) {
return rom_or.status();
Rom rom_storage;
Rom* rom = nullptr;
if (rom_context != nullptr && rom_context->is_loaded()) {
rom = rom_context;
} else {
auto rom_or = LoadRomFromFlag();
if (!rom_or.ok()) {
return rom_or.status();
}
rom_storage = std::move(rom_or.value());
rom = &rom_storage;
}
Rom rom = std::move(rom_or.value());
auto room = zelda3::LoadRoomFromRom(&rom, room_id);
auto room = zelda3::LoadRoomFromRom(rom, room_id);
const auto& sprites = room.GetSprites();
if (format == "json") {

View File

@@ -1,18 +1,159 @@
#include "cli/service/agent/conversational_agent_service.h"
#include <algorithm>
#include <cctype>
#include <set>
#include <string>
#include <vector>
#include "absl/strings/str_cat.h"
#include "absl/strings/str_join.h"
#include "absl/time/clock.h"
#include "cli/service/ai/service_factory.h"
#include "nlohmann/json.hpp"
namespace yaze {
namespace cli {
namespace agent {
namespace {
std::string TrimWhitespace(const std::string& input) {
auto begin = std::find_if_not(input.begin(), input.end(),
[](unsigned char c) { return std::isspace(c); });
auto end = std::find_if_not(input.rbegin(), input.rend(),
[](unsigned char c) { return std::isspace(c); })
.base();
if (begin >= end) {
return "";
}
return std::string(begin, end);
}
std::string JsonValueToString(const nlohmann::json& value) {
if (value.is_string()) {
return value.get<std::string>();
}
if (value.is_boolean()) {
return value.get<bool>() ? "true" : "false";
}
if (value.is_number()) {
return value.dump();
}
if (value.is_null()) {
return "null";
}
return value.dump();
}
std::set<std::string> CollectObjectKeys(const nlohmann::json& array) {
std::set<std::string> keys;
for (const auto& item : array) {
if (!item.is_object()) {
continue;
}
for (const auto& [key, _] : item.items()) {
keys.insert(key);
}
}
return keys;
}
std::optional<ChatMessage::TableData> BuildTableData(const nlohmann::json& data) {
using TableData = ChatMessage::TableData;
if (data.is_object()) {
TableData table;
table.headers = {"Key", "Value"};
table.rows.reserve(data.size());
for (const auto& [key, value] : data.items()) {
table.rows.push_back({key, JsonValueToString(value)});
}
return table;
}
if (data.is_array()) {
TableData table;
if (data.empty()) {
table.headers = {"Value"};
return table;
}
const bool all_objects = std::all_of(data.begin(), data.end(), [](const nlohmann::json& item) {
return item.is_object();
});
if (all_objects) {
auto keys = CollectObjectKeys(data);
if (keys.empty()) {
table.headers = {"Value"};
for (const auto& item : data) {
table.rows.push_back({JsonValueToString(item)});
}
return table;
}
table.headers.assign(keys.begin(), keys.end());
table.rows.reserve(data.size());
for (const auto& item : data) {
std::vector<std::string> row;
row.reserve(table.headers.size());
for (const auto& key : table.headers) {
if (item.contains(key)) {
row.push_back(JsonValueToString(item.at(key)));
} else {
row.emplace_back("-");
}
}
table.rows.push_back(std::move(row));
}
return table;
}
table.headers = {"Value"};
table.rows.reserve(data.size());
for (const auto& item : data) {
table.rows.push_back({JsonValueToString(item)});
}
return table;
}
return std::nullopt;
}
ChatMessage CreateMessage(ChatMessage::Sender sender, const std::string& content) {
ChatMessage message;
message.sender = sender;
message.message = content;
message.timestamp = absl::Now();
if (sender == ChatMessage::Sender::kAgent) {
const std::string trimmed = TrimWhitespace(content);
if (!trimmed.empty() && (trimmed.front() == '{' || trimmed.front() == '[')) {
try {
nlohmann::json parsed = nlohmann::json::parse(trimmed);
message.table_data = BuildTableData(parsed);
message.json_pretty = parsed.dump(2);
} catch (const nlohmann::json::parse_error&) {
// Ignore parse errors, fall back to raw text.
}
}
}
return message;
}
} // namespace
ConversationalAgentService::ConversationalAgentService() {
ai_service_ = CreateAIService();
}
void ConversationalAgentService::SetRomContext(Rom* rom) {
rom_context_ = rom;
tool_dispatcher_.SetRomContext(rom_context_);
}
absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
const std::string& message) {
if (message.empty() && history_.empty()) {
@@ -21,7 +162,7 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
}
if (!message.empty()) {
history_.push_back({ChatMessage::Sender::kUser, message, absl::Now()});
history_.push_back(CreateMessage(ChatMessage::Sender::kUser, message));
}
constexpr int kMaxToolIterations = 4;
@@ -46,7 +187,7 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
const std::string& tool_output = tool_result_or.value();
if (!tool_output.empty()) {
history_.push_back(
{ChatMessage::Sender::kAgent, tool_output, absl::Now()});
CreateMessage(ChatMessage::Sender::kAgent, tool_output));
}
executed_tool = true;
}
@@ -73,8 +214,8 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
response_text.append(absl::StrJoin(agent_response.commands, "\n"));
}
ChatMessage chat_response = {ChatMessage::Sender::kAgent, response_text,
absl::Now()};
ChatMessage chat_response =
CreateMessage(ChatMessage::Sender::kAgent, response_text);
history_.push_back(chat_response);
return chat_response;
}

View File

@@ -1,6 +1,7 @@
#ifndef YAZE_SRC_CLI_SERVICE_AGENT_CONVERSATIONAL_AGENT_SERVICE_H_
#define YAZE_SRC_CLI_SERVICE_AGENT_CONVERSATIONAL_AGENT_SERVICE_H_
#include <optional>
#include <string>
#include <vector>
@@ -9,14 +10,23 @@
#include "cli/service/agent/tool_dispatcher.h"
namespace yaze {
class Rom;
namespace cli {
namespace agent {
struct ChatMessage {
enum class Sender { kUser, kAgent };
struct TableData {
std::vector<std::string> headers;
std::vector<std::vector<std::string>> rows;
};
Sender sender;
std::string message;
absl::Time timestamp;
std::optional<std::string> json_pretty;
std::optional<TableData> table_data;
};
class ConversationalAgentService {
@@ -29,10 +39,14 @@ class ConversationalAgentService {
// Get the full chat history.
const std::vector<ChatMessage>& GetHistory() const;
// Provide the service with a ROM context for tool execution.
void SetRomContext(Rom* rom);
private:
std::vector<ChatMessage> history_;
std::unique_ptr<AIService> ai_service_;
ToolDispatcher tool_dispatcher_;
Rom* rom_context_ = nullptr;
};
} // namespace agent

View File

@@ -35,9 +35,9 @@ absl::StatusOr<std::string> ToolDispatcher::Dispatch(
absl::Status status;
if (tool_call.tool_name == "resource-list") {
status = HandleResourceListCommand(args);
status = HandleResourceListCommand(args, rom_context_);
} else if (tool_call.tool_name == "dungeon-list-sprites") {
status = HandleDungeonListSpritesCommand(args);
status = HandleDungeonListSpritesCommand(args, rom_context_);
} else {
status = absl::UnimplementedError(
absl::StrFormat("Unknown tool: %s", tool_call.tool_name));

View File

@@ -6,6 +6,9 @@
#include "cli/service/ai/common.h"
namespace yaze {
class Rom;
namespace cli {
namespace agent {
@@ -15,6 +18,11 @@ class ToolDispatcher {
// Execute a tool call and return the result as a string.
absl::StatusOr<std::string> Dispatch(const ToolCall& tool_call);
// Provide a ROM context for tool calls that require ROM access.
void SetRomContext(Rom* rom) { rom_context_ = rom; }
private:
Rom* rom_context_ = nullptr;
};
} // namespace agent

View File

@@ -6,6 +6,7 @@
#include "ftxui/component/component_base.hpp"
#include "ftxui/component/screen_interactive.hpp"
#include "ftxui/dom/elements.hpp"
#include "ftxui/dom/table.hpp"
namespace yaze {
namespace cli {
@@ -26,10 +27,42 @@ void ChatTUI::Run() {
auto renderer = Renderer(layout, [this] {
std::vector<Element> messages;
for (const auto& msg : agent_service_.GetHistory()) {
std::string prefix =
msg.sender == agent::ChatMessage::Sender::kUser ? "You: " : "Agent: ";
messages.push_back(text(prefix + msg.message));
messages.reserve(agent_service_.GetHistory().size());
const auto& history = agent_service_.GetHistory();
for (const auto& msg : history) {
Element header = text(msg.sender == agent::ChatMessage::Sender::kUser
? "You"
: "Agent") |
bold |
color(msg.sender == agent::ChatMessage::Sender::kUser
? Color::Yellow
: Color::Green);
Element body;
if (msg.table_data.has_value()) {
std::vector<std::vector<std::string>> table_rows;
table_rows.reserve(msg.table_data->rows.size() + 1);
table_rows.push_back(msg.table_data->headers);
for (const auto& row : msg.table_data->rows) {
table_rows.push_back(row);
}
Table table(table_rows);
table.SelectAll().Border(LIGHT);
table.SelectAll().SeparatorVertical(LIGHT);
table.SelectAll().SeparatorHorizontal(LIGHT);
if (!table_rows.empty()) {
table.SelectRow(0).Decorate(bold);
}
body = table.Render();
} else if (msg.json_pretty.has_value()) {
body = paragraph(msg.json_pretty.value());
} else {
body = paragraph(msg.message);
}
messages.push_back(vbox({header, hbox({text(" "), body}), separator()}));
}
return vbox({