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

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:
2026-04-29 19:23:33 +00:00
parent 0feacbc9da
commit e43e8378b0
20 changed files with 2399 additions and 6 deletions

View File

@@ -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 <provider>/<model>' 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 <name> <url>
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 <provider> <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 <provider> <org_id>
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 <name>
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 [<provider>/<model>]
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 <url>' 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 <key>' 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 <message>
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 <provider>/<model>' 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 <name> <url>");
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)
{