feat(ai): add AI client with multi-provider support and UI
Some checks failed
CI Code / Check coding style (pull_request) Successful in 47s
CI Code / Check spelling (pull_request) Successful in 59s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m41s
CI Code / Linux (arch) (pull_request) Failing after 3m15s
Some checks failed
CI Code / Check coding style (pull_request) Successful in 47s
CI Code / Check spelling (pull_request) Successful in 59s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m41s
CI Code / Linux (arch) (pull_request) Failing after 3m15s
Add an AI client module that integrates with OpenAI-compatible API providers (OpenAI, Perplexity, and custom providers) to provide AI-assisted responses within the profanity client. The implementation includes: - src/ai/ai_client.c/h: Core AI client with provider management, session handling, and async HTTP request handling via libcurl. Supports per-provider API keys stored in preferences, reference- counted sessions, and conversation history tracking. - src/ui/window.c/window_list.c: New AI window type (ProfAiWin) for displaying AI conversations, with response streaming and error display capabilities. - Command integration: New `/ai` command (cmd_defs.c, cmd_funcs.c) for creating sessions, sending prompts, and managing providers. Provider autocomplete support in cmd_ac.c. - Preferences integration: API keys for providers are persisted in the preferences system (config/preferences.c). - Unit tests: 472 lines of comprehensive tests covering provider management, session lifecycle, JSON escaping, and autocomplete (tests/unittests/test_ai_client.c). Architecture decisions: - Asynchronous design: HTTP requests run on a separate thread to avoid blocking the main UI loop. Callbacks are invoked on the main thread via direct function call (profanity uses ncurses, not GLib main loop). - Reference counting: Both AIProvider and AISession use ref counting for safe shared ownership. - Response size limit: 10MB cap on HTTP responses to prevent OOM.
This commit is contained in:
843
src/ai/ai_client.c
Normal file
843
src/ai/ai_client.c
Normal file
@@ -0,0 +1,843 @@
|
||||
/*
|
||||
* ai_client.c - AI client for interacting with OpenAI-compatible API providers
|
||||
*
|
||||
* Supports multiple providers (OpenAI, Perplexity, etc.) with per-provider
|
||||
* API keys, custom endpoints, and model selection.
|
||||
*
|
||||
* Copyright (C) 2026 CProof Developers
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception
|
||||
*/
|
||||
// vim: expandtab:ts=4:sts=4:sw=4
|
||||
|
||||
#include "ai_client.h"
|
||||
#include "common.h"
|
||||
#include "log.h"
|
||||
#include "config/preferences.h"
|
||||
#include "ui/window.h"
|
||||
#include "profanity.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <glib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Default providers */
|
||||
#define DEFAULT_OPENAI_URL "https://api.openai.com/v1/responses"
|
||||
#define DEFAULT_PERPLEXITY_URL "https://api.perplexity.ai/v1/responses"
|
||||
|
||||
/* Global state */
|
||||
static GHashTable* providers = NULL;
|
||||
static GHashTable* provider_keys = NULL;
|
||||
|
||||
/* ========================================================================
|
||||
* Curl helpers
|
||||
* ======================================================================== */
|
||||
|
||||
struct curl_response_t
|
||||
{
|
||||
gchar* data;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
static size_t
|
||||
_write_callback(void* ptr, size_t size, size_t nmemb, void* userdata)
|
||||
{
|
||||
size_t realsize = size * nmemb;
|
||||
struct curl_response_t* res = (struct curl_response_t*)userdata;
|
||||
|
||||
/* Limit response size to 10MB to prevent OOM */
|
||||
if (res->size + realsize > 10 * 1024 * 1024) {
|
||||
log_error("[AI-THREAD] Response too large, truncating");
|
||||
return realsize;
|
||||
}
|
||||
|
||||
gchar* new_data = g_realloc(res->data, res->size + realsize + 1);
|
||||
if (!new_data) {
|
||||
log_error("[AI-THREAD] Failed to allocate memory for response");
|
||||
return realsize;
|
||||
}
|
||||
|
||||
res->data = new_data;
|
||||
memcpy(res->data + res->size, ptr, realsize);
|
||||
res->size += realsize;
|
||||
res->data[res->size] = '\0';
|
||||
|
||||
return realsize;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Thread-safe UI callback helpers
|
||||
* ======================================================================== */
|
||||
|
||||
typedef struct
|
||||
{
|
||||
ai_response_cb response_cb;
|
||||
ai_error_cb error_cb;
|
||||
gpointer user_data;
|
||||
gchar* response;
|
||||
gchar* error;
|
||||
gboolean is_error;
|
||||
} ai_callback_data_t;
|
||||
|
||||
static gboolean
|
||||
_ai_callback_invoke(gpointer data)
|
||||
{
|
||||
log_debug("[AI-CALLBACK] _ai_callback_invoke ENTER");
|
||||
ai_callback_data_t* cb_data = (ai_callback_data_t*)data;
|
||||
|
||||
if (cb_data->is_error) {
|
||||
log_debug("[AI-CALLBACK] Invoking error_cb: %s", cb_data->error);
|
||||
if (cb_data->error_cb) {
|
||||
cb_data->error_cb(cb_data->error, cb_data->user_data);
|
||||
}
|
||||
} else {
|
||||
log_debug("[AI-CALLBACK] Invoking response_cb: %s", cb_data->response);
|
||||
if (cb_data->response_cb) {
|
||||
cb_data->response_cb(cb_data->response, cb_data->user_data);
|
||||
}
|
||||
}
|
||||
|
||||
g_free(cb_data->response);
|
||||
g_free(cb_data->error);
|
||||
g_free(cb_data);
|
||||
log_debug("[AI-CALLBACK] _ai_callback_invoke EXIT");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void
|
||||
_ai_invoke_callback(ai_callback_data_t* cb_data)
|
||||
{
|
||||
log_debug("[AI-CALLBACK] _ai_invoke_callback: is_error=%d, response_cb=%p, error_cb=%p",
|
||||
cb_data->is_error, (void*)cb_data->response_cb, (void*)cb_data->error_cb);
|
||||
/* profanity uses ncurses, not GLib main loop, so call directly */
|
||||
_ai_callback_invoke(cb_data);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* JSON helpers
|
||||
* ======================================================================== */
|
||||
|
||||
gchar*
|
||||
ai_json_escape(const gchar* str)
|
||||
{
|
||||
if (!str)
|
||||
return g_strdup("");
|
||||
|
||||
GString* result = g_string_new("");
|
||||
for (const gchar* p = str; *p; p++) {
|
||||
switch (*p) {
|
||||
case '"':
|
||||
g_string_append(result, "\\\"");
|
||||
break;
|
||||
case '\\':
|
||||
g_string_append(result, "\\\\");
|
||||
break;
|
||||
case '\b':
|
||||
g_string_append(result, "\\b");
|
||||
break;
|
||||
case '\f':
|
||||
g_string_append(result, "\\f");
|
||||
break;
|
||||
case '\n':
|
||||
g_string_append(result, "\\n");
|
||||
break;
|
||||
case '\r':
|
||||
g_string_append(result, "\\r");
|
||||
break;
|
||||
case '\t':
|
||||
g_string_append(result, "\\t");
|
||||
break;
|
||||
default:
|
||||
g_string_append_c(result, *p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g_string_free(result, FALSE);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management
|
||||
* ======================================================================== */
|
||||
|
||||
static AIProvider*
|
||||
ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
{
|
||||
AIProvider* provider = g_new0(AIProvider, 1);
|
||||
provider->name = g_strdup(name);
|
||||
provider->api_url = g_strdup(api_url ? api_url : "");
|
||||
provider->org_id = g_strdup(org_id);
|
||||
provider->project_id = NULL;
|
||||
provider->models = NULL;
|
||||
provider->ref_count = 1;
|
||||
return provider;
|
||||
}
|
||||
|
||||
static AIProvider*
|
||||
ai_provider_ref(AIProvider* provider)
|
||||
{
|
||||
if (provider) {
|
||||
provider->ref_count++;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
void
|
||||
ai_provider_unref(AIProvider* provider)
|
||||
{
|
||||
if (!provider)
|
||||
return;
|
||||
|
||||
provider->ref_count--;
|
||||
if (provider->ref_count > 0)
|
||||
return;
|
||||
|
||||
g_free(provider->name);
|
||||
g_free(provider->api_url);
|
||||
g_free(provider->org_id);
|
||||
g_free(provider->project_id);
|
||||
|
||||
GList* curr = provider->models;
|
||||
while (curr) {
|
||||
g_free(curr->data);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(provider->models);
|
||||
|
||||
g_free(provider);
|
||||
}
|
||||
|
||||
static void
|
||||
ai_load_keys(void)
|
||||
{
|
||||
if (!provider_keys) {
|
||||
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
}
|
||||
|
||||
GList* tokens = prefs_ai_list_tokens();
|
||||
if (!tokens) {
|
||||
return;
|
||||
}
|
||||
|
||||
GList* curr = tokens;
|
||||
while (curr) {
|
||||
gchar* provider = (gchar*)curr->data;
|
||||
gchar* key = prefs_ai_get_token(provider);
|
||||
if (key && strlen(key) > 0) {
|
||||
g_hash_table_insert(provider_keys, g_strdup(provider), g_strdup(key));
|
||||
}
|
||||
g_free(key);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
prefs_free_ai_tokens(tokens);
|
||||
log_info("Loaded %d saved API keys from config", g_hash_table_size(provider_keys));
|
||||
}
|
||||
|
||||
void
|
||||
ai_client_init(void)
|
||||
{
|
||||
if (providers)
|
||||
return; /* Already initialized */
|
||||
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
|
||||
/* Create hash tables */
|
||||
providers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, (GDestroyNotify)ai_provider_unref);
|
||||
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
|
||||
/* Add default providers */
|
||||
ai_add_provider("openai", DEFAULT_OPENAI_URL, NULL);
|
||||
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL, NULL);
|
||||
|
||||
/* Load saved API keys from config */
|
||||
ai_load_keys();
|
||||
|
||||
log_info("AI client initialized with default providers: openai, perplexity");
|
||||
}
|
||||
|
||||
void
|
||||
ai_client_shutdown(void)
|
||||
{
|
||||
if (!providers)
|
||||
return;
|
||||
|
||||
g_hash_table_destroy(providers);
|
||||
g_hash_table_destroy(provider_keys);
|
||||
providers = NULL;
|
||||
provider_keys = NULL;
|
||||
|
||||
curl_global_cleanup();
|
||||
log_info("AI client shutdown");
|
||||
}
|
||||
|
||||
AIProvider*
|
||||
ai_get_provider(const gchar* name)
|
||||
{
|
||||
if (!name || !providers)
|
||||
return NULL;
|
||||
return g_hash_table_lookup(providers, name);
|
||||
}
|
||||
|
||||
AIProvider*
|
||||
ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
{
|
||||
if (!name || !api_url)
|
||||
return NULL;
|
||||
|
||||
if (!providers) {
|
||||
ai_client_init();
|
||||
}
|
||||
|
||||
/* Check if provider already exists */
|
||||
AIProvider* existing = g_hash_table_lookup(providers, name);
|
||||
if (existing) {
|
||||
/* Update existing provider */
|
||||
g_free(existing->api_url);
|
||||
existing->api_url = g_strdup(api_url);
|
||||
g_free(existing->org_id);
|
||||
existing->org_id = g_strdup(org_id);
|
||||
log_info("Updated provider: %s", name);
|
||||
return ai_provider_ref(existing);
|
||||
}
|
||||
|
||||
/* Create new provider (ref_count=1 owned by hash table) */
|
||||
AIProvider* provider = ai_provider_new(name, api_url, org_id);
|
||||
g_hash_table_insert(providers, g_strdup(name), provider);
|
||||
|
||||
log_info("Added provider: %s (URL: %s)", name, api_url);
|
||||
return provider; /* Caller gets non-owning pointer; hash table owns ref */
|
||||
}
|
||||
|
||||
gboolean
|
||||
ai_remove_provider(const gchar* name)
|
||||
{
|
||||
if (!name || !providers)
|
||||
return FALSE;
|
||||
return g_hash_table_remove(providers, name);
|
||||
}
|
||||
|
||||
GList*
|
||||
ai_list_providers(void)
|
||||
{
|
||||
if (!providers)
|
||||
return NULL;
|
||||
|
||||
GList* result = NULL;
|
||||
GHashTableIter iter;
|
||||
gpointer key, value;
|
||||
|
||||
g_hash_table_iter_init(&iter, providers);
|
||||
while (g_hash_table_iter_next(&iter, &key, &value)) {
|
||||
result = g_list_append(result, ai_provider_ref((AIProvider*)value));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
char*
|
||||
ai_providers_find(const char* const search_str, gboolean previous, void* context)
|
||||
{
|
||||
if (!providers || !search_str || strlen(search_str) == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GHashTableIter iter;
|
||||
gpointer key;
|
||||
gpointer value;
|
||||
|
||||
/* Collect all matching providers */
|
||||
GList* matches = NULL;
|
||||
g_hash_table_iter_init(&iter, providers);
|
||||
while (g_hash_table_iter_next(&iter, &key, &value)) {
|
||||
if (g_str_has_prefix((gchar*)key, search_str)) {
|
||||
matches = g_list_append(matches, g_strdup((gchar*)key));
|
||||
}
|
||||
}
|
||||
|
||||
if (matches == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Return first or last match based on previous flag */
|
||||
gchar* result;
|
||||
if (previous) {
|
||||
result = g_strdup((gchar*)matches->data);
|
||||
} else {
|
||||
result = g_strdup((gchar*)g_list_last(matches)->data);
|
||||
}
|
||||
|
||||
g_list_free_full(matches, g_free);
|
||||
return result;
|
||||
}
|
||||
|
||||
gchar*
|
||||
ai_get_provider_key(const gchar* provider_name)
|
||||
{
|
||||
if (!provider_name || !provider_keys)
|
||||
return NULL;
|
||||
gchar* key = g_hash_table_lookup(provider_keys, provider_name);
|
||||
return g_strdup(key);
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_provider_key(const gchar* provider_name, const gchar* api_key)
|
||||
{
|
||||
if (!provider_name)
|
||||
return;
|
||||
|
||||
if (!provider_keys) {
|
||||
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
}
|
||||
|
||||
if (api_key) {
|
||||
g_hash_table_insert(provider_keys, g_strdup(provider_name), g_strdup(api_key));
|
||||
/* Persist to config file */
|
||||
prefs_ai_set_token(provider_name, api_key);
|
||||
log_info("API key set for provider: %s", provider_name);
|
||||
} else {
|
||||
g_hash_table_remove(provider_keys, provider_name);
|
||||
/* Remove from config file */
|
||||
prefs_ai_remove_token(provider_name);
|
||||
log_info("API key removed for provider: %s", provider_name);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Session Management
|
||||
* ======================================================================== */
|
||||
|
||||
AISession*
|
||||
ai_session_create(const gchar* provider_name, const gchar* model)
|
||||
{
|
||||
if (!provider_name || !model)
|
||||
return NULL;
|
||||
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
log_error("Provider not found: %s", provider_name);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
AISession* session = g_new0(AISession, 1);
|
||||
session->provider_name = g_strdup(provider_name);
|
||||
session->provider = ai_provider_ref(provider);
|
||||
session->model = g_strdup(model);
|
||||
session->api_key = g_strdup(ai_get_provider_key(provider_name)); /* Own a copy of the key */
|
||||
session->history = NULL;
|
||||
session->ref_count = 1;
|
||||
|
||||
log_info("AI session created: %s/%s", provider_name, model);
|
||||
return session;
|
||||
}
|
||||
|
||||
AISession*
|
||||
ai_session_ref(AISession* session)
|
||||
{
|
||||
if (session) {
|
||||
session->ref_count++;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_unref(AISession* session)
|
||||
{
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
session->ref_count--;
|
||||
if (session->ref_count > 0)
|
||||
return;
|
||||
|
||||
g_free(session->provider_name);
|
||||
ai_provider_unref(session->provider);
|
||||
g_free(session->model);
|
||||
g_free(session->api_key);
|
||||
|
||||
GList* curr = session->history;
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
g_free(msg->role);
|
||||
g_free(msg->content);
|
||||
g_free(msg);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(session->history);
|
||||
|
||||
g_free(session);
|
||||
log_debug("AI session destroyed");
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_add_message(AISession* session, const gchar* role, const gchar* content)
|
||||
{
|
||||
if (!session || !role || !content)
|
||||
return;
|
||||
|
||||
AIMessage* msg = g_new0(AIMessage, 1);
|
||||
msg->role = g_strdup(role);
|
||||
msg->content = g_strdup(content);
|
||||
session->history = g_list_append(session->history, msg);
|
||||
|
||||
log_debug("Added %s message to session (total: %d)", role, g_list_length(session->history));
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_clear_history(AISession* session)
|
||||
{
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
GList* curr = session->history;
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
g_free(msg->role);
|
||||
g_free(msg->content);
|
||||
g_free(msg);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(session->history);
|
||||
session->history = NULL;
|
||||
|
||||
log_info("AI session history cleared");
|
||||
}
|
||||
|
||||
const gchar*
|
||||
ai_session_get_model(AISession* session)
|
||||
{
|
||||
if (!session)
|
||||
return NULL;
|
||||
return session->model;
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_set_model(AISession* session, const gchar* model)
|
||||
{
|
||||
if (!session || !model)
|
||||
return;
|
||||
|
||||
g_free(session->model);
|
||||
session->model = g_strdup(model);
|
||||
log_info("Session model changed to: %s", model);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* API Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
static gchar*
|
||||
_build_json_payload(AISession* session, const gchar* prompt)
|
||||
{
|
||||
|
||||
/* OpenAI-compatible format with messages array:
|
||||
* {"model": "...", "messages": [...], "stream": false} */
|
||||
GString* messages_json = g_string_new("");
|
||||
GList* curr = session->history;
|
||||
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
auto_gchar gchar* escaped_content = ai_json_escape(msg->content);
|
||||
auto_gchar gchar* escaped_role = ai_json_escape(msg->role);
|
||||
|
||||
if (messages_json->len > 0) {
|
||||
g_string_append_c(messages_json, ',');
|
||||
}
|
||||
g_string_append_printf(messages_json, "{\"role\":\"%s\",\"content\":\"%s\"}",
|
||||
escaped_role, escaped_content);
|
||||
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
/* Add the new user message */
|
||||
auto_gchar gchar* escaped_prompt = ai_json_escape(prompt);
|
||||
if (messages_json->len > 0) {
|
||||
g_string_append_c(messages_json, ',');
|
||||
}
|
||||
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
|
||||
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(session->model);
|
||||
gchar* json_payload = g_strdup_printf(
|
||||
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false}",
|
||||
escaped_model, messages_json->str);
|
||||
|
||||
g_string_free(messages_json, TRUE);
|
||||
return json_payload;
|
||||
}
|
||||
|
||||
static gchar*
|
||||
_parse_ai_response(const gchar* response_json)
|
||||
{
|
||||
if (!response_json || strlen(response_json) == 0)
|
||||
return NULL;
|
||||
|
||||
/* Try Perplexity /v1/agent format first: look for "text":"..." inside output array
|
||||
* Response: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */
|
||||
const gchar* text_start = strstr(response_json, "\"text\":\"");
|
||||
if (text_start) {
|
||||
text_start += strlen("\"text\":\"");
|
||||
const gchar* p = text_start;
|
||||
while (*p) {
|
||||
if (*p == '\\' && *(p + 1) == '"') {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
gsize len = p - text_start;
|
||||
gchar* result = g_new0(gchar, len + 1);
|
||||
gchar* out = result;
|
||||
const gchar* in = text_start;
|
||||
while (in < p) {
|
||||
if (*in == '\\' && *(in + 1) == '"') {
|
||||
*out++ = '"';
|
||||
in += 2;
|
||||
} else {
|
||||
*out++ = *in++;
|
||||
}
|
||||
}
|
||||
*out = '\0';
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Try legacy OpenAI format: "content":"..."
|
||||
* Response: {"choices":[{"message":{"content":"..."}}]} */
|
||||
const gchar* content_start = strstr(response_json, "\"content\":\"");
|
||||
if (!content_start)
|
||||
return NULL;
|
||||
|
||||
content_start += strlen("\"content\":\"");
|
||||
|
||||
/* Find the closing quote, accounting for escaped quotes */
|
||||
const gchar* p = content_start;
|
||||
while (*p) {
|
||||
if (*p == '\\' && *(p + 1) == '"') {
|
||||
/* Escaped quote, skip both characters */
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
/* Found unescaped closing quote */
|
||||
gsize len = p - content_start;
|
||||
/* Unescape the content: convert \" back to " */
|
||||
gchar* result = g_new0(gchar, len + 1);
|
||||
gchar* out = result;
|
||||
const gchar* in = content_start;
|
||||
while (in < p) {
|
||||
if (*in == '\\' && *(in + 1) == '"') {
|
||||
*out++ = '"';
|
||||
in += 2;
|
||||
} else {
|
||||
*out++ = *in++;
|
||||
}
|
||||
}
|
||||
*out = '\0';
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static gpointer
|
||||
_ai_request_thread(gpointer data)
|
||||
{
|
||||
log_debug("[AI-THREAD] Starting AI request thread");
|
||||
|
||||
/* Data is an array: [0]=session, [1]=prompt, [2]=response_cb, [3]=error_cb, [4]=user_data */
|
||||
gpointer* args = (gpointer*)data;
|
||||
AISession* session = (AISession*)args[0];
|
||||
auto_gchar gchar* prompt = args[1];
|
||||
// ai_response_cb response_cb = (ai_response_cb)args[2];
|
||||
ai_error_cb error_cb = (ai_error_cb)args[3];
|
||||
gpointer user_data = args[4];
|
||||
|
||||
log_debug("[AI-THREAD] Session: %s/%s", session->provider_name, session->model);
|
||||
log_debug("[AI-THREAD] API key length: %zu", session->api_key ? strlen(session->api_key) : 0);
|
||||
|
||||
/* Check for API key first */
|
||||
if (!session->api_key || strlen(session->api_key) == 0) {
|
||||
auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s <key>' to configure.",
|
||||
session->provider_name, session->provider_name);
|
||||
ai_callback_data_t* cb_data = g_new0(ai_callback_data_t, 1);
|
||||
cb_data->error_cb = error_cb;
|
||||
cb_data->user_data = user_data;
|
||||
cb_data->error = g_strdup(error_msg);
|
||||
cb_data->is_error = TRUE;
|
||||
_ai_invoke_callback(cb_data);
|
||||
g_free(args);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
log_debug("[AI-THREAD] Curl initialized: %s", curl ? "OK" : "FAILED");
|
||||
if (!curl) {
|
||||
ai_callback_data_t* cb_data = g_new0(ai_callback_data_t, 1);
|
||||
cb_data->error_cb = error_cb;
|
||||
cb_data->user_data = user_data;
|
||||
cb_data->error = g_strdup("Failed to initialize curl.");
|
||||
cb_data->is_error = TRUE;
|
||||
log_debug("[AI-THREAD] Invoking error callback (curl init failed)");
|
||||
_ai_invoke_callback(cb_data);
|
||||
g_free(args);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Add user message to history FIRST */
|
||||
ai_session_add_message(session, "user", prompt);
|
||||
log_debug("[AI-THREAD] Added user message to history");
|
||||
|
||||
/* Build JSON payload (includes the message we just added) */
|
||||
log_debug("[AI-THREAD] Building JSON payload...");
|
||||
auto_gchar gchar* json_payload = _build_json_payload(session, prompt);
|
||||
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
|
||||
|
||||
/* Set up headers */
|
||||
struct curl_slist* headers = NULL;
|
||||
auto_gchar gchar* auth_header = g_strdup_printf("Authorization: Bearer %s", session->api_key);
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
headers = curl_slist_append(headers, auth_header);
|
||||
|
||||
/* Add organization header if configured */
|
||||
if (session->provider && session->provider->org_id && strlen(session->provider->org_id) > 0) {
|
||||
auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", session->provider->org_id);
|
||||
headers = curl_slist_append(headers, org_header);
|
||||
}
|
||||
|
||||
/* Response buffer */
|
||||
struct curl_response_t response;
|
||||
response.data = g_new0(gchar, 1);
|
||||
response.size = 0;
|
||||
|
||||
/* Configure request */
|
||||
const gchar* api_url = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL;
|
||||
log_debug("[AI-THREAD] API URL: %s", api_url);
|
||||
log_debug("[AI-THREAD] Model: %s", session->model);
|
||||
log_debug("[AI-THREAD] API Key: %s", session->api_key ? (strlen(session->api_key) > 10 ? g_strndup(session->api_key, 10) : session->api_key) : "NULL");
|
||||
curl_easy_setopt(curl, CURLOPT_URL, api_url);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_payload);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_callback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
log_debug("[AI-THREAD] curl_easy_perform completed, res: %d", res);
|
||||
|
||||
/* Get HTTP response code */
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
log_debug("[AI-THREAD] HTTP response code: %ld", http_code);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
log_debug("[AI-THREAD] Preparing error callback invocation...");
|
||||
ai_callback_data_t* cb_data = g_new0(ai_callback_data_t, 1);
|
||||
cb_data->error_cb = error_cb;
|
||||
cb_data->user_data = user_data;
|
||||
cb_data->error = g_strdup(error_msg);
|
||||
cb_data->is_error = TRUE;
|
||||
log_debug("[AI-THREAD] Calling _ai_invoke_callback (error)");
|
||||
_ai_invoke_callback(cb_data);
|
||||
log_debug("[AI-THREAD] _ai_invoke_callback (error) returned");
|
||||
} else {
|
||||
/* Handle HTTP errors */
|
||||
if (http_code >= 400) {
|
||||
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
|
||||
response.size, response.data ? response.data : "NULL");
|
||||
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
|
||||
response.data ? response.data : "Unknown error");
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
|
||||
/* Display error directly in AI window using aiwin_display_error */
|
||||
pthread_mutex_lock(&lock);
|
||||
ProfAiWin* aiwin = (ProfAiWin*)user_data;
|
||||
if (aiwin && aiwin->memcheck == PROFAIWIN_MEMCHECK) {
|
||||
aiwin_display_error(aiwin, error_msg);
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
|
||||
log_debug("[AI-THREAD] Displayed HTTP error via aiwin_display_error");
|
||||
} else {
|
||||
/* Parse response - transfer ownership to auto_gchar for cleanup */
|
||||
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
|
||||
auto_gchar gchar* response_data = response.data;
|
||||
response.data = NULL;
|
||||
auto_gchar gchar* content = _parse_ai_response(response_data);
|
||||
if (content) {
|
||||
/* Add assistant response to history */
|
||||
ai_session_add_message(session, "assistant", content);
|
||||
|
||||
/* Display response directly in AI window using aiwin_display_response */
|
||||
pthread_mutex_lock(&lock);
|
||||
ProfAiWin* aiwin = (ProfAiWin*)user_data;
|
||||
if (aiwin && aiwin->memcheck == PROFAIWIN_MEMCHECK) {
|
||||
aiwin_display_response(aiwin, content);
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
|
||||
log_debug("[AI-THREAD] Displayed AI response via aiwin_display_response");
|
||||
} else {
|
||||
log_error("AI response parse failed for %s/%s: %.200s...",
|
||||
session->provider_name, session->model, response_data);
|
||||
|
||||
/* Display parse error directly in AI window using aiwin_display_error */
|
||||
pthread_mutex_lock(&lock);
|
||||
ProfAiWin* aiwin = (ProfAiWin*)user_data;
|
||||
if (aiwin && aiwin->memcheck == PROFAIWIN_MEMCHECK) {
|
||||
aiwin_display_error(aiwin, "Failed to parse AI response.");
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
|
||||
log_debug("[AI-THREAD] Displayed parse error via aiwin_display_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
g_free(args);
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
gboolean
|
||||
ai_send_prompt(AISession* session, const gchar* prompt,
|
||||
ai_response_cb response_cb, ai_error_cb error_cb,
|
||||
gpointer user_data)
|
||||
{
|
||||
log_debug("[AI-PROMPT] ENTER: session=%p, prompt='%s', response_cb=%p, error_cb=%p, user_data=%p",
|
||||
(void*)session, prompt, (void*)response_cb, (void*)error_cb, user_data);
|
||||
|
||||
if (!session || !prompt) {
|
||||
log_error("[AI-PROMPT] FAIL: invalid session or prompt");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* Prepare thread arguments - add message to history inside thread after building payload */
|
||||
gpointer* args = g_new0(gpointer, 6);
|
||||
args[0] = ai_session_ref(session);
|
||||
args[1] = g_strdup(prompt);
|
||||
args[2] = (gpointer)response_cb;
|
||||
args[3] = (gpointer)error_cb;
|
||||
args[4] = user_data;
|
||||
args[5] = NULL; /* placeholder for built payload */
|
||||
log_debug("[AI-PROMPT] Prepared args, creating thread...");
|
||||
|
||||
GThread* thread = g_thread_new("ai_request", _ai_request_thread, args);
|
||||
if (!thread) {
|
||||
g_free(args[1]);
|
||||
ai_session_unref(session);
|
||||
g_free(args);
|
||||
log_error("[AI-PROMPT] FAIL: g_thread_new returned NULL");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
log_debug("[AI-PROMPT] Thread created successfully: %p", (void*)thread);
|
||||
g_thread_unref(thread);
|
||||
return TRUE;
|
||||
}
|
||||
204
src/ai/ai_client.h
Normal file
204
src/ai/ai_client.h
Normal file
@@ -0,0 +1,204 @@
|
||||
#ifndef AI_CLIENT_H
|
||||
#define AI_CLIENT_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/**
|
||||
* @brief Callback function for successful AI responses.
|
||||
* @param response The AI response text.
|
||||
* @param user_data User-provided data passed to the callback.
|
||||
*/
|
||||
typedef void (*ai_response_cb)(const gchar* response, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Callback function for AI errors.
|
||||
* @param error_msg The error message.
|
||||
* @param user_data User-provided data passed to the callback.
|
||||
*/
|
||||
typedef void (*ai_error_cb)(const gchar* error_msg, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief AI message structure for conversation history.
|
||||
*/
|
||||
typedef struct ai_message_t
|
||||
{
|
||||
gchar* role; /* "user" or "assistant" */
|
||||
gchar* content; /* Message content */
|
||||
} AIMessage;
|
||||
|
||||
/**
|
||||
* @brief AI provider configuration.
|
||||
*/
|
||||
typedef struct ai_provider_t
|
||||
{
|
||||
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
|
||||
gchar* api_url; /* API endpoint URL */
|
||||
gchar* org_id; /* Optional organization ID */
|
||||
gchar* project_id; /* Optional project ID (for some providers) */
|
||||
GList* models; /* List of available models (gchar*) */
|
||||
guint ref_count; /* Reference count */
|
||||
} AIProvider;
|
||||
|
||||
/**
|
||||
* @brief AI chat session structure.
|
||||
*/
|
||||
typedef struct ai_session_t
|
||||
{
|
||||
gchar* provider_name; /* Provider name */
|
||||
AIProvider* provider; /* Provider configuration */
|
||||
gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */
|
||||
gchar* api_key; /* API key for this session */
|
||||
GList* history; /* Conversation history (GList of AIMessage*) */
|
||||
guint ref_count; /* Reference count */
|
||||
} AISession;
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Initialize the AI client and load default providers.
|
||||
*/
|
||||
void ai_client_init(void);
|
||||
|
||||
/**
|
||||
* Shutdown the AI client and free resources.
|
||||
*/
|
||||
void ai_client_shutdown(void);
|
||||
|
||||
/**
|
||||
* Get a provider by name.
|
||||
* @param name The provider name (e.g., "openai", "perplexity")
|
||||
* @return AIProvider*, or NULL if not found
|
||||
*/
|
||||
AIProvider* ai_get_provider(const gchar* name);
|
||||
|
||||
/**
|
||||
* Add or update a provider configuration.
|
||||
* @param name The provider name
|
||||
* @param api_url The API endpoint URL
|
||||
* @param org_id Optional organization ID (can be NULL)
|
||||
* @return New AIProvider* (caller must unref when done)
|
||||
*/
|
||||
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
|
||||
|
||||
/**
|
||||
* Remove a provider by name.
|
||||
* @param name The provider name
|
||||
* @return TRUE if provider was removed, FALSE if not found
|
||||
*/
|
||||
gboolean ai_remove_provider(const gchar* name);
|
||||
|
||||
/**
|
||||
* Decrement the reference count of a provider.
|
||||
* @param provider The provider to unreference
|
||||
*/
|
||||
void ai_provider_unref(AIProvider* provider);
|
||||
|
||||
/**
|
||||
* List all configured providers.
|
||||
* @return GList of AIProvider* (caller must not free the list or providers)
|
||||
*/
|
||||
GList* ai_list_providers(void);
|
||||
|
||||
/**
|
||||
* Find a provider name for autocomplete.
|
||||
* @param search_str The search string
|
||||
* @param previous Whether to go to previous match
|
||||
* @param context Unused
|
||||
* @return Provider name, or NULL if not found
|
||||
*/
|
||||
char* ai_providers_find(const char* const search_str, gboolean previous, void* context);
|
||||
|
||||
/**
|
||||
* Get the API key for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @return The API key, or NULL if not set (caller must free)
|
||||
*/
|
||||
gchar* ai_get_provider_key(const gchar* provider_name);
|
||||
|
||||
/**
|
||||
* Set the API key for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param api_key The API key to set
|
||||
*/
|
||||
void ai_set_provider_key(const gchar* provider_name, const gchar* api_key);
|
||||
|
||||
/* ========================================================================
|
||||
* Session Management
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Create a new AI session with the specified provider and model.
|
||||
* @param provider_name The provider name (e.g., "openai")
|
||||
* @param model The model identifier (e.g., "gpt-4")
|
||||
* @return New AISession*, or NULL on failure
|
||||
*/
|
||||
AISession* ai_session_create(const gchar* provider_name, const gchar* model);
|
||||
|
||||
/**
|
||||
* Increment the reference count of an AI session.
|
||||
* @param session The session to reference
|
||||
* @return The same session pointer
|
||||
*/
|
||||
AISession* ai_session_ref(AISession* session);
|
||||
|
||||
/**
|
||||
* Decrement the reference count and free the session when it reaches zero.
|
||||
* @param session The session to unreference
|
||||
*/
|
||||
void ai_session_unref(AISession* session);
|
||||
|
||||
/**
|
||||
* Add a message to the session history.
|
||||
* @param session The session
|
||||
* @param role The message role ("user" or "assistant")
|
||||
* @param content The message content
|
||||
*/
|
||||
void ai_session_add_message(AISession* session, const gchar* role, const gchar* content);
|
||||
|
||||
/**
|
||||
* Clear the conversation history.
|
||||
* @param session The session
|
||||
*/
|
||||
void ai_session_clear_history(AISession* session);
|
||||
|
||||
/**
|
||||
* Get the current model for a session.
|
||||
* @param session The session
|
||||
* @return The model name (caller must not free)
|
||||
*/
|
||||
const gchar* ai_session_get_model(AISession* session);
|
||||
|
||||
/**
|
||||
* Set the model for a session.
|
||||
* @param session The session
|
||||
* @param model The model name
|
||||
*/
|
||||
void ai_session_set_model(AISession* session, const gchar* model);
|
||||
|
||||
/* ========================================================================
|
||||
* Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Send a prompt to the AI provider asynchronously.
|
||||
* @param session The AI session containing provider and model
|
||||
* @param prompt The prompt to send
|
||||
* @param response_cb Callback function for successful responses
|
||||
* @param error_cb Callback function for error handling
|
||||
* @param user_data User data to be passed to the callbacks
|
||||
* @return TRUE if the request was successfully queued, FALSE otherwise
|
||||
*/
|
||||
gboolean ai_send_prompt(AISession* session, const gchar* prompt,
|
||||
ai_response_cb response_cb, ai_error_cb error_cb,
|
||||
gpointer user_data);
|
||||
|
||||
/**
|
||||
* Escape a string for JSON embedding.
|
||||
* @param str The string to escape
|
||||
* @return Newly allocated escaped string (caller must free)
|
||||
*/
|
||||
gchar* ai_json_escape(const gchar* str);
|
||||
|
||||
#endif /* AI_CLIENT_H */
|
||||
Reference in New Issue
Block a user