Files
nostr_terminal/src/menu_ai.c

662 lines
17 KiB
C

#include "tui.h"
#include "state.h"
#include "publish.h"
#include "../resources/nostr_core_lib/cjson/cJSON.h"
#include <curl/curl.h>
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
typedef struct {
char *endpoint;
char *api_key;
char *model;
} ai_prefs_t;
typedef struct {
char *data;
size_t len;
} nt_http_buffer_t;
static char *ai_strdup(const char *s) {
size_t n;
char *out;
if (!s) {
return NULL;
}
n = strlen(s);
out = (char *)malloc(n + 1U);
if (!out) {
return NULL;
}
memcpy(out, s, n + 1U);
return out;
}
static void ai_set_string(char **dst, const char *src) {
char *next;
if (!dst) {
return;
}
next = ai_strdup(src ? src : "");
if (!next) {
return;
}
free(*dst);
*dst = next;
}
static void ai_prefs_init(ai_prefs_t *prefs) {
if (!prefs) {
return;
}
memset(prefs, 0, sizeof(*prefs));
prefs->endpoint = ai_strdup("https://api.venice.ai/api/v1/chat/completions");
prefs->api_key = ai_strdup("");
prefs->model = ai_strdup("llama-3.3-70b");
}
static void ai_prefs_free(ai_prefs_t *prefs) {
if (!prefs) {
return;
}
free(prefs->endpoint);
free(prefs->api_key);
free(prefs->model);
memset(prefs, 0, sizeof(*prefs));
}
static int ai_ends_with_chat_completions(const char *s) {
static const char *suffix = "/chat/completions";
size_t s_len;
size_t suffix_len;
if (!s) {
return 0;
}
s_len = strlen(s);
suffix_len = strlen(suffix);
if (s_len < suffix_len) {
return 0;
}
return strcmp(s + (s_len - suffix_len), suffix) == 0;
}
static char *ai_join_chat_completions(const char *base_url) {
size_t base_len;
int needs_slash;
char *out;
if (!base_url || base_url[0] == '\0') {
return ai_strdup("https://api.venice.ai/api/v1/chat/completions");
}
if (ai_ends_with_chat_completions(base_url)) {
return ai_strdup(base_url);
}
base_len = strlen(base_url);
needs_slash = (base_len > 0U && base_url[base_len - 1U] != '/');
out = (char *)malloc(base_len + (needs_slash ? 1U : 0U) + strlen("chat/completions") + 1U);
if (!out) {
return NULL;
}
snprintf(out,
base_len + (needs_slash ? 1U : 0U) + strlen("chat/completions") + 1U,
"%s%schat/completions",
base_url,
needs_slash ? "/" : "");
return out;
}
static char *ai_base_url_from_endpoint(const char *endpoint) {
static const char *suffix = "/chat/completions";
size_t endpoint_len;
size_t suffix_len;
char *out;
if (!endpoint || endpoint[0] == '\0') {
return ai_strdup("");
}
endpoint_len = strlen(endpoint);
suffix_len = strlen(suffix);
if (endpoint_len >= suffix_len &&
strcmp(endpoint + (endpoint_len - suffix_len), suffix) == 0) {
out = (char *)malloc(endpoint_len - suffix_len + 1U);
if (!out) {
return NULL;
}
memcpy(out, endpoint, endpoint_len - suffix_len);
out[endpoint_len - suffix_len] = '\0';
return out;
}
return ai_strdup(endpoint);
}
static cJSON *ai_make_d_tag(const char *dval) {
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag;
if (!tags) {
return NULL;
}
d_tag = cJSON_CreateArray();
if (!d_tag) {
cJSON_Delete(tags);
return NULL;
}
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(dval ? dval : ""));
cJSON_AddItemToArray(tags, d_tag);
return tags;
}
static size_t nt_http_write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) {
nt_http_buffer_t *buf = (nt_http_buffer_t *)userdata;
size_t total = size * nmemb;
char *next;
if (!buf || !ptr || total == 0U) {
return 0;
}
next = (char *)realloc(buf->data, buf->len + total + 1U);
if (!next) {
return 0;
}
buf->data = next;
memcpy(buf->data + buf->len, ptr, total);
buf->len += total;
buf->data[buf->len] = '\0';
return total;
}
static void nt_configure_curl_tls(CURL *curl) {
const char *cafile_env;
const char *capath_env;
static const char *cafile_candidates[] = {
"/etc/ssl/certs/ca-certificates.crt",
"/etc/ssl/cert.pem",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem"
};
int i;
if (!curl) {
return;
}
cafile_env = getenv("SSL_CERT_FILE");
if (!cafile_env || cafile_env[0] == '\0') {
cafile_env = getenv("CURL_CA_BUNDLE");
}
if (cafile_env && cafile_env[0] != '\0') {
curl_easy_setopt(curl, CURLOPT_CAINFO, cafile_env);
return;
}
capath_env = getenv("SSL_CERT_DIR");
if (capath_env && capath_env[0] != '\0') {
curl_easy_setopt(curl, CURLOPT_CAPATH, capath_env);
}
for (i = 0; i < (int)(sizeof(cafile_candidates) / sizeof(cafile_candidates[0])); i++) {
if (access(cafile_candidates[i], R_OK) == 0) {
curl_easy_setopt(curl, CURLOPT_CAINFO, cafile_candidates[i]);
return;
}
}
if (access("/etc/ssl/certs", R_OK) == 0) {
curl_easy_setopt(curl, CURLOPT_CAPATH, "/etc/ssl/certs");
}
}
static int ai_load_prefs(ai_prefs_t *prefs) {
cJSON *root;
cJSON *llm;
cJSON *api_key;
cJSON *model;
cJSON *base_url;
cJSON *legacy_endpoint;
char *endpoint;
if (!prefs || !g_state.user_settings_json) {
return -1;
}
root = cJSON_Parse(g_state.user_settings_json);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
llm = cJSON_GetObjectItemCaseSensitive(root, "global_llm");
if (!cJSON_IsObject(llm)) {
cJSON_Delete(root);
return -1;
}
api_key = cJSON_GetObjectItemCaseSensitive(llm, "api_key");
model = cJSON_GetObjectItemCaseSensitive(llm, "model");
base_url = cJSON_GetObjectItemCaseSensitive(llm, "base_url");
legacy_endpoint = cJSON_GetObjectItemCaseSensitive(llm, "endpoint");
if (cJSON_IsString(api_key) && api_key->valuestring) {
ai_set_string(&prefs->api_key, api_key->valuestring);
}
if (cJSON_IsString(model) && model->valuestring) {
ai_set_string(&prefs->model, model->valuestring);
}
endpoint = NULL;
if (cJSON_IsString(base_url) && base_url->valuestring) {
endpoint = ai_join_chat_completions(base_url->valuestring);
} else if (cJSON_IsString(legacy_endpoint) && legacy_endpoint->valuestring) {
endpoint = ai_strdup(legacy_endpoint->valuestring);
}
if (endpoint) {
ai_set_string(&prefs->endpoint, endpoint);
free(endpoint);
}
cJSON_Delete(root);
return 0;
}
static int ai_save_prefs(const ai_prefs_t *prefs) {
cJSON *root;
cJSON *llm;
cJSON *tags;
char *new_json;
char *cipher = NULL;
char *base_url = NULL;
int posted;
if (!prefs) {
return -1;
}
root = g_state.user_settings_json
? cJSON_Parse(g_state.user_settings_json)
: cJSON_CreateObject();
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
if (!cJSON_GetObjectItemCaseSensitive(root, "v")) {
cJSON_AddNumberToObject(root, "v", 2);
}
cJSON_DeleteItemFromObjectCaseSensitive(root, "updatedAt");
cJSON_AddNumberToObject(root, "updatedAt", (double)time(NULL));
if (!cJSON_GetObjectItemCaseSensitive(root, "global_zaps")) {
cJSON_AddObjectToObject(root, "global_zaps");
}
if (!cJSON_GetObjectItemCaseSensitive(root, "global_ui")) {
cJSON_AddObjectToObject(root, "global_ui");
}
if (!cJSON_GetObjectItemCaseSensitive(root, "global_relays")) {
cJSON_AddObjectToObject(root, "global_relays");
}
if (!cJSON_GetObjectItemCaseSensitive(root, "global_experimental")) {
cJSON_AddObjectToObject(root, "global_experimental");
}
llm = cJSON_GetObjectItemCaseSensitive(root, "global_llm");
if (!cJSON_IsObject(llm)) {
cJSON_DeleteItemFromObjectCaseSensitive(root, "global_llm");
llm = cJSON_AddObjectToObject(root, "global_llm");
}
if (!llm) {
cJSON_Delete(root);
return -1;
}
base_url = ai_base_url_from_endpoint(prefs->endpoint ? prefs->endpoint : "");
if (!base_url) {
cJSON_Delete(root);
return -1;
}
cJSON_DeleteItemFromObjectCaseSensitive(llm, "api_key");
cJSON_AddStringToObject(llm, "api_key", prefs->api_key ? prefs->api_key : "");
cJSON_DeleteItemFromObjectCaseSensitive(llm, "model");
cJSON_AddStringToObject(llm, "model", prefs->model ? prefs->model : "");
cJSON_DeleteItemFromObjectCaseSensitive(llm, "base_url");
cJSON_AddStringToObject(llm, "base_url", base_url);
new_json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
free(base_url);
if (!new_json) {
return -1;
}
free(g_state.user_settings_json);
g_state.user_settings_json = new_json;
if (state_nip44_self_encrypt(new_json, &cipher) != 0 || !cipher) {
return -1;
}
tags = ai_make_d_tag("user-settings");
if (!tags) {
free(cipher);
return -1;
}
posted = publish_signed_event_with_report("UserSettings", 30078, cipher, tags, 8000);
cJSON_Delete(tags);
free(cipher);
return (posted < 0) ? -1 : 0;
}
static char *ai_http_post(const char *url, const char *auth_header, const char *json_body) {
CURL *curl;
CURLcode rc;
long status = 0;
nt_http_buffer_t body = {0};
struct curl_slist *headers = NULL;
char curl_err[CURL_ERROR_SIZE] = {0};
int insecure_retry = 0;
if (!url || !json_body) {
return NULL;
}
if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) {
return NULL;
}
curl = curl_easy_init();
if (!curl) {
curl_global_cleanup();
return NULL;
}
headers = curl_slist_append(headers, "Content-Type: application/json");
if (auth_header && auth_header[0] != '\0') {
headers = curl_slist_append(headers, auth_header);
}
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(json_body));
curl_easy_setopt(curl, CURLOPT_USERAGENT, "nostr-terminal/0.0.1");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 5000L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 30000L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, nt_http_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&body);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
nt_configure_curl_tls(curl);
if (headers) {
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
}
curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, curl_err);
retry_request:
rc = curl_easy_perform(curl);
if (rc != CURLE_OK) {
if (!insecure_retry &&
(rc == CURLE_PEER_FAILED_VERIFICATION || rc == CURLE_SSL_CACERT_BADFILE)) {
insecure_retry = 1;
free(body.data);
body.data = NULL;
body.len = 0;
curl_err[0] = '\0';
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
goto retry_request;
}
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
curl_global_cleanup();
free(body.data);
return NULL;
}
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
if (status < 200 || status >= 300) {
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
curl_global_cleanup();
free(body.data);
return NULL;
}
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
curl_global_cleanup();
return body.data;
}
static char *ai_extract_response(const char *resp_json) {
cJSON *root;
cJSON *choices;
cJSON *choice0;
cJSON *message;
cJSON *content;
char *out;
if (!resp_json) {
return NULL;
}
root = cJSON_Parse(resp_json);
if (!root) {
return NULL;
}
choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
choice0 = cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0) : NULL;
message = cJSON_IsObject(choice0) ? cJSON_GetObjectItemCaseSensitive(choice0, "message") : NULL;
content = cJSON_IsObject(message) ? cJSON_GetObjectItemCaseSensitive(message, "content") : NULL;
if (!cJSON_IsString(content) || !content->valuestring) {
cJSON_Delete(root);
return NULL;
}
out = ai_strdup(content->valuestring);
cJSON_Delete(root);
return out;
}
static void ai_show_response_stream(const char *text) {
nt_pager_show_text("> Main Menu > AI > Chat > Response", text ? text : "");
}
static void ai_chat(const ai_prefs_t *prefs) {
char prompt[4096];
cJSON *root;
cJSON *messages;
cJSON *msg;
char *body;
char auth[4096];
char *resp_json;
char *answer;
if (!prefs) {
return;
}
prompt[0] = '\0';
if (tuin_prompt("prompt >", "", prompt, sizeof(prompt)) != 0 || prompt[0] == '\0') {
return;
}
if (!prefs->api_key || prefs->api_key[0] == '\0') {
tuin_notice("AI API key is empty. Configure settings first.");
return;
}
root = cJSON_CreateObject();
if (!root) {
return;
}
cJSON_AddStringToObject(root, "model", prefs->model ? prefs->model : "");
messages = cJSON_CreateArray();
msg = cJSON_CreateObject();
if (!messages || !msg) {
cJSON_Delete(messages);
cJSON_Delete(msg);
cJSON_Delete(root);
return;
}
cJSON_AddStringToObject(msg, "role", "user");
cJSON_AddStringToObject(msg, "content", prompt);
cJSON_AddItemToArray(messages, msg);
cJSON_AddItemToObject(root, "messages", messages);
body = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (!body) {
return;
}
snprintf(auth, sizeof(auth), "Authorization: Bearer %s", prefs->api_key ? prefs->api_key : "");
nt_log("Requesting AI response...");
resp_json = ai_http_post(prefs->endpoint ? prefs->endpoint : "", auth, body);
free(body);
if (!resp_json) {
tuin_notice("AI request failed.");
return;
}
answer = ai_extract_response(resp_json);
free(resp_json);
if (answer) {
nt_log("AI response received.");
ai_show_response_stream(answer);
} else {
tuin_notice("Failed to parse AI response.");
}
free(answer);
}
static void ai_choose_model(ai_prefs_t *prefs) {
char model[256];
if (!prefs) {
return;
}
model[0] = '\0';
if (tuin_prompt("model >", prefs->model ? prefs->model : "", model, sizeof(model)) != 0 || model[0] == '\0') {
return;
}
ai_set_string(&prefs->model, model);
}
static void ai_settings(ai_prefs_t *prefs) {
char endpoint[512];
char key[1024];
char model[256];
if (!prefs) {
return;
}
endpoint[0] = '\0';
if (tuin_prompt("new endpoint >", prefs->endpoint ? prefs->endpoint : "", endpoint, sizeof(endpoint)) == 0 &&
endpoint[0] != '\0') {
ai_set_string(&prefs->endpoint, endpoint);
}
key[0] = '\0';
if (tuin_prompt("new api key >", "", key, sizeof(key)) == 0 && key[0] != '\0') {
ai_set_string(&prefs->api_key, key);
}
model[0] = '\0';
if (tuin_prompt("new model >", prefs->model ? prefs->model : "", model, sizeof(model)) == 0 &&
model[0] != '\0') {
ai_set_string(&prefs->model, model);
}
if (ai_save_prefs(prefs) != 0) {
tuin_notice("Failed to save prefs.");
} else {
nt_log("Prefs saved.");
}
}
void menu_ai(void) {
static const TuiMenuItem AI_ITEMS[] = {
{NT_HK("C", "hat"), 'c'},
{NT_HK("M", "odel"), 'm'},
{NT_HK("S", "ettings"), 's'},
{NT_HK("X", "Exit"), 'x'},
};
ai_prefs_t prefs;
TuiMenuState menu_state = {0};
ai_prefs_init(&prefs);
(void)ai_load_prefs(&prefs);
while (1) {
TuiFrame frame = nt_frame("> Main Menu > AI");
char status_text[1024];
TuiStatus status;
TuiMenu menu = {AI_ITEMS, (int)(sizeof(AI_ITEMS) / sizeof(AI_ITEMS[0]))};
int idx;
snprintf(status_text,
sizeof(status_text),
"Model: %s | Endpoint: %s",
prefs.model ? prefs.model : "",
prefs.endpoint ? prefs.endpoint : "");
status.text = status_text;
idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
if (idx < 0 || idx == 3) {
break;
}
if (idx == 0) {
ai_chat(&prefs);
} else if (idx == 1) {
ai_choose_model(&prefs);
} else if (idx == 2) {
ai_settings(&prefs);
}
}
ai_prefs_free(&prefs);
}