From e43e8378b065a063641801fb97ed8a69aebc1cad Mon Sep 17 00:00:00 2001 From: Jabber Developer Date: Wed, 29 Apr 2026 19:23:33 +0000 Subject: [PATCH] feat(ai): add AI client with multi-provider support and UI 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. --- Makefile.am | 6 +- src/ai/ai_client.c | 843 +++++++++++++++++++++++++++++++ src/ai/ai_client.h | 204 ++++++++ src/command/cmd_ac.c | 113 ++++- src/command/cmd_defs.c | 55 ++ src/command/cmd_funcs.c | 303 ++++++++++- src/command/cmd_funcs.h | 7 + src/config/preferences.c | 91 +++- src/config/preferences.h | 10 + src/profanity.c | 2 + src/ui/ui.h | 2 + src/ui/win_types.h | 16 +- src/ui/window.c | 123 +++++ src/ui/window.h | 9 + src/ui/window_list.c | 38 +- src/ui/window_list.h | 1 + tests/unittests/test_ai_client.c | 472 +++++++++++++++++ tests/unittests/test_ai_client.h | 42 ++ tests/unittests/ui/stub_ai.c | 30 ++ tests/unittests/unittests.c | 38 ++ 20 files changed, 2399 insertions(+), 6 deletions(-) create mode 100644 src/ai/ai_client.c create mode 100644 src/ai/ai_client.h create mode 100644 tests/unittests/test_ai_client.c create mode 100644 tests/unittests/test_ai_client.h create mode 100644 tests/unittests/ui/stub_ai.c diff --git a/Makefile.am b/Makefile.am index a7648fcb..e54b0f04 100644 --- a/Makefile.am +++ b/Makefile.am @@ -74,7 +74,8 @@ core_sources = \ src/plugins/themes.c src/plugins/themes.h \ src/plugins/settings.c src/plugins/settings.h \ src/plugins/disco.c src/plugins/disco.h \ - src/ui/tray.h src/ui/tray.c + src/ui/tray.h src/ui/tray.c \ + src/ai/ai_client.h src/ai/ai_client.c unittest_sources = \ src/xmpp/contact.c src/xmpp/contact.h src/common.c \ @@ -85,6 +86,7 @@ unittest_sources = \ src/xmpp/chat_state.h src/xmpp/chat_state.c \ src/xmpp/roster_list.c src/xmpp/roster_list.h \ src/xmpp/xmpp.h src/xmpp/form.c \ + src/ai/ai_client.h src/ai/ai_client.c \ src/ui/ui.h \ src/otr/otr.h \ src/pgp/gpg.h \ @@ -132,6 +134,7 @@ unittest_sources = \ tests/unittests/xmpp/stub_message.c \ tests/unittests/ui/stub_ui.c tests/unittests/ui/stub_ui.h \ tests/unittests/ui/stub_vcardwin.c \ + tests/unittests/ui/stub_ai.c \ tests/unittests/log/stub_log.c \ tests/unittests/chatlog/stub_chatlog.c \ tests/unittests/database/stub_database.c \ @@ -168,6 +171,7 @@ unittest_sources = \ tests/unittests/test_callbacks.c tests/unittests/test_callbacks.h \ tests/unittests/test_plugins_disco.c tests/unittests/test_plugins_disco.h \ tests/unittests/test_forced_encryption.c tests/unittests/test_forced_encryption.h \ + tests/unittests/test_ai_client.c tests/unittests/test_ai_client.h \ tests/unittests/unittests.c functionaltest_sources = \ diff --git a/src/ai/ai_client.c b/src/ai/ai_client.c new file mode 100644 index 00000000..989d9b99 --- /dev/null +++ b/src/ai/ai_client.c @@ -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 +#include +#include + +/* 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 ' 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; +} diff --git a/src/ai/ai_client.h b/src/ai/ai_client.h new file mode 100644 index 00000000..409f4ead --- /dev/null +++ b/src/ai/ai_client.h @@ -0,0 +1,204 @@ +#ifndef AI_CLIENT_H +#define AI_CLIENT_H + +#include + +/** + * @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 */ diff --git a/src/command/cmd_ac.c b/src/command/cmd_ac.c index facbbd30..21887004 100644 --- a/src/command/cmd_ac.c +++ b/src/command/cmd_ac.c @@ -66,6 +66,8 @@ #include "omemo/omemo.h" #endif +#include "ai/ai_client.h" + static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous); @@ -137,6 +139,7 @@ static char* _strophe_autocomplete(ProfWin* window, const char* const input, gbo static char* _adhoc_cmd_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _vcard_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolean previous); +static char* _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _script_autocomplete_func(const char* const prefix, gboolean previous, void* context); @@ -278,6 +281,9 @@ static Autocomplete privacy_log_ac; static Autocomplete color_ac; static Autocomplete correction_ac; static Autocomplete avatar_ac; +static Autocomplete ai_subcommands_ac; +static Autocomplete ai_set_subcommands_ac; +static Autocomplete ai_remove_subcommands_ac; static Autocomplete url_ac; static Autocomplete executable_ac; static Autocomplete executable_param_ac; @@ -452,7 +458,10 @@ static Autocomplete* all_acs[] = { &vcard_togglable_param_ac, &vcard_address_type_ac, &force_encryption_ac, - &force_encryption_policy_ac + &force_encryption_policy_ac, + &ai_subcommands_ac, + &ai_set_subcommands_ac, + &ai_remove_subcommands_ac }; static GHashTable* ac_funcs = NULL; @@ -1153,6 +1162,19 @@ cmd_ac_init(void) autocomplete_add(correction_ac, "off"); autocomplete_add(correction_ac, "char"); + autocomplete_add(ai_subcommands_ac, "set"); + autocomplete_add(ai_subcommands_ac, "remove"); + autocomplete_add(ai_subcommands_ac, "start"); + autocomplete_add(ai_subcommands_ac, "clear"); + autocomplete_add(ai_subcommands_ac, "correct"); + autocomplete_add(ai_subcommands_ac, "providers"); + + autocomplete_add(ai_set_subcommands_ac, "provider"); + autocomplete_add(ai_set_subcommands_ac, "token"); + autocomplete_add(ai_set_subcommands_ac, "org"); + + autocomplete_add(ai_remove_subcommands_ac, "provider"); + autocomplete_add(avatar_ac, "set"); autocomplete_add(avatar_ac, "disable"); autocomplete_add(avatar_ac, "get"); @@ -1428,6 +1450,7 @@ cmd_ac_init(void) g_hash_table_insert(ac_funcs, "/wins", _wins_autocomplete); g_hash_table_insert(ac_funcs, "/wintitle", _wintitle_autocomplete); g_hash_table_insert(ac_funcs, "/force-encryption", _force_encryption_autocomplete); + g_hash_table_insert(ac_funcs, "/ai", _ai_autocomplete); } void @@ -4415,5 +4438,93 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea } result = autocomplete_param_with_ac(input, "/force-encryption", force_encryption_ac, TRUE, previous); + return result; +} + +static char* +_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous) +{ + char* result = NULL; + gboolean parsed = FALSE; + + auto_gcharv gchar** args = parse_args(input, 0, 3, &parsed); + + if (parsed) { + int num_args = g_strv_length(args); + + /* Top-level /ai autocomplete (e.g., /ai s -> /ai set) */ + if (num_args >= 1) { + /* Try to match subcommand prefix first */ + result = autocomplete_param_with_ac(input, "/ai", ai_subcommands_ac, FALSE, previous); + if (result) { + return result; + } + + if (g_strcmp0(args[0], "set") == 0) { + if (num_args >= 2) { + if (g_strcmp0(args[1], "provider") == 0) { + // /ai set provider - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL); + if (result) { + return result; + } + } else if (g_strcmp0(args[1], "token") == 0) { + // /ai set token - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai set token", ai_providers_find, previous, NULL); + if (result) { + return result; + } + } else if (g_strcmp0(args[1], "org") == 0) { + // /ai set org - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai set org", ai_providers_find, previous, NULL); + if (result) { + return result; + } + } + } else { + // /ai set - autocomplete subcommands + result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous); + if (result) { + return result; + } + } + } else if (g_strcmp0(args[0], "remove") == 0) { + if (num_args >= 2 && g_strcmp0(args[1], "provider") == 0) { + // /ai remove provider - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai remove provider", ai_providers_find, previous, NULL); + if (result) { + return result; + } + } else { + result = autocomplete_param_with_ac(input, "/ai remove", ai_remove_subcommands_ac, TRUE, previous); + if (result) { + return result; + } + } + } else if (g_strcmp0(args[0], "start") == 0) { + // /ai start [/] - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL); + if (result) { + return result; + } + } else if (g_strcmp0(args[0], "clear") == 0) { + // /ai clear [] - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai clear", ai_providers_find, previous, NULL); + if (result) { + return result; + } + } else if (g_strcmp0(args[0], "correct") == 0) { + // /ai correct + result = autocomplete_param_with_func(input, "/ai correct", ai_providers_find, previous, NULL); + if (result) { + return result; + } + } else if (g_strcmp0(args[0], "providers") == 0) { + // /ai providers - no subcommands + return NULL; + } + } + } + return result; } \ No newline at end of file diff --git a/src/command/cmd_defs.c b/src/command/cmd_defs.c index 5003be66..62d8a3e0 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -2771,6 +2771,61 @@ static const struct cmd_t command_defs[] = { "/force-encryption policy block") }, + { CMD_PREAMBLE("/ai", + parse_args, 0, 5, NULL) + CMD_SUBFUNCS( + { "set", cmd_ai_set }, + { "remove", cmd_ai_remove }, + { "start", cmd_ai_start }, + { "clear", cmd_ai_clear }, + { "correct", cmd_ai_correct }, + { "providers", cmd_ai_providers }) + CMD_MAINFUNC(cmd_ai) + CMD_TAGS( + CMD_TAG_CHAT) + CMD_SYN( + "/ai", + "/ai set provider ", + "/ai set token ", + "/ai set org ", + "/ai remove provider ", + "/ai providers", + "/ai providers-list", + "/ai start [/]", + "/ai model ", + "/ai clear", + "/ai correct ") + CMD_DESC( + "Interact with AI models via OpenAI-compatible APIs. " + "Supports multiple providers (openai, perplexity, custom). " + "Each provider has its own API key and endpoint configuration. " + "Chat history is maintained per session and not persisted locally.") + CMD_ARGS( + { "", "Display current AI settings and configured providers" }, + { "set provider ", "Add or update a provider with custom API endpoint" }, + { "set token ", "Set API token for a provider (e.g., openai, perplexity)" }, + { "set org ", "Set organization ID for a provider (optional)" }, + { "remove provider ", "Remove a custom provider" }, + { "providers", "List all available providers" }, + { "providers-list", "List configured providers with keys status" }, + { "start [/]", "Start new AI chat (e.g., openai/gpt-4o)" }, + { "model ", "Change model in current chat window" }, + { "clear", "Clear current chat history" }, + { "correct ", "Correct last user message and get new response" }) + CMD_EXAMPLES( + "/ai", + "/ai set token openai sk-xxx", + "/ai set token perplexity pplx-xxx", + "/ai set provider custom https://my-api.com/v1/chat/completions", + "/ai set org openai my-org-id", + "/ai remove provider custom", + "/ai start openai/gpt-4o", + "/ai start perplexity/sonar", + "/ai providers-list", + "/ai clear", + "/ai correct I meant something else") + }, + // NEXT-COMMAND (search helper) }; diff --git a/src/command/cmd_funcs.c b/src/command/cmd_funcs.c index 67815bc6..f473d1b9 100644 --- a/src/command/cmd_funcs.c +++ b/src/command/cmd_funcs.c @@ -117,6 +117,8 @@ #include "tools/clipboard.h" #endif +#include "ai/ai_client.h" + #ifdef HAVE_PYTHON #include "plugins/python_plugins.h" #endif @@ -8326,7 +8328,7 @@ _cmd_execute_default(ProfWin* window, const char* inp) } // handle non commands in non chat or plugin windows - if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML) { + if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML && window->type != WIN_AI) { cons_show("Unknown command: %s", inp); cons_alert(NULL); return TRUE; @@ -8372,6 +8374,13 @@ _cmd_execute_default(ProfWin* window, const char* inp) connection_send_stanza(inp); break; } + case WIN_AI: + { + ProfAiWin* aiwin = (ProfAiWin*)window; + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + aiwin_send_message(aiwin, inp, NULL); + break; + } default: break; } @@ -10640,6 +10649,298 @@ cmd_vcard_save(ProfWin* window, const char* const command, gchar** args) return TRUE; } +gboolean +cmd_ai(ProfWin* window, const char* const command, gchar** args) +{ + if (args[0] == NULL) { + // Display current AI settings + cons_show("AI Chat - OpenAI-compatible API client"); + cons_show(""); + + // List available providers + GList* providers = ai_list_providers(); + cons_show("Available providers:"); + for (GList* curr = providers; curr; curr = g_list_next(curr)) { + AIProvider* provider = curr->data; + const gchar* key = ai_get_provider_key(provider->name); + cons_show(" %s (URL: %s, Key: %s)", + provider->name, + provider->api_url, + key ? "set" : "not set"); + } + g_list_free(providers); + + cons_show(""); + cons_show("Use '/ai start /' to begin a chat."); + cons_show("Available models: https://models.litellm.ai/"); + return TRUE; + } + + cons_bad_cmd_usage(command); + return TRUE; +} + +gboolean +cmd_ai_set(ProfWin* window, const char* const command, gchar** args) +{ + if (args[1] == NULL) { + cons_bad_cmd_usage(command); + return TRUE; + } + + if (g_strcmp0(args[1], "provider") == 0) { + // /ai set provider + if (g_strv_length(args) < 4) { + cons_bad_cmd_usage(command); + return TRUE; + } + if (ai_add_provider(args[2], args[3], NULL)) { + cons_show("Provider '%s' configured with URL: %s", args[2], args[3]); + } else { + cons_show_error("Failed to configure provider '%s'.", args[2]); + } + cons_show(""); + return TRUE; + } else if (g_strcmp0(args[1], "token") == 0) { + // /ai set token + if (g_strv_length(args) < 4) { + cons_bad_cmd_usage(command); + return TRUE; + } + ai_set_provider_key(args[2], args[3]); + cons_show("API token set for provider: %s", args[2]); + cons_show(""); + return TRUE; + } else if (g_strcmp0(args[1], "org") == 0) { + // /ai set org + if (g_strv_length(args) < 4) { + cons_bad_cmd_usage(command); + return TRUE; + } + AIProvider* provider = ai_get_provider(args[2]); + if (provider) { + g_free(provider->org_id); + provider->org_id = g_strdup(args[3]); + cons_show("Organization ID set for provider: %s", args[2]); + } else { + cons_show("Provider '%s' not found. Add it first with '/ai set provider'.", args[2]); + } + cons_show(""); + return TRUE; + } + + cons_bad_cmd_usage(command); + return TRUE; +} + +gboolean +cmd_ai_remove(ProfWin* window, const char* const command, gchar** args) +{ + // /ai remove provider + if (g_strcmp0(args[1], "provider") != 0) { + cons_bad_cmd_usage(command); + return TRUE; + } + + if (g_strv_length(args) < 3) { + cons_bad_cmd_usage(command); + return TRUE; + } + + if (ai_remove_provider(args[2])) { + cons_show("Provider '%s' removed.", args[2]); + } else { + cons_show("Provider '%s' not found.", args[2]); + } + cons_show(""); + return TRUE; +} + +gboolean +cmd_ai_start(ProfWin* window, const char* const command, gchar** args) +{ + // /ai start [/] + const gchar* provider_model = (g_strv_length(args) >= 2) ? args[1] : NULL; + + const gchar* provider_name = "openai"; + const gchar* model = "gpt-4o"; + + if (provider_model) { + const gchar* slash = strchr(provider_model, '/'); + if (slash) { + provider_name = g_strndup(provider_model, slash - provider_model); + model = slash + 1; + } else { + // Just a model name, use default provider + model = provider_model; + } + } + + // Check if provider exists + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) { + cons_show_error("Provider '%s' not found. Use '/ai set provider %s ' to add it.", + provider_name, provider_name); + return TRUE; + } + + // Check for API key + gchar* api_key = ai_get_provider_key(provider_name); + if (!api_key || strlen(api_key) == 0) { + cons_show_error("No API key set for provider '%s'. Use '/ai set token %s ' first.", + provider_name, provider_name); + g_free(api_key); + return TRUE; + } + g_free(api_key); + + // Create new AI session + AISession* session = ai_session_create(provider_name, model); + if (!session) { + log_error("[AI-CMD] Failed to create AI session"); + cons_show_error("Failed to create AI session for %s/%s.", provider_name, model); + return TRUE; + } + log_debug("[AI-CMD] AI session created successfully"); + + // Create AI chat window + log_debug("[AI-CMD] Calling wins_new_ai()..."); + ProfWin* ai_win = wins_new_ai(session); + if (!ai_win) { + log_error("[AI-CMD] wins_new_ai() returned NULL"); + cons_show_error("Failed to create AI chat window."); + ai_session_unref(session); + return TRUE; + } + log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type); + + // Add welcome message to the AI window + log_debug("[AI-CMD] Adding welcome messages..."); + win_println(ai_win, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model); + win_println(ai_win, THEME_DEFAULT, "-", "Type your message and press Enter to start a conversation."); + log_debug("[AI-CMD] Welcome messages added"); + + // Focus the new window + log_debug("[AI-CMD] Calling ui_focus_win()..."); + ui_focus_win(ai_win); + log_debug("[AI-CMD] ui_focus_win() returned"); + + cons_show("Started AI chat: %s/%s", provider_name, model); + cons_show(""); + log_debug("[AI-CMD] AI start command completed"); + + return TRUE; +} + +gboolean +cmd_ai_clear(ProfWin* window, const char* const command, gchar** args) +{ + // /ai clear + ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai(); + + if (aiwin) { + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + if (aiwin->session) { + ai_session_clear_history(aiwin->session); + } + cons_show("Chat history cleared."); + } else { + cons_show("No active AI chat window to clear."); + } + cons_show(""); + return TRUE; +} + +gboolean +cmd_ai_correct(ProfWin* window, const char* const command, gchar** args) +{ + // /ai correct + if (args[1] == NULL) { + cons_bad_cmd_usage(command); + return TRUE; + } + + // Get current AI window + ProfAiWin* aiwin = wins_get_ai(); + if (!aiwin) { + cons_show("No active AI chat window. Use '/ai start /' first."); + return TRUE; + } + + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + + if (!aiwin->session) { + cons_show("No active session in this chat window."); + return TRUE; + } + + // Get the last user message and replace it + GList* history = aiwin->session->history; + GList* last_user_msg = NULL; + for (GList* curr = history; curr; curr = g_list_next(curr)) { + AIMessage* msg = curr->data; + if (g_strcmp0(msg->role, "user") == 0) { + last_user_msg = curr; + } + } + + if (!last_user_msg) { + cons_show("No user messages in this chat to correct."); + return TRUE; + } + + // Replace the last user message + AIMessage* msg = last_user_msg->data; + g_free(msg->content); + msg->content = g_strdup(args[1]); + + // Resend the prompt + cons_show("Correcting message..."); + ai_send_prompt(aiwin->session, args[1], + (ai_response_cb)aiwin_display_response, + (ai_error_cb)aiwin_display_error, + aiwin); + + return TRUE; +} + +gboolean +cmd_ai_providers(ProfWin* window, const char* const command, gchar** args) +{ + if (args[1] == NULL || g_strcmp0(args[1], "list") != 0) { + // List all available providers + cons_show("Available AI providers:"); + cons_show(""); + cons_show(" openai - OpenAI API (https://api.openai.com)"); + cons_show(" perplexity - Perplexity API (https://api.perplexity.ai)"); + cons_show(""); + cons_show("Add custom providers with: /ai set provider "); + return TRUE; + } + + // List configured providers with key status + cons_show("Configured providers:"); + cons_show(""); + + GList* providers = ai_list_providers(); + for (GList* curr = providers; curr; curr = g_list_next(curr)) { + AIProvider* provider = curr->data; + gchar* key = ai_get_provider_key(provider->name); + cons_show(" %s", provider->name); + cons_show(" URL: %s", provider->api_url); + cons_show(" Key: %s", key ? "configured" : "NOT configured"); + if (provider->org_id && strlen(provider->org_id) > 0) { + cons_show(" Org: %s", provider->org_id); + } + cons_show(""); + g_free(key); + ai_provider_unref(provider); + } + g_list_free(providers); + + return TRUE; +} + gboolean cmd_force_encryption(ProfWin* window, const char* const command, gchar** args) { diff --git a/src/command/cmd_funcs.h b/src/command/cmd_funcs.h index b8b74e3c..57f5fbeb 100644 --- a/src/command/cmd_funcs.h +++ b/src/command/cmd_funcs.h @@ -166,6 +166,13 @@ gboolean cmd_console(ProfWin* window, const char* const command, gchar** args); gboolean cmd_command_list(ProfWin* window, const char* const command, gchar** args); gboolean cmd_command_exec(ProfWin* window, const char* const command, gchar** args); gboolean cmd_change_password(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_set(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_remove(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_start(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_clear(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_correct(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_providers(ProfWin* window, const char* const command, gchar** args); gboolean cmd_plugins(ProfWin* window, const char* const command, gchar** args); gboolean cmd_plugins_sourcepath(ProfWin* window, const char* const command, gchar** args); diff --git a/src/config/preferences.c b/src/config/preferences.c index bad809c8..4dbea77a 100644 --- a/src/config/preferences.c +++ b/src/config/preferences.c @@ -66,6 +66,7 @@ #define PREF_GROUP_MUC "muc" #define PREF_GROUP_PLUGINS "plugins" #define PREF_GROUP_EXECUTABLES "executables" +#define PREF_GROUP_AI "ai" #define INPBLOCK_DEFAULT 1000 @@ -1706,6 +1707,83 @@ _save_prefs(void) save_keyfile(&prefs_prof_keyfile); } +/* ======================================================================== + * AI Token Management + * ======================================================================== */ + +gboolean +prefs_ai_set_token(const char* const provider, const char* const token) +{ + if (!provider) + return FALSE; + + if (!prefs) + return FALSE; + + if (token) { + g_key_file_set_string(prefs, PREF_GROUP_AI, provider, token); + return TRUE; + } else { + return prefs_ai_remove_token(provider); + } +} + +gboolean +prefs_ai_remove_token(const char* const provider) +{ + if (!provider) + return FALSE; + + if (!prefs) + return FALSE; + + if (!g_key_file_has_key(prefs, PREF_GROUP_AI, provider, NULL)) { + return FALSE; + } + + g_key_file_remove_key(prefs, PREF_GROUP_AI, provider, NULL); + return TRUE; +} + +char* +prefs_ai_get_token(const char* const provider) +{ + if (!provider || !prefs) + return NULL; + + return g_key_file_get_string(prefs, PREF_GROUP_AI, provider, NULL); +} + +GList* +prefs_ai_list_tokens(void) +{ + if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) { + return NULL; + } + + GList* result = NULL; + gsize len; + auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL); + + for (gsize i = 0; i < len; i++) { + char* name = keys[i]; + auto_gchar gchar* value = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL); + if (value) { + result = g_list_append(result, g_strdup(name)); + } + } + + return result; +} + +void +prefs_free_ai_tokens(GList* tokens) +{ + if (tokens) { + g_list_free_full(tokens, g_free); + } +} + // get the preference group for a specific preference // for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs // to the [ui] section. @@ -1865,6 +1943,9 @@ _get_group(preference_t pref) return PREF_GROUP_OMEMO; case PREF_OX_LOG: return PREF_GROUP_OX; + case PREF_AI_PROVIDER: + case PREF_AI_API_KEY: + return PREF_GROUP_AI; default: return NULL; } @@ -2142,6 +2223,10 @@ _get_key(preference_t pref) return "stamp.incoming"; case PREF_OX_LOG: return "log"; + case PREF_AI_PROVIDER: + return "provider"; + case PREF_AI_API_KEY: + return "api_key"; case PREF_MOOD: return "mood"; case PREF_VCARD_PHOTO_CMD: @@ -2319,7 +2404,11 @@ _get_default_string(preference_t pref) return "on"; case PREF_FORCE_ENCRYPTION_MODE: return "resend-to-confirm"; + case PREF_AI_PROVIDER: + return "openai"; + case PREF_AI_API_KEY: + return ""; default: return NULL; } -} +} \ No newline at end of file diff --git a/src/config/preferences.h b/src/config/preferences.h index 750a87ee..f306ce12 100644 --- a/src/config/preferences.h +++ b/src/config/preferences.h @@ -183,6 +183,9 @@ typedef enum { PREF_OX_LOG, PREF_MOOD, PREF_STROPHE_VERBOSITY, + PREF_AI_PROVIDER, + PREF_AI_API_KEY, + PREF_AI_DEFAULT_MODEL, PREF_STROPHE_SM_ENABLED, PREF_STROPHE_SM_RESEND, PREF_VCARD_PHOTO_CMD, @@ -356,4 +359,11 @@ gboolean prefs_get_room_notify(const char* const roomjid); gboolean prefs_get_room_notify_mention(const char* const roomjid); gboolean prefs_get_room_notify_trigger(const char* const roomjid); +/* AI token management */ +gboolean prefs_ai_set_token(const char* const provider, const char* const token); +gboolean prefs_ai_remove_token(const char* const provider); +char* prefs_ai_get_token(const char* const provider); +GList* prefs_ai_list_tokens(void); +void prefs_free_ai_tokens(GList* tokens); + #endif diff --git a/src/profanity.c b/src/profanity.c index b9b0cc87..023601d0 100644 --- a/src/profanity.c +++ b/src/profanity.c @@ -69,6 +69,7 @@ #include "xmpp/xmpp.h" #include "xmpp/muc.h" #include "xmpp/chat_session.h" +#include "ai/ai_client.h" #include "xmpp/chat_state.h" #include "xmpp/contact.h" #include "xmpp/roster_list.h" @@ -249,6 +250,7 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name) } session_init(); cmd_init(); + ai_client_init(); log_info("Initialising contact list"); muc_init(); tlscerts_init(); diff --git a/src/ui/ui.h b/src/ui/ui.h index 09b49717..35a0c5b1 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -386,6 +386,8 @@ ProfWin* win_create_config(const char* const title, DataForm* form, ProfConfWinC ProfWin* win_create_private(const char* const fulljid); ProfWin* win_create_plugin(const char* const plugin_name, const char* const tag); ProfWin* win_create_vcard(vCard* vcard); +ProfWin* win_create_ai(AISession* session); +ProfWin* wins_new_ai(AISession* session); void win_update_virtual(ProfWin* window); void win_free(ProfWin* window); gboolean win_notify_remind(ProfWin* window); diff --git a/src/ui/win_types.h b/src/ui/win_types.h index 360732eb..2d4f80da 100644 --- a/src/ui/win_types.h +++ b/src/ui/win_types.h @@ -62,6 +62,7 @@ #define PROFXMLWIN_MEMCHECK 87333463 #define PROFPLUGINWIN_MEMCHECK 43434777 #define PROFVCARDWIN_MEMCHECK 68947523 +#define PROFAIWIN_MEMCHECK 91827364 typedef enum { FIELD_HIDDEN, @@ -144,7 +145,8 @@ typedef enum { WIN_PRIVATE, WIN_XML, WIN_PLUGIN, - WIN_VCARD + WIN_VCARD, + WIN_AI } win_type_t; typedef enum { @@ -258,4 +260,16 @@ typedef struct prof_vcard_win_t unsigned long memcheck; } ProfVcardWin; +/* Forward declaration */ +typedef struct ai_session_t AISession; + +typedef struct prof_ai_win_t +{ + ProfWin window; + ProfBuff message_buffer; + GString* conversation_history; + AISession* session; + unsigned long memcheck; +} ProfAiWin; + #endif diff --git a/src/ui/window.c b/src/ui/window.c index 33fcac33..15369dce 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -36,6 +36,7 @@ #include "config.h" #include "database.h" +#include "ui/win_types.h" #include "ui/window_list.h" #include @@ -62,6 +63,7 @@ #include "ui/screen.h" #include "xmpp/xmpp.h" #include "xmpp/roster_list.h" +#include "ai/ai_client.h" static const char* LOADING_MESSAGE = "Loading older messages…"; static const char* CONS_WIN_TITLE = "CProof. Type /help for help information."; @@ -329,6 +331,33 @@ win_create_vcard(vCard* vcard) return &new_win->window; } +ProfWin* +win_create_ai(AISession* session) +{ + ProfAiWin* new_win = g_new0(ProfAiWin, 1); + new_win->window.type = WIN_AI; + new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.layout = _win_create_simple_layout(); + + new_win->message_buffer = buffer_create(); + new_win->conversation_history = g_string_new(""); + new_win->session = session ? ai_session_ref(session) : NULL; + new_win->memcheck = PROFAIWIN_MEMCHECK; + + return &new_win->window; +} + +void +win_ai_free(ProfAiWin* win) +{ + assert(win->memcheck == PROFAIWIN_MEMCHECK); + buffer_free(win->message_buffer); + g_string_free(win->conversation_history, TRUE); + if (win->session) { + ai_session_unref(win->session); + } +} + gchar* win_get_title(ProfWin* window) { @@ -407,6 +436,13 @@ win_get_title(ProfWin* window) return g_string_free(title, FALSE); } + case WIN_AI: + { + const ProfAiWin* aiwin = (ProfAiWin*)window; + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + + return g_strdup_printf("AI Assistant (%s: %s)", aiwin->session->provider_name, aiwin->session->model); + } } assert(FALSE); } @@ -454,6 +490,10 @@ win_get_tab_identifier(ProfWin* window) { return strdup("vcard"); } + case WIN_AI: + { + return strdup("ai"); + } } assert(FALSE); } @@ -536,6 +576,12 @@ win_to_string(ProfWin* window) ProfVcardWin* vcardwin = (ProfVcardWin*)window; return vcardwin_get_string(vcardwin); } + case WIN_AI: + { + ProfAiWin* aiwin = (ProfAiWin*)window; + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + return aiwin_get_string(aiwin); + } } assert(FALSE); } @@ -662,6 +708,17 @@ win_free(ProfWin* window) free(pluginwin->plugin_name); break; } + case WIN_AI: + { + ProfAiWin* aiwin = (ProfAiWin*)window; + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + buffer_free(aiwin->message_buffer); + g_string_free(aiwin->conversation_history, TRUE); + if (aiwin->session) { + ai_session_unref(aiwin->session); + } + break; + } default: break; } @@ -2367,3 +2424,69 @@ get_enc_char(prof_enc_t enc_mode, const char* alt) else return get_show_char(enc_mode); } + +// AI Window Functions + +char* +aiwin_get_string(ProfAiWin* win) +{ + assert(win->memcheck == PROFAIWIN_MEMCHECK); + return g_strdup_printf("AI Chat (%s: %s)", win->session->provider_name, win->session->model); +} + +void +aiwin_send_message(ProfAiWin* win, const char* message, const char* id) +{ + log_debug("[AI-WIN] aiwin_send_message ENTER: win=%p, message='%s'", (void*)win, message); + assert(win->memcheck == PROFAIWIN_MEMCHECK); + + if (!win->session) { + log_error("[AI-WIN] FAIL: AI session not initialized"); + return; + } + + // Display user message + win_print_outgoing(&win->window, ">>", id, NULL, message); + log_debug("[AI-WIN] Displayed user message"); + + // Send to AI (ai_send_prompt adds message to history internally) + log_debug("[AI-WIN] Calling ai_send_prompt..."); + gboolean result = ai_send_prompt(win->session, message, + (ai_response_cb)aiwin_display_response, + (ai_error_cb)aiwin_display_error, + win); + log_debug("[AI-WIN] ai_send_prompt returned: %d", result); +} + +void +aiwin_display_response(ProfAiWin* win, const char* response) +{ + log_debug("[AI-WIN] aiwin_display_response ENTER: win=%p, response='%s'", (void*)win, response); + assert(win->memcheck == PROFAIWIN_MEMCHECK); + + if (!response) { + log_error("[AI-WIN] FAIL: null response"); + return; + } + + // Display AI response + win_println(&win->window, THEME_DEFAULT, "<< LLM:", "%s", response); + log_debug("[AI-WIN] Displayed AI response"); + + // Add to conversation history + ai_session_add_message(win->session, "assistant", response); + log_debug("[AI-WIN] Added assistant message to session history"); +} + +void +aiwin_display_error(ProfAiWin* win, const char* error_msg) +{ + log_debug("[AI-WIN] aiwin_display_error ENTER: win=%p, error='%s'", (void*)win, error_msg); + assert(win->memcheck == PROFAIWIN_MEMCHECK); + + const char* msg = error_msg ? error_msg : "Unknown error"; + win_println(&win->window, THEME_ERROR, "[ERROR]", "%s", msg); + + // Also show error in console so user sees it regardless of active window + cons_show_error("AI Error: %s", msg); +} diff --git a/src/ui/window.h b/src/ui/window.h index 2c4da707..289bc131 100644 --- a/src/ui/window.h +++ b/src/ui/window.h @@ -101,4 +101,13 @@ char* win_quote_autocomplete(ProfWin* window, const char* const input, gboolean char* get_show_char(prof_enc_t encryption_mode); char* get_enc_char(prof_enc_t enc_mode, const char* alt); +#include "ai/ai_client.h" + +// AI window functions +void win_ai_free(ProfAiWin* win); +char* aiwin_get_string(ProfAiWin* win); +void aiwin_send_message(ProfAiWin* win, const char* message, const char* id); +void aiwin_display_response(ProfAiWin* win, const char* response); +void aiwin_display_error(ProfAiWin* win, const char* error_msg); + #endif diff --git a/src/ui/window_list.c b/src/ui/window_list.c index 889344b6..d7383e27 100644 --- a/src/ui/window_list.c +++ b/src/ui/window_list.c @@ -44,9 +44,11 @@ #include "common.h" #include "config/preferences.h" +#include "log.h" #include "config/theme.h" #include "plugins/plugins.h" #include "ui/ui.h" +#include "ui/window.h" #include "ui/window_list.h" #include "xmpp/xmpp.h" #include "xmpp/roster_list.h" @@ -374,6 +376,8 @@ wins_set_current_by_num(int i) status_bar_active(1, conswin->type, "console"); } } + } else { + log_error("Window not found for index %d", i); } } @@ -714,6 +718,20 @@ wins_new_vcard(vCard* vcard) return newwin; } +ProfWin* +wins_new_ai(AISession* session) +{ + int result = _wins_get_next_available_num(keys); + ProfWin* newwin = win_create_ai(session); + if (!newwin) { + return NULL; + } + _wins_htable_insert(windows, GINT_TO_POINTER(result), newwin); + autocomplete_add(wins_ac, "ai"); + autocomplete_add(wins_close_ac, "ai"); + return newwin; +} + gboolean wins_do_notify_remind(void) { @@ -841,7 +859,7 @@ wins_get_prune_wins(void) while (curr) { ProfWin* window = curr->data; - if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE) { + if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE && window->type != WIN_AI) { result = g_slist_append(result, window); } curr = g_list_next(curr); @@ -849,6 +867,24 @@ wins_get_prune_wins(void) return result; } +ProfAiWin* +wins_get_ai(void) +{ + GList* curr = values; + + while (curr) { + ProfWin* window = curr->data; + if (window->type == WIN_AI) { + ProfAiWin* aiwin = (ProfAiWin*)window; + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + return aiwin; + } + curr = g_list_next(curr); + } + + return NULL; +} + void wins_lost_connection(void) { diff --git a/src/ui/window_list.h b/src/ui/window_list.h index d9d11c58..4cb155c0 100644 --- a/src/ui/window_list.h +++ b/src/ui/window_list.h @@ -63,6 +63,7 @@ ProfPrivateWin* wins_get_private(const char* const fulljid); ProfPluginWin* wins_get_plugin(const char* const tag); ProfXMLWin* wins_get_xmlconsole(void); ProfVcardWin* wins_get_vcard(void); +ProfAiWin* wins_get_ai(void); void wins_close_plugin(char* tag); diff --git a/tests/unittests/test_ai_client.c b/tests/unittests/test_ai_client.c new file mode 100644 index 00000000..66a8da8c --- /dev/null +++ b/tests/unittests/test_ai_client.c @@ -0,0 +1,472 @@ +#include "prof_cmocka.h" +#include "common.h" +#include "ai/ai_client.h" +#include + +/* ======================================================================== + * Setup/Teardown + * ======================================================================== */ + +int +ai_client_setup(void** state) +{ + ai_client_init(); + return 0; +} + +int +ai_client_teardown(void** state) +{ + ai_client_shutdown(); + return 0; +} + +/* ======================================================================== + * Provider Management Tests + * ======================================================================== */ + +void +test_ai_client_init(void** state) +{ + /* After init, default providers should exist */ + AIProvider* openai = ai_get_provider("openai"); + assert_non_null(openai); + assert_string_equal("openai", openai->name); + assert_string_equal("https://api.openai.com/v1/chat/completions", openai->api_url); + + AIProvider* perplexity = ai_get_provider("perplexity"); + assert_non_null(perplexity); + assert_string_equal("perplexity", perplexity->name); + assert_string_equal("https://api.perplexity.ai/v1/chat/completions", perplexity->api_url); +} + +void +test_ai_add_provider(void** state) +{ + /* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */ + AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1", "my-org"); + assert_non_null(provider); + assert_string_equal("custom", provider->name); + assert_string_equal("https://custom.api.com/v1", provider->api_url); + assert_string_equal("my-org", provider->org_id); + + /* Update existing provider (returns ref; caller owns it) */ + AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1", NULL); + assert_non_null(updated); + assert_string_equal("https://new.api.com/v1", updated->api_url); + assert_null(updated->org_id); + + ai_provider_unref(updated); +} + +void +test_ai_remove_provider(void** state) +{ + /* Remove default provider should fail */ + assert_false(ai_remove_provider("nonexistent")); + + /* Add and remove custom provider */ + ai_add_provider("temp", "https://temp.api.com/v1", NULL); + assert_true(ai_remove_provider("temp")); + assert_null(ai_get_provider("temp")); +} + +void +test_ai_list_providers(void** state) +{ + GList* providers = ai_list_providers(); + assert_non_null(providers); + assert_int_equal(2, g_list_length(providers)); /* openai and perplexity */ + + /* Free list and unref providers (ai_list_providers returns ref'd providers) */ + for (GList* l = providers; l; l = g_list_next(l)) { + ai_provider_unref(l->data); + } + g_list_free(providers); + + /* Add another provider */ + ai_add_provider("test", "https://test.api.com/v1", NULL); + providers = ai_list_providers(); + assert_int_equal(3, g_list_length(providers)); + + /* Free list and unref providers */ + for (GList* l = providers; l; l = g_list_next(l)) { + ai_provider_unref(l->data); + } + g_list_free(providers); +} + +/* ======================================================================== + * API Key Tests + * ======================================================================== */ + +void +test_ai_set_provider_key(void** state) +{ + ai_set_provider_key("openai", "sk-test-key-123"); + { + auto_gchar gchar* key = ai_get_provider_key("openai"); + assert_non_null(key); + assert_string_equal("sk-test-key-123", key); + } + + /* Update key */ + ai_set_provider_key("openai", "sk-new-key-456"); + { + auto_gchar gchar* key = ai_get_provider_key("openai"); + assert_string_equal("sk-new-key-456", key); + } + + /* Remove key */ + ai_set_provider_key("openai", NULL); + { + auto_gchar gchar* key = ai_get_provider_key("openai"); + assert_null(key); + } +} + +void +test_ai_get_provider_key(void** state) +{ + /* No key set initially */ + { + auto_gchar gchar* key = ai_get_provider_key("openai"); + assert_null(key); + } + + /* Set and get key */ + ai_set_provider_key("perplexity", "pplx-abc123"); + { + auto_gchar gchar* key = ai_get_provider_key("perplexity"); + assert_non_null(key); + assert_string_equal("pplx-abc123", key); + } + + /* Wrong provider returns null */ + { + auto_gchar gchar* key = ai_get_provider_key("openai"); + assert_null(key); + } +} + +/* ======================================================================== + * Session Tests + * ======================================================================== */ + +void +test_ai_session_create(void** state) +{ + AISession* session = ai_session_create("openai", "gpt-4"); + assert_non_null(session); + assert_string_equal("openai", session->provider_name); + assert_string_equal("gpt-4", session->model); + assert_null(session->api_key); /* No key set */ + + ai_session_unref(session); +} + +void +test_ai_session_ref_unref(void** state) +{ + AISession* session = ai_session_create("openai", "gpt-4"); + assert_non_null(session); + + /* Reference */ + AISession* ref = ai_session_ref(session); + assert_true(ref == session); + + /* Unreference twice */ + ai_session_unref(session); + ai_session_unref(ref); /* Should free here */ +} + +void +test_ai_session_add_message(void** state) +{ + AISession* session = ai_session_create("openai", "gpt-4"); + assert_non_null(session); + + ai_session_add_message(session, "user", "Hello"); + ai_session_add_message(session, "assistant", "Hi there!"); + + assert_int_equal(2, g_list_length(session->history)); + + AIMessage* first = session->history->data; + assert_string_equal("user", first->role); + assert_string_equal("Hello", first->content); + + AIMessage* second = g_list_next(session->history)->data; + assert_string_equal("assistant", second->role); + assert_string_equal("Hi there!", second->content); + + ai_session_unref(session); +} + +void +test_ai_session_clear_history(void** state) +{ + AISession* session = ai_session_create("openai", "gpt-4"); + + ai_session_add_message(session, "user", "Message 1"); + ai_session_add_message(session, "user", "Message 2"); + ai_session_add_message(session, "assistant", "Response"); + + assert_int_equal(3, g_list_length(session->history)); + + ai_session_clear_history(session); + assert_int_equal(0, g_list_length(session->history)); + + ai_session_unref(session); +} + +void +test_ai_session_set_model(void** state) +{ + AISession* session = ai_session_create("openai", "gpt-4"); + assert_string_equal("gpt-4", session->model); + + ai_session_set_model(session, "gpt-3.5-turbo"); + assert_string_equal("gpt-3.5-turbo", session->model); + + ai_session_unref(session); +} + +/* ======================================================================== + * JSON Escape Tests + * ======================================================================== */ + +void +test_ai_json_escape(void** state) +{ + gchar* escaped = ai_json_escape("hello \"world\""); + assert_string_equal("hello \\\"world\\\"", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_null(void** state) +{ + gchar* escaped = ai_json_escape(NULL); + assert_string_equal("", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_empty(void** state) +{ + gchar* escaped = ai_json_escape(""); + assert_string_equal("", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_special_chars(void** state) +{ + gchar* escaped = ai_json_escape("line1\nline2\ttab\\backslash\"quote"); + assert_string_equal("line1\\nline2\\ttab\\\\backslash\\\"quote", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_percent_signs(void** state) +{ + /* Critical: % characters in content must be escaped for JSON, not treated as format specifiers */ + gchar* escaped = ai_json_escape("100% complete with %s and %d format strings"); + assert_string_equal("100% complete with %s and %d format strings", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_backslash_quote(void** state) +{ + /* Test escaped quote handling */ + gchar* escaped = ai_json_escape("He said \"hello\" and \\ goodbye"); + assert_string_equal("He said \\\"hello\\\" and \\\\ goodbye", escaped); + g_free(escaped); +} + +void +test_ai_session_api_key_is_copied(void** state) +{ + /* Verify that session owns its own copy of the API key */ + ai_set_provider_key("openai", "sk-test-key-123"); + + AISession* session = ai_session_create("openai", "gpt-4"); + assert_non_null(session); + assert_string_equal("sk-test-key-123", session->api_key); + + /* Remove the provider key - session should still have its copy */ + ai_set_provider_key("openai", NULL); + assert_non_null(session->api_key); + assert_string_equal("sk-test-key-123", session->api_key); + + ai_session_unref(session); +} + +void +test_ai_add_provider_update_existing(void** state) +{ + /* Add a provider (hash table owns ref) */ + AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1", "org1"); + assert_non_null(provider); + assert_string_equal("https://first.api.com/v1", provider->api_url); + assert_string_equal("org1", provider->org_id); + + /* Update the same provider (returns ref) */ + provider = ai_add_provider("custom", "https://second.api.com/v1", "org2"); + assert_non_null(provider); + assert_string_equal("https://second.api.com/v1", provider->api_url); + assert_string_equal("org2", provider->org_id); + ai_provider_unref(provider); +} + +void +test_ai_add_provider_null_name_returns_null(void** state) +{ + assert_null(ai_add_provider(NULL, "https://api.com/v1", NULL)); +} + +void +test_ai_add_provider_null_url_returns_null(void** state) +{ + assert_null(ai_add_provider("test", NULL, NULL)); +} + +void +test_ai_session_create_null_provider_returns_null(void** state) +{ + assert_null(ai_session_create("nonexistent", "gpt-4")); +} + +void +test_ai_session_create_null_model_returns_null(void** state) +{ + assert_null(ai_session_create("openai", NULL)); +} + +void +test_ai_session_api_key_null_when_no_key_set(void** state) +{ + /* openai has no key set by default */ + AISession* session = ai_session_create("openai", "gpt-4"); + assert_non_null(session); + assert_null(session->api_key); + ai_session_unref(session); +} + +/* ======================================================================== + * Provider Autocomplete Tests + * ======================================================================== */ + +void +test_ai_providers_find_forward(void** state) +{ + /* Test forward iteration - should return first match */ + char* result = ai_providers_find("o", FALSE, NULL); + assert_non_null(result); + assert_string_equal("openai", result); + g_free(result); +} + +void +test_ai_providers_find_forward_perplexity(void** state) +{ + /* Test forward iteration for perplexity */ + char* result = ai_providers_find("p", FALSE, NULL); + assert_non_null(result); + assert_string_equal("perplexity", result); + g_free(result); +} + +void +test_ai_providers_find_forward_custom(void** state) +{ + /* Add a custom provider and test */ + ai_add_provider("custom", "https://custom.api.com/v1", NULL); + + char* result = ai_providers_find("c", FALSE, NULL); + assert_non_null(result); + assert_string_equal("custom", result); + g_free(result); +} + +void +test_ai_providers_find_forward_no_match(void** state) +{ + /* Test no match */ + char* result = ai_providers_find("z", FALSE, NULL); + assert_null(result); +} + +void +test_ai_providers_find_forward_partial_match(void** state) +{ + /* Test partial match - should return providers starting with "ope" */ + char* result = ai_providers_find("ope", FALSE, NULL); + assert_non_null(result); + assert_string_equal("openai", result); + g_free(result); +} + +void +test_ai_providers_find_next(void** state) +{ + /* Test that stateless implementation returns same result each call */ + char* result1 = ai_providers_find("o", FALSE, NULL); + assert_non_null(result1); + assert_string_equal("openai", result1); + g_free(result1); + + /* Second call with same params returns same result (stateless) */ + char* result2 = ai_providers_find("o", FALSE, NULL); + assert_non_null(result2); + assert_string_equal("openai", result2); + g_free(result2); +} + +void +test_ai_providers_find_previous(void** state) +{ + /* Test that previous=TRUE returns last match in list */ + /* With only "openai" starting with "o", both FALSE and TRUE return same result */ + char* result1 = ai_providers_find("o", FALSE, NULL); + assert_non_null(result1); + assert_string_equal("openai", result1); + g_free(result1); + + /* previous=TRUE also returns "openai" (only one match, so first==last) */ + char* result2 = ai_providers_find("o", TRUE, NULL); + assert_non_null(result2); + assert_string_equal("openai", result2); + g_free(result2); +} + +void +test_ai_providers_find_null_search_str(void** state) +{ + char* result = ai_providers_find(NULL, FALSE, NULL); + assert_null(result); +} + +void +test_ai_providers_find_empty_search_str(void** state) +{ + char* result = ai_providers_find("", FALSE, NULL); + assert_null(result); +} + +void +test_ai_providers_find_case_sensitive(void** state) +{ + /* Test that matching is case-sensitive */ + char* result = ai_providers_find("OPENAI", FALSE, NULL); + assert_null(result); + + result = ai_providers_find("OpenAI", FALSE, NULL); + assert_null(result); + + result = ai_providers_find("openai", FALSE, NULL); + assert_non_null(result); + assert_string_equal("openai", result); + g_free(result); +} diff --git a/tests/unittests/test_ai_client.h b/tests/unittests/test_ai_client.h new file mode 100644 index 00000000..d56d2e26 --- /dev/null +++ b/tests/unittests/test_ai_client.h @@ -0,0 +1,42 @@ +/* + * test_ai_client.h - AI client unit test declarations + */ + +int ai_client_setup(void** state); +int ai_client_teardown(void** state); + +void test_ai_client_init(void** state); +void test_ai_add_provider(void** state); +void test_ai_remove_provider(void** state); +void test_ai_list_providers(void** state); +void test_ai_set_provider_key(void** state); +void test_ai_get_provider_key(void** state); +void test_ai_session_create(void** state); +void test_ai_session_ref_unref(void** state); +void test_ai_session_add_message(void** state); +void test_ai_session_clear_history(void** state); +void test_ai_session_set_model(void** state); +void test_ai_json_escape(void** state); +void test_ai_json_escape_null(void** state); +void test_ai_json_escape_empty(void** state); +void test_ai_json_escape_special_chars(void** state); +void test_ai_json_escape_percent_signs(void** state); +void test_ai_json_escape_backslash_quote(void** state); +void test_ai_session_api_key_is_copied(void** state); +void test_ai_add_provider_update_existing(void** state); +void test_ai_add_provider_null_name_returns_null(void** state); +void test_ai_add_provider_null_url_returns_null(void** state); +void test_ai_session_create_null_provider_returns_null(void** state); +void test_ai_session_create_null_model_returns_null(void** state); +void test_ai_session_api_key_null_when_no_key_set(void** state); +/* Provider autocomplete tests */ +void test_ai_providers_find_forward(void** state); +void test_ai_providers_find_forward_perplexity(void** state); +void test_ai_providers_find_forward_custom(void** state); +void test_ai_providers_find_forward_no_match(void** state); +void test_ai_providers_find_forward_partial_match(void** state); +void test_ai_providers_find_next(void** state); +void test_ai_providers_find_previous(void** state); +void test_ai_providers_find_null_search_str(void** state); +void test_ai_providers_find_empty_search_str(void** state); +void test_ai_providers_find_case_sensitive(void** state); diff --git a/tests/unittests/ui/stub_ai.c b/tests/unittests/ui/stub_ai.c new file mode 100644 index 00000000..451994f9 --- /dev/null +++ b/tests/unittests/ui/stub_ai.c @@ -0,0 +1,30 @@ +/* + * stub_ai.c - Stubs for AI window functions + */ + +#include "ui/window.h" +#include "ai/ai_client.h" + +ProfWin* +win_create_ai(AISession* session) +{ + /* Return NULL to simulate failure in unit tests */ + (void)session; + return NULL; +} + +void +aiwin_display_response(ProfAiWin* win, const char* response) +{ + /* Stub: do nothing */ + (void)win; + (void)response; +} + +void +aiwin_display_error(ProfAiWin* win, const char* error_msg) +{ + /* Stub: do nothing */ + (void)win; + (void)error_msg; +} diff --git a/tests/unittests/unittests.c b/tests/unittests/unittests.c index 69fbd4bf..13698f95 100644 --- a/tests/unittests/unittests.c +++ b/tests/unittests/unittests.c @@ -36,6 +36,7 @@ #include "test_form.h" #include "test_callbacks.h" #include "test_plugins_disco.h" +#include "test_ai_client.h" #define muc_unit_test(f) cmocka_unit_test_setup_teardown(f, muc_before_test, muc_after_test) @@ -656,6 +657,43 @@ main(int argc, char* argv[]) cmocka_unit_test_setup_teardown(test_allow_unencrypted_message_confirms_on_second_attempt, load_preferences, close_preferences), cmocka_unit_test_setup_teardown(test_cmd_force_encryption_invalid_policy, load_preferences, close_preferences), cmocka_unit_test_setup_teardown(test_allow_unencrypted_message_invalid_mode, load_preferences, close_preferences), + + // AI client tests + cmocka_unit_test_setup_teardown(test_ai_client_init, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_remove_provider, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_list_providers, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_set_provider_key, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_get_provider_key, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_create, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_ref_unref, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_add_message, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_clear_history, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_set_model, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_api_key_is_copied, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider_update_existing, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider_null_name_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider_null_url_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_create_null_provider_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_create_null_model_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_api_key_null_when_no_key_set, ai_client_setup, ai_client_teardown), + /* Provider autocomplete tests */ + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_perplexity, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_custom, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_no_match, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_partial_match, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_next, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_previous, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_null_search_str, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_empty_search_str, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_case_sensitive, ai_client_setup, ai_client_teardown), + cmocka_unit_test(test_ai_json_escape), + cmocka_unit_test(test_ai_json_escape_null), + cmocka_unit_test(test_ai_json_escape_empty), + cmocka_unit_test(test_ai_json_escape_special_chars), + cmocka_unit_test(test_ai_json_escape_percent_signs), + cmocka_unit_test(test_ai_json_escape_backslash_quote), }; return cmocka_run_group_tests(all_tests, NULL, NULL); }