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:
@@ -13,6 +13,8 @@ idf_component_register(
|
|||||||
"cli/serial_cli.c"
|
"cli/serial_cli.c"
|
||||||
"ota/ota_manager.c"
|
"ota/ota_manager.c"
|
||||||
"proxy/http_proxy.c"
|
"proxy/http_proxy.c"
|
||||||
|
"tools/tool_registry.c"
|
||||||
|
"tools/tool_web_search.c"
|
||||||
INCLUDE_DIRS
|
INCLUDE_DIRS
|
||||||
"."
|
"."
|
||||||
REQUIRES
|
REQUIRES
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_http_client.h"
|
#include "esp_http_client.h"
|
||||||
#include "esp_crt_bundle.h"
|
#include "esp_crt_bundle.h"
|
||||||
|
#include "esp_heap_caps.h"
|
||||||
#include "nvs.h"
|
#include "nvs.h"
|
||||||
#include "cJSON.h"
|
#include "cJSON.h"
|
||||||
|
|
||||||
@@ -15,113 +16,84 @@ static const char *TAG = "llm";
|
|||||||
static char s_api_key[128] = {0};
|
static char s_api_key[128] = {0};
|
||||||
static char s_model[64] = MIMI_LLM_DEFAULT_MODEL;
|
static char s_model[64] = MIMI_LLM_DEFAULT_MODEL;
|
||||||
|
|
||||||
/* Streaming response accumulator */
|
/* ── Response buffer ──────────────────────────────────────────── */
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char *buf;
|
char *data;
|
||||||
size_t len;
|
size_t len;
|
||||||
size_t cap;
|
size_t cap;
|
||||||
/* SSE line buffer for partial lines */
|
} resp_buf_t;
|
||||||
char line_buf[1024];
|
|
||||||
size_t line_len;
|
|
||||||
/* Accumulated response text */
|
|
||||||
char *response;
|
|
||||||
size_t resp_len;
|
|
||||||
size_t resp_cap;
|
|
||||||
} sse_ctx_t;
|
|
||||||
|
|
||||||
static void sse_process_line(sse_ctx_t *ctx, const char *line)
|
static esp_err_t resp_buf_init(resp_buf_t *rb, size_t initial_cap)
|
||||||
{
|
{
|
||||||
/* SSE format: "data: {...}" */
|
rb->data = heap_caps_calloc(1, initial_cap, MALLOC_CAP_SPIRAM);
|
||||||
if (strncmp(line, "data: ", 6) != 0) return;
|
if (!rb->data) return ESP_ERR_NO_MEM;
|
||||||
const char *json_str = line + 6;
|
rb->len = 0;
|
||||||
|
rb->cap = initial_cap;
|
||||||
/* Check for stream end */
|
return ESP_OK;
|
||||||
if (strcmp(json_str, "[DONE]") == 0) return;
|
|
||||||
|
|
||||||
cJSON *root = cJSON_Parse(json_str);
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
cJSON *type = cJSON_GetObjectItem(root, "type");
|
|
||||||
if (!type || !cJSON_IsString(type)) {
|
|
||||||
cJSON_Delete(root);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strcmp(type->valuestring, "content_block_delta") == 0) {
|
|
||||||
cJSON *delta = cJSON_GetObjectItem(root, "delta");
|
|
||||||
if (delta) {
|
|
||||||
cJSON *delta_type = cJSON_GetObjectItem(delta, "type");
|
|
||||||
if (delta_type && strcmp(delta_type->valuestring, "text_delta") == 0) {
|
|
||||||
cJSON *text = cJSON_GetObjectItem(delta, "text");
|
|
||||||
if (text && cJSON_IsString(text)) {
|
|
||||||
size_t tlen = strlen(text->valuestring);
|
|
||||||
/* Grow response buffer if needed */
|
|
||||||
while (ctx->resp_len + tlen >= ctx->resp_cap) {
|
|
||||||
size_t new_cap = ctx->resp_cap * 2;
|
|
||||||
char *tmp = realloc(ctx->response, new_cap);
|
|
||||||
if (!tmp) break;
|
|
||||||
ctx->response = tmp;
|
|
||||||
ctx->resp_cap = new_cap;
|
|
||||||
}
|
|
||||||
memcpy(ctx->response + ctx->resp_len, text->valuestring, tlen);
|
|
||||||
ctx->resp_len += tlen;
|
|
||||||
ctx->response[ctx->resp_len] = '\0';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (strcmp(type->valuestring, "error") == 0) {
|
|
||||||
cJSON *error = cJSON_GetObjectItem(root, "error");
|
|
||||||
if (error) {
|
|
||||||
cJSON *msg = cJSON_GetObjectItem(error, "message");
|
|
||||||
if (msg && cJSON_IsString(msg)) {
|
|
||||||
ESP_LOGE(TAG, "API error: %s", msg->valuestring);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cJSON_Delete(root);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void sse_feed(sse_ctx_t *ctx, const char *data, size_t len)
|
static esp_err_t resp_buf_append(resp_buf_t *rb, const char *data, size_t len)
|
||||||
{
|
{
|
||||||
for (size_t i = 0; i < len; i++) {
|
while (rb->len + len >= rb->cap) {
|
||||||
char c = data[i];
|
size_t new_cap = rb->cap * 2;
|
||||||
if (c == '\n') {
|
char *tmp = heap_caps_realloc(rb->data, new_cap, MALLOC_CAP_SPIRAM);
|
||||||
ctx->line_buf[ctx->line_len] = '\0';
|
if (!tmp) return ESP_ERR_NO_MEM;
|
||||||
if (ctx->line_len > 0) {
|
rb->data = tmp;
|
||||||
sse_process_line(ctx, ctx->line_buf);
|
rb->cap = new_cap;
|
||||||
}
|
|
||||||
ctx->line_len = 0;
|
|
||||||
} else if (c != '\r') {
|
|
||||||
if (ctx->line_len < sizeof(ctx->line_buf) - 1) {
|
|
||||||
ctx->line_buf[ctx->line_len++] = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
memcpy(rb->data + rb->len, data, len);
|
||||||
|
rb->len += len;
|
||||||
|
rb->data[rb->len] = '\0';
|
||||||
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void resp_buf_free(resp_buf_t *rb)
|
||||||
|
{
|
||||||
|
free(rb->data);
|
||||||
|
rb->data = NULL;
|
||||||
|
rb->len = 0;
|
||||||
|
rb->cap = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HTTP event handler (for esp_http_client direct path) ─────── */
|
||||||
|
|
||||||
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
|
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
|
||||||
{
|
{
|
||||||
sse_ctx_t *ctx = (sse_ctx_t *)evt->user_data;
|
resp_buf_t *rb = (resp_buf_t *)evt->user_data;
|
||||||
if (evt->event_id == HTTP_EVENT_ON_DATA) {
|
if (evt->event_id == HTTP_EVENT_ON_DATA) {
|
||||||
sse_feed(ctx, (const char *)evt->data, evt->data_len);
|
resp_buf_append(rb, (const char *)evt->data, evt->data_len);
|
||||||
}
|
}
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Init ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
esp_err_t llm_proxy_init(void)
|
esp_err_t llm_proxy_init(void)
|
||||||
{
|
{
|
||||||
nvs_handle_t nvs;
|
/* Build-time secrets take highest priority */
|
||||||
esp_err_t err = nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs);
|
if (MIMI_SECRET_API_KEY[0] != '\0') {
|
||||||
if (err == ESP_OK) {
|
strncpy(s_api_key, MIMI_SECRET_API_KEY, sizeof(s_api_key) - 1);
|
||||||
size_t len = sizeof(s_api_key);
|
}
|
||||||
nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, s_api_key, &len);
|
if (MIMI_SECRET_MODEL[0] != '\0') {
|
||||||
|
strncpy(s_model, MIMI_SECRET_MODEL, sizeof(s_model) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
len = sizeof(s_model);
|
/* Fall back to NVS for values not set at build time */
|
||||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, s_model, &len) != ESP_OK) {
|
if (s_api_key[0] == '\0' || s_model[0] == '\0') {
|
||||||
strncpy(s_model, MIMI_LLM_DEFAULT_MODEL, sizeof(s_model) - 1);
|
nvs_handle_t nvs;
|
||||||
|
esp_err_t err = nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs);
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
if (s_api_key[0] == '\0') {
|
||||||
|
size_t len = sizeof(s_api_key);
|
||||||
|
nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, s_api_key, &len);
|
||||||
|
}
|
||||||
|
if (strcmp(s_model, MIMI_LLM_DEFAULT_MODEL) == 0) {
|
||||||
|
size_t len = sizeof(s_model);
|
||||||
|
nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, s_model, &len);
|
||||||
|
}
|
||||||
|
nvs_close(nvs);
|
||||||
}
|
}
|
||||||
nvs_close(nvs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s_api_key[0]) {
|
if (s_api_key[0]) {
|
||||||
@@ -134,12 +106,12 @@ esp_err_t llm_proxy_init(void)
|
|||||||
|
|
||||||
/* ── Direct path: esp_http_client ───────────────────────────── */
|
/* ── Direct path: esp_http_client ───────────────────────────── */
|
||||||
|
|
||||||
static esp_err_t llm_chat_direct(const char *post_data, sse_ctx_t *ctx, int *out_status)
|
static esp_err_t llm_http_direct(const char *post_data, resp_buf_t *rb, int *out_status)
|
||||||
{
|
{
|
||||||
esp_http_client_config_t config = {
|
esp_http_client_config_t config = {
|
||||||
.url = MIMI_LLM_API_URL,
|
.url = MIMI_LLM_API_URL,
|
||||||
.event_handler = http_event_handler,
|
.event_handler = http_event_handler,
|
||||||
.user_data = ctx,
|
.user_data = rb,
|
||||||
.timeout_ms = 120 * 1000,
|
.timeout_ms = 120 * 1000,
|
||||||
.buffer_size = 4096,
|
.buffer_size = 4096,
|
||||||
.buffer_size_tx = 4096,
|
.buffer_size_tx = 4096,
|
||||||
@@ -163,12 +135,11 @@ static esp_err_t llm_chat_direct(const char *post_data, sse_ctx_t *ctx, int *out
|
|||||||
|
|
||||||
/* ── Proxy path: manual HTTP over CONNECT tunnel ────────────── */
|
/* ── Proxy path: manual HTTP over CONNECT tunnel ────────────── */
|
||||||
|
|
||||||
static esp_err_t llm_chat_via_proxy(const char *post_data, sse_ctx_t *ctx, int *out_status)
|
static esp_err_t llm_http_via_proxy(const char *post_data, resp_buf_t *rb, int *out_status)
|
||||||
{
|
{
|
||||||
proxy_conn_t *conn = proxy_conn_open("api.anthropic.com", 443, 30000);
|
proxy_conn_t *conn = proxy_conn_open("api.anthropic.com", 443, 30000);
|
||||||
if (!conn) return ESP_ERR_HTTP_CONNECT;
|
if (!conn) return ESP_ERR_HTTP_CONNECT;
|
||||||
|
|
||||||
/* Build HTTP request */
|
|
||||||
int body_len = strlen(post_data);
|
int body_len = strlen(post_data);
|
||||||
char header[512];
|
char header[512];
|
||||||
int hlen = snprintf(header, sizeof(header),
|
int hlen = snprintf(header, sizeof(header),
|
||||||
@@ -187,55 +158,71 @@ static esp_err_t llm_chat_via_proxy(const char *post_data, sse_ctx_t *ctx, int *
|
|||||||
return ESP_ERR_HTTP_WRITE_DATA;
|
return ESP_ERR_HTTP_WRITE_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read response — first line is status */
|
/* Read full response into buffer */
|
||||||
size_t raw_len = 0;
|
char tmp[4096];
|
||||||
size_t raw_cap = 32768;
|
|
||||||
char *raw = calloc(1, raw_cap);
|
|
||||||
if (!raw) { proxy_conn_close(conn); return ESP_ERR_NO_MEM; }
|
|
||||||
|
|
||||||
while (1) {
|
while (1) {
|
||||||
if (raw_len + 4096 >= raw_cap) {
|
int n = proxy_conn_read(conn, tmp, sizeof(tmp), 120000);
|
||||||
raw_cap *= 2;
|
|
||||||
char *tmp = realloc(raw, raw_cap);
|
|
||||||
if (!tmp) break;
|
|
||||||
raw = tmp;
|
|
||||||
}
|
|
||||||
int n = proxy_conn_read(conn, raw + raw_len, 4096, 120000);
|
|
||||||
if (n <= 0) break;
|
if (n <= 0) break;
|
||||||
raw_len += n;
|
if (resp_buf_append(rb, tmp, n) != ESP_OK) break;
|
||||||
}
|
}
|
||||||
raw[raw_len] = '\0';
|
|
||||||
proxy_conn_close(conn);
|
proxy_conn_close(conn);
|
||||||
|
|
||||||
/* Parse status line */
|
/* Parse status line */
|
||||||
*out_status = 0;
|
*out_status = 0;
|
||||||
if (strncmp(raw, "HTTP/", 5) == 0) {
|
if (rb->len > 5 && strncmp(rb->data, "HTTP/", 5) == 0) {
|
||||||
const char *sp = strchr(raw, ' ');
|
const char *sp = strchr(rb->data, ' ');
|
||||||
if (sp) *out_status = atoi(sp + 1);
|
if (sp) *out_status = atoi(sp + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Find body after \r\n\r\n */
|
/* Strip HTTP headers, keep body only */
|
||||||
char *body = strstr(raw, "\r\n\r\n");
|
char *body = strstr(rb->data, "\r\n\r\n");
|
||||||
if (body) {
|
if (body) {
|
||||||
body += 4;
|
body += 4;
|
||||||
size_t body_len = raw_len - (body - raw);
|
size_t blen = rb->len - (body - rb->data);
|
||||||
if (*out_status == 200) {
|
memmove(rb->data, body, blen);
|
||||||
/* Feed body to SSE parser */
|
rb->len = blen;
|
||||||
sse_feed(ctx, body, body_len);
|
rb->data[rb->len] = '\0';
|
||||||
} else {
|
|
||||||
/* For error responses, capture raw body */
|
|
||||||
size_t copy_len = body_len < ctx->resp_cap - 1 ? body_len : ctx->resp_cap - 1;
|
|
||||||
memcpy(ctx->response, body, copy_len);
|
|
||||||
ctx->response[copy_len] = '\0';
|
|
||||||
ctx->resp_len = copy_len;
|
|
||||||
ESP_LOGE(TAG, "API error body: %.500s", body);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
free(raw);
|
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shared HTTP dispatch ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
static esp_err_t llm_http_call(const char *post_data, resp_buf_t *rb, int *out_status)
|
||||||
|
{
|
||||||
|
if (http_proxy_is_enabled()) {
|
||||||
|
return llm_http_via_proxy(post_data, rb, out_status);
|
||||||
|
} else {
|
||||||
|
return llm_http_direct(post_data, rb, out_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parse text from JSON response ────────────────────────────── */
|
||||||
|
|
||||||
|
static void extract_text(cJSON *root, char *buf, size_t size)
|
||||||
|
{
|
||||||
|
buf[0] = '\0';
|
||||||
|
cJSON *content = cJSON_GetObjectItem(root, "content");
|
||||||
|
if (!content || !cJSON_IsArray(content)) return;
|
||||||
|
|
||||||
|
size_t off = 0;
|
||||||
|
cJSON *block;
|
||||||
|
cJSON_ArrayForEach(block, content) {
|
||||||
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
||||||
|
if (!btype || strcmp(btype->valuestring, "text") != 0) continue;
|
||||||
|
cJSON *text = cJSON_GetObjectItem(block, "text");
|
||||||
|
if (!text || !cJSON_IsString(text)) continue;
|
||||||
|
size_t tlen = strlen(text->valuestring);
|
||||||
|
size_t copy = (tlen < size - off - 1) ? tlen : size - off - 1;
|
||||||
|
memcpy(buf + off, text->valuestring, copy);
|
||||||
|
off += copy;
|
||||||
|
}
|
||||||
|
buf[off] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Public: simple chat (backward compat) ────────────────────── */
|
||||||
|
|
||||||
esp_err_t llm_chat(const char *system_prompt, const char *messages_json,
|
esp_err_t llm_chat(const char *system_prompt, const char *messages_json,
|
||||||
char *response_buf, size_t buf_size)
|
char *response_buf, size_t buf_size)
|
||||||
{
|
{
|
||||||
@@ -244,21 +231,16 @@ esp_err_t llm_chat(const char *system_prompt, const char *messages_json,
|
|||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Build request body */
|
/* Build request body (non-streaming) */
|
||||||
cJSON *body = cJSON_CreateObject();
|
cJSON *body = cJSON_CreateObject();
|
||||||
cJSON_AddStringToObject(body, "model", s_model);
|
cJSON_AddStringToObject(body, "model", s_model);
|
||||||
cJSON_AddNumberToObject(body, "max_tokens", MIMI_LLM_MAX_TOKENS);
|
cJSON_AddNumberToObject(body, "max_tokens", MIMI_LLM_MAX_TOKENS);
|
||||||
cJSON_AddBoolToObject(body, "stream", 1);
|
|
||||||
|
|
||||||
/* System prompt — Anthropic format: top-level "system" field */
|
|
||||||
cJSON_AddStringToObject(body, "system", system_prompt);
|
cJSON_AddStringToObject(body, "system", system_prompt);
|
||||||
|
|
||||||
/* Messages array (parse from JSON string) */
|
|
||||||
cJSON *messages = cJSON_Parse(messages_json);
|
cJSON *messages = cJSON_Parse(messages_json);
|
||||||
if (messages) {
|
if (messages) {
|
||||||
cJSON_AddItemToObject(body, "messages", messages);
|
cJSON_AddItemToObject(body, "messages", messages);
|
||||||
} else {
|
} else {
|
||||||
/* Fallback: single user message */
|
|
||||||
cJSON *arr = cJSON_CreateArray();
|
cJSON *arr = cJSON_CreateArray();
|
||||||
cJSON *msg = cJSON_CreateObject();
|
cJSON *msg = cJSON_CreateObject();
|
||||||
cJSON_AddStringToObject(msg, "role", "user");
|
cJSON_AddStringToObject(msg, "role", "user");
|
||||||
@@ -269,65 +251,224 @@ esp_err_t llm_chat(const char *system_prompt, const char *messages_json,
|
|||||||
|
|
||||||
char *post_data = cJSON_PrintUnformatted(body);
|
char *post_data = cJSON_PrintUnformatted(body);
|
||||||
cJSON_Delete(body);
|
cJSON_Delete(body);
|
||||||
|
|
||||||
if (!post_data) {
|
if (!post_data) {
|
||||||
snprintf(response_buf, buf_size, "Error: Failed to build request");
|
snprintf(response_buf, buf_size, "Error: Failed to build request");
|
||||||
return ESP_ERR_NO_MEM;
|
return ESP_ERR_NO_MEM;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Calling Claude API (model: %s, body: %d bytes)", s_model, (int)strlen(post_data));
|
ESP_LOGI(TAG, "Calling Claude API (model: %s, body: %d bytes)",
|
||||||
|
s_model, (int)strlen(post_data));
|
||||||
|
|
||||||
/* SSE context */
|
resp_buf_t rb;
|
||||||
sse_ctx_t ctx = {0};
|
if (resp_buf_init(&rb, MIMI_LLM_STREAM_BUF_SIZE) != ESP_OK) {
|
||||||
ctx.response = calloc(1, MIMI_LLM_STREAM_BUF_SIZE);
|
|
||||||
ctx.resp_cap = MIMI_LLM_STREAM_BUF_SIZE;
|
|
||||||
if (!ctx.response) {
|
|
||||||
free(post_data);
|
free(post_data);
|
||||||
snprintf(response_buf, buf_size, "Error: Out of memory");
|
snprintf(response_buf, buf_size, "Error: Out of memory");
|
||||||
return ESP_ERR_NO_MEM;
|
return ESP_ERR_NO_MEM;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t err;
|
|
||||||
int status = 0;
|
int status = 0;
|
||||||
|
esp_err_t err = llm_http_call(post_data, &rb, &status);
|
||||||
if (http_proxy_is_enabled()) {
|
|
||||||
err = llm_chat_via_proxy(post_data, &ctx, &status);
|
|
||||||
} else {
|
|
||||||
err = llm_chat_direct(post_data, &ctx, &status);
|
|
||||||
}
|
|
||||||
free(post_data);
|
free(post_data);
|
||||||
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err));
|
ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err));
|
||||||
free(ctx.response);
|
resp_buf_free(&rb);
|
||||||
snprintf(response_buf, buf_size, "Error: HTTP request failed (%s)", esp_err_to_name(err));
|
snprintf(response_buf, buf_size, "Error: HTTP request failed (%s)",
|
||||||
|
esp_err_to_name(err));
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status != 200) {
|
if (status != 200) {
|
||||||
ESP_LOGE(TAG, "API returned status %d", status);
|
ESP_LOGE(TAG, "API returned status %d", status);
|
||||||
if (ctx.resp_len > 0) {
|
snprintf(response_buf, buf_size, "API error (HTTP %d): %.200s",
|
||||||
snprintf(response_buf, buf_size, "API error (HTTP %d): %.200s", status, ctx.response);
|
status, rb.data ? rb.data : "");
|
||||||
} else {
|
resp_buf_free(&rb);
|
||||||
snprintf(response_buf, buf_size, "API error (HTTP %d)", status);
|
|
||||||
}
|
|
||||||
free(ctx.response);
|
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Copy accumulated response */
|
/* Parse JSON response */
|
||||||
if (ctx.resp_len > 0) {
|
cJSON *root = cJSON_Parse(rb.data);
|
||||||
strncpy(response_buf, ctx.response, buf_size - 1);
|
resp_buf_free(&rb);
|
||||||
response_buf[buf_size - 1] = '\0';
|
|
||||||
ESP_LOGI(TAG, "Claude response: %d bytes", (int)ctx.resp_len);
|
if (!root) {
|
||||||
} else {
|
snprintf(response_buf, buf_size, "Error: Failed to parse response");
|
||||||
snprintf(response_buf, buf_size, "No response from Claude API");
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_text(root, response_buf, buf_size);
|
||||||
|
cJSON_Delete(root);
|
||||||
|
|
||||||
|
if (response_buf[0] == '\0') {
|
||||||
|
snprintf(response_buf, buf_size, "No response from Claude API");
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Claude response: %d bytes", (int)strlen(response_buf));
|
||||||
}
|
}
|
||||||
|
|
||||||
free(ctx.response);
|
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Public: chat with tools (non-streaming) ──────────────────── */
|
||||||
|
|
||||||
|
void llm_response_free(llm_response_t *resp)
|
||||||
|
{
|
||||||
|
free(resp->text);
|
||||||
|
resp->text = NULL;
|
||||||
|
resp->text_len = 0;
|
||||||
|
for (int i = 0; i < resp->call_count; i++) {
|
||||||
|
free(resp->calls[i].input);
|
||||||
|
resp->calls[i].input = NULL;
|
||||||
|
}
|
||||||
|
resp->call_count = 0;
|
||||||
|
resp->tool_use = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t llm_chat_tools(const char *system_prompt,
|
||||||
|
cJSON *messages,
|
||||||
|
const char *tools_json,
|
||||||
|
llm_response_t *resp)
|
||||||
|
{
|
||||||
|
memset(resp, 0, sizeof(*resp));
|
||||||
|
|
||||||
|
if (s_api_key[0] == '\0') return ESP_ERR_INVALID_STATE;
|
||||||
|
|
||||||
|
/* Build request body (non-streaming) */
|
||||||
|
cJSON *body = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(body, "model", s_model);
|
||||||
|
cJSON_AddNumberToObject(body, "max_tokens", MIMI_LLM_MAX_TOKENS);
|
||||||
|
cJSON_AddStringToObject(body, "system", system_prompt);
|
||||||
|
|
||||||
|
/* Deep-copy messages so caller keeps ownership */
|
||||||
|
cJSON *msgs_copy = cJSON_Duplicate(messages, 1);
|
||||||
|
cJSON_AddItemToObject(body, "messages", msgs_copy);
|
||||||
|
|
||||||
|
/* Add tools array if provided */
|
||||||
|
if (tools_json) {
|
||||||
|
cJSON *tools = cJSON_Parse(tools_json);
|
||||||
|
if (tools) {
|
||||||
|
cJSON_AddItemToObject(body, "tools", tools);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
char *post_data = cJSON_PrintUnformatted(body);
|
||||||
|
cJSON_Delete(body);
|
||||||
|
if (!post_data) return ESP_ERR_NO_MEM;
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Calling Claude API with tools (model: %s, body: %d bytes)",
|
||||||
|
s_model, (int)strlen(post_data));
|
||||||
|
|
||||||
|
/* HTTP call */
|
||||||
|
resp_buf_t rb;
|
||||||
|
if (resp_buf_init(&rb, MIMI_LLM_STREAM_BUF_SIZE) != ESP_OK) {
|
||||||
|
free(post_data);
|
||||||
|
return ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
int status = 0;
|
||||||
|
esp_err_t err = llm_http_call(post_data, &rb, &status);
|
||||||
|
free(post_data);
|
||||||
|
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err));
|
||||||
|
resp_buf_free(&rb);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != 200) {
|
||||||
|
ESP_LOGE(TAG, "API error %d: %.500s", status, rb.data ? rb.data : "");
|
||||||
|
resp_buf_free(&rb);
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parse full JSON response */
|
||||||
|
cJSON *root = cJSON_Parse(rb.data);
|
||||||
|
resp_buf_free(&rb);
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
ESP_LOGE(TAG, "Failed to parse API response JSON");
|
||||||
|
return ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stop_reason */
|
||||||
|
cJSON *stop_reason = cJSON_GetObjectItem(root, "stop_reason");
|
||||||
|
if (stop_reason && cJSON_IsString(stop_reason)) {
|
||||||
|
resp->tool_use = (strcmp(stop_reason->valuestring, "tool_use") == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Iterate content blocks */
|
||||||
|
cJSON *content = cJSON_GetObjectItem(root, "content");
|
||||||
|
if (content && cJSON_IsArray(content)) {
|
||||||
|
/* Accumulate total text length first */
|
||||||
|
size_t total_text = 0;
|
||||||
|
cJSON *block;
|
||||||
|
cJSON_ArrayForEach(block, content) {
|
||||||
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
||||||
|
if (btype && strcmp(btype->valuestring, "text") == 0) {
|
||||||
|
cJSON *text = cJSON_GetObjectItem(block, "text");
|
||||||
|
if (text && cJSON_IsString(text)) {
|
||||||
|
total_text += strlen(text->valuestring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allocate and copy text */
|
||||||
|
if (total_text > 0) {
|
||||||
|
resp->text = calloc(1, total_text + 1);
|
||||||
|
if (resp->text) {
|
||||||
|
cJSON_ArrayForEach(block, content) {
|
||||||
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
||||||
|
if (!btype || strcmp(btype->valuestring, "text") != 0) continue;
|
||||||
|
cJSON *text = cJSON_GetObjectItem(block, "text");
|
||||||
|
if (!text || !cJSON_IsString(text)) continue;
|
||||||
|
size_t tlen = strlen(text->valuestring);
|
||||||
|
memcpy(resp->text + resp->text_len, text->valuestring, tlen);
|
||||||
|
resp->text_len += tlen;
|
||||||
|
}
|
||||||
|
resp->text[resp->text_len] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extract tool_use blocks */
|
||||||
|
cJSON_ArrayForEach(block, content) {
|
||||||
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
||||||
|
if (!btype || strcmp(btype->valuestring, "tool_use") != 0) continue;
|
||||||
|
if (resp->call_count >= MIMI_MAX_TOOL_CALLS) break;
|
||||||
|
|
||||||
|
llm_tool_call_t *call = &resp->calls[resp->call_count];
|
||||||
|
|
||||||
|
cJSON *id = cJSON_GetObjectItem(block, "id");
|
||||||
|
if (id && cJSON_IsString(id)) {
|
||||||
|
strncpy(call->id, id->valuestring, sizeof(call->id) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON *name = cJSON_GetObjectItem(block, "name");
|
||||||
|
if (name && cJSON_IsString(name)) {
|
||||||
|
strncpy(call->name, name->valuestring, sizeof(call->name) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON *input = cJSON_GetObjectItem(block, "input");
|
||||||
|
if (input) {
|
||||||
|
char *input_str = cJSON_PrintUnformatted(input);
|
||||||
|
if (input_str) {
|
||||||
|
call->input = input_str;
|
||||||
|
call->input_len = strlen(input_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp->call_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_Delete(root);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Response: %d bytes text, %d tool calls, stop=%s",
|
||||||
|
(int)resp->text_len, resp->call_count,
|
||||||
|
resp->tool_use ? "tool_use" : "end_turn");
|
||||||
|
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NVS helpers ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
esp_err_t llm_set_api_key(const char *api_key)
|
esp_err_t llm_set_api_key(const char *api_key)
|
||||||
{
|
{
|
||||||
nvs_handle_t nvs;
|
nvs_handle_t nvs;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
|
#include "cJSON.h"
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include "mimi_config.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the LLM proxy. Reads API key and model from NVS.
|
* Initialize the LLM proxy. Reads API key and model from NVS.
|
||||||
@@ -29,3 +33,36 @@ esp_err_t llm_set_api_key(const char *api_key);
|
|||||||
* Save the model identifier to NVS.
|
* Save the model identifier to NVS.
|
||||||
*/
|
*/
|
||||||
esp_err_t llm_set_model(const char *model);
|
esp_err_t llm_set_model(const char *model);
|
||||||
|
|
||||||
|
/* ── Tool Use Support ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char id[64]; /* "toolu_xxx" */
|
||||||
|
char name[32]; /* "web_search" */
|
||||||
|
char *input; /* heap-allocated JSON string */
|
||||||
|
size_t input_len;
|
||||||
|
} llm_tool_call_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char *text; /* accumulated text blocks */
|
||||||
|
size_t text_len;
|
||||||
|
llm_tool_call_t calls[MIMI_MAX_TOOL_CALLS];
|
||||||
|
int call_count;
|
||||||
|
bool tool_use; /* stop_reason == "tool_use" */
|
||||||
|
} llm_response_t;
|
||||||
|
|
||||||
|
void llm_response_free(llm_response_t *resp);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat completion request with tools to Anthropic Messages API (streaming).
|
||||||
|
*
|
||||||
|
* @param system_prompt System prompt string
|
||||||
|
* @param messages cJSON array of messages (caller owns)
|
||||||
|
* @param tools_json Pre-built JSON string of tools array, or NULL for no tools
|
||||||
|
* @param resp Output: structured response with text and tool calls
|
||||||
|
* @return ESP_OK on success
|
||||||
|
*/
|
||||||
|
esp_err_t llm_chat_tools(const char *system_prompt,
|
||||||
|
cJSON *messages,
|
||||||
|
const char *tools_json,
|
||||||
|
llm_response_t *resp);
|
||||||
|
|||||||
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
/* MimiClaw Global Configuration */
|
/* MimiClaw Global Configuration */
|
||||||
|
|
||||||
|
/* Build-time secrets (highest priority, override NVS) */
|
||||||
|
#if __has_include("mimi_secrets.h")
|
||||||
|
#include "mimi_secrets.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef MIMI_SECRET_WIFI_SSID
|
||||||
|
#define MIMI_SECRET_WIFI_SSID ""
|
||||||
|
#endif
|
||||||
|
#ifndef MIMI_SECRET_WIFI_PASS
|
||||||
|
#define MIMI_SECRET_WIFI_PASS ""
|
||||||
|
#endif
|
||||||
|
#ifndef MIMI_SECRET_TG_TOKEN
|
||||||
|
#define MIMI_SECRET_TG_TOKEN ""
|
||||||
|
#endif
|
||||||
|
#ifndef MIMI_SECRET_API_KEY
|
||||||
|
#define MIMI_SECRET_API_KEY ""
|
||||||
|
#endif
|
||||||
|
#ifndef MIMI_SECRET_MODEL
|
||||||
|
#define MIMI_SECRET_MODEL ""
|
||||||
|
#endif
|
||||||
|
#ifndef MIMI_SECRET_PROXY_HOST
|
||||||
|
#define MIMI_SECRET_PROXY_HOST ""
|
||||||
|
#endif
|
||||||
|
#ifndef MIMI_SECRET_PROXY_PORT
|
||||||
|
#define MIMI_SECRET_PROXY_PORT ""
|
||||||
|
#endif
|
||||||
|
#ifndef MIMI_SECRET_SEARCH_KEY
|
||||||
|
#define MIMI_SECRET_SEARCH_KEY ""
|
||||||
|
#endif
|
||||||
|
|
||||||
/* WiFi */
|
/* WiFi */
|
||||||
#define MIMI_WIFI_MAX_RETRY 10
|
#define MIMI_WIFI_MAX_RETRY 10
|
||||||
#define MIMI_WIFI_RETRY_BASE_MS 1000
|
#define MIMI_WIFI_RETRY_BASE_MS 1000
|
||||||
@@ -19,6 +49,8 @@
|
|||||||
#define MIMI_AGENT_PRIO 6
|
#define MIMI_AGENT_PRIO 6
|
||||||
#define MIMI_AGENT_CORE 1
|
#define MIMI_AGENT_CORE 1
|
||||||
#define MIMI_AGENT_MAX_HISTORY 20
|
#define MIMI_AGENT_MAX_HISTORY 20
|
||||||
|
#define MIMI_AGENT_MAX_TOOL_ITER 10
|
||||||
|
#define MIMI_MAX_TOOL_CALLS 4
|
||||||
|
|
||||||
/* LLM */
|
/* LLM */
|
||||||
#define MIMI_LLM_DEFAULT_MODEL "claude-opus-4-6"
|
#define MIMI_LLM_DEFAULT_MODEL "claude-opus-4-6"
|
||||||
@@ -58,6 +90,7 @@
|
|||||||
#define MIMI_NVS_TG "tg_config"
|
#define MIMI_NVS_TG "tg_config"
|
||||||
#define MIMI_NVS_LLM "llm_config"
|
#define MIMI_NVS_LLM "llm_config"
|
||||||
#define MIMI_NVS_PROXY "proxy_config"
|
#define MIMI_NVS_PROXY "proxy_config"
|
||||||
|
#define MIMI_NVS_SEARCH "search_config"
|
||||||
|
|
||||||
/* NVS Keys */
|
/* NVS Keys */
|
||||||
#define MIMI_NVS_KEY_SSID "ssid"
|
#define MIMI_NVS_KEY_SSID "ssid"
|
||||||
|
|||||||
92
main/tools/tool_registry.c
Normal file
92
main/tools/tool_registry.c
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#include "tool_registry.h"
|
||||||
|
#include "tools/tool_web_search.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "cJSON.h"
|
||||||
|
|
||||||
|
static const char *TAG = "tools";
|
||||||
|
|
||||||
|
#define MAX_TOOLS 8
|
||||||
|
|
||||||
|
static mimi_tool_t s_tools[MAX_TOOLS];
|
||||||
|
static int s_tool_count = 0;
|
||||||
|
static char *s_tools_json = NULL; /* cached JSON array string */
|
||||||
|
|
||||||
|
static void register_tool(const mimi_tool_t *tool)
|
||||||
|
{
|
||||||
|
if (s_tool_count >= MAX_TOOLS) {
|
||||||
|
ESP_LOGE(TAG, "Tool registry full");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_tools[s_tool_count++] = *tool;
|
||||||
|
ESP_LOGI(TAG, "Registered tool: %s", tool->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void build_tools_json(void)
|
||||||
|
{
|
||||||
|
cJSON *arr = cJSON_CreateArray();
|
||||||
|
|
||||||
|
for (int i = 0; i < s_tool_count; i++) {
|
||||||
|
cJSON *tool = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(tool, "name", s_tools[i].name);
|
||||||
|
cJSON_AddStringToObject(tool, "description", s_tools[i].description);
|
||||||
|
|
||||||
|
cJSON *schema = cJSON_Parse(s_tools[i].input_schema_json);
|
||||||
|
if (schema) {
|
||||||
|
cJSON_AddItemToObject(tool, "input_schema", schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_AddItemToArray(arr, tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(s_tools_json);
|
||||||
|
s_tools_json = cJSON_PrintUnformatted(arr);
|
||||||
|
cJSON_Delete(arr);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Tools JSON built (%d tools)", s_tool_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t tool_registry_init(void)
|
||||||
|
{
|
||||||
|
s_tool_count = 0;
|
||||||
|
|
||||||
|
/* Register web_search */
|
||||||
|
tool_web_search_init();
|
||||||
|
|
||||||
|
mimi_tool_t ws = {
|
||||||
|
.name = "web_search",
|
||||||
|
.description = "Search the web for current information. Use this when you need up-to-date facts, news, weather, or anything beyond your training data.",
|
||||||
|
.input_schema_json =
|
||||||
|
"{\"type\":\"object\","
|
||||||
|
"\"properties\":{\"query\":{\"type\":\"string\",\"description\":\"The search query\"}},"
|
||||||
|
"\"required\":[\"query\"]}",
|
||||||
|
.execute = tool_web_search_execute,
|
||||||
|
};
|
||||||
|
register_tool(&ws);
|
||||||
|
|
||||||
|
build_tools_json();
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Tool registry initialized");
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *tool_registry_get_tools_json(void)
|
||||||
|
{
|
||||||
|
return s_tools_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t tool_registry_execute(const char *name, const char *input_json,
|
||||||
|
char *output, size_t output_size)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < s_tool_count; i++) {
|
||||||
|
if (strcmp(s_tools[i].name, name) == 0) {
|
||||||
|
ESP_LOGI(TAG, "Executing tool: %s", name);
|
||||||
|
return s_tools[i].execute(input_json, output, output_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGW(TAG, "Unknown tool: %s", name);
|
||||||
|
snprintf(output, output_size, "Error: unknown tool '%s'", name);
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
|
}
|
||||||
34
main/tools/tool_registry.h
Normal file
34
main/tools/tool_registry.h
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *name;
|
||||||
|
const char *description;
|
||||||
|
const char *input_schema_json; /* JSON Schema string for input */
|
||||||
|
esp_err_t (*execute)(const char *input_json, char *output, size_t output_size);
|
||||||
|
} mimi_tool_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize tool registry and register all built-in tools.
|
||||||
|
*/
|
||||||
|
esp_err_t tool_registry_init(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pre-built tools JSON array string for the API request.
|
||||||
|
* Returns NULL if no tools are registered.
|
||||||
|
*/
|
||||||
|
const char *tool_registry_get_tools_json(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a tool by name.
|
||||||
|
*
|
||||||
|
* @param name Tool name (e.g. "web_search")
|
||||||
|
* @param input_json JSON string of tool input
|
||||||
|
* @param output Output buffer for tool result text
|
||||||
|
* @param output_size Size of output buffer
|
||||||
|
* @return ESP_OK on success, ESP_ERR_NOT_FOUND if tool unknown
|
||||||
|
*/
|
||||||
|
esp_err_t tool_registry_execute(const char *name, const char *input_json,
|
||||||
|
char *output, size_t output_size);
|
||||||
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;
|
||||||
|
}
|
||||||
24
main/tools/tool_web_search.h
Normal file
24
main/tools/tool_web_search.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize web search tool — loads API key from NVS.
|
||||||
|
*/
|
||||||
|
esp_err_t tool_web_search_init(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a web search.
|
||||||
|
*
|
||||||
|
* @param input_json JSON string with "query" field
|
||||||
|
* @param output Output buffer for formatted search results
|
||||||
|
* @param output_size Size of output buffer
|
||||||
|
* @return ESP_OK on success
|
||||||
|
*/
|
||||||
|
esp_err_t tool_web_search_execute(const char *input_json, char *output, size_t output_size);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save Brave Search API key to NVS.
|
||||||
|
*/
|
||||||
|
esp_err_t tool_web_search_set_key(const char *api_key);
|
||||||
Reference in New Issue
Block a user