feat: Implement Ollama AI service integration with health checks and command generation
This commit is contained in:
@@ -19,6 +19,8 @@
|
||||
#include "cli/handlers/agent/common.h"
|
||||
#include "cli/modern_cli.h"
|
||||
#include "cli/service/ai_service.h"
|
||||
#include "cli/service/ollama_ai_service.h"
|
||||
#include "cli/service/gemini_ai_service.h"
|
||||
#include "cli/service/proposal_registry.h"
|
||||
#include "cli/service/resource_catalog.h"
|
||||
#include "cli/service/rom_sandbox_manager.h"
|
||||
@@ -34,6 +36,48 @@ namespace agent {
|
||||
|
||||
namespace {
|
||||
|
||||
// Helper: Select AI service based on environment variables
|
||||
std::unique_ptr<AIService> CreateAIService() {
|
||||
// Priority: Ollama (local) > Gemini (remote) > Mock (testing)
|
||||
|
||||
const char* provider_env = std::getenv("YAZE_AI_PROVIDER");
|
||||
const char* gemini_key = std::getenv("GEMINI_API_KEY");
|
||||
const char* ollama_model = std::getenv("OLLAMA_MODEL");
|
||||
|
||||
// Explicit provider selection
|
||||
if (provider_env && std::string(provider_env) == "ollama") {
|
||||
OllamaConfig config;
|
||||
|
||||
// Allow model override via env
|
||||
if (ollama_model && std::strlen(ollama_model) > 0) {
|
||||
config.model = ollama_model;
|
||||
}
|
||||
|
||||
auto service = std::make_unique<OllamaAIService>(config);
|
||||
|
||||
// Health check
|
||||
if (auto status = service->CheckAvailability(); !status.ok()) {
|
||||
std::cerr << "⚠️ Ollama unavailable: " << status.message() << std::endl;
|
||||
std::cerr << " Falling back to MockAIService" << std::endl;
|
||||
return std::make_unique<MockAIService>();
|
||||
}
|
||||
|
||||
std::cout << "🤖 Using Ollama AI with model: " << config.model << std::endl;
|
||||
return service;
|
||||
}
|
||||
|
||||
// Gemini if API key provided
|
||||
if (gemini_key && std::strlen(gemini_key) > 0) {
|
||||
std::cout << "🤖 Using Gemini AI (remote)" << std::endl;
|
||||
return std::make_unique<GeminiAIService>(gemini_key);
|
||||
}
|
||||
|
||||
// Default: Mock service for testing
|
||||
std::cout << "🤖 Using MockAIService (no LLM configured)" << std::endl;
|
||||
std::cout << " Tip: Set YAZE_AI_PROVIDER=ollama or GEMINI_API_KEY to enable LLM" << std::endl;
|
||||
return std::make_unique<MockAIService>();
|
||||
}
|
||||
|
||||
struct DescribeOptions {
|
||||
std::optional<std::string> resource;
|
||||
std::string format = "json";
|
||||
@@ -141,8 +185,8 @@ absl::Status HandleRunCommand(const std::vector<std::string>& arg_vec,
|
||||
RETURN_IF_ERROR(ProposalRegistry::Instance().AppendLog(
|
||||
proposal.id, absl::StrCat("Starting agent run with prompt: ", prompt)));
|
||||
|
||||
MockAIService ai_service;
|
||||
auto commands_or = ai_service.GetCommands(prompt);
|
||||
auto ai_service = CreateAIService(); // Use service factory
|
||||
auto commands_or = ai_service->GetCommands(prompt);
|
||||
if (!commands_or.ok()) {
|
||||
RETURN_IF_ERROR(ProposalRegistry::Instance().AppendLog(
|
||||
proposal.id,
|
||||
@@ -225,8 +269,9 @@ absl::Status HandlePlanCommand(const std::vector<std::string>& arg_vec) {
|
||||
return absl::InvalidArgumentError("Usage: agent plan --prompt <prompt>");
|
||||
}
|
||||
std::string prompt = arg_vec[1];
|
||||
MockAIService ai_service;
|
||||
auto commands_or = ai_service.GetCommands(prompt);
|
||||
|
||||
auto ai_service = CreateAIService(); // Use service factory
|
||||
auto commands_or = ai_service->GetCommands(prompt);
|
||||
if (!commands_or.ok()) {
|
||||
return commands_or.status();
|
||||
}
|
||||
|
||||
292
src/cli/service/ollama_ai_service.cc
Normal file
292
src/cli/service/ollama_ai_service.cc
Normal file
@@ -0,0 +1,292 @@
|
||||
#include "cli/service/ollama_ai_service.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "absl/strings/str_cat.h"
|
||||
#include "absl/strings/str_format.h"
|
||||
|
||||
// Check if we have httplib available (from vcpkg or bundled)
|
||||
#if __has_include("httplib.h")
|
||||
#define YAZE_HAS_HTTPLIB 1
|
||||
#include "httplib.h"
|
||||
#elif __has_include("incl/httplib.h")
|
||||
#define YAZE_HAS_HTTPLIB 1
|
||||
#include "incl/httplib.h"
|
||||
#else
|
||||
#define YAZE_HAS_HTTPLIB 0
|
||||
#endif
|
||||
|
||||
// Check if we have JSON library available
|
||||
#if __has_include("third_party/json/src/json.hpp")
|
||||
#define YAZE_HAS_JSON 1
|
||||
#include "third_party/json/src/json.hpp"
|
||||
#elif __has_include("json.hpp")
|
||||
#define YAZE_HAS_JSON 1
|
||||
#include "json.hpp"
|
||||
#else
|
||||
#define YAZE_HAS_JSON 0
|
||||
#endif
|
||||
|
||||
namespace yaze {
|
||||
namespace cli {
|
||||
|
||||
OllamaAIService::OllamaAIService(const OllamaConfig& config) : config_(config) {
|
||||
if (config_.system_prompt.empty()) {
|
||||
config_.system_prompt = BuildSystemPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
std::string OllamaAIService::BuildSystemPrompt() {
|
||||
// TODO: Eventually load from docs/api/z3ed-resources.yaml for full command catalogue
|
||||
// For now, use a comprehensive hardcoded prompt
|
||||
return R"(You are an expert ROM hacking assistant for The Legend of Zelda: A Link to the Past.
|
||||
Your role is to generate PRECISE z3ed CLI commands to fulfill user requests.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Output ONLY a JSON array of command strings
|
||||
2. Each command must follow exact z3ed syntax
|
||||
3. Commands must be executable without modification
|
||||
4. Use only commands from the available command set
|
||||
5. Include all required arguments with proper flags
|
||||
|
||||
AVAILABLE COMMANDS:
|
||||
- rom info --rom <path>
|
||||
- rom validate --rom <path>
|
||||
- rom diff --rom1 <path1> --rom2 <path2>
|
||||
- palette export --group <group> --id <id> --to <file>
|
||||
- palette import --group <group> --id <id> --from <file>
|
||||
- palette set-color --file <file> --index <index> --color <hex_color>
|
||||
- overworld get-tile --map <map_id> --x <x> --y <y>
|
||||
- overworld set-tile --map <map_id> --x <x> --y <y> --tile <tile_id>
|
||||
- dungeon export-room --room <room_id> --to <file>
|
||||
- dungeon import-room --room <room_id> --from <file>
|
||||
|
||||
RESPONSE FORMAT:
|
||||
["command1", "command2", "command3"]
|
||||
|
||||
EXAMPLE 1:
|
||||
User: "Validate the ROM"
|
||||
Response: ["rom validate --rom zelda3.sfc"]
|
||||
|
||||
EXAMPLE 2:
|
||||
User: "Make all soldier armors red"
|
||||
Response: ["palette export --group sprites --id soldier --to /tmp/soldier.pal", "palette set-color --file /tmp/soldier.pal --index 5 --color FF0000", "palette import --group sprites --id soldier --from /tmp/soldier.pal"]
|
||||
|
||||
EXAMPLE 3:
|
||||
User: "Export the first overworld palette"
|
||||
Response: ["palette export --group overworld --id 0 --to /tmp/ow_pal_0.pal"]
|
||||
|
||||
Begin your response now.)";
|
||||
}
|
||||
|
||||
absl::Status OllamaAIService::CheckAvailability() {
|
||||
#if !YAZE_HAS_HTTPLIB || !YAZE_HAS_JSON
|
||||
return absl::UnimplementedError(
|
||||
"Ollama service requires httplib and JSON support. "
|
||||
"Install vcpkg dependencies or use bundled libraries.");
|
||||
#else
|
||||
try {
|
||||
httplib::Client cli(config_.base_url);
|
||||
cli.set_connection_timeout(5); // 5 second timeout
|
||||
|
||||
auto res = cli.Get("/api/tags");
|
||||
if (!res) {
|
||||
return absl::UnavailableError(absl::StrFormat(
|
||||
"Cannot connect to Ollama server at %s.\n"
|
||||
"Make sure Ollama is installed and running:\n"
|
||||
" 1. Install: brew install ollama (macOS) or https://ollama.com/download\n"
|
||||
" 2. Start: ollama serve\n"
|
||||
" 3. Verify: curl http://localhost:11434/api/tags",
|
||||
config_.base_url));
|
||||
}
|
||||
|
||||
if (res->status != 200) {
|
||||
return absl::InternalError(absl::StrFormat(
|
||||
"Ollama server error: HTTP %d\nResponse: %s",
|
||||
res->status, res->body));
|
||||
}
|
||||
|
||||
// Check if requested model is available
|
||||
nlohmann::json models_json = nlohmann::json::parse(res->body);
|
||||
bool model_found = false;
|
||||
|
||||
if (models_json.contains("models") && models_json["models"].is_array()) {
|
||||
for (const auto& model : models_json["models"]) {
|
||||
if (model.contains("name")) {
|
||||
std::string model_name = model["name"].get<std::string>();
|
||||
if (model_name.find(config_.model) != std::string::npos) {
|
||||
model_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!model_found) {
|
||||
return absl::NotFoundError(absl::StrFormat(
|
||||
"Model '%s' not found on Ollama server.\n"
|
||||
"Pull it with: ollama pull %s\n"
|
||||
"Available models: ollama list",
|
||||
config_.model, config_.model));
|
||||
}
|
||||
|
||||
return absl::OkStatus();
|
||||
} catch (const std::exception& e) {
|
||||
return absl::InternalError(absl::StrCat(
|
||||
"Ollama health check failed: ", e.what()));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
absl::StatusOr<std::vector<std::string>> OllamaAIService::ListAvailableModels() {
|
||||
#if !YAZE_HAS_HTTPLIB || !YAZE_HAS_JSON
|
||||
return absl::UnimplementedError("Requires httplib and JSON support");
|
||||
#else
|
||||
try {
|
||||
httplib::Client cli(config_.base_url);
|
||||
cli.set_connection_timeout(5);
|
||||
|
||||
auto res = cli.Get("/api/tags");
|
||||
|
||||
if (!res || res->status != 200) {
|
||||
return absl::UnavailableError(
|
||||
"Cannot list Ollama models. Is the server running?");
|
||||
}
|
||||
|
||||
nlohmann::json models_json = nlohmann::json::parse(res->body);
|
||||
std::vector<std::string> models;
|
||||
|
||||
if (models_json.contains("models") && models_json["models"].is_array()) {
|
||||
for (const auto& model : models_json["models"]) {
|
||||
if (model.contains("name")) {
|
||||
models.push_back(model["name"].get<std::string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
} catch (const std::exception& e) {
|
||||
return absl::InternalError(absl::StrCat(
|
||||
"Failed to list models: ", e.what()));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
absl::StatusOr<std::string> OllamaAIService::ParseOllamaResponse(
|
||||
const std::string& json_response) {
|
||||
#if !YAZE_HAS_JSON
|
||||
return absl::UnimplementedError("Requires JSON support");
|
||||
#else
|
||||
try {
|
||||
nlohmann::json response_json = nlohmann::json::parse(json_response);
|
||||
|
||||
if (!response_json.contains("response")) {
|
||||
return absl::InvalidArgumentError(
|
||||
"Ollama response missing 'response' field");
|
||||
}
|
||||
|
||||
return response_json["response"].get<std::string>();
|
||||
} catch (const nlohmann::json::exception& e) {
|
||||
return absl::InternalError(absl::StrCat(
|
||||
"Failed to parse Ollama response: ", e.what()));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
absl::StatusOr<std::vector<std::string>> OllamaAIService::GetCommands(
|
||||
const std::string& prompt) {
|
||||
#if !YAZE_HAS_HTTPLIB || !YAZE_HAS_JSON
|
||||
return absl::UnimplementedError(
|
||||
"Ollama service requires httplib and JSON support. "
|
||||
"Install vcpkg dependencies or use bundled libraries.");
|
||||
#else
|
||||
|
||||
// Build request payload
|
||||
nlohmann::json request_body = {
|
||||
{"model", config_.model},
|
||||
{"prompt", config_.system_prompt + "\n\nUSER REQUEST: " + prompt},
|
||||
{"stream", false},
|
||||
{"options", {
|
||||
{"temperature", config_.temperature},
|
||||
{"num_predict", config_.max_tokens}
|
||||
}},
|
||||
{"format", "json"} // Force JSON output
|
||||
};
|
||||
|
||||
try {
|
||||
httplib::Client cli(config_.base_url);
|
||||
cli.set_read_timeout(60); // Longer timeout for inference
|
||||
|
||||
auto res = cli.Post("/api/generate", request_body.dump(), "application/json");
|
||||
|
||||
if (!res) {
|
||||
return absl::UnavailableError(
|
||||
"Failed to connect to Ollama. Is 'ollama serve' running?\n"
|
||||
"Start with: ollama serve");
|
||||
}
|
||||
|
||||
if (res->status != 200) {
|
||||
return absl::InternalError(absl::StrFormat(
|
||||
"Ollama API error: HTTP %d\nResponse: %s",
|
||||
res->status, res->body));
|
||||
}
|
||||
|
||||
// Parse response to extract generated text
|
||||
auto generated_text_or = ParseOllamaResponse(res->body);
|
||||
if (!generated_text_or.ok()) {
|
||||
return generated_text_or.status();
|
||||
}
|
||||
std::string generated_text = generated_text_or.value();
|
||||
|
||||
// Parse the command array from generated text
|
||||
nlohmann::json commands_json;
|
||||
try {
|
||||
commands_json = nlohmann::json::parse(generated_text);
|
||||
} catch (const nlohmann::json::exception& e) {
|
||||
// Sometimes the LLM includes extra text - try to extract JSON array
|
||||
size_t start = generated_text.find('[');
|
||||
size_t end = generated_text.rfind(']');
|
||||
|
||||
if (start != std::string::npos && end != std::string::npos && end > start) {
|
||||
std::string json_only = generated_text.substr(start, end - start + 1);
|
||||
try {
|
||||
commands_json = nlohmann::json::parse(json_only);
|
||||
} catch (const nlohmann::json::exception&) {
|
||||
return absl::InvalidArgumentError(
|
||||
"LLM did not return valid JSON. Response:\n" + generated_text);
|
||||
}
|
||||
} else {
|
||||
return absl::InvalidArgumentError(
|
||||
"LLM did not return a JSON array. Response:\n" + generated_text);
|
||||
}
|
||||
}
|
||||
|
||||
if (!commands_json.is_array()) {
|
||||
return absl::InvalidArgumentError(
|
||||
"LLM did not return a JSON array. Response:\n" + generated_text);
|
||||
}
|
||||
|
||||
std::vector<std::string> commands;
|
||||
for (const auto& cmd : commands_json) {
|
||||
if (cmd.is_string()) {
|
||||
commands.push_back(cmd.get<std::string>());
|
||||
}
|
||||
}
|
||||
|
||||
if (commands.empty()) {
|
||||
return absl::InvalidArgumentError(
|
||||
"LLM returned empty command list. Prompt may be unclear.\n"
|
||||
"Try rephrasing your request to be more specific.");
|
||||
}
|
||||
|
||||
return commands;
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
return absl::InternalError(absl::StrCat(
|
||||
"Ollama request failed: ", e.what()));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace cli
|
||||
} // namespace yaze
|
||||
50
src/cli/service/ollama_ai_service.h
Normal file
50
src/cli/service/ollama_ai_service.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#ifndef YAZE_SRC_CLI_OLLAMA_AI_SERVICE_H_
|
||||
#define YAZE_SRC_CLI_OLLAMA_AI_SERVICE_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include "cli/service/ai_service.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace cli {
|
||||
|
||||
// Ollama configuration for local LLM inference
|
||||
struct OllamaConfig {
|
||||
std::string base_url = "http://localhost:11434"; // Default Ollama endpoint
|
||||
std::string model = "qwen2.5-coder:7b"; // Recommended for code generation
|
||||
float temperature = 0.1; // Low temp for deterministic commands
|
||||
int max_tokens = 2048; // Sufficient for command lists
|
||||
std::string system_prompt; // Injected from resource catalogue
|
||||
};
|
||||
|
||||
class OllamaAIService : public AIService {
|
||||
public:
|
||||
explicit OllamaAIService(const OllamaConfig& config);
|
||||
|
||||
// Generate z3ed commands from natural language prompt
|
||||
absl::StatusOr<std::vector<std::string>> GetCommands(
|
||||
const std::string& prompt) override;
|
||||
|
||||
// Health check: verify Ollama server is running and model is available
|
||||
absl::Status CheckAvailability();
|
||||
|
||||
// List available models on Ollama server
|
||||
absl::StatusOr<std::vector<std::string>> ListAvailableModels();
|
||||
|
||||
private:
|
||||
OllamaConfig config_;
|
||||
|
||||
// Build system prompt from resource catalogue
|
||||
std::string BuildSystemPrompt();
|
||||
|
||||
// Parse JSON response from Ollama API
|
||||
absl::StatusOr<std::string> ParseOllamaResponse(const std::string& json_response);
|
||||
};
|
||||
|
||||
} // namespace cli
|
||||
} // namespace yaze
|
||||
|
||||
#endif // YAZE_SRC_CLI_OLLAMA_AI_SERVICE_H_
|
||||
@@ -47,6 +47,7 @@ add_executable(
|
||||
cli/handlers/agent/test_commands.cc
|
||||
cli/handlers/agent/gui_commands.cc
|
||||
cli/service/ai_service.cc
|
||||
cli/service/ollama_ai_service.cc
|
||||
cli/service/proposal_registry.cc
|
||||
cli/service/resource_catalog.cc
|
||||
cli/service/rom_sandbox_manager.cc
|
||||
|
||||
Reference in New Issue
Block a user