feat: add non-streaming LLM API, tool registry, and web_search tool
Replace SSE streaming with non-streaming JSON for Anthropic API. Add llm_chat_tools() returning structured llm_response_t with text and tool_use blocks. Implement tool registry with dispatch-by-name and web_search tool via Brave Search API (direct + proxy support). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
308
main/tools/tool_web_search.c
Normal file
308
main/tools/tool_web_search.c
Normal file
@@ -0,0 +1,308 @@
|
||||
#include "tool_web_search.h"
|
||||
#include "mimi_config.h"
|
||||
#include "proxy/http_proxy.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "nvs.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
static const char *TAG = "web_search";
|
||||
|
||||
static char s_search_key[128] = {0};
|
||||
|
||||
#define SEARCH_BUF_SIZE (16 * 1024)
|
||||
#define SEARCH_RESULT_COUNT 5
|
||||
|
||||
/* ── Response accumulator ─────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
char *data;
|
||||
size_t len;
|
||||
size_t cap;
|
||||
} search_buf_t;
|
||||
|
||||
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
|
||||
{
|
||||
search_buf_t *sb = (search_buf_t *)evt->user_data;
|
||||
if (evt->event_id == HTTP_EVENT_ON_DATA) {
|
||||
size_t needed = sb->len + evt->data_len;
|
||||
if (needed < sb->cap) {
|
||||
memcpy(sb->data + sb->len, evt->data, evt->data_len);
|
||||
sb->len += evt->data_len;
|
||||
sb->data[sb->len] = '\0';
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ── Init ─────────────────────────────────────────────────────── */
|
||||
|
||||
esp_err_t tool_web_search_init(void)
|
||||
{
|
||||
/* Build-time secret takes highest priority */
|
||||
if (MIMI_SECRET_SEARCH_KEY[0] != '\0') {
|
||||
strncpy(s_search_key, MIMI_SECRET_SEARCH_KEY, sizeof(s_search_key) - 1);
|
||||
} else {
|
||||
nvs_handle_t nvs;
|
||||
esp_err_t err = nvs_open(MIMI_NVS_SEARCH, NVS_READONLY, &nvs);
|
||||
if (err == ESP_OK) {
|
||||
size_t len = sizeof(s_search_key);
|
||||
nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, s_search_key, &len);
|
||||
nvs_close(nvs);
|
||||
}
|
||||
}
|
||||
|
||||
if (s_search_key[0]) {
|
||||
ESP_LOGI(TAG, "Web search initialized (key configured)");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No search API key. Use CLI: set_search_key <KEY>");
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t tool_web_search_set_key(const char *api_key)
|
||||
{
|
||||
nvs_handle_t nvs;
|
||||
ESP_ERROR_CHECK(nvs_open(MIMI_NVS_SEARCH, NVS_READWRITE, &nvs));
|
||||
ESP_ERROR_CHECK(nvs_set_str(nvs, MIMI_NVS_KEY_API_KEY, api_key));
|
||||
ESP_ERROR_CHECK(nvs_commit(nvs));
|
||||
nvs_close(nvs);
|
||||
|
||||
strncpy(s_search_key, api_key, sizeof(s_search_key) - 1);
|
||||
ESP_LOGI(TAG, "Search API key saved");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ── URL-encode a query string ────────────────────────────────── */
|
||||
|
||||
static size_t url_encode(const char *src, char *dst, size_t dst_size)
|
||||
{
|
||||
static const char hex[] = "0123456789ABCDEF";
|
||||
size_t pos = 0;
|
||||
|
||||
for (; *src && pos < dst_size - 3; src++) {
|
||||
unsigned char c = (unsigned char)*src;
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
dst[pos++] = c;
|
||||
} else if (c == ' ') {
|
||||
dst[pos++] = '+';
|
||||
} else {
|
||||
dst[pos++] = '%';
|
||||
dst[pos++] = hex[c >> 4];
|
||||
dst[pos++] = hex[c & 0x0F];
|
||||
}
|
||||
}
|
||||
dst[pos] = '\0';
|
||||
return pos;
|
||||
}
|
||||
|
||||
/* ── Format results as readable text ──────────────────────────── */
|
||||
|
||||
static void format_results(cJSON *root, char *output, size_t output_size)
|
||||
{
|
||||
cJSON *web = cJSON_GetObjectItem(root, "web");
|
||||
if (!web) {
|
||||
snprintf(output, output_size, "No web results found.");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *results = cJSON_GetObjectItem(web, "results");
|
||||
if (!results || !cJSON_IsArray(results) || cJSON_GetArraySize(results) == 0) {
|
||||
snprintf(output, output_size, "No web results found.");
|
||||
return;
|
||||
}
|
||||
|
||||
size_t off = 0;
|
||||
int idx = 0;
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, results) {
|
||||
if (idx >= SEARCH_RESULT_COUNT) break;
|
||||
|
||||
cJSON *title = cJSON_GetObjectItem(item, "title");
|
||||
cJSON *url = cJSON_GetObjectItem(item, "url");
|
||||
cJSON *desc = cJSON_GetObjectItem(item, "description");
|
||||
|
||||
off += snprintf(output + off, output_size - off,
|
||||
"%d. %s\n %s\n %s\n\n",
|
||||
idx + 1,
|
||||
(title && cJSON_IsString(title)) ? title->valuestring : "(no title)",
|
||||
(url && cJSON_IsString(url)) ? url->valuestring : "",
|
||||
(desc && cJSON_IsString(desc)) ? desc->valuestring : "");
|
||||
|
||||
if (off >= output_size - 1) break;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Direct HTTPS request ─────────────────────────────────────── */
|
||||
|
||||
static esp_err_t search_direct(const char *url, search_buf_t *sb)
|
||||
{
|
||||
esp_http_client_config_t config = {
|
||||
.url = url,
|
||||
.event_handler = http_event_handler,
|
||||
.user_data = sb,
|
||||
.timeout_ms = 15000,
|
||||
.buffer_size = 4096,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
};
|
||||
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
if (!client) return ESP_FAIL;
|
||||
|
||||
esp_http_client_set_header(client, "Accept", "application/json");
|
||||
esp_http_client_set_header(client, "X-Subscription-Token", s_search_key);
|
||||
|
||||
esp_err_t err = esp_http_client_perform(client);
|
||||
int status = esp_http_client_get_status_code(client);
|
||||
esp_http_client_cleanup(client);
|
||||
|
||||
if (err != ESP_OK) return err;
|
||||
if (status != 200) {
|
||||
ESP_LOGE(TAG, "Search API returned %d", status);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ── Proxy HTTPS request ──────────────────────────────────────── */
|
||||
|
||||
static esp_err_t search_via_proxy(const char *path, search_buf_t *sb)
|
||||
{
|
||||
proxy_conn_t *conn = proxy_conn_open("api.search.brave.com", 443, 15000);
|
||||
if (!conn) return ESP_ERR_HTTP_CONNECT;
|
||||
|
||||
char header[512];
|
||||
int hlen = snprintf(header, sizeof(header),
|
||||
"GET %s HTTP/1.1\r\n"
|
||||
"Host: api.search.brave.com\r\n"
|
||||
"Accept: application/json\r\n"
|
||||
"X-Subscription-Token: %s\r\n"
|
||||
"Connection: close\r\n\r\n",
|
||||
path, s_search_key);
|
||||
|
||||
if (proxy_conn_write(conn, header, hlen) < 0) {
|
||||
proxy_conn_close(conn);
|
||||
return ESP_ERR_HTTP_WRITE_DATA;
|
||||
}
|
||||
|
||||
/* Read full response */
|
||||
char tmp[4096];
|
||||
size_t total = 0;
|
||||
while (1) {
|
||||
int n = proxy_conn_read(conn, tmp, sizeof(tmp), 15000);
|
||||
if (n <= 0) break;
|
||||
size_t copy = (total + n < sb->cap - 1) ? (size_t)n : sb->cap - 1 - total;
|
||||
if (copy > 0) {
|
||||
memcpy(sb->data + total, tmp, copy);
|
||||
total += copy;
|
||||
}
|
||||
}
|
||||
sb->data[total] = '\0';
|
||||
sb->len = total;
|
||||
proxy_conn_close(conn);
|
||||
|
||||
/* Check status */
|
||||
int status = 0;
|
||||
if (total > 5 && strncmp(sb->data, "HTTP/", 5) == 0) {
|
||||
const char *sp = strchr(sb->data, ' ');
|
||||
if (sp) status = atoi(sp + 1);
|
||||
}
|
||||
|
||||
/* Strip headers */
|
||||
char *body = strstr(sb->data, "\r\n\r\n");
|
||||
if (body) {
|
||||
body += 4;
|
||||
size_t blen = total - (body - sb->data);
|
||||
memmove(sb->data, body, blen);
|
||||
sb->len = blen;
|
||||
sb->data[sb->len] = '\0';
|
||||
}
|
||||
|
||||
if (status != 200) {
|
||||
ESP_LOGE(TAG, "Search API returned %d via proxy", status);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ── Execute ──────────────────────────────────────────────────── */
|
||||
|
||||
esp_err_t tool_web_search_execute(const char *input_json, char *output, size_t output_size)
|
||||
{
|
||||
if (s_search_key[0] == '\0') {
|
||||
snprintf(output, output_size, "Error: No search API key configured. Use set_search_key command.");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Parse input to get query */
|
||||
cJSON *input = cJSON_Parse(input_json);
|
||||
if (!input) {
|
||||
snprintf(output, output_size, "Error: Invalid input JSON");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
cJSON *query = cJSON_GetObjectItem(input, "query");
|
||||
if (!query || !cJSON_IsString(query) || query->valuestring[0] == '\0') {
|
||||
cJSON_Delete(input);
|
||||
snprintf(output, output_size, "Error: Missing 'query' field");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Searching: %s", query->valuestring);
|
||||
|
||||
/* Build URL */
|
||||
char encoded_query[256];
|
||||
url_encode(query->valuestring, encoded_query, sizeof(encoded_query));
|
||||
cJSON_Delete(input);
|
||||
|
||||
char path[384];
|
||||
snprintf(path, sizeof(path),
|
||||
"/res/v1/web/search?q=%s&count=%d", encoded_query, SEARCH_RESULT_COUNT);
|
||||
|
||||
/* Allocate response buffer from PSRAM */
|
||||
search_buf_t sb = {0};
|
||||
sb.data = heap_caps_calloc(1, SEARCH_BUF_SIZE, MALLOC_CAP_SPIRAM);
|
||||
if (!sb.data) {
|
||||
snprintf(output, output_size, "Error: Out of memory");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
sb.cap = SEARCH_BUF_SIZE;
|
||||
|
||||
/* Make HTTP request */
|
||||
esp_err_t err;
|
||||
if (http_proxy_is_enabled()) {
|
||||
err = search_via_proxy(path, &sb);
|
||||
} else {
|
||||
char url[512];
|
||||
snprintf(url, sizeof(url), "https://api.search.brave.com%s", path);
|
||||
err = search_direct(url, &sb);
|
||||
}
|
||||
|
||||
if (err != ESP_OK) {
|
||||
free(sb.data);
|
||||
snprintf(output, output_size, "Error: Search request failed");
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Parse and format results */
|
||||
cJSON *root = cJSON_Parse(sb.data);
|
||||
free(sb.data);
|
||||
|
||||
if (!root) {
|
||||
snprintf(output, output_size, "Error: Failed to parse search results");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
format_results(root, output, output_size);
|
||||
cJSON_Delete(root);
|
||||
|
||||
ESP_LOGI(TAG, "Search complete, %d bytes result", (int)strlen(output));
|
||||
return ESP_OK;
|
||||
}
|
||||
Reference in New Issue
Block a user