feat: Add resource search and dungeon room description commands
- Implemented `resource-search` command to allow fuzzy searching of resource labels. - Added `dungeon-describe-room` command to summarize metadata for a specified dungeon room. - Enhanced `agent` command handler to support new commands and updated usage documentation. - Introduced read-only accessors for room metadata in the Room class. - Updated AI service to recognize and handle new commands for resource searching and room description. - Improved metrics tracking for user interactions, including command execution and response times. - Enhanced TUI to display command metrics and session summaries.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "resource_list",
|
"name": "resource-list",
|
||||||
"description": "List all labeled resources of a specific type (dungeons, sprites, palettes)",
|
"description": "List all labeled resources of a specific type (dungeons, sprites, palettes)",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -8,7 +8,53 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Resource type to list",
|
"description": "Resource type to list",
|
||||||
"enum": ["dungeon", "sprite", "palette", "all"]
|
"enum": [
|
||||||
|
"dungeon",
|
||||||
|
"room",
|
||||||
|
"entrance",
|
||||||
|
"overworld",
|
||||||
|
"sprite",
|
||||||
|
"palette",
|
||||||
|
"item",
|
||||||
|
"tile16",
|
||||||
|
"all"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Output format",
|
||||||
|
"enum": ["json", "table", "text"],
|
||||||
|
"default": "table"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resource-search",
|
||||||
|
"description": "Search labeled resources by name, ID, or partial match",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Search text (case-insensitive substring match)"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional resource category to filter",
|
||||||
|
"enum": [
|
||||||
|
"dungeon",
|
||||||
|
"room",
|
||||||
|
"entrance",
|
||||||
|
"overworld",
|
||||||
|
"sprite",
|
||||||
|
"palette",
|
||||||
|
"item",
|
||||||
|
"tile16",
|
||||||
|
"all"
|
||||||
|
],
|
||||||
|
"default": "all"
|
||||||
},
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -17,11 +63,11 @@
|
|||||||
"default": "json"
|
"default": "json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["type"]
|
"required": ["query"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dungeon_list_sprites",
|
"name": "dungeon-list-sprites",
|
||||||
"description": "List all sprites in a specific dungeon room",
|
"description": "List all sprites in a specific dungeon room",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -40,7 +86,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "overworld_find_tile",
|
"name": "dungeon-describe-room",
|
||||||
|
"description": "Summarize dungeon room metadata, hazards, and counts",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"room": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Room ID in hex format (e.g., 0x012)"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["json", "text"],
|
||||||
|
"default": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["room"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "overworld-find-tile",
|
||||||
"description": "Find all occurrences of a specific tile16 ID on overworld maps",
|
"description": "Find all occurrences of a specific tile16 ID on overworld maps",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -63,7 +128,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "overworld_describe_map",
|
"name": "overworld-describe-map",
|
||||||
"description": "Get summary information about an overworld map",
|
"description": "Get summary information about an overworld map",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -82,7 +147,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "overworld_list_warps",
|
"name": "overworld-list-warps",
|
||||||
"description": "List warp/entrance/exit points on the overworld",
|
"description": "List warp/entrance/exit points on the overworld",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -94,7 +159,8 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional: filter by warp type",
|
"description": "Optional: filter by warp type",
|
||||||
"enum": ["entrance", "exit", "hole", "all"]
|
"enum": ["entrance", "exit", "hole", "all"],
|
||||||
|
"default": "all"
|
||||||
},
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -36,16 +36,32 @@ commands:
|
|||||||
tools:
|
tools:
|
||||||
- name: resource-list
|
- name: resource-list
|
||||||
description: "List project-defined resource labels for the requested category."
|
description: "List project-defined resource labels for the requested category."
|
||||||
usage_notes: "Use this whenever you need to reference project-specific labels or IDs from the ROM. Valid categories are: room, entrance, sprite, overlord, item."
|
usage_notes: "Use this whenever you need to reference project-specific labels or IDs from the ROM. Valid categories: dungeon, room, entrance, overworld, sprite, palette, item, tile16, or all."
|
||||||
arguments:
|
arguments:
|
||||||
- name: type
|
- name: type
|
||||||
description: "Resource category. Valid values: room, entrance, sprite, overlord, item."
|
description: "Resource category. Valid values: dungeon, room, entrance, overworld, sprite, palette, item, tile16, all."
|
||||||
required: true
|
required: true
|
||||||
example: room
|
example: dungeon
|
||||||
- name: format
|
- name: format
|
||||||
description: "Response format (json or table). Defaults to JSON if omitted."
|
description: "Response format (json or table). Defaults to JSON if omitted."
|
||||||
required: false
|
required: false
|
||||||
example: json
|
example: json
|
||||||
|
- name: resource-search
|
||||||
|
description: "Search resource labels by partial name or ID."
|
||||||
|
usage_notes: "Use to locate specific rooms, sprites, palettes, entrances, overworld maps, or tile16 entries based on fuzzy text."
|
||||||
|
arguments:
|
||||||
|
- name: query
|
||||||
|
description: "Search term to match against labels and IDs."
|
||||||
|
required: true
|
||||||
|
example: soldier
|
||||||
|
- name: type
|
||||||
|
description: "Optional category filter (dungeon, room, entrance, overworld, sprite, palette, item, tile16, all)."
|
||||||
|
required: false
|
||||||
|
example: sprite
|
||||||
|
- name: format
|
||||||
|
description: "Response format (json or text). Defaults to JSON."
|
||||||
|
required: false
|
||||||
|
example: json
|
||||||
- name: dungeon-list-sprites
|
- name: dungeon-list-sprites
|
||||||
description: "Inspect sprite placements for a specific dungeon room."
|
description: "Inspect sprite placements for a specific dungeon room."
|
||||||
usage_notes: "Returns sprite IDs, positions, and metadata for the requested room."
|
usage_notes: "Returns sprite IDs, positions, and metadata for the requested room."
|
||||||
@@ -62,6 +78,18 @@ tools:
|
|||||||
description: "Response format (json or table). Defaults to JSON if omitted."
|
description: "Response format (json or table). Defaults to JSON if omitted."
|
||||||
required: false
|
required: false
|
||||||
example: json
|
example: json
|
||||||
|
- name: dungeon-describe-room
|
||||||
|
description: "Summarize dungeon room metadata, hazards, and object counts."
|
||||||
|
usage_notes: "Great for understanding room state before proposing edits. Includes lighting, effect flags, chests, staircases, and sample sprites."
|
||||||
|
arguments:
|
||||||
|
- name: room
|
||||||
|
description: "Room label or numeric ID (supports hex like 0x123)."
|
||||||
|
required: true
|
||||||
|
example: 0x012
|
||||||
|
- name: format
|
||||||
|
description: "Response format (json or text). Defaults to JSON if omitted."
|
||||||
|
required: false
|
||||||
|
example: json
|
||||||
- name: overworld-find-tile
|
- name: overworld-find-tile
|
||||||
description: "Search all overworld maps for occurrences of a specific tile16 ID."
|
description: "Search all overworld maps for occurrences of a specific tile16 ID."
|
||||||
usage_notes: "Ideal for tile lookup questions. Includes coordinates for each match."
|
usage_notes: "Ideal for tile lookup questions. Includes coordinates for each match."
|
||||||
@@ -188,7 +216,14 @@ examples:
|
|||||||
tool_calls:
|
tool_calls:
|
||||||
- tool_name: resource-list
|
- tool_name: resource-list
|
||||||
args:
|
args:
|
||||||
type: room
|
type: dungeon
|
||||||
|
- user_prompt: "Search for soldier sprites in the label database"
|
||||||
|
reasoning: "The user wants to find sprite labels. I should use the `resource-search` tool with a sprite filter."
|
||||||
|
tool_calls:
|
||||||
|
- tool_name: resource-search
|
||||||
|
args:
|
||||||
|
query: soldier
|
||||||
|
type: sprite
|
||||||
- user_prompt: "[TOOL RESULT] {\"resources\": [{\"id\": 0, \"label\": \"Ganon\"}, {\"id\": 1, \"label\": \"Hyrule Castle\"}, {\"id\": 2, \"label\": \"Eastern Palace\"}, {\"id\": 3, \"label\": \"Desert Palace\"}, {\"id\": 4, \"label\": \"Tower of Hera\"}, {\"id\": 5, \"label\": \"Palace of Darkness\"}]}"
|
- user_prompt: "[TOOL RESULT] {\"resources\": [{\"id\": 0, \"label\": \"Ganon\"}, {\"id\": 1, \"label\": \"Hyrule Castle\"}, {\"id\": 2, \"label\": \"Eastern Palace\"}, {\"id\": 3, \"label\": \"Desert Palace\"}, {\"id\": 4, \"label\": \"Tower of Hera\"}, {\"id\": 5, \"label\": \"Palace of Darkness\"}]}"
|
||||||
text_response: "Based on the ROM data, this project contains 297 rooms. The first few include: Ganon's Room, Hyrule Castle, Eastern Palace, Desert Palace, Tower of Hera, and Palace of Darkness."
|
text_response: "Based on the ROM data, this project contains 297 rooms. The first few include: Ganon's Room, Hyrule Castle, Eastern Palace, Desert Palace, Tower of Hera, and Palace of Darkness."
|
||||||
reasoning: "I received the tool results showing room labels. Now I provide a natural language summary of this information."
|
reasoning: "I received the tool results showing room labels. Now I provide a natural language summary of this information."
|
||||||
@@ -198,6 +233,12 @@ examples:
|
|||||||
- tool_name: dungeon-list-sprites
|
- tool_name: dungeon-list-sprites
|
||||||
args:
|
args:
|
||||||
room: "5"
|
room: "5"
|
||||||
|
- user_prompt: "Describe dungeon room 0x012"
|
||||||
|
reasoning: "The user wants overall room metadata. I should call `dungeon-describe-room` to gather the summary."
|
||||||
|
tool_calls:
|
||||||
|
- tool_name: dungeon-describe-room
|
||||||
|
args:
|
||||||
|
room: "0x012"
|
||||||
- user_prompt: "[TOOL RESULT] {\"sprites\": [{\"id\": 0x41, \"name\": \"soldier\", \"x\": 5, \"y\": 3}, {\"id\": 0x41, \"name\": \"soldier\", \"x\": 10, \"y\": 3}]}"
|
- user_prompt: "[TOOL RESULT] {\"sprites\": [{\"id\": 0x41, \"name\": \"soldier\", \"x\": 5, \"y\": 3}, {\"id\": 0x41, \"name\": \"soldier\", \"x\": 10, \"y\": 3}]}"
|
||||||
text_response: "Room 5 contains 2 sprites: two soldiers positioned at coordinates (5, 3) and (10, 3). Both are sprite ID 0x41."
|
text_response: "Room 5 contains 2 sprites: two soldiers positioned at coordinates (5, 3) and (10, 3). Both are sprite ID 0x41."
|
||||||
reasoning: "The tool returned sprite data for room 5. I've formatted this into a readable response for the user."
|
reasoning: "The tool returned sprite data for room 5. I've formatted this into a readable response for the user."
|
||||||
|
|||||||
@@ -113,48 +113,23 @@ The agent can call these tools autonomously:
|
|||||||
| Tool | Purpose | Example |
|
| Tool | Purpose | Example |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
| `resource-list` | List labeled resources | "What dungeons exist?" |
|
| `resource-list` | List labeled resources | "What dungeons exist?" |
|
||||||
|
| `resource-search` | Fuzzy search across labels | "Search for soldier labels" |
|
||||||
| `dungeon-list-sprites` | Sprites in room | "Show soldiers in room 0x12" |
|
| `dungeon-list-sprites` | Sprites in room | "Show soldiers in room 0x12" |
|
||||||
|
| `dungeon-describe-room` | Room metadata summary | "Describe room 0x012" |
|
||||||
| `overworld-find-tile` | Find tile locations | "Where is tile 0x2E used?" |
|
| `overworld-find-tile` | Find tile locations | "Where is tile 0x2E used?" |
|
||||||
| `overworld-describe-map` | Map metadata | "Describe map 0x05" |
|
| `overworld-describe-map` | Map metadata | "Describe map 0x05" |
|
||||||
| `overworld-list-warps` | List entrances/exits | "Show all cave entrances" |
|
| `overworld-list-warps` | List entrances/exits | "Show all cave entrances" |
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- **[AGENT-ROADMAP.md](AGENT-ROADMAP.md)** - Vision, priorities, and technical architecture
|
|
||||||
- **[E6-z3ed-cli-design.md](E6-z3ed-cli-design.md)** - CLI design and command structure
|
|
||||||
- **[E6-z3ed-reference.md](E6-z3ed-reference.md)** - Complete command reference
|
|
||||||
|
|
||||||
## Recent Updates (Oct 3, 2025)
|
|
||||||
|
|
||||||
### ✅ Implemented
|
|
||||||
- **Simple Chat Mode**: Text-based REPL for automation
|
|
||||||
- **GUI Widget Fixes**: Corrected API usage, table rendering
|
|
||||||
- **Condensed Documentation**: Streamlined README and ROADMAP
|
|
||||||
- **Z3ED_AI Flag**: Simplified build with single master flag
|
|
||||||
|
|
||||||
### 🎯 Next Steps
|
### 🎯 Next Steps
|
||||||
1. **Live LLM Testing** (1-2h): Verify function calling works
|
|
||||||
2. **GUI Integration** (4-6h): Wire chat widget into main app
|
2. **GUI Integration** (4-6h): Wire chat widget into main app
|
||||||
3. **Proposal Integration** (6-8h): Connect chat to ROM modification
|
3. **Proposal Integration** (6-8h): Connect chat to ROM modification
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "AI features not available"
|
|
||||||
**Solution**: Rebuild with `-DZ3ED_AI=ON`
|
|
||||||
|
|
||||||
### "OpenSSL not found"
|
|
||||||
**Impact**: Gemini won't work
|
|
||||||
**Solutions**:
|
|
||||||
- Use Ollama (no SSL needed)
|
|
||||||
- Install OpenSSL: `brew install openssl`
|
|
||||||
|
|
||||||
### Chat mode freezes
|
### Chat mode freezes
|
||||||
**Solution**: Use `agent simple-chat` instead of `agent chat`
|
**Solution**: Use `agent simple-chat` instead of `agent chat`
|
||||||
|
|
||||||
### Tool not being called
|
|
||||||
**Cause**: Model doesn't support function calling
|
|
||||||
**Solution**: Use qwen2.5-coder (Ollama) or Gemini 2.0
|
|
||||||
|
|
||||||
## Example Workflows
|
## Example Workflows
|
||||||
|
|
||||||
### Explore ROM
|
### Explore ROM
|
||||||
@@ -463,13 +438,6 @@ AI agent features require:
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "OpenSSL not found" warning
|
|
||||||
**Impact**: Gemini API won't work (HTTPS required)
|
|
||||||
**Solutions**:
|
|
||||||
- Use Ollama instead (no SSL needed, runs locally) - **RECOMMENDED**
|
|
||||||
- Install OpenSSL: `brew install openssl` (macOS) or `apt-get install libssl-dev` (Linux)
|
|
||||||
- Windows: Use Ollama (localhost) instead of Gemini
|
|
||||||
|
|
||||||
### "Build with -DZ3ED_AI=ON" warning
|
### "Build with -DZ3ED_AI=ON" warning
|
||||||
**Impact**: AI agent features disabled (no Ollama or Gemini)
|
**Impact**: AI agent features disabled (no Ollama or Gemini)
|
||||||
**Solution**: Rebuild with AI support:
|
**Solution**: Rebuild with AI support:
|
||||||
|
|||||||
@@ -313,6 +313,15 @@ class Room {
|
|||||||
void SetStair3Target(uint8_t target) { stair3_.target = target; }
|
void SetStair3Target(uint8_t target) { stair3_.target = target; }
|
||||||
void SetStair4Target(uint8_t target) { stair4_.target = target; }
|
void SetStair4Target(uint8_t target) { stair4_.target = target; }
|
||||||
|
|
||||||
|
// Read-only accessors for metadata
|
||||||
|
EffectKey effect() const { return effect_; }
|
||||||
|
TagKey tag1() const { return tag1_; }
|
||||||
|
TagKey tag2() const { return tag2_; }
|
||||||
|
CollisionKey collision() const { return collision_; }
|
||||||
|
const LayerMergeType& layer_merging() const { return layer_merging_; }
|
||||||
|
|
||||||
|
int id() const { return room_id_; }
|
||||||
|
|
||||||
uint8_t blockset = 0;
|
uint8_t blockset = 0;
|
||||||
uint8_t spriteset = 0;
|
uint8_t spriteset = 0;
|
||||||
uint8_t palette = 0;
|
uint8_t palette = 0;
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ constexpr absl::string_view kUsage =
|
|||||||
" resource-list List labeled resources (dungeons, sprites, etc.)\n"
|
" resource-list List labeled resources (dungeons, sprites, etc.)\n"
|
||||||
" Example: agent resource-list --type=dungeon --format=json\n"
|
" Example: agent resource-list --type=dungeon --format=json\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
" resource-search Search resource labels by fuzzy text\n"
|
||||||
|
" Example: agent resource-search --query=soldier --type=sprite\n"
|
||||||
|
"\n"
|
||||||
" dungeon-list-sprites List sprites in a dungeon room\n"
|
" dungeon-list-sprites List sprites in a dungeon room\n"
|
||||||
" Example: agent dungeon-list-sprites --room=5 --format=json\n"
|
" Example: agent dungeon-list-sprites --room=5 --format=json\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
" dungeon-describe-room Summarize metadata for a dungeon room\n"
|
||||||
|
" Example: agent dungeon-describe-room --room=0x12 --format=text\n"
|
||||||
|
"\n"
|
||||||
" overworld-find-tile Search for tile placements in overworld\n"
|
" overworld-find-tile Search for tile placements in overworld\n"
|
||||||
" Example: agent overworld-find-tile --tile=0x02E --format=json\n"
|
" Example: agent overworld-find-tile --tile=0x02E --format=json\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -121,9 +127,15 @@ absl::Status Agent::Run(const std::vector<std::string>& arg_vec) {
|
|||||||
if (subcommand == "resource-list") {
|
if (subcommand == "resource-list") {
|
||||||
return agent::HandleResourceListCommand(subcommand_args);
|
return agent::HandleResourceListCommand(subcommand_args);
|
||||||
}
|
}
|
||||||
|
if (subcommand == "resource-search") {
|
||||||
|
return agent::HandleResourceSearchCommand(subcommand_args);
|
||||||
|
}
|
||||||
if (subcommand == "dungeon-list-sprites") {
|
if (subcommand == "dungeon-list-sprites") {
|
||||||
return agent::HandleDungeonListSpritesCommand(subcommand_args);
|
return agent::HandleDungeonListSpritesCommand(subcommand_args);
|
||||||
}
|
}
|
||||||
|
if (subcommand == "dungeon-describe-room") {
|
||||||
|
return agent::HandleDungeonDescribeRoomCommand(subcommand_args);
|
||||||
|
}
|
||||||
if (subcommand == "overworld-find-tile") {
|
if (subcommand == "overworld-find-tile") {
|
||||||
return agent::HandleOverworldFindTileCommand(subcommand_args);
|
return agent::HandleOverworldFindTileCommand(subcommand_args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,15 @@ absl::Status HandleDescribeCommand(const std::vector<std::string>& arg_vec);
|
|||||||
absl::Status HandleResourceListCommand(
|
absl::Status HandleResourceListCommand(
|
||||||
const std::vector<std::string>& arg_vec,
|
const std::vector<std::string>& arg_vec,
|
||||||
Rom* rom_context = nullptr);
|
Rom* rom_context = nullptr);
|
||||||
|
absl::Status HandleResourceSearchCommand(
|
||||||
|
const std::vector<std::string>& arg_vec,
|
||||||
|
Rom* rom_context = nullptr);
|
||||||
absl::Status HandleDungeonListSpritesCommand(
|
absl::Status HandleDungeonListSpritesCommand(
|
||||||
const std::vector<std::string>& arg_vec,
|
const std::vector<std::string>& arg_vec,
|
||||||
Rom* rom_context = nullptr);
|
Rom* rom_context = nullptr);
|
||||||
|
absl::Status HandleDungeonDescribeRoomCommand(
|
||||||
|
const std::vector<std::string>& arg_vec,
|
||||||
|
Rom* rom_context = nullptr);
|
||||||
absl::Status HandleOverworldFindTileCommand(
|
absl::Status HandleOverworldFindTileCommand(
|
||||||
const std::vector<std::string>& arg_vec,
|
const std::vector<std::string>& arg_vec,
|
||||||
Rom* rom_context = nullptr);
|
Rom* rom_context = nullptr);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
#include "cli/handlers/agent/commands.h"
|
#include "cli/handlers/agent/commands.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "absl/base/macros.h"
|
||||||
#include "absl/flags/declare.h"
|
#include "absl/flags/declare.h"
|
||||||
#include "absl/flags/flag.h"
|
#include "absl/flags/flag.h"
|
||||||
#include "absl/status/status.h"
|
#include "absl/status/status.h"
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
#include "app/zelda3/overworld/overworld.h"
|
#include "app/zelda3/overworld/overworld.h"
|
||||||
#include "cli/handlers/overworld_inspect.h"
|
#include "cli/handlers/overworld_inspect.h"
|
||||||
#include "cli/service/resources/resource_context_builder.h"
|
#include "cli/service/resources/resource_context_builder.h"
|
||||||
|
#include "util/macro.h"
|
||||||
|
|
||||||
ABSL_DECLARE_FLAG(std::string, rom);
|
ABSL_DECLARE_FLAG(std::string, rom);
|
||||||
|
|
||||||
@@ -145,6 +148,194 @@ absl::Status HandleResourceListCommand(
|
|||||||
return absl::OkStatus();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
absl::Status HandleResourceSearchCommand(
|
||||||
|
const std::vector<std::string>& arg_vec, Rom* rom_context) {
|
||||||
|
std::string query;
|
||||||
|
std::string type = "all";
|
||||||
|
std::string format = "json";
|
||||||
|
|
||||||
|
for (size_t i = 0; i < arg_vec.size(); ++i) {
|
||||||
|
const std::string& token = arg_vec[i];
|
||||||
|
if (token == "--query") {
|
||||||
|
if (i + 1 >= arg_vec.size()) {
|
||||||
|
return absl::InvalidArgumentError("--query requires a value.");
|
||||||
|
}
|
||||||
|
query = arg_vec[++i];
|
||||||
|
} else if (absl::StartsWith(token, "--query=")) {
|
||||||
|
query = token.substr(8);
|
||||||
|
} else if (token == "--type") {
|
||||||
|
if (i + 1 >= arg_vec.size()) {
|
||||||
|
return absl::InvalidArgumentError("--type requires a value.");
|
||||||
|
}
|
||||||
|
type = arg_vec[++i];
|
||||||
|
} else if (absl::StartsWith(token, "--type=")) {
|
||||||
|
type = token.substr(7);
|
||||||
|
} else if (token == "--format") {
|
||||||
|
if (i + 1 >= arg_vec.size()) {
|
||||||
|
return absl::InvalidArgumentError("--format requires a value.");
|
||||||
|
}
|
||||||
|
format = arg_vec[++i];
|
||||||
|
} else if (absl::StartsWith(token, "--format=")) {
|
||||||
|
format = token.substr(9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.empty()) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
"Usage: agent resource-search --query <text> [--type <category>] [--format <json|text>]");
|
||||||
|
}
|
||||||
|
|
||||||
|
format = absl::AsciiStrToLower(format);
|
||||||
|
if (format != "json" && format != "text") {
|
||||||
|
return absl::InvalidArgumentError("--format must be either json or text");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto normalize_category = [](std::string value) {
|
||||||
|
value = absl::AsciiStrToLower(value);
|
||||||
|
if (value.size() > 1 && value.back() == 's') {
|
||||||
|
value.pop_back();
|
||||||
|
}
|
||||||
|
if (value == "tile16s") {
|
||||||
|
return std::string("tile16");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::vector<std::string> known_categories = {
|
||||||
|
"overworld", "dungeon", "entrance", "room",
|
||||||
|
"sprite", "palette", "item", "tile16"};
|
||||||
|
|
||||||
|
std::vector<std::string> categories;
|
||||||
|
std::string normalized_type = normalize_category(type);
|
||||||
|
if (normalized_type == "all" || normalized_type.empty()) {
|
||||||
|
categories = known_categories;
|
||||||
|
} else {
|
||||||
|
bool recognized = false;
|
||||||
|
for (const auto& candidate : known_categories) {
|
||||||
|
if (candidate == normalized_type) {
|
||||||
|
recognized = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!recognized) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
absl::StrCat("Unknown resource category: ", type,
|
||||||
|
". Known categories: overworld, dungeon, entrance, room, sprite, palette, item, tile16."));
|
||||||
|
}
|
||||||
|
categories.push_back(normalized_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure labels are available similar to resource-list
|
||||||
|
if (rom->resource_label() && !rom->resource_label()->labels_loaded_) {
|
||||||
|
core::YazeProject project;
|
||||||
|
auto labels_status = project.InitializeEmbeddedLabels();
|
||||||
|
if (labels_status.ok()) {
|
||||||
|
rom->resource_label()->labels_ = project.resource_labels;
|
||||||
|
rom->resource_label()->labels_loaded_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ResourceContextBuilder context_builder(rom);
|
||||||
|
|
||||||
|
struct SearchResult {
|
||||||
|
std::string category;
|
||||||
|
std::string id;
|
||||||
|
std::string label;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<SearchResult> results;
|
||||||
|
std::string lowered_query = absl::AsciiStrToLower(query);
|
||||||
|
|
||||||
|
for (const auto& category : categories) {
|
||||||
|
auto labels_or = context_builder.GetLabels(category);
|
||||||
|
if (!labels_or.ok()) {
|
||||||
|
// If the category was explicitly requested and not "all", surface the error.
|
||||||
|
if (normalized_type != "all") {
|
||||||
|
return labels_or.status();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto& labels = labels_or.value();
|
||||||
|
for (const auto& [id, label] : labels) {
|
||||||
|
std::string lowered_label = absl::AsciiStrToLower(label);
|
||||||
|
std::string lowered_id = absl::AsciiStrToLower(id);
|
||||||
|
if (lowered_label.find(lowered_query) != std::string::npos ||
|
||||||
|
lowered_id.find(lowered_query) != std::string::npos) {
|
||||||
|
results.push_back({category, id, label});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::sort(results.begin(), results.end(),
|
||||||
|
[](const SearchResult& a, const SearchResult& b) {
|
||||||
|
if (a.category == b.category) {
|
||||||
|
return a.id < b.id;
|
||||||
|
}
|
||||||
|
return a.category < b.category;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.empty()) {
|
||||||
|
if (format == "json") {
|
||||||
|
std::cout << "{\n"
|
||||||
|
<< " \"query\": \"" << query << "\",\n"
|
||||||
|
<< " \"match_count\": 0,\n"
|
||||||
|
<< " \"results\": []\n"
|
||||||
|
<< "}\n";
|
||||||
|
} else {
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
"🔍 No matches found for \"%s\" in %s resources.\n",
|
||||||
|
query, normalized_type == "all" ? std::string("any") : type);
|
||||||
|
}
|
||||||
|
return absl::OkStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format == "json") {
|
||||||
|
std::cout << "{\n";
|
||||||
|
std::cout << " \"query\": \"" << query << "\",\n";
|
||||||
|
std::cout << absl::StrFormat(" \"match_count\": %zu,\n", results.size());
|
||||||
|
std::cout << " \"results\": [\n";
|
||||||
|
for (size_t i = 0; i < results.size(); ++i) {
|
||||||
|
const auto& result = results[i];
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" {\"category\": \"%s\", \"id\": \"%s\", \"label\": \"%s\"}%s\n",
|
||||||
|
result.category, result.id, result.label,
|
||||||
|
(i + 1 == results.size()) ? "" : ",");
|
||||||
|
}
|
||||||
|
std::cout << " ]\n";
|
||||||
|
std::cout << "}\n";
|
||||||
|
} else {
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
"🔍 %zu match(es) for \"%s\" (categories: %s)\n",
|
||||||
|
results.size(), query,
|
||||||
|
normalized_type == "all" ? "all" : type);
|
||||||
|
std::string current_category;
|
||||||
|
for (const auto& result : results) {
|
||||||
|
if (result.category != current_category) {
|
||||||
|
current_category = result.category;
|
||||||
|
std::cout << absl::StrFormat("\n[%s]\n",
|
||||||
|
absl::AsciiStrToUpper(current_category));
|
||||||
|
}
|
||||||
|
std::cout << absl::StrFormat(" %-12s → %s\n", result.id, result.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return absl::OkStatus();
|
||||||
|
}
|
||||||
|
|
||||||
absl::Status HandleDungeonListSpritesCommand(
|
absl::Status HandleDungeonListSpritesCommand(
|
||||||
const std::vector<std::string>& arg_vec, Rom* rom_context) {
|
const std::vector<std::string>& arg_vec, Rom* rom_context) {
|
||||||
std::string room_id_str;
|
std::string room_id_str;
|
||||||
@@ -220,6 +411,236 @@ absl::Status HandleDungeonListSpritesCommand(
|
|||||||
return absl::OkStatus();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
absl::Status HandleDungeonDescribeRoomCommand(
|
||||||
|
const std::vector<std::string>& arg_vec, Rom* rom_context) {
|
||||||
|
std::string room_id_str;
|
||||||
|
std::string format = "json";
|
||||||
|
std::optional<std::string> rom_override;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < arg_vec.size(); ++i) {
|
||||||
|
const std::string& token = arg_vec[i];
|
||||||
|
if (token == "--room") {
|
||||||
|
if (i + 1 >= arg_vec.size()) {
|
||||||
|
return absl::InvalidArgumentError("--room requires a value.");
|
||||||
|
}
|
||||||
|
room_id_str = arg_vec[++i];
|
||||||
|
} else if (absl::StartsWith(token, "--room=")) {
|
||||||
|
room_id_str = token.substr(7);
|
||||||
|
} else if (token == "--format") {
|
||||||
|
if (i + 1 >= arg_vec.size()) {
|
||||||
|
return absl::InvalidArgumentError("--format requires a value.");
|
||||||
|
}
|
||||||
|
format = arg_vec[++i];
|
||||||
|
} else if (absl::StartsWith(token, "--format=")) {
|
||||||
|
format = token.substr(9);
|
||||||
|
} else if (token == "--rom") {
|
||||||
|
if (i + 1 >= arg_vec.size()) {
|
||||||
|
return absl::InvalidArgumentError("--rom requires a value.");
|
||||||
|
}
|
||||||
|
rom_override = arg_vec[++i];
|
||||||
|
} else if (absl::StartsWith(token, "--rom=")) {
|
||||||
|
rom_override = token.substr(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room_id_str.empty()) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
"Usage: agent dungeon-describe-room --room <hex> [--format <json|text>]");
|
||||||
|
}
|
||||||
|
|
||||||
|
int room_id = 0;
|
||||||
|
if (!absl::SimpleHexAtoi(room_id_str, &room_id)) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
absl::StrCat("Invalid room ID: ", room_id_str,
|
||||||
|
" (expected hexadecimal, e.g. 0x02A)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
format = absl::AsciiStrToLower(format);
|
||||||
|
if (format != "json" && format != "text") {
|
||||||
|
return absl::InvalidArgumentError("--format must be either json or text");
|
||||||
|
}
|
||||||
|
|
||||||
|
Rom rom_storage;
|
||||||
|
Rom* rom = nullptr;
|
||||||
|
if (rom_context != nullptr && rom_context->is_loaded() && !rom_override.has_value()) {
|
||||||
|
rom = rom_context;
|
||||||
|
} else {
|
||||||
|
ASSIGN_OR_RETURN(auto rom_or, LoadRomFromPathOrFlag(rom_override));
|
||||||
|
rom_storage = std::move(rom_or);
|
||||||
|
rom = &rom_storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto room = zelda3::LoadRoomFromRom(rom, room_id);
|
||||||
|
room.LoadObjects();
|
||||||
|
room.LoadSprites();
|
||||||
|
|
||||||
|
auto dimensions = room.GetLayout().GetDimensions();
|
||||||
|
const auto& sprites = room.GetSprites();
|
||||||
|
const auto& chests = room.GetChests();
|
||||||
|
const auto& stairs = room.GetStairs();
|
||||||
|
const size_t sprite_count = sprites.size();
|
||||||
|
const size_t chest_count = chests.size();
|
||||||
|
const size_t stair_count = stairs.size();
|
||||||
|
const size_t object_count = room.GetTileObjectCount();
|
||||||
|
|
||||||
|
constexpr size_t kRoomNameCount =
|
||||||
|
sizeof(zelda3::kRoomNames) / sizeof(zelda3::kRoomNames[0]);
|
||||||
|
std::string room_name = "Unknown";
|
||||||
|
if (room_id >= 0 && static_cast<size_t>(room_id) < kRoomNameCount) {
|
||||||
|
room_name = std::string(zelda3::kRoomNames[room_id]);
|
||||||
|
if (room_name.empty()) {
|
||||||
|
room_name = "Unnamed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr size_t kRoomEffectCount =
|
||||||
|
sizeof(zelda3::RoomEffect) / sizeof(zelda3::RoomEffect[0]);
|
||||||
|
const size_t effect_index = static_cast<size_t>(room.effect());
|
||||||
|
std::string effect_name = "Unknown";
|
||||||
|
if (effect_index < kRoomEffectCount) {
|
||||||
|
effect_name = zelda3::RoomEffect[effect_index];
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr size_t kRoomTagCount =
|
||||||
|
sizeof(zelda3::RoomTag) / sizeof(zelda3::RoomTag[0]);
|
||||||
|
const auto tag_name = [&](zelda3::TagKey tag) {
|
||||||
|
const size_t index = static_cast<size_t>(tag);
|
||||||
|
if (index < kRoomTagCount) {
|
||||||
|
return std::string(zelda3::RoomTag[index]);
|
||||||
|
}
|
||||||
|
return std::string("Unknown");
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr absl::string_view kCollisionNames[] = {
|
||||||
|
"Layer 1 Only",
|
||||||
|
"Both Layers",
|
||||||
|
"Both + Scroll",
|
||||||
|
"Moving Floor",
|
||||||
|
"Moving Water",
|
||||||
|
};
|
||||||
|
std::string collision_name = "Unknown";
|
||||||
|
const size_t collision_index = static_cast<size_t>(room.collision());
|
||||||
|
if (collision_index < ABSL_ARRAYSIZE(kCollisionNames)) {
|
||||||
|
collision_name = std::string(kCollisionNames[collision_index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format == "json") {
|
||||||
|
std::cout << "{\n";
|
||||||
|
std::cout << absl::StrFormat(" \"room\": \"0x%03X\",\n", room_id);
|
||||||
|
std::cout << absl::StrFormat(" \"name\": \"%s\",\n", room_name);
|
||||||
|
std::cout << absl::StrFormat(" \"light\": %s,\n",
|
||||||
|
room.IsLight() ? "true" : "false");
|
||||||
|
std::cout << absl::StrFormat(" \"layout\": {\"width\": %d, \"height\": %d},\n",
|
||||||
|
dimensions.first, dimensions.second);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" \"counts\": {\"sprites\": %zu, \"chests\": %zu, \"stairs\": %zu, \"tile_objects\": %zu},\n",
|
||||||
|
sprite_count, chest_count, stair_count, object_count);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" \"state\": {\"effect\": \"%s\", \"tag1\": \"%s\", \"tag2\": \"%s\", \"collision\": \"%s\", \"layer_merge\": \"%s\"},\n",
|
||||||
|
effect_name, tag_name(room.tag1()), tag_name(room.tag2()),
|
||||||
|
collision_name, room.layer_merging().Name);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" \"graphics\": {\"blockset\": %u, \"spriteset\": %u, \"palette\": %u},\n",
|
||||||
|
room.blockset, room.spriteset, room.palette);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" \"floors\": {\"primary\": %u, \"secondary\": %u},\n",
|
||||||
|
room.floor1, room.floor2);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" \"message_id\": \"0x%03X\",\n", room.message_id_);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" \"hole_warp\": \"0x%02X\",\n", room.holewarp);
|
||||||
|
|
||||||
|
std::cout << " \"staircases\": [";
|
||||||
|
for (size_t i = 0; i < stair_count; ++i) {
|
||||||
|
const auto& stair = stairs[i];
|
||||||
|
std::cout << (i == 0 ? "\n" : ",\n");
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" {\"id\": %u, \"target_room\": \"0x%02X\", \"label\": \"%s\"}",
|
||||||
|
stair.id, stair.room, stair.label ? stair.label : "");
|
||||||
|
}
|
||||||
|
if (stair_count > 0) {
|
||||||
|
std::cout << "\n ],\n";
|
||||||
|
} else {
|
||||||
|
std::cout << "],\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << " \"chests\": [";
|
||||||
|
for (size_t i = 0; i < chest_count; ++i) {
|
||||||
|
const auto& chest = chests[i];
|
||||||
|
std::cout << (i == 0 ? "\n" : ",\n");
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" {\"item_id\": \"0x%02X\", \"is_big\": %s}",
|
||||||
|
chest.id, chest.size ? "true" : "false");
|
||||||
|
}
|
||||||
|
if (chest_count > 0) {
|
||||||
|
std::cout << "\n ],\n";
|
||||||
|
} else {
|
||||||
|
std::cout << "],\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
const int sample_sprite_count =
|
||||||
|
static_cast<int>(std::min<size_t>(sprite_count, 5));
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" \"sample_sprites\": %d,\n", sample_sprite_count);
|
||||||
|
if (!sprites.empty()) {
|
||||||
|
std::cout << " \"sprites\": [\n";
|
||||||
|
const size_t limit = std::min<size_t>(sprites.size(), 5);
|
||||||
|
for (size_t i = 0; i < limit; ++i) {
|
||||||
|
const auto& spr = sprites[i];
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" {\"index\": %zu, \"id\": \"0x%02X\", \"x\": %d, \"y\": %d, \"layer\": %d, \"subtype\": %d}",
|
||||||
|
i, spr.id(), spr.x(), spr.y(), spr.layer(), spr.subtype());
|
||||||
|
if (i + 1 < limit) {
|
||||||
|
std::cout << ",";
|
||||||
|
}
|
||||||
|
std::cout << "\n";
|
||||||
|
}
|
||||||
|
std::cout << " ]\n";
|
||||||
|
} else {
|
||||||
|
std::cout << " \"sprites\": []\n";
|
||||||
|
}
|
||||||
|
std::cout << "}\n";
|
||||||
|
} else {
|
||||||
|
std::cout << absl::StrFormat("🏰 Room 0x%03X — %s\n", room_id, room_name);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" Layout: %d×%d tiles | Lighting: %s\n",
|
||||||
|
dimensions.first, dimensions.second,
|
||||||
|
room.IsLight() ? "light" : "dark");
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" Sprites: %zu Chests: %zu Stairs: %zu Tile Objects: %zu\n",
|
||||||
|
sprite_count, chest_count, stair_count, object_count);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" Effect: %s | Tags: %s / %s | Collision: %s | Layer Merge: %s\n",
|
||||||
|
effect_name, tag_name(room.tag1()), tag_name(room.tag2()),
|
||||||
|
collision_name, room.layer_merging().Name);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" Graphics → Blockset:%u Spriteset:%u Palette:%u\n",
|
||||||
|
room.blockset, room.spriteset, room.palette);
|
||||||
|
std::cout << absl::StrFormat(
|
||||||
|
" Floors → Main:%u Alt:%u Message ID:0x%03X Hole warp:0x%02X\n",
|
||||||
|
room.floor1, room.floor2, room.message_id_, room.holewarp);
|
||||||
|
if (!stairs.empty()) {
|
||||||
|
std::cout << " Staircases:\n";
|
||||||
|
for (const auto& stair : stairs) {
|
||||||
|
std::cout << absl::StrFormat(" - ID %u → Room 0x%02X (%s)\n",
|
||||||
|
stair.id, stair.room,
|
||||||
|
stair.label ? stair.label : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!chests.empty()) {
|
||||||
|
std::cout << " Chests:\n";
|
||||||
|
for (size_t i = 0; i < chests.size(); ++i) {
|
||||||
|
const auto& chest = chests[i];
|
||||||
|
std::cout << absl::StrFormat(" - #%zu Item 0x%02X %s\n", i,
|
||||||
|
chest.id,
|
||||||
|
chest.size ? "(big)" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return absl::OkStatus();
|
||||||
|
}
|
||||||
|
|
||||||
absl::Status HandleOverworldFindTileCommand(
|
absl::Status HandleOverworldFindTileCommand(
|
||||||
const std::vector<std::string>& arg_vec, Rom* rom_context) {
|
const std::vector<std::string>& arg_vec, Rom* rom_context) {
|
||||||
std::optional<std::string> tile_value;
|
std::optional<std::string> tile_value;
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
#include "absl/strings/str_cat.h"
|
#include "absl/strings/str_cat.h"
|
||||||
#include "absl/strings/str_format.h"
|
#include "absl/strings/str_format.h"
|
||||||
#include "absl/strings/str_join.h"
|
#include "absl/strings/str_join.h"
|
||||||
|
#include "absl/strings/string_view.h"
|
||||||
#include "absl/time/clock.h"
|
#include "absl/time/clock.h"
|
||||||
|
#include "absl/time/time.h"
|
||||||
#include "app/rom.h"
|
#include "app/rom.h"
|
||||||
#include "cli/service/agent/proposal_executor.h"
|
#include "cli/service/agent/proposal_executor.h"
|
||||||
#include "cli/service/ai/service_factory.h"
|
#include "cli/service/ai/service_factory.h"
|
||||||
@@ -132,6 +134,20 @@ std::optional<ChatMessage::TableData> BuildTableData(const nlohmann::json& data)
|
|||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsExecutableCommand(absl::string_view command) {
|
||||||
|
return !command.empty() && command.front() != '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
int CountExecutableCommands(const std::vector<std::string>& commands) {
|
||||||
|
int count = 0;
|
||||||
|
for (const auto& command : commands) {
|
||||||
|
if (IsExecutableCommand(command)) {
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
ChatMessage CreateMessage(ChatMessage::Sender sender, const std::string& content) {
|
ChatMessage CreateMessage(ChatMessage::Sender sender, const std::string& content) {
|
||||||
ChatMessage message;
|
ChatMessage message;
|
||||||
message.sender = sender;
|
message.sender = sender;
|
||||||
@@ -175,6 +191,38 @@ void ConversationalAgentService::SetRomContext(Rom* rom) {
|
|||||||
|
|
||||||
void ConversationalAgentService::ResetConversation() {
|
void ConversationalAgentService::ResetConversation() {
|
||||||
history_.clear();
|
history_.clear();
|
||||||
|
metrics_ = InternalMetrics{};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConversationalAgentService::TrimHistoryIfNeeded() {
|
||||||
|
if (!config_.trim_history || config_.max_history_messages == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (history_.size() > config_.max_history_messages) {
|
||||||
|
history_.erase(history_.begin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatMessage::SessionMetrics ConversationalAgentService::BuildMetricsSnapshot() const {
|
||||||
|
ChatMessage::SessionMetrics snapshot;
|
||||||
|
snapshot.turn_index = metrics_.turns_completed;
|
||||||
|
snapshot.total_user_messages = metrics_.user_messages;
|
||||||
|
snapshot.total_agent_messages = metrics_.agent_messages;
|
||||||
|
snapshot.total_tool_calls = metrics_.tool_calls;
|
||||||
|
snapshot.total_commands = metrics_.commands_generated;
|
||||||
|
snapshot.total_proposals = metrics_.proposals_created;
|
||||||
|
snapshot.total_elapsed_seconds = absl::ToDoubleSeconds(metrics_.total_latency);
|
||||||
|
snapshot.average_latency_seconds =
|
||||||
|
metrics_.turns_completed > 0
|
||||||
|
? snapshot.total_elapsed_seconds /
|
||||||
|
static_cast<double>(metrics_.turns_completed)
|
||||||
|
: 0.0;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatMessage::SessionMetrics ConversationalAgentService::GetMetrics() const {
|
||||||
|
return BuildMetricsSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
||||||
@@ -186,10 +234,13 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
|||||||
|
|
||||||
if (!message.empty()) {
|
if (!message.empty()) {
|
||||||
history_.push_back(CreateMessage(ChatMessage::Sender::kUser, message));
|
history_.push_back(CreateMessage(ChatMessage::Sender::kUser, message));
|
||||||
|
TrimHistoryIfNeeded();
|
||||||
|
++metrics_.user_messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int max_iterations = config_.max_tool_iterations;
|
const int max_iterations = config_.max_tool_iterations;
|
||||||
bool waiting_for_text_response = false;
|
bool waiting_for_text_response = false;
|
||||||
|
absl::Time turn_start = absl::Now();
|
||||||
|
|
||||||
if (config_.verbose) {
|
if (config_.verbose) {
|
||||||
util::PrintInfo(absl::StrCat("Starting agent loop (max ", max_iterations, " iterations)"));
|
util::PrintInfo(absl::StrCat("Starting agent loop (max ", max_iterations, " iterations)"));
|
||||||
@@ -269,6 +320,7 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
|||||||
const std::string& tool_output = tool_result_or.value();
|
const std::string& tool_output = tool_result_or.value();
|
||||||
if (!tool_output.empty()) {
|
if (!tool_output.empty()) {
|
||||||
util::PrintSuccess("Tool executed successfully");
|
util::PrintSuccess("Tool executed successfully");
|
||||||
|
++metrics_.tool_calls;
|
||||||
|
|
||||||
if (config_.verbose) {
|
if (config_.verbose) {
|
||||||
std::cout << util::colors::kDim << "Tool output (truncated):"
|
std::cout << util::colors::kDim << "Tool output (truncated):"
|
||||||
@@ -358,6 +410,8 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
|||||||
response_text.append("Reasoning: ");
|
response_text.append("Reasoning: ");
|
||||||
response_text.append(agent_response.reasoning);
|
response_text.append(agent_response.reasoning);
|
||||||
}
|
}
|
||||||
|
const int executable_commands =
|
||||||
|
CountExecutableCommands(agent_response.commands);
|
||||||
if (!agent_response.commands.empty()) {
|
if (!agent_response.commands.empty()) {
|
||||||
if (!response_text.empty()) {
|
if (!response_text.empty()) {
|
||||||
response_text.append("\n\n");
|
response_text.append("\n\n");
|
||||||
@@ -365,6 +419,7 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
|||||||
response_text.append("Commands:\n");
|
response_text.append("Commands:\n");
|
||||||
response_text.append(absl::StrJoin(agent_response.commands, "\n"));
|
response_text.append(absl::StrJoin(agent_response.commands, "\n"));
|
||||||
}
|
}
|
||||||
|
metrics_.commands_generated += executable_commands;
|
||||||
|
|
||||||
if (proposal_result.has_value()) {
|
if (proposal_result.has_value()) {
|
||||||
const auto& metadata = proposal_result->metadata;
|
const auto& metadata = proposal_result->metadata;
|
||||||
@@ -381,6 +436,7 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
|||||||
proposal_result->executed_commands == 1 ? "" : "s",
|
proposal_result->executed_commands == 1 ? "" : "s",
|
||||||
metadata.id, metadata.sandbox_rom_path.string(),
|
metadata.id, metadata.sandbox_rom_path.string(),
|
||||||
proposal_result->proposal_json_path.string()));
|
proposal_result->proposal_json_path.string()));
|
||||||
|
++metrics_.proposals_created;
|
||||||
} else if (attempted_proposal && !proposal_status.ok()) {
|
} else if (attempted_proposal && !proposal_status.ok()) {
|
||||||
if (!response_text.empty()) {
|
if (!response_text.empty()) {
|
||||||
response_text.append("\n\n");
|
response_text.append("\n\n");
|
||||||
@@ -392,7 +448,12 @@ absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
|||||||
|
|
||||||
ChatMessage chat_response =
|
ChatMessage chat_response =
|
||||||
CreateMessage(ChatMessage::Sender::kAgent, response_text);
|
CreateMessage(ChatMessage::Sender::kAgent, response_text);
|
||||||
|
++metrics_.agent_messages;
|
||||||
|
++metrics_.turns_completed;
|
||||||
|
metrics_.total_latency += absl::Now() - turn_start;
|
||||||
|
chat_response.metrics = BuildMetricsSnapshot();
|
||||||
history_.push_back(chat_response);
|
history_.push_back(chat_response);
|
||||||
|
TrimHistoryIfNeeded();
|
||||||
return chat_response;
|
return chat_response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "absl/status/statusor.h"
|
#include "absl/status/statusor.h"
|
||||||
|
#include "absl/time/time.h"
|
||||||
#include "cli/service/ai/ai_service.h"
|
#include "cli/service/ai/ai_service.h"
|
||||||
#include "cli/service/agent/tool_dispatcher.h"
|
#include "cli/service/agent/tool_dispatcher.h"
|
||||||
|
|
||||||
@@ -27,6 +28,17 @@ struct ChatMessage {
|
|||||||
absl::Time timestamp;
|
absl::Time timestamp;
|
||||||
std::optional<std::string> json_pretty;
|
std::optional<std::string> json_pretty;
|
||||||
std::optional<TableData> table_data;
|
std::optional<TableData> table_data;
|
||||||
|
struct SessionMetrics {
|
||||||
|
int turn_index = 0;
|
||||||
|
int total_user_messages = 0;
|
||||||
|
int total_agent_messages = 0;
|
||||||
|
int total_tool_calls = 0;
|
||||||
|
int total_commands = 0;
|
||||||
|
int total_proposals = 0;
|
||||||
|
double total_elapsed_seconds = 0.0;
|
||||||
|
double average_latency_seconds = 0.0;
|
||||||
|
};
|
||||||
|
std::optional<SessionMetrics> metrics;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct AgentConfig {
|
struct AgentConfig {
|
||||||
@@ -34,6 +46,8 @@ struct AgentConfig {
|
|||||||
int max_retry_attempts = 3; // Maximum retries on errors
|
int max_retry_attempts = 3; // Maximum retries on errors
|
||||||
bool verbose = false; // Enable verbose diagnostic output
|
bool verbose = false; // Enable verbose diagnostic output
|
||||||
bool show_reasoning = true; // Show LLM reasoning in output
|
bool show_reasoning = true; // Show LLM reasoning in output
|
||||||
|
size_t max_history_messages = 50; // Maximum stored history messages per session
|
||||||
|
bool trim_history = true; // Whether to trim history beyond the limit
|
||||||
};
|
};
|
||||||
|
|
||||||
class ConversationalAgentService {
|
class ConversationalAgentService {
|
||||||
@@ -57,12 +71,28 @@ class ConversationalAgentService {
|
|||||||
void SetConfig(const AgentConfig& config) { config_ = config; }
|
void SetConfig(const AgentConfig& config) { config_ = config; }
|
||||||
const AgentConfig& GetConfig() const { return config_; }
|
const AgentConfig& GetConfig() const { return config_; }
|
||||||
|
|
||||||
|
ChatMessage::SessionMetrics GetMetrics() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct InternalMetrics {
|
||||||
|
int user_messages = 0;
|
||||||
|
int agent_messages = 0;
|
||||||
|
int tool_calls = 0;
|
||||||
|
int commands_generated = 0;
|
||||||
|
int proposals_created = 0;
|
||||||
|
int turns_completed = 0;
|
||||||
|
absl::Duration total_latency = absl::ZeroDuration();
|
||||||
|
};
|
||||||
|
|
||||||
|
void TrimHistoryIfNeeded();
|
||||||
|
ChatMessage::SessionMetrics BuildMetricsSnapshot() const;
|
||||||
|
|
||||||
std::vector<ChatMessage> history_;
|
std::vector<ChatMessage> history_;
|
||||||
std::unique_ptr<AIService> ai_service_;
|
std::unique_ptr<AIService> ai_service_;
|
||||||
ToolDispatcher tool_dispatcher_;
|
ToolDispatcher tool_dispatcher_;
|
||||||
Rom* rom_context_ = nullptr;
|
Rom* rom_context_ = nullptr;
|
||||||
AgentConfig config_;
|
AgentConfig config_;
|
||||||
|
InternalMetrics metrics_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace agent
|
} // namespace agent
|
||||||
|
|||||||
@@ -80,6 +80,20 @@ void SimpleChatSession::PrintMessage(const ChatMessage& msg, bool show_timestamp
|
|||||||
} else {
|
} else {
|
||||||
std::cout << msg.message << "\n";
|
std::cout << msg.message << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.metrics.has_value()) {
|
||||||
|
const auto& metrics = msg.metrics.value();
|
||||||
|
std::cout << " 📊 Turn " << metrics.turn_index
|
||||||
|
<< " summary — users: " << metrics.total_user_messages
|
||||||
|
<< ", agents: " << metrics.total_agent_messages
|
||||||
|
<< ", tools: " << metrics.total_tool_calls
|
||||||
|
<< ", commands: " << metrics.total_commands
|
||||||
|
<< ", proposals: " << metrics.total_proposals
|
||||||
|
<< ", elapsed: "
|
||||||
|
<< absl::StrFormat("%.2fs avg %.2fs", metrics.total_elapsed_seconds,
|
||||||
|
metrics.average_latency_seconds)
|
||||||
|
<< "\n";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
absl::Status SimpleChatSession::SendAndWaitForResponse(
|
absl::Status SimpleChatSession::SendAndWaitForResponse(
|
||||||
@@ -142,6 +156,18 @@ absl::Status SimpleChatSession::RunInteractive() {
|
|||||||
PrintMessage(result.value(), false);
|
PrintMessage(result.value(), false);
|
||||||
std::cout << "\n";
|
std::cout << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto metrics = agent_service_.GetMetrics();
|
||||||
|
std::cout << "Session totals — turns: " << metrics.turn_index
|
||||||
|
<< ", user messages: " << metrics.total_user_messages
|
||||||
|
<< ", agent messages: " << metrics.total_agent_messages
|
||||||
|
<< ", tool calls: " << metrics.total_tool_calls
|
||||||
|
<< ", commands: " << metrics.total_commands
|
||||||
|
<< ", proposals: " << metrics.total_proposals
|
||||||
|
<< ", elapsed: "
|
||||||
|
<< absl::StrFormat("%.2fs avg %.2fs\n\n",
|
||||||
|
metrics.total_elapsed_seconds,
|
||||||
|
metrics.average_latency_seconds);
|
||||||
|
|
||||||
return absl::OkStatus();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
@@ -175,6 +201,18 @@ absl::Status SimpleChatSession::RunBatch(const std::string& input_file) {
|
|||||||
PrintMessage(result.value(), false);
|
PrintMessage(result.value(), false);
|
||||||
std::cout << "\n";
|
std::cout << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto metrics = agent_service_.GetMetrics();
|
||||||
|
std::cout << "Batch session totals — turns: " << metrics.turn_index
|
||||||
|
<< ", user messages: " << metrics.total_user_messages
|
||||||
|
<< ", agent messages: " << metrics.total_agent_messages
|
||||||
|
<< ", tool calls: " << metrics.total_tool_calls
|
||||||
|
<< ", commands: " << metrics.total_commands
|
||||||
|
<< ", proposals: " << metrics.total_proposals
|
||||||
|
<< ", elapsed: "
|
||||||
|
<< absl::StrFormat("%.2fs avg %.2fs\n\n",
|
||||||
|
metrics.total_elapsed_seconds,
|
||||||
|
metrics.average_latency_seconds);
|
||||||
|
|
||||||
return absl::OkStatus();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ absl::StatusOr<std::string> ToolDispatcher::Dispatch(
|
|||||||
absl::Status status;
|
absl::Status status;
|
||||||
if (tool_call.tool_name == "resource-list") {
|
if (tool_call.tool_name == "resource-list") {
|
||||||
status = HandleResourceListCommand(args, rom_context_);
|
status = HandleResourceListCommand(args, rom_context_);
|
||||||
|
} else if (tool_call.tool_name == "resource-search") {
|
||||||
|
status = HandleResourceSearchCommand(args, rom_context_);
|
||||||
} else if (tool_call.tool_name == "dungeon-list-sprites") {
|
} else if (tool_call.tool_name == "dungeon-list-sprites") {
|
||||||
status = HandleDungeonListSpritesCommand(args, rom_context_);
|
status = HandleDungeonListSpritesCommand(args, rom_context_);
|
||||||
|
} else if (tool_call.tool_name == "dungeon-describe-room") {
|
||||||
|
status = HandleDungeonDescribeRoomCommand(args, rom_context_);
|
||||||
} else if (tool_call.tool_name == "overworld-find-tile") {
|
} else if (tool_call.tool_name == "overworld-find-tile") {
|
||||||
status = HandleOverworldFindTileCommand(args, rom_context_);
|
status = HandleOverworldFindTileCommand(args, rom_context_);
|
||||||
} else if (tool_call.tool_name == "overworld-describe-map") {
|
} else if (tool_call.tool_name == "overworld-describe-map") {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
#include "absl/strings/ascii.h"
|
#include "absl/strings/ascii.h"
|
||||||
#include "absl/strings/match.h"
|
#include "absl/strings/match.h"
|
||||||
@@ -51,6 +52,38 @@ std::string ExtractRoomId(const std::string& normalized_prompt) {
|
|||||||
return "0x000";
|
return "0x000";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string ExtractKeyword(const std::string& normalized_prompt) {
|
||||||
|
static const char* kStopwords[] = {
|
||||||
|
"search", "for", "resource", "resources", "label", "labels",
|
||||||
|
"please", "the", "a", "an", "list", "of", "in", "find"};
|
||||||
|
|
||||||
|
auto is_stopword = [](const std::string& word) {
|
||||||
|
for (const char* stop : kStopwords) {
|
||||||
|
if (word == stop) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::istringstream stream(normalized_prompt);
|
||||||
|
std::string token;
|
||||||
|
while (stream >> token) {
|
||||||
|
token.erase(std::remove_if(token.begin(), token.end(), [](unsigned char c) {
|
||||||
|
return !std::isalnum(c) && c != '_' && c != '-';
|
||||||
|
}),
|
||||||
|
token.end());
|
||||||
|
if (token.empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is_stopword(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
absl::StatusOr<AgentResponse> MockAIService::GenerateResponse(
|
absl::StatusOr<AgentResponse> MockAIService::GenerateResponse(
|
||||||
@@ -96,6 +129,20 @@ absl::StatusOr<AgentResponse> MockAIService::GenerateResponse(
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (absl::StrContains(normalized, "search") &&
|
||||||
|
(absl::StrContains(normalized, "resource") ||
|
||||||
|
absl::StrContains(normalized, "label"))) {
|
||||||
|
ToolCall call;
|
||||||
|
call.tool_name = "resource-search";
|
||||||
|
call.args.emplace("query", ExtractKeyword(normalized));
|
||||||
|
response.text_response =
|
||||||
|
"Let me look through the labelled resources for matches.";
|
||||||
|
response.reasoning =
|
||||||
|
"Resource search provides fuzzy matching against the ROM label catalogue.";
|
||||||
|
response.tool_calls.push_back(call);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
if (absl::StrContains(normalized, "sprite") &&
|
if (absl::StrContains(normalized, "sprite") &&
|
||||||
absl::StrContains(normalized, "room")) {
|
absl::StrContains(normalized, "room")) {
|
||||||
ToolCall call;
|
ToolCall call;
|
||||||
@@ -109,6 +156,19 @@ absl::StatusOr<AgentResponse> MockAIService::GenerateResponse(
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (absl::StrContains(normalized, "describe") &&
|
||||||
|
absl::StrContains(normalized, "room")) {
|
||||||
|
ToolCall call;
|
||||||
|
call.tool_name = "dungeon-describe-room";
|
||||||
|
call.args.emplace("room", ExtractRoomId(normalized));
|
||||||
|
response.text_response =
|
||||||
|
"I'll summarize the room's metadata and hazards.";
|
||||||
|
response.reasoning =
|
||||||
|
"Room description tool surfaces lighting, effects, and object counts before planning edits.";
|
||||||
|
response.tool_calls.push_back(call);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
response.text_response =
|
response.text_response =
|
||||||
"I'm just a mock service. Please load a provider like ollama or gemini.";
|
"I'm just a mock service. Please load a provider like ollama or gemini.";
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -123,11 +123,13 @@ void GeminiAIService::EnableFunctionCalling(bool enable) {
|
|||||||
|
|
||||||
std::vector<std::string> GeminiAIService::GetAvailableTools() const {
|
std::vector<std::string> GeminiAIService::GetAvailableTools() const {
|
||||||
return {
|
return {
|
||||||
"resource_list",
|
"resource-list",
|
||||||
"dungeon_list_sprites",
|
"resource-search",
|
||||||
"overworld_find_tile",
|
"dungeon-list-sprites",
|
||||||
"overworld_describe_map",
|
"dungeon-describe-room",
|
||||||
"overworld_list_warps"
|
"overworld-find-tile",
|
||||||
|
"overworld-describe-map",
|
||||||
|
"overworld-list-warps"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
#include "cli/tui/chat_tui.h"
|
#include "cli/tui/chat_tui.h"
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "absl/strings/str_cat.h"
|
||||||
|
#include "absl/strings/str_format.h"
|
||||||
#include "ftxui/component/captured_mouse.hpp"
|
#include "ftxui/component/captured_mouse.hpp"
|
||||||
#include "ftxui/component/component.hpp"
|
#include "ftxui/component/component.hpp"
|
||||||
#include "ftxui/component/component_base.hpp"
|
#include "ftxui/component/component_base.hpp"
|
||||||
|
#include "ftxui/component/event.hpp"
|
||||||
#include "ftxui/component/screen_interactive.hpp"
|
#include "ftxui/component/screen_interactive.hpp"
|
||||||
#include "ftxui/dom/elements.hpp"
|
#include "ftxui/dom/elements.hpp"
|
||||||
#include "ftxui/dom/table.hpp"
|
#include "ftxui/dom/table.hpp"
|
||||||
@@ -27,18 +31,24 @@ void ChatTUI::SetRomContext(Rom* rom_context) {
|
|||||||
|
|
||||||
void ChatTUI::Run() {
|
void ChatTUI::Run() {
|
||||||
auto input = Input(&input_message_, "Enter your message...");
|
auto input = Input(&input_message_, "Enter your message...");
|
||||||
auto button = Button("Send", [this] { OnSubmit(); });
|
input = CatchEvent(input, [this](Event event) {
|
||||||
|
if (event == Event::Return) {
|
||||||
auto layout = Container::Vertical({
|
OnSubmit();
|
||||||
input,
|
return true;
|
||||||
button,
|
}
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
auto renderer = Renderer(layout, [this] {
|
auto button = Button("Send", [this] { OnSubmit(); });
|
||||||
std::vector<Element> messages;
|
|
||||||
messages.reserve(agent_service_.GetHistory().size());
|
|
||||||
|
|
||||||
|
auto controls = Container::Horizontal({input, button});
|
||||||
|
auto layout = Container::Vertical({controls});
|
||||||
|
|
||||||
|
auto renderer = Renderer(layout, [this, input, button] {
|
||||||
|
Elements message_blocks;
|
||||||
const auto& history = agent_service_.GetHistory();
|
const auto& history = agent_service_.GetHistory();
|
||||||
|
message_blocks.reserve(history.size());
|
||||||
|
|
||||||
for (const auto& msg : history) {
|
for (const auto& msg : history) {
|
||||||
Element header = text(msg.sender == agent::ChatMessage::Sender::kUser
|
Element header = text(msg.sender == agent::ChatMessage::Sender::kUser
|
||||||
? "You"
|
? "You"
|
||||||
@@ -71,15 +81,56 @@ void ChatTUI::Run() {
|
|||||||
body = paragraph(msg.message);
|
body = paragraph(msg.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push_back(vbox({header, hbox({text(" "), body}), separator()}));
|
Elements block = {header, hbox({text(" "), body})};
|
||||||
|
if (msg.metrics.has_value()) {
|
||||||
|
const auto& metrics = msg.metrics.value();
|
||||||
|
block.push_back(text(absl::StrFormat(
|
||||||
|
" 📊 Turn %d — users:%d agents:%d tools:%d commands:%d proposals:%d elapsed %.2fs avg %.2fs",
|
||||||
|
metrics.turn_index, metrics.total_user_messages,
|
||||||
|
metrics.total_agent_messages, metrics.total_tool_calls,
|
||||||
|
metrics.total_commands, metrics.total_proposals,
|
||||||
|
metrics.total_elapsed_seconds,
|
||||||
|
metrics.average_latency_seconds)) |
|
||||||
|
color(Color::Cyan));
|
||||||
|
}
|
||||||
|
block.push_back(separator());
|
||||||
|
message_blocks.push_back(vbox(block));
|
||||||
}
|
}
|
||||||
|
|
||||||
return vbox({
|
if (message_blocks.empty()) {
|
||||||
vbox(messages) | flex,
|
message_blocks.push_back(text("No messages yet. Start chatting!") | dim);
|
||||||
separator(),
|
}
|
||||||
hbox(text(" > "), text(input_message_)),
|
|
||||||
}) |
|
const auto metrics = agent_service_.GetMetrics();
|
||||||
border;
|
Element metrics_bar = text(absl::StrFormat(
|
||||||
|
"Turns:%d Users:%d Agents:%d Tools:%d Commands:%d Proposals:%d Elapsed:%.2fs avg %.2fs",
|
||||||
|
metrics.turn_index, metrics.total_user_messages,
|
||||||
|
metrics.total_agent_messages, metrics.total_tool_calls,
|
||||||
|
metrics.total_commands, metrics.total_proposals,
|
||||||
|
metrics.total_elapsed_seconds, metrics.average_latency_seconds)) |
|
||||||
|
color(Color::Cyan);
|
||||||
|
|
||||||
|
Elements content{
|
||||||
|
vbox(message_blocks) | flex | frame,
|
||||||
|
separator(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (last_error_.has_value()) {
|
||||||
|
content.push_back(text(absl::StrCat("⚠ ", *last_error_)) |
|
||||||
|
color(Color::Red));
|
||||||
|
content.push_back(separator());
|
||||||
|
}
|
||||||
|
|
||||||
|
content.push_back(metrics_bar);
|
||||||
|
content.push_back(separator());
|
||||||
|
content.push_back(hbox({
|
||||||
|
text("You: ") | bold,
|
||||||
|
input->Render() | flex,
|
||||||
|
text(" "),
|
||||||
|
button->Render(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return vbox(content) | border;
|
||||||
});
|
});
|
||||||
|
|
||||||
screen_.Loop(renderer);
|
screen_.Loop(renderer);
|
||||||
@@ -90,7 +141,12 @@ void ChatTUI::OnSubmit() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(void)agent_service_.SendMessage(input_message_);
|
auto response = agent_service_.SendMessage(input_message_);
|
||||||
|
if (!response.ok()) {
|
||||||
|
last_error_ = response.status().message();
|
||||||
|
} else {
|
||||||
|
last_error_.reset();
|
||||||
|
}
|
||||||
input_message_.clear();
|
input_message_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#ifndef YAZE_SRC_CLI_TUI_CHAT_TUI_H_
|
#ifndef YAZE_SRC_CLI_TUI_CHAT_TUI_H_
|
||||||
#define YAZE_SRC_CLI_TUI_CHAT_TUI_H_
|
#define YAZE_SRC_CLI_TUI_CHAT_TUI_H_
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
#include "ftxui/component/component.hpp"
|
#include "ftxui/component/component.hpp"
|
||||||
#include "ftxui/component/screen_interactive.hpp"
|
#include "ftxui/component/screen_interactive.hpp"
|
||||||
#include "cli/service/agent/conversational_agent_service.h"
|
#include "cli/service/agent/conversational_agent_service.h"
|
||||||
@@ -19,13 +21,13 @@ class ChatTUI {
|
|||||||
void SetRomContext(Rom* rom_context);
|
void SetRomContext(Rom* rom_context);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void Render();
|
|
||||||
void OnSubmit();
|
void OnSubmit();
|
||||||
|
|
||||||
ftxui::ScreenInteractive screen_ = ftxui::ScreenInteractive::Fullscreen();
|
ftxui::ScreenInteractive screen_ = ftxui::ScreenInteractive::Fullscreen();
|
||||||
std::string input_message_;
|
std::string input_message_;
|
||||||
agent::ConversationalAgentService agent_service_;
|
agent::ConversationalAgentService agent_service_;
|
||||||
Rom* rom_context_ = nullptr;
|
Rom* rom_context_ = nullptr;
|
||||||
|
std::optional<std::string> last_error_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace tui
|
} // namespace tui
|
||||||
|
|||||||
Reference in New Issue
Block a user