feat(ai): add AI client with multi-provider support and UI
Some checks failed
CI Code / Check coding style (pull_request) Successful in 47s
CI Code / Check spelling (pull_request) Successful in 59s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m41s
CI Code / Linux (arch) (pull_request) Failing after 3m15s
Some checks failed
CI Code / Check coding style (pull_request) Successful in 47s
CI Code / Check spelling (pull_request) Successful in 59s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m41s
CI Code / Linux (arch) (pull_request) Failing after 3m15s
Add an AI client module that integrates with OpenAI-compatible API providers (OpenAI, Perplexity, and custom providers) to provide AI-assisted responses within the profanity client. The implementation includes: - src/ai/ai_client.c/h: Core AI client with provider management, session handling, and async HTTP request handling via libcurl. Supports per-provider API keys stored in preferences, reference- counted sessions, and conversation history tracking. - src/ui/window.c/window_list.c: New AI window type (ProfAiWin) for displaying AI conversations, with response streaming and error display capabilities. - Command integration: New `/ai` command (cmd_defs.c, cmd_funcs.c) for creating sessions, sending prompts, and managing providers. Provider autocomplete support in cmd_ac.c. - Preferences integration: API keys for providers are persisted in the preferences system (config/preferences.c). - Unit tests: 472 lines of comprehensive tests covering provider management, session lifecycle, JSON escaping, and autocomplete (tests/unittests/test_ai_client.c). Architecture decisions: - Asynchronous design: HTTP requests run on a separate thread to avoid blocking the main UI loop. Callbacks are invoked on the main thread via direct function call (profanity uses ncurses, not GLib main loop). - Reference counting: Both AIProvider and AISession use ref counting for safe shared ownership. - Response size limit: 10MB cap on HTTP responses to prevent OOM.
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user