yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
gemini_ai_service.cc
Go to the documentation of this file.
2
3#include <atomic>
4#include <cstdlib>
5#include <iostream>
6#include <map>
7#include <mutex>
8#include <string>
9#include <vector>
10
11#include "absl/strings/str_cat.h"
12#include "absl/strings/str_format.h"
13#include "absl/strings/str_split.h"
14#include "absl/strings/strip.h"
15#include "absl/time/clock.h"
16#include "absl/time/time.h"
21#include "util/platform_paths.h"
22
23#if defined(__APPLE__)
24#include <TargetConditionals.h>
25#endif
26
27#if defined(__APPLE__) && \
28 (TARGET_OS_IPHONE == 1 || TARGET_IPHONE_SIMULATOR == 1)
30#define YAZE_AI_IOS_URLSESSION 1
31#endif
32
33#ifdef YAZE_WITH_JSON
34#include <filesystem>
35#include <fstream>
36
37#include "httplib.h"
38#include "nlohmann/json.hpp"
39
40// OpenSSL initialization for HTTPS support
41#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
42#include <openssl/crypto.h>
43#include <openssl/err.h>
44#include <openssl/ssl.h>
45
46// Global flag to track OpenSSL initialization
47static std::atomic<bool> g_openssl_initialized{false};
48static std::mutex g_openssl_init_mutex;
49
50static void InitializeOpenSSL() {
51 std::lock_guard<std::mutex> lock(g_openssl_init_mutex);
52 if (!g_openssl_initialized.exchange(true)) {
53 OPENSSL_init_ssl(
54 OPENSSL_INIT_LOAD_SSL_STRINGS | OPENSSL_INIT_LOAD_CRYPTO_STRINGS,
55 nullptr);
56 std::cerr << "✓ OpenSSL initialized for HTTPS support" << std::endl;
57 }
58}
59#endif
60#endif
61
62namespace yaze {
63namespace cli {
64
65namespace {
66
67absl::StatusOr<nlohmann::json> BuildGeminiToolPayload(
68 const PromptBuilder& prompt_builder) {
69 auto declarations_or =
71 if (!declarations_or.ok()) {
72 return declarations_or.status();
73 }
74 return ToolSchemaBuilder::BuildGeminiTools(*declarations_or);
75}
76
77} // namespace
78
79GeminiAIService::GeminiAIService(const GeminiConfig& config)
80 : function_calling_enabled_(config.use_function_calling), config_(config) {
81 if (config_.verbose) {
82 std::cerr << "[DEBUG] Initializing Gemini service..." << std::endl;
83 std::cerr << "[DEBUG] Function calling: "
84 << (function_calling_enabled_ ? "enabled" : "disabled")
85 << std::endl;
86 std::cerr << "[DEBUG] Prompt version: " << config_.prompt_version
87 << std::endl;
88 }
89
90#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
91 // Initialize OpenSSL for HTTPS support
92 InitializeOpenSSL();
93 if (config_.verbose) {
94 std::cerr << "[DEBUG] OpenSSL initialized for HTTPS" << std::endl;
95 }
96#endif
97
98 // Load command documentation into prompt builder with specified version
99 std::string catalogue_path = config_.prompt_version == "v2"
100 ? "assets/agent/prompt_catalogue_v2.yaml"
101 : "assets/agent/prompt_catalogue.yaml";
102 if (auto status = prompt_builder_.LoadResourceCatalogue(catalogue_path);
103 !status.ok()) {
104 std::cerr << "⚠️ Failed to load agent prompt catalogue: "
105 << status.message() << std::endl;
106 }
107
108 if (config_.verbose) {
109 std::cerr << "[DEBUG] Loaded prompt catalogue" << std::endl;
110 }
111
112 if (config_.system_instruction.empty()) {
113 if (config_.verbose) {
114 std::cerr << "[DEBUG] Building system instruction..." << std::endl;
115 }
116
117 // Try to load version-specific system prompt file using FindAsset
118 std::string prompt_file;
119 if (config_.prompt_version == "v3") {
120 prompt_file = "agent/system_prompt_v3.txt";
121 } else if (config_.prompt_version == "v2") {
122 prompt_file = "agent/system_prompt_v2.txt";
123 } else {
124 prompt_file = "agent/system_prompt.txt";
125 }
126
127 auto prompt_path = util::PlatformPaths::FindAsset(prompt_file);
128 bool loaded = false;
129
130 if (prompt_path.ok()) {
131 std::ifstream file(prompt_path->string());
132 if (file.good()) {
133 std::stringstream buffer;
134 buffer << file.rdbuf();
135 config_.system_instruction = buffer.str();
136 if (config_.verbose) {
137 std::cerr << "[DEBUG] Loaded prompt: " << prompt_path->string()
138 << std::endl;
139 }
140 loaded = true;
141 }
142 }
143
144 if (!loaded) {
145 // Fallback to builder
146 if (config_.use_enhanced_prompting) {
147 config_.system_instruction =
148 prompt_builder_.BuildSystemInstructionWithExamples();
149 } else {
150 config_.system_instruction = BuildSystemInstruction();
151 }
152 }
153 }
154
155 if (config_.verbose) {
156 std::cerr << "[DEBUG] Gemini service initialized" << std::endl;
157 }
158}
159
161 function_calling_enabled_ = enable;
162}
163
164std::vector<std::string> GeminiAIService::GetAvailableTools() const {
165 return {"resource-list", "resource-search",
166 "dungeon-list-sprites", "dungeon-describe-room",
167 "overworld-find-tile", "overworld-describe-map",
168 "overworld-list-warps"};
169}
170
171std::string GeminiAIService::BuildSystemInstruction() {
172 // Fallback prompt if enhanced prompting is disabled
173 // Use PromptBuilder's basic system instruction
174 return prompt_builder_.BuildSystemInstruction();
175}
176
177void GeminiAIService::SetRomContext(Rom* rom) {
178 prompt_builder_.SetRom(rom);
179}
180
181absl::StatusOr<std::vector<ModelInfo>> GeminiAIService::ListAvailableModels() {
182#ifndef YAZE_WITH_JSON
183 return absl::UnimplementedError("Gemini AI service requires JSON support");
184#else
185 if (config_.api_key.empty()) {
186 // Return default known models if API key is missing
187 std::vector<ModelInfo> defaults = {
188 {.name = "gemini-3.0-preview",
189 .display_name = "Gemini 3.0 Preview",
190 .provider = kProviderGemini,
191 .description = "Cutting-edge model, currently in preview"},
192 {.name = "gemini-3.0-flash-preview",
193 .display_name = "Gemini 3.0 Flash Preview",
194 .provider = kProviderGemini,
195 .description = "Fastest preview model"},
196 {.name = "gemini-2.5-pro",
197 .display_name = "Gemini 2.5 Pro",
198 .provider = kProviderGemini,
199 .description = "High intelligence for complex tasks"},
200 {.name = "gemini-2.5-flash",
201 .display_name = "Gemini 2.5 Flash",
202 .provider = kProviderGemini,
203 .description = "Fastest multimodal model"}};
204 return defaults;
205 }
206
207 try {
208 std::string endpoint =
209 "https://generativelanguage.googleapis.com/v1beta/models?key=" +
210 config_.api_key;
211
212 if (config_.verbose) {
213 std::cerr << "[DEBUG] Listing models: "
214 << endpoint.substr(0, endpoint.find("key=")) << "...'"
215 << std::endl;
216 }
217
218 std::string response_str;
219#if defined(YAZE_AI_IOS_URLSESSION)
220 auto resp_or = ios::UrlSessionHttpRequest("GET", endpoint, {}, "", 8000);
221 if (!resp_or.ok()) {
222 if (config_.verbose) {
223 std::cerr << "[DEBUG] Gemini models request failed: "
224 << resp_or.status().message() << std::endl;
225 }
226 return absl::InternalError("Failed to list Gemini models");
227 }
228 response_str = resp_or->body;
229#else
230 // Use curl to list models from the API
231 std::string curl_cmd = "curl -s -X GET '" + endpoint + "' 2>&1";
232
233#ifdef _WIN32
234 FILE* pipe = _popen(curl_cmd.c_str(), "r");
235#else
236 FILE* pipe = popen(curl_cmd.c_str(), "r");
237#endif
238 if (!pipe) {
239 return absl::InternalError("Failed to execute curl command");
240 }
241
242 char buffer[4096];
243 while (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
244 response_str += buffer;
245 }
246
247#ifdef _WIN32
248 _pclose(pipe);
249#else
250 pclose(pipe);
251#endif
252#endif // YAZE_AI_IOS_URLSESSION
253
254 auto models_json = nlohmann::json::parse(response_str, nullptr, false);
255 if (models_json.is_discarded()) {
256 return absl::InternalError("Failed to parse Gemini models JSON");
257 }
258
259 if (!models_json.contains("models")) {
260 // Return defaults on error
261 std::vector<ModelInfo> defaults = {{.name = "gemini-2.5-flash",
262 .display_name = "Gemini 2.0 Flash",
263 .provider = kProviderGemini},
264 {.name = "gemini-1.5-flash",
265 .display_name = "Gemini 1.5 Flash",
266 .provider = kProviderGemini},
267 {.name = "gemini-1.5-pro",
268 .display_name = "Gemini 1.5 Pro",
269 .provider = kProviderGemini}};
270 return defaults;
271 }
272
273 std::vector<ModelInfo> models;
274 for (const auto& m : models_json["models"]) {
275 std::string name = m.value("name", "");
276 // Name comes as "models/gemini-pro", strip prefix
277 if (absl::StartsWith(name, "models/")) {
278 name = name.substr(7);
279 }
280
281 // Filter for gemini models
282 if (absl::StartsWith(name, "gemini")) {
283 ModelInfo info;
284 info.name = name;
285 info.display_name = m.value("displayName", name);
286 info.provider = kProviderGemini;
287 info.description = m.value("description", "");
288 info.family = "gemini";
289 info.is_local = false;
290 models.push_back(std::move(info));
291 }
292 }
293 return models;
294
295 } catch (const std::exception& e) {
296 return absl::InternalError(
297 absl::StrCat("Failed to list models: ", e.what()));
298 }
299#endif
300}
301
303#ifndef YAZE_WITH_JSON
304 return absl::UnimplementedError(
305 "Gemini AI service requires JSON support. Build with "
306 "-DYAZE_WITH_JSON=ON");
307#else
308 try {
309 if (config_.verbose) {
310 std::cerr << "[DEBUG] CheckAvailability: start" << std::endl;
311 }
312
313 if (config_.api_key.empty()) {
314 return absl::FailedPreconditionError(
315 "❌ Gemini API key not configured\n"
316 " Set GEMINI_API_KEY environment variable\n"
317 " Get your API key at: https://makersuite.google.com/app/apikey");
318 }
319
320 if (config_.verbose) {
321 std::cerr << "[DEBUG] CheckAvailability: creating HTTPS client"
322 << std::endl;
323 }
324 // Test API connectivity with a simple request
325 httplib::Client cli("https://generativelanguage.googleapis.com");
326 if (config_.verbose) {
327 std::cerr << "[DEBUG] CheckAvailability: client created" << std::endl;
328 }
329
330 cli.set_connection_timeout(5, 0); // 5 seconds timeout
331
332 if (config_.verbose) {
333 std::cerr << "[DEBUG] CheckAvailability: building endpoint" << std::endl;
334 }
335 std::string test_endpoint = "/v1beta/models/" + config_.model;
336 httplib::Headers headers = {
337 {"x-goog-api-key", config_.api_key},
338 };
339
340 if (config_.verbose) {
341 std::cerr << "[DEBUG] CheckAvailability: making request to "
342 << test_endpoint << std::endl;
343 }
344 auto res = cli.Get(test_endpoint.c_str(), headers);
345
346 if (config_.verbose) {
347 std::cerr << "[DEBUG] CheckAvailability: got response" << std::endl;
348 }
349
350 if (!res) {
351 return absl::UnavailableError(
352 "❌ Cannot reach Gemini API\n"
353 " Check your internet connection");
354 }
355
356 if (res->status == 401 || res->status == 403) {
357 return absl::PermissionDeniedError(
358 "❌ Invalid Gemini API key\n"
359 " Verify your key at: https://makersuite.google.com/app/apikey");
360 }
361
362 if (res->status == 404) {
363 return absl::NotFoundError(
364 absl::StrCat("❌ Model '", config_.model, "' not found\n",
365 " Try: gemini-2.5-flash or gemini-1.5-pro"));
366 }
367
368 if (res->status != 200) {
369 return absl::InternalError(absl::StrCat(
370 "❌ Gemini API error: ", res->status, "\n ", res->body));
371 }
372
373 return absl::OkStatus();
374 } catch (const std::exception& e) {
375 if (config_.verbose) {
376 std::cerr << "[DEBUG] CheckAvailability: EXCEPTION: " << e.what()
377 << std::endl;
378 }
379 return absl::InternalError(
380 absl::StrCat("Exception during availability check: ", e.what()));
381 } catch (...) {
382 if (config_.verbose) {
383 std::cerr << "[DEBUG] CheckAvailability: UNKNOWN EXCEPTION" << std::endl;
384 }
385 return absl::InternalError("Unknown exception during availability check");
386 }
387#endif
388}
389
390absl::StatusOr<AgentResponse> GeminiAIService::GenerateResponse(
391 const std::string& prompt) {
392 return GenerateResponse(
393 {{{agent::ChatMessage::Sender::kUser, prompt, absl::Now()}}});
394}
395
396absl::StatusOr<AgentResponse> GeminiAIService::GenerateResponse(
397 const std::vector<agent::ChatMessage>& history) {
398#ifndef YAZE_WITH_JSON
399 return absl::UnimplementedError(
400 "Gemini AI service requires JSON support. Build with "
401 "-DYAZE_WITH_JSON=ON");
402#else
403 if (history.empty()) {
404 return absl::InvalidArgumentError("History cannot be empty.");
405 }
406
407 // Build a structured conversation history for better context
408 // Gemini supports multi-turn conversations via the contents array
409 std::string prompt = prompt_builder_.BuildPromptFromHistory(history);
410
411 // Skip availability check - causes segfault with current SSL setup
412 // TODO: Fix SSL/TLS initialization issue
413 // if (auto status = CheckAvailability(); !status.ok()) {
414 // return status;
415 // }
416
417 if (config_.api_key.empty()) {
418 return absl::FailedPreconditionError("Gemini API key not configured");
419 }
420
421 absl::Time request_start = absl::Now();
422
423 try {
424 if (config_.verbose) {
425 std::cerr << "[DEBUG] Using curl for HTTPS request" << std::endl;
426 std::cerr << "[DEBUG] Processing " << history.size()
427 << " messages in history" << std::endl;
428 }
429
430 // Build conversation history for multi-turn context
431 // Gemini supports alternating user/model messages for better context
432 nlohmann::json contents = nlohmann::json::array();
433
434 // Add conversation history (up to last 10 messages for context window)
435 int start_idx = std::max(0, static_cast<int>(history.size()) - 10);
436 for (size_t i = start_idx; i < history.size(); ++i) {
437 const auto& msg = history[i];
438 std::string role =
439 (msg.sender == agent::ChatMessage::Sender::kUser) ? "user" : "model";
440
441 nlohmann::json message = {{"role", role},
442 {"parts", {{{"text", msg.message}}}}};
443 contents.push_back(message);
444 }
445
446 // If the last message is from the model, we need to ensure the conversation
447 // ends with a user message for Gemini
448 if (!history.empty() &&
449 history.back().sender == agent::ChatMessage::Sender::kAgent) {
450 // Add a continuation prompt
451 nlohmann::json user_continuation = {
452 {"role", "user"},
453 {"parts", {{{"text", "Please continue or clarify your response."}}}}};
454 contents.push_back(user_continuation);
455 }
456
457 // Build request with proper Gemini API v1beta format
458 nlohmann::json request_body = {
459 {"system_instruction",
460 {{"parts", {{"text", config_.system_instruction}}}}},
461 {"contents", contents},
462 {"generationConfig",
463 {{"temperature", config_.temperature},
464 {"maxOutputTokens", config_.max_output_tokens}}}};
465
466 if (config_.verbose) {
467 std::cerr << "[DEBUG] Sending " << contents.size()
468 << " conversation turns to Gemini" << std::endl;
469 }
470
471 // Only add responseMimeType if NOT using function calling
472 // (Gemini doesn't support both at the same time)
473 if (!function_calling_enabled_) {
474 request_body["generationConfig"]["responseMimeType"] = "application/json";
475 }
476
477 // Add function calling tools if enabled
478 if (function_calling_enabled_) {
479 auto tools_or = BuildGeminiToolPayload(prompt_builder_);
480 if (!tools_or.ok()) {
481 if (config_.verbose) {
482 std::cerr << "[DEBUG] Function calling schemas unavailable: "
483 << tools_or.status().message() << std::endl;
484 }
485 } else if (!tools_or->empty()) {
486 if (config_.verbose) {
487 std::string tools_str = tools_or->dump();
488 std::cerr << "[DEBUG] Function calling schemas: "
489 << tools_str.substr(0, 200) << "..." << std::endl;
490 }
491
492 request_body["tools"] = *tools_or;
493 }
494 }
495
496 std::string endpoint =
497 "https://generativelanguage.googleapis.com/v1beta/models/" +
498 config_.model + ":generateContent";
499 std::string response_str;
500#if defined(YAZE_AI_IOS_URLSESSION)
501 std::map<std::string, std::string> headers;
502 headers.emplace("Content-Type", "application/json");
503 headers.emplace("x-goog-api-key", config_.api_key);
504 auto resp_or = ios::UrlSessionHttpRequest("POST", endpoint, headers,
505 request_body.dump(), 60000);
506 if (!resp_or.ok()) {
507 return resp_or.status();
508 }
509 if (resp_or->status_code != 200) {
510 return absl::InternalError(absl::StrCat(
511 "Gemini API error: ", resp_or->status_code, "\n", resp_or->body));
512 }
513 response_str = resp_or->body;
514#else
515 // Write request body to temp file
516 std::string temp_file = "/tmp/gemini_request.json";
517 std::ofstream out(temp_file);
518 out << request_body.dump();
519 out.close();
520
521 // Use curl to make the request (avoiding httplib SSL issues)
522 std::string curl_cmd = "curl -s -X POST '" + endpoint +
523 "' "
524 "-H 'Content-Type: application/json' "
525 "-H 'x-goog-api-key: " +
526 config_.api_key +
527 "' "
528 "-d @" +
529 temp_file + " 2>&1";
530
531 if (config_.verbose) {
532 std::cerr << "[DEBUG] Executing API request..." << std::endl;
533 }
534
535#ifdef _WIN32
536 FILE* pipe = _popen(curl_cmd.c_str(), "r");
537#else
538 FILE* pipe = popen(curl_cmd.c_str(), "r");
539#endif
540 if (!pipe) {
541 return absl::InternalError("Failed to execute curl command");
542 }
543
544 char buffer[4096];
545 while (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
546 response_str += buffer;
547 }
548
549#ifdef _WIN32
550 int status = _pclose(pipe);
551#else
552 int status = pclose(pipe);
553#endif
554 std::remove(temp_file.c_str());
555
556 if (status != 0) {
557 return absl::InternalError(
558 absl::StrCat("Curl failed with status ", status));
559 }
560#endif // YAZE_AI_IOS_URLSESSION
561
562 if (response_str.empty()) {
563 return absl::InternalError("Empty response from Gemini API");
564 }
565
566 // Debug: print response
567 if (config_.verbose) {
568 std::cout << "\n"
569 << "\033[35m"
570 << "🔍 Raw Gemini API Response:"
571 << "\033[0m"
572 << "\n"
573 << "\033[2m" << response_str.substr(0, 500) << "\033[0m"
574 << "\n\n";
575 }
576
577 if (config_.verbose) {
578 std::cerr << "[DEBUG] Parsing response..." << std::endl;
579 }
580 auto parsed_or = ParseGeminiResponse(response_str);
581 if (!parsed_or.ok()) {
582 return parsed_or.status();
583 }
584 AgentResponse agent_response = std::move(parsed_or.value());
585 agent_response.provider = kProviderGemini;
586 agent_response.model = config_.model;
587 agent_response.latency_seconds =
588 absl::ToDoubleSeconds(absl::Now() - request_start);
589 agent_response.parameters["prompt_version"] = config_.prompt_version;
590 agent_response.parameters["temperature"] =
591 absl::StrFormat("%.2f", config_.temperature);
592 agent_response.parameters["max_output_tokens"] =
593 absl::StrFormat("%d", config_.max_output_tokens);
594 agent_response.parameters["function_calling"] =
595 function_calling_enabled_ ? "true" : "false";
596 return agent_response;
597
598 } catch (const std::exception& e) {
599 if (config_.verbose) {
600 std::cerr << "[ERROR] Exception: " << e.what() << std::endl;
601 }
602 return absl::InternalError(
603 absl::StrCat("Exception during generation: ", e.what()));
604 } catch (...) {
605 if (config_.verbose) {
606 std::cerr << "[ERROR] Unknown exception" << std::endl;
607 }
608 return absl::InternalError("Unknown exception during generation");
609 }
610#endif
611}
612
613absl::StatusOr<AgentResponse> GeminiAIService::ParseGeminiResponse(
614 const std::string& response_body) {
615#ifndef YAZE_WITH_JSON
616 return absl::UnimplementedError("JSON support required");
617#else
618 AgentResponse agent_response;
619
620 auto response_json = nlohmann::json::parse(response_body, nullptr, false);
621 if (response_json.is_discarded()) {
622 return absl::InternalError("❌ Failed to parse Gemini response JSON");
623 }
624
625 // Navigate Gemini's response structure
626 if (!response_json.contains("candidates") ||
627 response_json["candidates"].empty()) {
628 return absl::InternalError("❌ No candidates in Gemini response");
629 }
630
631 for (const auto& candidate : response_json["candidates"]) {
632 if (!candidate.contains("content") ||
633 !candidate["content"].contains("parts")) {
634 continue;
635 }
636
637 for (const auto& part : candidate["content"]["parts"]) {
638 if (part.contains("text")) {
639 std::string text_content = part["text"].get<std::string>();
640
641 // Debug: Print raw LLM output when verbose mode is enabled
642 if (config_.verbose) {
643 std::cout << "\n"
644 << "\033[35m"
645 << "🔍 Raw LLM Response:"
646 << "\033[0m"
647 << "\n"
648 << "\033[2m" << text_content << "\033[0m"
649 << "\n\n";
650 }
651
652 // Strip markdown code blocks if present (```json ... ```)
653 text_content = std::string(absl::StripAsciiWhitespace(text_content));
654 if (absl::StartsWith(text_content, "```json")) {
655 text_content = text_content.substr(7); // Remove ```json
656 } else if (absl::StartsWith(text_content, "```")) {
657 text_content = text_content.substr(3); // Remove ```
658 }
659 if (absl::EndsWith(text_content, "```")) {
660 text_content = text_content.substr(0, text_content.length() - 3);
661 }
662 text_content = std::string(absl::StripAsciiWhitespace(text_content));
663
664 // Try to parse as JSON object
665 auto parsed_text = nlohmann::json::parse(text_content, nullptr, false);
666 if (!parsed_text.is_discarded()) {
667 // Extract text_response
668 if (parsed_text.contains("text_response") &&
669 parsed_text["text_response"].is_string()) {
670 agent_response.text_response =
671 parsed_text["text_response"].get<std::string>();
672 }
673
674 // Extract reasoning
675 if (parsed_text.contains("reasoning") &&
676 parsed_text["reasoning"].is_string()) {
677 agent_response.reasoning =
678 parsed_text["reasoning"].get<std::string>();
679 }
680
681 // Extract commands
682 if (parsed_text.contains("commands") &&
683 parsed_text["commands"].is_array()) {
684 for (const auto& cmd : parsed_text["commands"]) {
685 if (cmd.is_string()) {
686 std::string command = cmd.get<std::string>();
687 if (absl::StartsWith(command, "z3ed ")) {
688 command = command.substr(5);
689 }
690 agent_response.commands.push_back(command);
691 }
692 }
693 }
694
695 // Extract tool_calls from the parsed JSON
696 if (parsed_text.contains("tool_calls") &&
697 parsed_text["tool_calls"].is_array()) {
698 for (const auto& call : parsed_text["tool_calls"]) {
699 if (call.contains("tool_name") && call["tool_name"].is_string()) {
700 ToolCall tool_call;
701 tool_call.tool_name = call["tool_name"].get<std::string>();
702
703 if (call.contains("args") && call["args"].is_object()) {
704 tool_call.args = ai::DecodeToolCallArguments(call["args"]);
705 }
706 agent_response.tool_calls.push_back(tool_call);
707 }
708 }
709 }
710 } else {
711 // If parsing the full object fails, fallback to extracting commands
712 // from text
713 std::vector<std::string> lines = absl::StrSplit(text_content, '\n');
714 for (const auto& line : lines) {
715 std::string trimmed = std::string(absl::StripAsciiWhitespace(line));
716 if (!trimmed.empty() && (absl::StartsWith(trimmed, "z3ed ") ||
717 absl::StartsWith(trimmed, "palette ") ||
718 absl::StartsWith(trimmed, "overworld ") ||
719 absl::StartsWith(trimmed, "sprite ") ||
720 absl::StartsWith(trimmed, "dungeon "))) {
721 if (absl::StartsWith(trimmed, "z3ed ")) {
722 trimmed = trimmed.substr(5);
723 }
724 agent_response.commands.push_back(trimmed);
725 }
726 }
727 }
728 } else if (part.contains("functionCall")) {
729 const auto& call = part["functionCall"];
730 if (call.contains("name") && call["name"].is_string()) {
731 ToolCall tool_call;
732 tool_call.tool_name = call["name"].get<std::string>();
733 if (call.contains("args") && call["args"].is_object()) {
734 tool_call.args = ai::DecodeToolCallArguments(call["args"]);
735 }
736 agent_response.tool_calls.push_back(tool_call);
737 }
738 }
739 }
740 }
741
742 if (agent_response.text_response.empty() && agent_response.commands.empty() &&
743 agent_response.tool_calls.empty()) {
744 return absl::InternalError(
745 "❌ No valid response extracted from Gemini\n"
746 " Expected at least one of: text_response, commands, or tool_calls\n"
747 " Raw response: " +
748 response_body);
749 }
750
751 return agent_response;
752#endif
753}
754
755absl::StatusOr<std::string> GeminiAIService::EncodeImageToBase64(
756 const std::string& image_path) const {
757#ifndef YAZE_WITH_JSON
758 (void)image_path; // Suppress unused parameter warning
759 return absl::UnimplementedError(
760 "Gemini AI service requires JSON support. Build with "
761 "-DYAZE_WITH_JSON=ON");
762#else
763 std::ifstream file(image_path, std::ios::binary);
764 if (!file.is_open()) {
765 return absl::NotFoundError(
766 absl::StrCat("Failed to open image file: ", image_path));
767 }
768
769 // Read file into buffer
770 file.seekg(0, std::ios::end);
771 size_t size = file.tellg();
772 file.seekg(0, std::ios::beg);
773
774 std::vector<unsigned char> buffer(size);
775 if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
776 return absl::InternalError("Failed to read image file");
777 }
778
779 // Base64 encode
780 static const char* base64_chars =
781 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
782
783 std::string encoded;
784 encoded.reserve(((size + 2) / 3) * 4);
785
786 int i = 0;
787 int j = 0;
788 unsigned char char_array_3[3];
789 unsigned char char_array_4[4];
790
791 for (size_t idx = 0; idx < size; idx++) {
792 char_array_3[i++] = buffer[idx];
793 if (i == 3) {
794 char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
795 char_array_4[1] =
796 ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
797 char_array_4[2] =
798 ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
799 char_array_4[3] = char_array_3[2] & 0x3f;
800
801 for (i = 0; i < 4; i++)
802 encoded += base64_chars[char_array_4[i]];
803 i = 0;
804 }
805 }
806
807 if (i) {
808 for (j = i; j < 3; j++)
809 char_array_3[j] = '\0';
810
811 char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
812 char_array_4[1] =
813 ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
814 char_array_4[2] =
815 ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
816
817 for (j = 0; j < i + 1; j++)
818 encoded += base64_chars[char_array_4[j]];
819
820 while (i++ < 3)
821 encoded += '=';
822 }
823
824 return encoded;
825#endif
826}
827
828absl::StatusOr<AgentResponse> GeminiAIService::GenerateMultimodalResponse(
829 const std::string& image_path, const std::string& prompt) {
830#ifndef YAZE_WITH_JSON
831 (void)image_path; // Suppress unused parameter warnings
832 (void)prompt;
833 return absl::UnimplementedError(
834 "Gemini AI service requires JSON support. Build with "
835 "-DYAZE_WITH_JSON=ON");
836#else
837 if (config_.api_key.empty()) {
838 return absl::FailedPreconditionError("Gemini API key not configured");
839 }
840
841 // Determine MIME type from file extension
842 std::string mime_type = "image/png";
843 if (image_path.ends_with(".jpg") || image_path.ends_with(".jpeg")) {
844 mime_type = "image/jpeg";
845 } else if (image_path.ends_with(".bmp")) {
846 mime_type = "image/bmp";
847 } else if (image_path.ends_with(".webp")) {
848 mime_type = "image/webp";
849 }
850
851 // Encode image to base64
852 auto encoded_or = EncodeImageToBase64(image_path);
853 if (!encoded_or.ok()) {
854 return encoded_or.status();
855 }
856 std::string encoded_image = std::move(encoded_or.value());
857
858 try {
859 if (config_.verbose) {
860 std::cerr << "[DEBUG] Preparing multimodal request with image"
861 << std::endl;
862 }
863
864 // Build multimodal request with image and text
865 nlohmann::json request_body = {
866 {"contents",
867 {{{"parts",
868 {{{"inline_data",
869 {{"mime_type", mime_type}, {"data", encoded_image}}}},
870 {{"text", prompt}}}}}}},
871 {"generationConfig",
872 {{"temperature", config_.temperature},
873 {"maxOutputTokens", config_.max_output_tokens}}}};
874
875 std::string endpoint =
876 "https://generativelanguage.googleapis.com/v1beta/models/" +
877 config_.model + ":generateContent";
878 std::string response_str;
879#if defined(YAZE_AI_IOS_URLSESSION)
880 std::map<std::string, std::string> headers;
881 headers.emplace("Content-Type", "application/json");
882 headers.emplace("x-goog-api-key", config_.api_key);
883 auto resp_or = ios::UrlSessionHttpRequest("POST", endpoint, headers,
884 request_body.dump(), 60000);
885 if (!resp_or.ok()) {
886 return resp_or.status();
887 }
888 if (resp_or->status_code != 200) {
889 return absl::InternalError(absl::StrCat(
890 "Gemini API error: ", resp_or->status_code, "\n", resp_or->body));
891 }
892 response_str = resp_or->body;
893#else
894 // Write request body to temp file
895 std::string temp_file = "/tmp/gemini_multimodal_request.json";
896 std::ofstream out(temp_file);
897 out << request_body.dump();
898 out.close();
899
900 // Use curl to make the request
901 std::string curl_cmd = "curl -s -X POST '" + endpoint +
902 "' "
903 "-H 'Content-Type: application/json' "
904 "-H 'x-goog-api-key: " +
905 config_.api_key +
906 "' "
907 "-d @" +
908 temp_file + " 2>&1";
909
910 if (config_.verbose) {
911 std::cerr << "[DEBUG] Executing multimodal API request..." << std::endl;
912 }
913
914#ifdef _WIN32
915 FILE* pipe = _popen(curl_cmd.c_str(), "r");
916#else
917 FILE* pipe = popen(curl_cmd.c_str(), "r");
918#endif
919 if (!pipe) {
920 return absl::InternalError("Failed to execute curl command");
921 }
922
923 char buffer[4096];
924 while (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
925 response_str += buffer;
926 }
927
928#ifdef _WIN32
929 int status = _pclose(pipe);
930#else
931 int status = pclose(pipe);
932#endif
933 std::remove(temp_file.c_str());
934
935 if (status != 0) {
936 return absl::InternalError(
937 absl::StrCat("Curl failed with status ", status));
938 }
939#endif // YAZE_AI_IOS_URLSESSION
940
941 if (response_str.empty()) {
942 return absl::InternalError("Empty response from Gemini API");
943 }
944
945 if (config_.verbose) {
946 std::cout << "\n"
947 << "\033[35m"
948 << "🔍 Raw Gemini Multimodal Response:"
949 << "\033[0m"
950 << "\n"
951 << "\033[2m" << response_str.substr(0, 500) << "\033[0m"
952 << "\n\n";
953 }
954
955 return ParseGeminiResponse(response_str);
956
957 } catch (const std::exception& e) {
958 if (config_.verbose) {
959 std::cerr << "[ERROR] Exception: " << e.what() << std::endl;
960 }
961 return absl::InternalError(
962 absl::StrCat("Exception during multimodal generation: ", e.what()));
963 }
964#endif
965}
966
967} // namespace cli
968} // namespace yaze
absl::StatusOr< AgentResponse > GenerateMultimodalResponse(const std::string &, const std::string &)
std::vector< std::string > GetAvailableTools() const
absl::StatusOr< std::vector< ModelInfo > > ListAvailableModels() override
GeminiAIService(const GeminiConfig &)
void SetRomContext(Rom *) override
absl::StatusOr< AgentResponse > GenerateResponse(const std::string &prompt) override
static nlohmann::json BuildGeminiTools(const nlohmann::json &function_declarations)
static absl::StatusOr< nlohmann::json > ResolveFunctionDeclarations(const PromptBuilder &prompt_builder)
static absl::StatusOr< std::filesystem::path > FindAsset(const std::string &relative_path)
Find an asset file in multiple standard locations.
std::map< std::string, std::string > DecodeToolCallArguments(const nlohmann::json &arguments)
absl::StatusOr< nlohmann::json > BuildGeminiToolPayload(const PromptBuilder &prompt_builder)
absl::StatusOr< UrlSessionHttpResponse > UrlSessionHttpRequest(const std::string &method, const std::string &url, const std::map< std::string, std::string > &headers, const std::string &body, int timeout_ms)
constexpr char kProviderGemini[]
Definition provider_ids.h:9