Compare commits
33 Commits
aeb5cfd7db
...
test/ai-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
e67ae4e7a5
|
|||
|
010b2062a4
|
|||
|
ecb07fd00c
|
|||
|
a898cb212d
|
|||
|
07d267b5bc
|
|||
|
acae543057
|
|||
|
731b55fa19
|
|||
|
e229ed1281
|
|||
|
f4221e27ac
|
|||
|
cdcc45b7c6
|
|||
|
f133d81a05
|
|||
|
a16237d16b
|
|||
|
cfe6ea46e2
|
|||
|
2fc7f3d672
|
|||
|
fe0a46da58
|
|||
|
cead417e78
|
|||
|
00f11eb704
|
|||
|
4913a3d5a4
|
|||
|
634fb7d7eb
|
|||
|
461c0c32dd
|
|||
|
002a6ed15b
|
|||
|
dc75f16221
|
|||
|
da0bf43d73
|
|||
|
9e1f9b666e
|
|||
|
93ad7379e2
|
|||
|
caa0b3ccba
|
|||
|
c9a5239117
|
|||
|
25e0459979
|
|||
|
bccd3ecded
|
|||
|
cff05ca802
|
|||
|
9c8ad57b59
|
|||
|
1f1770bd58
|
|||
|
e43e8378b0
|
12
Makefile.am
12
Makefile.am
@@ -37,6 +37,7 @@ core_sources = \
|
||||
src/ui/window_list.c src/ui/window_list.h \
|
||||
src/ui/rosterwin.c src/ui/occupantswin.c \
|
||||
src/ui/buffer.c src/ui/buffer.h \
|
||||
src/ui/aiwin.c \
|
||||
src/ui/chatwin.c \
|
||||
src/ui/mucwin.c \
|
||||
src/ui/privwin.c \
|
||||
@@ -78,7 +79,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 \
|
||||
@@ -89,6 +91,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 \
|
||||
@@ -138,6 +141,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 \
|
||||
@@ -174,6 +178,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/test_database_export.c tests/unittests/test_database_export.h \
|
||||
tests/unittests/test_database_stress.c tests/unittests/test_database_stress.h \
|
||||
tests/unittests/unittests.c
|
||||
@@ -198,8 +203,13 @@ functionaltest_sources = \
|
||||
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
|
||||
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
|
||||
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
|
||||
tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \
|
||||
tests/functionaltests/ai_http_stub.c tests/functionaltests/ai_http_stub.h \
|
||||
tests/functionaltests/test_ai_http.c tests/functionaltests/test_ai_http.h \
|
||||
tests/functionaltests/functionaltests.c
|
||||
|
||||
dist_check_DATA = tests/functionaltests/ai_http_stub.py
|
||||
|
||||
main_source = src/main.c
|
||||
|
||||
python_sources = \
|
||||
|
||||
1482
src/ai/ai_client.c
Normal file
1482
src/ai/ai_client.c
Normal file
File diff suppressed because it is too large
Load Diff
275
src/ai/ai_client.h
Normal file
275
src/ai/ai_client.h
Normal file
@@ -0,0 +1,275 @@
|
||||
#ifndef AI_CLIENT_H
|
||||
#define AI_CLIENT_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/**
|
||||
* @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) */
|
||||
gchar* default_model; /* Default model for this provider */
|
||||
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
|
||||
GList* models; /* Cached models (gchar*) */
|
||||
gboolean models_fresh; /* Whether models cache is current */
|
||||
gint32 ref_count; /* Reference count (atomic) */
|
||||
} 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*) */
|
||||
gint32 ref_count; /* Reference count (atomic) */
|
||||
} 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,
|
||||
* and must not unref the returned 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
|
||||
*/
|
||||
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
|
||||
|
||||
/**
|
||||
* Find a model name for autocomplete.
|
||||
* @param search_str The search string
|
||||
* @param previous Whether to go to previous match
|
||||
* @param context ProfAiWin* for the current session
|
||||
* @return Model name, or NULL if not found
|
||||
*/
|
||||
gchar* ai_models_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);
|
||||
|
||||
/**
|
||||
* Set the default model for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param model The default model name
|
||||
*/
|
||||
void ai_set_provider_default_model(const gchar* provider_name, const gchar* model);
|
||||
|
||||
/**
|
||||
* Get the default model for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @return The default model name (caller must not free), or NULL if not set
|
||||
*/
|
||||
const gchar* ai_get_provider_default_model(const gchar* provider_name);
|
||||
|
||||
/**
|
||||
* Set a custom setting for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param setting The setting key (e.g., "tools", "search")
|
||||
* @param value The setting value
|
||||
*/
|
||||
void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
|
||||
|
||||
/**
|
||||
* Get a custom setting for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @param setting The setting key
|
||||
* @return The setting value (caller must free), or NULL if not set
|
||||
*/
|
||||
gchar* ai_get_provider_setting(const gchar* provider_name, const gchar* setting);
|
||||
|
||||
/**
|
||||
* Fetch available models from a provider's API.
|
||||
* @param provider_name The provider name
|
||||
* @param user_data User data (ProfAiWin* for UI display)
|
||||
* @return TRUE if the request was successfully queued, FALSE otherwise
|
||||
*/
|
||||
gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data);
|
||||
|
||||
/**
|
||||
* Check if models cache is fresh for a provider.
|
||||
* @param provider_name The provider name
|
||||
* @return TRUE if models are fresh, FALSE otherwise
|
||||
*/
|
||||
gboolean ai_models_are_fresh(const gchar* provider_name);
|
||||
|
||||
/* ========================================================================
|
||||
* Parsing helpers (exposed for testing)
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Parse model IDs from an OpenAI-compatible API response.
|
||||
* Expected format: {"object":"list","data":[{"id":"model1",...},...]}
|
||||
* @param provider The provider to add models to
|
||||
* @param json The JSON response from the API
|
||||
*/
|
||||
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
|
||||
|
||||
/**
|
||||
* Extract assistant content from an LLM response body. Tries Perplexity
|
||||
* /v1/responses "text" first, falls back to OpenAI "content". Decodes
|
||||
* the \" and \n escape sequences only.
|
||||
* @param response_json The JSON body from the provider
|
||||
* @return Newly allocated content string, or NULL if not found (caller frees)
|
||||
*/
|
||||
gchar* ai_parse_response(const gchar* response_json);
|
||||
|
||||
/**
|
||||
* Extract human-readable error message from an API error envelope.
|
||||
* Expected format: {"error":{"message":"...","type":"...","code":...}}
|
||||
* Decodes \" \n \\ \t escapes.
|
||||
* @param error_json The JSON body of the error response
|
||||
* @return Newly allocated error message, or NULL if not parseable (caller frees)
|
||||
*/
|
||||
gchar* ai_parse_error_message(const gchar* error_json);
|
||||
|
||||
/* ========================================================================
|
||||
* 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 user_data User data (ProfAiWin* for UI display)
|
||||
* @return TRUE if the request was successfully queued, FALSE otherwise
|
||||
*/
|
||||
gboolean ai_send_prompt(AISession* session, const gchar* prompt,
|
||||
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 */
|
||||
@@ -66,8 +66,11 @@
|
||||
#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* _ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* prefix);
|
||||
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous);
|
||||
@@ -137,6 +140,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);
|
||||
|
||||
@@ -280,11 +284,16 @@ static Autocomplete history_switch_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_set_custom_subcommands_ac;
|
||||
static Autocomplete ai_remove_subcommands_ac;
|
||||
static Autocomplete url_ac;
|
||||
static Autocomplete executable_ac;
|
||||
static Autocomplete executable_param_ac;
|
||||
static Autocomplete intype_ac;
|
||||
static Autocomplete mood_ac;
|
||||
static Autocomplete ai_models_ac;
|
||||
static Autocomplete mood_type_ac;
|
||||
static Autocomplete strophe_ac;
|
||||
static Autocomplete strophe_sm_ac;
|
||||
@@ -456,7 +465,11 @@ 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_set_custom_subcommands_ac,
|
||||
&ai_remove_subcommands_ac
|
||||
};
|
||||
|
||||
static GHashTable* ac_funcs = NULL;
|
||||
@@ -1169,6 +1182,27 @@ cmd_ac_init(void)
|
||||
autocomplete_add(correction_ac, "off");
|
||||
autocomplete_add(correction_ac, "char");
|
||||
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "set", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "remove", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "start", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "clear", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "providers", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "switch", FALSE);
|
||||
autocomplete_add_unsorted(ai_subcommands_ac, "models", FALSE);
|
||||
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-provider", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE);
|
||||
|
||||
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "tools", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "search", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "memory", FALSE);
|
||||
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "plugins", FALSE);
|
||||
|
||||
autocomplete_add_unsorted(ai_remove_subcommands_ac, "provider", FALSE);
|
||||
|
||||
autocomplete_add(avatar_ac, "set");
|
||||
autocomplete_add(avatar_ac, "disable");
|
||||
autocomplete_add(avatar_ac, "get");
|
||||
@@ -1444,6 +1478,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
|
||||
@@ -4450,4 +4485,167 @@ _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;
|
||||
|
||||
/* Parse once for reuse - /ai <subcommand> [<arg1>] [<arg2>] */
|
||||
gboolean parse_result = FALSE;
|
||||
auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result);
|
||||
gboolean space_at_end = g_str_has_suffix(input, " ");
|
||||
int num_args = g_strv_length(args);
|
||||
|
||||
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
|
||||
result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set token <provider> <token> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set token", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set default-provider <provider> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set default-provider", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set default-model <provider> <model> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set default-model", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set custom <provider> <setting> <value> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set custom <provider> <setting> - autocomplete settings
|
||||
/* args[0]="set", args[1]="custom", args[2]=provider (if typed), args[3]=setting (if typed) */
|
||||
if (num_args == 3 && g_strcmp0(args[1], "custom") == 0) {
|
||||
/* /ai set custom <provider> - check if provider is valid */
|
||||
if (ai_get_provider(args[2])) {
|
||||
/* Valid provider, try settings autocomplete */
|
||||
result = autocomplete_param_with_ac(input, "/ai set custom ", ai_set_custom_subcommands_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
// /ai set <subcommand> - autocomplete subcommands
|
||||
result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai remove provider <name> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai remove provider", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/ai remove", ai_remove_subcommands_ac, TRUE, previous);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai start [<provider>] [<model>] - use shared parse_args
|
||||
if (parse_result && args && args[0] != NULL && g_strcmp0(args[0], "start") == 0) {
|
||||
/* args[0]="start", args[1]=provider (if typed), args[2]=model (if typed) */
|
||||
if (num_args == 1 || (num_args == 2 && !space_at_end)) {
|
||||
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
|
||||
} else {
|
||||
/* /ai start <provider> <model> - model autocomplete */
|
||||
result = _ai_model_autocomplete(window, input, previous, "/ai start ");
|
||||
}
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// /ai switch <provider> <model> - autocomplete model names for specified provider
|
||||
result = _ai_model_autocomplete(window, input, previous, "/ai switch");
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai switch <model> - autocomplete model names from current session's provider
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
result = autocomplete_param_with_func(input, "/ai switch", ai_models_find, previous, aiwin);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai models <provider> - autocomplete provider names
|
||||
result = autocomplete_param_with_func(input, "/ai models", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai clear - no autocomplete
|
||||
// /ai providers - no autocomplete
|
||||
|
||||
result = autocomplete_param_with_ac(input, "/ai", ai_subcommands_ac, FALSE, previous);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete model names for /ai <cmd> <provider> <model> patterns.
|
||||
* Extracts provider from input and provides model completions from that provider's cached models.
|
||||
* Uses static ai_models_ac to preserve cycling state (last_found) across calls.
|
||||
*/
|
||||
static char*
|
||||
_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
|
||||
{
|
||||
if (!g_str_has_prefix(input, cmd_prefix)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Extract provider name from input (after cmd_prefix) */
|
||||
auto_gchar gchar* rest = g_strdup(input + strlen(cmd_prefix));
|
||||
char* space = strchr(rest, ' ');
|
||||
if (space) {
|
||||
*space = '\0';
|
||||
}
|
||||
|
||||
/* Look up the provider by name and get its models */
|
||||
AIProvider* prov = ai_get_provider(rest);
|
||||
if (!prov || !prov->models) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!ai_models_ac) {
|
||||
ai_models_ac = autocomplete_new();
|
||||
}
|
||||
/* Convert GList* to char** for autocomplete_update */
|
||||
int model_count = g_list_length(prov->models);
|
||||
char** model_array = g_new0(char*, model_count + 1);
|
||||
GList* curr = prov->models;
|
||||
int i = 0;
|
||||
while (curr) {
|
||||
model_array[i++] = curr->data;
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
autocomplete_update(ai_models_ac, model_array);
|
||||
g_free(model_array);
|
||||
|
||||
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
|
||||
|
||||
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2787,6 +2787,71 @@ 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 },
|
||||
{ "providers", cmd_ai_providers },
|
||||
{ "switch", cmd_ai_switch },
|
||||
{ "models", cmd_ai_models })
|
||||
CMD_MAINFUNC(cmd_ai)
|
||||
CMD_TAGS(
|
||||
CMD_TAG_CHAT)
|
||||
CMD_SYN(
|
||||
"/ai",
|
||||
"/ai set provider <name> <url>",
|
||||
"/ai set token <provider> <token>",
|
||||
"/ai set default-provider <provider>",
|
||||
"/ai set default-model <provider> <model>",
|
||||
"/ai set custom <provider> <setting> <value>",
|
||||
"/ai remove provider <name>",
|
||||
"/ai providers",
|
||||
"/ai start [<provider>] [<model>]",
|
||||
"/ai switch <provider> [<model>]",
|
||||
"/ai switch <model>",
|
||||
"/ai models <provider>",
|
||||
"/ai clear")
|
||||
CMD_DESC(
|
||||
"Interact with AI models via OpenAI-compatible APIs. "
|
||||
"Supports multiple providers (openai, perplexity, custom). "
|
||||
"Each provider has its own API key, endpoint, default model, and settings. "
|
||||
"Chat history is maintained per session and not persisted locally.")
|
||||
CMD_ARGS(
|
||||
{ "", "Display current AI settings and configured providers" },
|
||||
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
|
||||
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
|
||||
{ "set default-provider <provider>", "Set global default provider for /ai start" },
|
||||
{ "set default-model <provider> <model>", "Set default model for a provider" },
|
||||
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
|
||||
{ "remove provider <name>", "Remove a custom provider" },
|
||||
{ "providers", "List configured providers with full details" },
|
||||
{ "start [<provider>] [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
|
||||
{ "switch <provider> [<model>]", "Switch to different provider (and optionally model)" },
|
||||
{ "switch <model>", "Change model in current session (keeps provider)" },
|
||||
{ "models <provider>", "Fetch and display available models for provider" },
|
||||
{ "clear", "Clear current chat history" })
|
||||
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 default-provider perplexity",
|
||||
"/ai set default-model perplexity sonar",
|
||||
"/ai set custom perplexity tools enabled",
|
||||
"/ai remove provider custom",
|
||||
"/ai start",
|
||||
"/ai start perplexity",
|
||||
"/ai start perplexity sonar",
|
||||
"/ai start openai gpt-4o",
|
||||
"/ai switch gpt-4o",
|
||||
"/ai switch openai gpt-5.4-nano",
|
||||
"/ai models perplexity",
|
||||
"/ai clear")
|
||||
},
|
||||
|
||||
// NEXT-COMMAND (search helper)
|
||||
};
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
#include "tools/bookmark_ignore.h"
|
||||
#include "tools/editor.h"
|
||||
#include "plugins/plugins.h"
|
||||
#include "ui/inputwin.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "xmpp/avatar.h"
|
||||
@@ -117,6 +116,8 @@
|
||||
#include "tools/clipboard.h"
|
||||
#endif
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
|
||||
#ifdef HAVE_PYTHON
|
||||
#include "plugins/python_plugins.h"
|
||||
#endif
|
||||
@@ -8466,7 +8467,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;
|
||||
@@ -8512,6 +8513,12 @@ _cmd_execute_default(ProfWin* window, const char* inp)
|
||||
connection_send_stanza(inp);
|
||||
break;
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
cl_ev_send_ai_msg(aiwin, inp, NULL);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -10780,6 +10787,500 @@ 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 configured providers with full details
|
||||
GList* providers = ai_list_providers();
|
||||
cons_show("Configured providers:");
|
||||
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||
AIProvider* provider = curr->data;
|
||||
auto_gchar gchar* key = ai_get_provider_key(provider->name);
|
||||
const gchar* default_model = ai_get_provider_default_model(provider->name);
|
||||
cons_show(" %s", provider->name);
|
||||
cons_show(" URL: %s", provider->api_url);
|
||||
cons_show(" Key: %s", key ? "configured" : "NOT configured");
|
||||
if (default_model) {
|
||||
cons_show(" Default model: %s", default_model);
|
||||
}
|
||||
if (provider->models && g_list_length(provider->models) > 0) {
|
||||
cons_show(" Cached models: %d", g_list_length(provider->models));
|
||||
}
|
||||
cons_show("");
|
||||
}
|
||||
g_list_free(providers);
|
||||
|
||||
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
|
||||
cons_show("Use '/ai models <provider>' to fetch available models.");
|
||||
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], "default-model") == 0) {
|
||||
// /ai set default-model <provider> <model>
|
||||
if (g_strv_length(args) < 4) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
ai_set_provider_default_model(args[2], args[3]);
|
||||
cons_show("Default model for provider '%s' set to: %s", args[2], args[3]);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
} else if (g_strcmp0(args[1], "custom") == 0) {
|
||||
// /ai set custom <provider> <setting> <value>
|
||||
if (g_strv_length(args) < 5) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
ai_set_provider_setting(args[2], args[3], args[4]);
|
||||
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
} else if (g_strcmp0(args[1], "default-provider") == 0) {
|
||||
// /ai set default-provider <provider>
|
||||
if (g_strv_length(args) < 3) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
const gchar* provider_name = args[2];
|
||||
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;
|
||||
}
|
||||
prefs_set_string(PREF_AI_PROVIDER, provider_name);
|
||||
cons_show("Default provider set to: %s", provider_name);
|
||||
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>]
|
||||
// Space-separated, no slash syntax
|
||||
const gchar* provider_name = NULL;
|
||||
const gchar* model = NULL;
|
||||
|
||||
if (g_strv_length(args) >= 2) {
|
||||
provider_name = args[1];
|
||||
}
|
||||
if (g_strv_length(args) >= 3) {
|
||||
model = args[2];
|
||||
}
|
||||
|
||||
// Resolve defaults
|
||||
auto_gchar gchar* owned_provider_name = NULL;
|
||||
if (!provider_name) {
|
||||
// Use default provider from preferences
|
||||
auto_gchar gchar* default_provider = prefs_get_string(PREF_AI_PROVIDER);
|
||||
if (default_provider && strlen(default_provider) > 0) {
|
||||
provider_name = default_provider;
|
||||
}
|
||||
}
|
||||
if (!provider_name) {
|
||||
provider_name = "openai"; // Fallback
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Get model: explicit > provider default > hardcoded fallback
|
||||
if (!model) {
|
||||
model = ai_get_provider_default_model(provider_name);
|
||||
}
|
||||
if (!model) {
|
||||
model = "gpt-4o"; // Fallback
|
||||
}
|
||||
|
||||
// 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_model(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai model <model>
|
||||
if (args[1] == NULL) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
const gchar* model = args[1];
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : 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;
|
||||
}
|
||||
|
||||
ai_session_set_model(aiwin->session, model);
|
||||
cons_show("Session model changed to: %s", model);
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai switch <provider> [<model>] - Change provider and optionally model
|
||||
// /ai switch <model> - Change model only (keeps current provider)
|
||||
// Modifies the existing session's provider and model instead of recreating it
|
||||
if (g_strv_length(args) < 2) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : 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;
|
||||
}
|
||||
|
||||
const gchar* arg1 = args[1];
|
||||
const gchar* arg2 = (g_strv_length(args) >= 3) ? args[2] : NULL;
|
||||
|
||||
const gchar* provider_name;
|
||||
const gchar* model;
|
||||
gboolean changed_provider = FALSE;
|
||||
|
||||
// Check if arg1 is a known provider
|
||||
AIProvider* provider = ai_get_provider(arg1);
|
||||
if (provider) {
|
||||
// arg1 is a provider name
|
||||
provider_name = arg1;
|
||||
changed_provider = TRUE;
|
||||
|
||||
// Get model: arg2 > provider default > error
|
||||
if (arg2) {
|
||||
model = arg2;
|
||||
} else {
|
||||
model = ai_get_provider_default_model(provider_name);
|
||||
}
|
||||
if (!model) {
|
||||
cons_show_error("No model specified and no default model set for provider '%s'.", provider_name);
|
||||
cons_show("Use '/ai set default-model %s <model>' or '/ai switch %s <model>'.", provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
} else {
|
||||
// arg1 is a model name, keep current provider
|
||||
provider_name = aiwin->session->provider_name;
|
||||
model = arg1;
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
auto_gchar 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);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Update the existing session's provider and model
|
||||
if (changed_provider) {
|
||||
AIProvider* new_provider = ai_get_provider(provider_name);
|
||||
g_free(aiwin->session->provider_name);
|
||||
aiwin->session->provider_name = g_strdup(provider_name);
|
||||
ai_provider_unref(aiwin->session->provider);
|
||||
aiwin->session->provider = new_provider;
|
||||
aiwin->session->provider->ref_count++;
|
||||
}
|
||||
g_free(aiwin->session->model);
|
||||
aiwin->session->model = g_strdup(model);
|
||||
g_free(aiwin->session->api_key);
|
||||
aiwin->session->api_key = g_strdup(api_key);
|
||||
|
||||
// Update window title
|
||||
win_println((ProfWin*)aiwin, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
|
||||
|
||||
cons_show("Switched to %s/%s", provider_name, model);
|
||||
cons_show("");
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_models(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai models <provider> [--cached]
|
||||
// Default: fetch fresh models from API
|
||||
// --cached: display cached models (if available)
|
||||
if (args[1] == NULL) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
const gchar* provider_name = args[1];
|
||||
gboolean use_cached = (g_strv_length(args) >= 3) && (g_strcmp0(args[2], "--cached") == 0);
|
||||
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
cons_show_error("Provider '%s' not found.", provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (use_cached) {
|
||||
// Display cached models (if available)
|
||||
if (!provider->models) {
|
||||
cons_show("No cached models for provider '%s'. Use '/ai models %s' to fetch from API.", provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
cons_show("Cached models for provider '%s':", provider_name);
|
||||
GList* curr = provider->models;
|
||||
while (curr) {
|
||||
cons_show(" %s", (gchar*)curr->data);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
cons_show("");
|
||||
cons_show("Use '/ai models %s' to fetch fresh models from the API.", provider_name);
|
||||
} else {
|
||||
// Default: fetch fresh models from API
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
ai_fetch_models(provider_name, aiwin);
|
||||
}
|
||||
|
||||
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>
|
||||
// Join all arguments from args[1] onwards to support multi-word prompts
|
||||
auto_gchar gchar* prompt = g_strjoinv(" ", &args[1]);
|
||||
if (prompt == NULL || strlen(prompt) == 0) {
|
||||
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(prompt);
|
||||
|
||||
// Resend the prompt
|
||||
cons_show("Correcting message...");
|
||||
ai_send_prompt(aiwin->session, prompt, 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);
|
||||
}
|
||||
g_list_free(providers);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_force_encryption(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
|
||||
@@ -166,6 +166,14 @@ 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_providers(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_switch(ProfWin* window, const char* const command, gchar** args);
|
||||
gboolean cmd_ai_models(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);
|
||||
|
||||
@@ -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,162 @@ _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);
|
||||
_save_prefs();
|
||||
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);
|
||||
_save_prefs();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count)
|
||||
{
|
||||
if (!provider || count <= 0)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
/* Build semicolon-separated model list */
|
||||
GString* model_str = g_string_new("");
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i > 0) {
|
||||
g_string_append_c(model_str, ';');
|
||||
}
|
||||
g_string_append(model_str, models[i]);
|
||||
}
|
||||
|
||||
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str);
|
||||
g_string_free(model_str, TRUE);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_models(const char* const provider)
|
||||
{
|
||||
if (!provider)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, key, NULL);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_get_models(const char* const provider)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
auto_gchar gchar* model_str = g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
|
||||
if (!model_str || strlen(model_str) == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GList* result = NULL;
|
||||
gchar** models = g_strsplit(model_str, ";", -1);
|
||||
for (int i = 0; models[i] != NULL; i++) {
|
||||
result = g_list_append(result, g_strdup(models[i]));
|
||||
}
|
||||
g_strfreev(models);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
prefs_free_ai_models(GList* models)
|
||||
{
|
||||
if (models) {
|
||||
g_list_free_full(models, 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 +2022,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 +2302,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,6 +2483,10 @@ _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;
|
||||
}
|
||||
|
||||
@@ -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,17 @@ 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);
|
||||
|
||||
/* AI model cache management */
|
||||
gboolean prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count);
|
||||
gboolean prefs_ai_remove_models(const char* const provider);
|
||||
GList* prefs_ai_get_models(const char* const provider);
|
||||
void prefs_free_ai_models(GList* models);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -37,8 +37,10 @@
|
||||
#include "config.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <assert.h>
|
||||
#include <glib.h>
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
#include "log.h"
|
||||
#include "chatlog.h"
|
||||
#include "database.h"
|
||||
@@ -47,7 +49,7 @@
|
||||
#include "event/common.h"
|
||||
#include "plugins/plugins.h"
|
||||
#include "ui/inputwin.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "ui/window.h"
|
||||
#include "xmpp/chat_session.h"
|
||||
#include "xmpp/session.h"
|
||||
#include "xmpp/xmpp.h"
|
||||
@@ -284,3 +286,30 @@ allow_unencrypted_message(ProfChatWin* chatwin, const char* const msg)
|
||||
win_println((ProfWin*)chatwin, THEME_ERROR, "-", "Message not sent: invalid encryption mode (%s). Use '/force-encryption policy resend-to-confirm'.", force_enc_mode);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id)
|
||||
{
|
||||
if (!aiwin || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
log_error("[AI] AI session not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Reset paged flag before printing user message.
|
||||
* If the user scrolled up to view history, paged=1 would suppress
|
||||
* the message in _win_printf(). Reset it here so the message displays. */
|
||||
aiwin->window.layout->paged = 0;
|
||||
aiwin->window.layout->unread_msg = 0;
|
||||
|
||||
// Display user message in AI window.
|
||||
win_print_outgoing(&aiwin->window, ">>", id, NULL, message);
|
||||
|
||||
// Send to AI provider.
|
||||
ai_send_prompt(aiwin->session, message, aiwin);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ void cl_ev_send_msg(ProfChatWin* chatwin, const char* const msg, const char* con
|
||||
void cl_ev_send_muc_msg_corrected(ProfMucWin* mucwin, const char* const msg, const char* const oob_url, gboolean correct_last_msg);
|
||||
void cl_ev_send_muc_msg(ProfMucWin* mucwin, const char* const msg, const char* const oob_url);
|
||||
void cl_ev_send_priv_msg(ProfPrivateWin* privwin, const char* const msg, const char* const oob_url);
|
||||
void cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id);
|
||||
|
||||
// Checks if an unencrypted message can be sent based on encryption preferences.
|
||||
// Returns TRUE if allowed, FALSE if blocked.
|
||||
|
||||
@@ -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();
|
||||
|
||||
61
src/ui/aiwin.c
Normal file
61
src/ui/aiwin.c
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* aiwin.c - AI Chat Window Management
|
||||
*
|
||||
* Provides functions for managing the AI chat window (ProfAiWin) in the
|
||||
* profanity client. This includes retrieving window strings for identification,
|
||||
* displaying AI/LLM responses in the chat view, and handling error messages
|
||||
* from AI interactions.
|
||||
*
|
||||
* Key functions:
|
||||
* aiwin_get_string() - Returns a descriptive string for the window
|
||||
* aiwin_display_response() - Displays an AI/LLM response and appends it
|
||||
* to the conversation history
|
||||
* aiwin_display_error() - Displays an error message from AI operations
|
||||
*
|
||||
* vim: expandtab:ts=4:sts=4:sw=4
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <assert.h>
|
||||
|
||||
#include "ai/ai_client.h"
|
||||
#include "log.h"
|
||||
#include "ui/ui.h"
|
||||
#include "ui/win_types.h"
|
||||
|
||||
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_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");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -445,4 +447,9 @@ void notify_invite(const char* const from, const char* const room, const char* c
|
||||
void notify(const char* const message, int timeout, const char* const category);
|
||||
void notify_subscription(const char* const from);
|
||||
|
||||
/* AI window */
|
||||
char* aiwin_get_string(ProfAiWin* win);
|
||||
void aiwin_display_response(ProfAiWin* win, const char* response);
|
||||
void aiwin_display_error(ProfAiWin* win, const char* error_msg);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "database.h"
|
||||
#include "ui/win_types.h"
|
||||
#include "ui/window_list.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
@@ -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,22 @@ 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;
|
||||
}
|
||||
|
||||
gchar*
|
||||
win_get_title(ProfWin* window)
|
||||
{
|
||||
@@ -407,6 +425,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 +479,10 @@ win_get_tab_identifier(ProfWin* window)
|
||||
{
|
||||
return strdup("vcard");
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
return strdup("ai");
|
||||
}
|
||||
}
|
||||
assert(FALSE);
|
||||
}
|
||||
@@ -536,6 +565,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 +697,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;
|
||||
}
|
||||
|
||||
@@ -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,45 @@ 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;
|
||||
}
|
||||
|
||||
gboolean
|
||||
wins_ai_exists(ProfAiWin* const aiwin)
|
||||
{
|
||||
if (!aiwin) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
GList* curr = values;
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (window->type == WIN_AI) {
|
||||
if ((ProfAiWin*)window == aiwin) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
wins_lost_connection(void)
|
||||
{
|
||||
|
||||
@@ -63,6 +63,8 @@ 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);
|
||||
gboolean wins_ai_exists(ProfAiWin* const aiwin);
|
||||
|
||||
void wins_close_plugin(char* tag);
|
||||
|
||||
|
||||
100
tests/functionaltests/ai_http_stub.c
Normal file
100
tests/functionaltests/ai_http_stub.c
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* ai_http_stub.c
|
||||
*
|
||||
* Spawns tests/functionaltests/ai_http_stub.py as a child process for
|
||||
* functional tests that exercise the AI client's HTTP code path. The
|
||||
* stub listens on 127.0.0.1 on an OS-chosen port and prints "PORT=<n>"
|
||||
* as its first line; we read it back and hand the port to the caller.
|
||||
*/
|
||||
|
||||
#include "ai_http_stub.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define STUB_SCRIPT "../tests/functionaltests/ai_http_stub.py"
|
||||
#define READLINE_MAX 64
|
||||
|
||||
static pid_t stub_pid = 0;
|
||||
|
||||
int
|
||||
ai_http_stub_start(const char* mode, const char* reply)
|
||||
{
|
||||
int pipefd[2];
|
||||
if (pipe(pipefd) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
/* Child: redirect stdout to the pipe write end, then exec the stub. */
|
||||
close(pipefd[0]);
|
||||
dup2(pipefd[1], STDOUT_FILENO);
|
||||
close(pipefd[1]);
|
||||
|
||||
if (mode) {
|
||||
setenv("AI_STUB_MODE", mode, 1);
|
||||
}
|
||||
if (reply) {
|
||||
setenv("AI_STUB_REPLY", reply, 1);
|
||||
}
|
||||
/* Argument "0" -> let the OS pick a free port. */
|
||||
execlp("python3", "python3", STUB_SCRIPT, "0", (char*)NULL);
|
||||
/* Falls through on exec failure. */
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Parent: read "PORT=<n>\n" from the child's stdout. */
|
||||
close(pipefd[1]);
|
||||
|
||||
char buf[READLINE_MAX] = { 0 };
|
||||
ssize_t total = 0;
|
||||
while (total < (ssize_t)sizeof(buf) - 1) {
|
||||
ssize_t n = read(pipefd[0], buf + total, sizeof(buf) - 1 - total);
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
total += n;
|
||||
if (memchr(buf, '\n', total)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close(pipefd[0]);
|
||||
|
||||
int port = 0;
|
||||
if (sscanf(buf, "PORT=%d", &port) != 1 || port <= 0) {
|
||||
kill(pid, SIGTERM);
|
||||
waitpid(pid, NULL, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
stub_pid = pid;
|
||||
return port;
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_stub_stop(void)
|
||||
{
|
||||
if (stub_pid <= 0) {
|
||||
return;
|
||||
}
|
||||
kill(stub_pid, SIGTERM);
|
||||
/* Give the stub a moment to exit cleanly, then reap. */
|
||||
int status = 0;
|
||||
pid_t waited = waitpid(stub_pid, &status, 0);
|
||||
(void)waited;
|
||||
(void)status;
|
||||
stub_pid = 0;
|
||||
}
|
||||
25
tests/functionaltests/ai_http_stub.h
Normal file
25
tests/functionaltests/ai_http_stub.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#ifndef __H_AI_HTTP_STUB
|
||||
#define __H_AI_HTTP_STUB
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
/*
|
||||
* Spawn the Python AI stub script (tests/functionaltests/ai_http_stub.py).
|
||||
*
|
||||
* mode — one of "ok", "openai", "401", "500", "models" (see the stub
|
||||
* script). Selects the response body and HTTP status.
|
||||
* reply — body text used by "ok"/"openai"/"401" modes. Pass NULL for
|
||||
* the default ("MOCK-REPLY").
|
||||
*
|
||||
* Returns the listening port (>0) on success, 0 on failure. The child's
|
||||
* pid is recorded internally; call ai_http_stub_stop() to terminate it.
|
||||
*/
|
||||
int ai_http_stub_start(const char* mode, const char* reply);
|
||||
|
||||
/*
|
||||
* Send SIGTERM to the most recently spawned stub and reap it.
|
||||
* No-op if no stub is running.
|
||||
*/
|
||||
void ai_http_stub_stop(void);
|
||||
|
||||
#endif
|
||||
117
tests/functionaltests/ai_http_stub.py
Normal file
117
tests/functionaltests/ai_http_stub.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal HTTP stub for AI functional tests.
|
||||
|
||||
Listens on 127.0.0.1:<port> and serves canned JSON responses for any POST.
|
||||
Behaviour is selected via the URL path and the AI_STUB_MODE environment
|
||||
variable, set by the test runner before exec().
|
||||
|
||||
Modes:
|
||||
- "ok" : 200 with a valid OpenAI Responses-API body
|
||||
({"output":[{"content":[{"text":"...","type":"output_text"}]}]})
|
||||
- "openai" : 200 with a legacy OpenAI Chat Completions body
|
||||
({"choices":[{"message":{"content":"..."}}]})
|
||||
- "401" : 401 with an OpenAI-style error envelope
|
||||
- "500" : 500 with a body that triggers the parse-error fallback
|
||||
- "models" : 200 with a /v1/models list body
|
||||
|
||||
The reply text is taken from AI_STUB_REPLY (default: "MOCK-REPLY").
|
||||
|
||||
The stub writes its actual listening port to stdout as a single line
|
||||
"PORT=<n>" so the parent can read it back, then runs until SIGTERM.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
|
||||
REPLY = os.environ.get("AI_STUB_REPLY", "MOCK-REPLY")
|
||||
MODE = os.environ.get("AI_STUB_MODE", "ok")
|
||||
|
||||
|
||||
def _dumps(body: object) -> bytes:
|
||||
"""Produce compact JSON without whitespace — profanity's hand-rolled
|
||||
parser does strstr for `"key":"`, which fails when json.dumps inserts
|
||||
a space after the colon by default."""
|
||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _body_for(mode: str) -> tuple[int, bytes]:
|
||||
if mode == "ok":
|
||||
body = {
|
||||
"output": [
|
||||
{
|
||||
"content": [
|
||||
{"text": REPLY, "type": "output_text"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
if mode == "openai":
|
||||
body = {
|
||||
"choices": [
|
||||
{"message": {"role": "assistant", "content": REPLY}}
|
||||
]
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
if mode == "401":
|
||||
body = {
|
||||
"error": {
|
||||
"message": REPLY,
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
}
|
||||
return 401, _dumps(body)
|
||||
if mode == "500":
|
||||
return 500, b"<html><body>Internal Server Error</body></html>"
|
||||
if mode == "models":
|
||||
body = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "model-a", "object": "model"},
|
||||
{"id": "model-b", "object": "model"},
|
||||
{"id": "model-c", "object": "model"},
|
||||
],
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
return 200, b"{}"
|
||||
|
||||
|
||||
class StubHandler(BaseHTTPRequestHandler):
|
||||
def _send(self):
|
||||
status, body = _body_for(MODE)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
self._send()
|
||||
|
||||
def do_GET(self):
|
||||
self._send()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
||||
srv = HTTPServer(("127.0.0.1", port), StubHandler)
|
||||
actual = srv.server_address[1]
|
||||
print(f"PORT={actual}", flush=True)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -59,6 +59,8 @@
|
||||
#ifdef HAVE_SQLITE
|
||||
#include "test_export_import.h"
|
||||
#endif
|
||||
#include "test_ai.h"
|
||||
#include "test_ai_http.h"
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) \
|
||||
@@ -66,6 +68,14 @@
|
||||
#test, test, init_prof_test, close_prof_test, #test \
|
||||
}
|
||||
|
||||
/* AI tests use a custom init that primes stabber via prof_connect so
|
||||
* stbbr_stop() in close_prof_test() doesn't hang on a never-connected
|
||||
* server thread. See ai_init_test() in test_ai.c. */
|
||||
#define PROF_FUNC_TEST_AI(test) \
|
||||
{ \
|
||||
#test, test, ai_init_test, close_prof_test, #test \
|
||||
}
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
|
||||
static int
|
||||
@@ -84,9 +94,9 @@ main(int argc, char* argv[])
|
||||
int group = 0; /* 0 = all groups */
|
||||
if (argc > 1) {
|
||||
group = atoi(argv[1]);
|
||||
if (group < 1 || group > 4) {
|
||||
if (group < 1 || group > 5) {
|
||||
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
|
||||
fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n");
|
||||
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -307,6 +317,39 @@ main(int argc, char* argv[])
|
||||
#endif
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 5: AI command surface (console + HTTP stub)
|
||||
* Standalone because AI tests don't use stabber's XMPP path; mixing
|
||||
* them with XMPP-bound tests in the same group poisons stabber state
|
||||
* between fixture init/teardown cycles and hangs subsequent
|
||||
* prof_connect() calls.
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group5_tests[] = {
|
||||
/* Tier A — no network calls */
|
||||
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
|
||||
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
|
||||
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
|
||||
PROF_FUNC_TEST_AI(ai_set_provider_adds_custom),
|
||||
PROF_FUNC_TEST_AI(ai_set_token_marks_key_set),
|
||||
PROF_FUNC_TEST_AI(ai_start_unknown_provider_errors),
|
||||
PROF_FUNC_TEST_AI(ai_start_without_key_errors),
|
||||
PROF_FUNC_TEST_AI(ai_start_with_key_opens_window),
|
||||
PROF_FUNC_TEST_AI(ai_clear_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_remove_provider_works),
|
||||
PROF_FUNC_TEST_AI(ai_remove_provider_unknown_errors),
|
||||
PROF_FUNC_TEST_AI(ai_set_default_provider_unknown_errors),
|
||||
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
|
||||
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
|
||||
|
||||
/* Tier B — full request/response through Python HTTP stub */
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_returns_ok),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_openai_format),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_401_shows_error),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_500_surfaced),
|
||||
PROF_FUNC_TEST_AI(ai_http_models_fetch_lists_models),
|
||||
};
|
||||
|
||||
/* Test group registry for easy extension */
|
||||
struct
|
||||
{
|
||||
@@ -318,6 +361,7 @@ main(int argc, char* argv[])
|
||||
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
|
||||
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
|
||||
{ "Group 5: AI command surface (Tier A + Tier B)", group5_tests, ARRAY_SIZE(group5_tests) },
|
||||
};
|
||||
const int num_groups = ARRAY_SIZE(groups);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
#include "proftest.h"
|
||||
|
||||
/* Number of parallel test groups for CI builds */
|
||||
#define TEST_GROUPS 4
|
||||
#define TEST_GROUPS 5
|
||||
|
||||
/* Number of ports reserved per test group; allows skipping TIME_WAIT ports */
|
||||
#define PORTS_PER_GROUP 50
|
||||
|
||||
241
tests/functionaltests/test_ai.c
Normal file
241
tests/functionaltests/test_ai.c
Normal file
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* test_ai.c
|
||||
*
|
||||
* Functional tests for the /ai command surface (Tier A — no network).
|
||||
*
|
||||
* These tests interact with profanity through its PTY console and exercise
|
||||
* paths that do not reach libcurl: command parsing, provider registration,
|
||||
* key/setting/default storage, error messages, and AI window creation.
|
||||
*
|
||||
* Tests that require an actual provider HTTP exchange (prompt -> response,
|
||||
* /ai models <provider>, HTTP error envelopes) are out of scope here and
|
||||
* belong in a separate suite backed by a local HTTP stub.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "proftest.h"
|
||||
#include "test_ai.h"
|
||||
|
||||
int
|
||||
ai_init_test(void** state)
|
||||
{
|
||||
int ret = init_prof_test(state);
|
||||
if (ret != 0) {
|
||||
return ret;
|
||||
}
|
||||
/* Connect to stabber even though AI tests don't use XMPP. Without this
|
||||
* stabber's accept loop has no client to disconnect on /quit, and
|
||||
* stbbr_stop() in close_prof_test() hangs uninterruptibly when joining
|
||||
* the server thread. */
|
||||
prof_connect();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
ai_no_args_shows_help(void** state)
|
||||
{
|
||||
/* `/ai` with no arguments lists the built-in providers and a usage hint. */
|
||||
prof_input("/ai");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client"));
|
||||
assert_true(prof_output_exact("Configured providers:"));
|
||||
assert_true(prof_output_regex("openai"));
|
||||
assert_true(prof_output_regex("perplexity"));
|
||||
assert_true(prof_output_regex("Use '/ai start' to begin a chat"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_providers_lists_defaults(void** state)
|
||||
{
|
||||
/* `/ai providers` (no "list") shows the built-in list with URLs. */
|
||||
prof_input("/ai providers");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("Available AI providers:"));
|
||||
assert_true(prof_output_regex("openai"));
|
||||
assert_true(prof_output_regex("perplexity"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_providers_list_shows_key_status(void** state)
|
||||
{
|
||||
/* `/ai providers list` lists each configured provider with key status. */
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("Configured providers:"));
|
||||
/* No tokens have been set yet in this fresh session. */
|
||||
assert_true(prof_output_regex("Key: NOT configured"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_provider_adds_custom(void** state)
|
||||
{
|
||||
/* Adding a custom provider should make it appear in /ai providers list. */
|
||||
prof_input("/ai set provider mock http://127.0.0.1:1/");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'mock' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("mock"));
|
||||
assert_true(prof_output_regex("http://127.0.0.1:1/"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_token_marks_key_set(void** state)
|
||||
{
|
||||
/* Setting a token must flip the provider's key status to "configured". */
|
||||
prof_input("/ai set token openai sk-fake-test-key");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: openai"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
/* openai now shows "Key: configured" while perplexity stays unconfigured. */
|
||||
assert_true(prof_output_regex("Key: configured"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_unknown_provider_errors(void** state)
|
||||
{
|
||||
/* Unknown provider name should error out without creating a window. */
|
||||
prof_input("/ai start nope_provider somemodel");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'nope_provider' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_without_key_errors(void** state)
|
||||
{
|
||||
/* Known provider without an API key should refuse to start. */
|
||||
prof_input("/ai start openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No API key set for provider 'openai'"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_with_key_opens_window(void** state)
|
||||
{
|
||||
/* With a token set, /ai start should create a WIN_AI window. */
|
||||
prof_input("/ai set token openai sk-fake-test-key");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: openai"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai start openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
/* /ai start switches focus to the new WIN_AI window; cons_show output
|
||||
* to the console is therefore not the right place to look. The window
|
||||
* itself prints "AI Chat: <provider>/<model>" as its first line. */
|
||||
assert_true(prof_output_regex("AI Chat: openai/gpt-4"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_clear_without_window_errors(void** state)
|
||||
{
|
||||
/* /ai clear from the console (no active AI window) should report nicely. */
|
||||
prof_input("/ai clear");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No active AI chat window to clear"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_remove_provider_works(void** state)
|
||||
{
|
||||
/* Round-trip: add -> verify present -> remove -> verify gone. */
|
||||
prof_input("/ai set provider tmpprov http://127.0.0.1:2/");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai remove provider tmpprov");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' removed"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai remove provider tmpprov");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_remove_provider_unknown_errors(void** state)
|
||||
{
|
||||
/* Removing a provider that was never added must report "not found". */
|
||||
prof_input("/ai remove provider nonexistent_xyz");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_default_provider_unknown_errors(void** state)
|
||||
{
|
||||
/* Setting an unknown default provider should error, not silently accept. */
|
||||
prof_input("/ai set default-provider does_not_exist");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'does_not_exist' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_default_model_updates_provider(void** state)
|
||||
{
|
||||
/* Setting a default model is acknowledged on the console. */
|
||||
prof_input("/ai set default-model openai gpt-5-preview");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Default model for provider 'openai' set to: gpt-5-preview"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_switch_without_window_errors(void** state)
|
||||
{
|
||||
/* /ai switch with no active AI window should produce an actionable error. */
|
||||
prof_input("/ai switch openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No active AI chat window"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_bad_subcommand_shows_usage(void** state)
|
||||
{
|
||||
/* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */
|
||||
prof_input("/ai not_a_subcommand");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Invalid usage, see '/help ai'"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
28
tests/functionaltests/test_ai.h
Normal file
28
tests/functionaltests/test_ai.h
Normal file
@@ -0,0 +1,28 @@
|
||||
#ifndef __H_FUNC_TEST_AI
|
||||
#define __H_FUNC_TEST_AI
|
||||
|
||||
/*
|
||||
* AI tests don't exercise the XMPP path, but the test fixture's
|
||||
* stbbr_stop() hangs indefinitely when no client ever connected to
|
||||
* stabber. ai_init_test wraps init_prof_test with a prof_connect()
|
||||
* so stabber sees a graceful disconnect at teardown.
|
||||
*/
|
||||
int ai_init_test(void** state);
|
||||
|
||||
void ai_no_args_shows_help(void** state);
|
||||
void ai_providers_lists_defaults(void** state);
|
||||
void ai_providers_list_shows_key_status(void** state);
|
||||
void ai_set_provider_adds_custom(void** state);
|
||||
void ai_set_token_marks_key_set(void** state);
|
||||
void ai_start_unknown_provider_errors(void** state);
|
||||
void ai_start_without_key_errors(void** state);
|
||||
void ai_start_with_key_opens_window(void** state);
|
||||
void ai_clear_without_window_errors(void** state);
|
||||
void ai_remove_provider_works(void** state);
|
||||
void ai_remove_provider_unknown_errors(void** state);
|
||||
void ai_set_default_provider_unknown_errors(void** state);
|
||||
void ai_set_default_model_updates_provider(void** state);
|
||||
void ai_switch_without_window_errors(void** state);
|
||||
void ai_bad_subcommand_shows_usage(void** state);
|
||||
|
||||
#endif
|
||||
157
tests/functionaltests/test_ai_http.c
Normal file
157
tests/functionaltests/test_ai_http.c
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* test_ai_http.c
|
||||
*
|
||||
* Functional tests for the /ai command surface that exercise the libcurl
|
||||
* HTTP code path. A small Python HTTP stub (ai_http_stub.py) is spawned
|
||||
* per-test on a free local port; profanity is configured to point at it
|
||||
* via `/ai set provider`. The stub serves canned JSON whose shape is
|
||||
* controlled via AI_STUB_MODE.
|
||||
*
|
||||
* These tests intentionally do not race the worker thread directly —
|
||||
* they rely on prof_timeout() retries to catch the response once it
|
||||
* propagates back through the AI window.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "proftest.h"
|
||||
#include "ai_http_stub.h"
|
||||
|
||||
#define WAIT_AI_RESPONSE_SEC 10
|
||||
|
||||
/*
|
||||
* Configure profanity to use the spawned stub as a provider named "mock"
|
||||
* with a dummy bearer token, then issue /ai start to open the AI window.
|
||||
* Returns the stub port on success, fails the test on stub-spawn failure.
|
||||
*/
|
||||
static int
|
||||
_setup_mock_provider(const char* mode, const char* reply)
|
||||
{
|
||||
int port = ai_http_stub_start(mode, reply);
|
||||
assert_int_not_equal(0, port);
|
||||
|
||||
char cmd[256];
|
||||
g_snprintf(cmd, sizeof(cmd), "/ai set provider mock http://127.0.0.1:%d/", port);
|
||||
prof_input(cmd);
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'mock' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai set token mock fake-test-token");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: mock"));
|
||||
prof_timeout_reset();
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_returns_ok(void** state)
|
||||
{
|
||||
/* Happy path: stub returns Responses API "text" body; reply appears in window. */
|
||||
_setup_mock_provider("ok", "STUB-HELLO");
|
||||
|
||||
prof_input("/ai start mock testmodel");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/testmodel"));
|
||||
prof_timeout_reset();
|
||||
|
||||
/* Plain text in AI window goes through cl_ev_send_ai_msg -> ai_send_prompt. */
|
||||
prof_input("hello stub");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("STUB-HELLO"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_openai_format(void** state)
|
||||
{
|
||||
/* Legacy OpenAI chat completions shape: "choices[].message.content". */
|
||||
_setup_mock_provider("openai", "FROM-CHOICES");
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("anything");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("FROM-CHOICES"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_401_shows_error(void** state)
|
||||
{
|
||||
/* 401 with an OpenAI-style error envelope.
|
||||
* ai_parse_error_message() should extract the inner message; the AI
|
||||
* window then displays "HTTP 401: <message>". */
|
||||
_setup_mock_provider("401", "Bad token, try another");
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("prompt");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
/* Either the parsed message surfaces, or the raw HTTP 401 envelope. */
|
||||
assert_true(prof_output_regex("HTTP 401") && prof_output_regex("Bad token, try another"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_500_surfaced(void** state)
|
||||
{
|
||||
/* 500 with a non-JSON HTML body. Parser falls back to raw body in HTTP error. */
|
||||
_setup_mock_provider("500", NULL);
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("trigger");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("HTTP 500"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_models_fetch_lists_models(void** state)
|
||||
{
|
||||
/* /v1/models endpoint returns a list of three models. /ai models <p>
|
||||
* caches them silently; /ai models <p> --cached then displays the names. */
|
||||
_setup_mock_provider("models", NULL);
|
||||
|
||||
prof_input("/ai models mock");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("Cached 3 models for provider 'mock'"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai models mock --cached");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("model-a"));
|
||||
assert_true(prof_output_regex("model-b"));
|
||||
assert_true(prof_output_regex("model-c"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
10
tests/functionaltests/test_ai_http.h
Normal file
10
tests/functionaltests/test_ai_http.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#ifndef __H_FUNC_TEST_AI_HTTP
|
||||
#define __H_FUNC_TEST_AI_HTTP
|
||||
|
||||
void ai_http_prompt_returns_ok(void** state);
|
||||
void ai_http_prompt_openai_format(void** state);
|
||||
void ai_http_prompt_401_shows_error(void** state);
|
||||
void ai_http_prompt_500_surfaced(void** state);
|
||||
void ai_http_models_fetch_lists_models(void** state);
|
||||
|
||||
#endif
|
||||
1467
tests/unittests/test_ai_client.c
Normal file
1467
tests/unittests/test_ai_client.c
Normal file
File diff suppressed because it is too large
Load Diff
134
tests/unittests/test_ai_client.h
Normal file
134
tests/unittests/test_ai_client.h
Normal file
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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_insensitive(void** state);
|
||||
/* Provider default model and settings tests */
|
||||
void test_ai_set_provider_default_model(void** state);
|
||||
void test_ai_get_provider_default_model(void** state);
|
||||
void test_ai_set_provider_setting(void** state);
|
||||
void test_ai_get_provider_setting(void** state);
|
||||
/* Model caching tests */
|
||||
void test_ai_models_are_fresh_initial(void** state);
|
||||
/* Model parsing tests */
|
||||
void test_ai_parse_models_openai_format(void** state);
|
||||
void test_ai_parse_models_perplexity_format(void** state);
|
||||
void test_ai_parse_models_array_format(void** state);
|
||||
void test_ai_parse_models_empty_json(void** state);
|
||||
void test_ai_parse_models_null_handling(void** state);
|
||||
void test_ai_parse_models_escaped_quotes(void** state);
|
||||
void test_ai_parse_models_with_whitespace(void** state);
|
||||
/* AI autocomplete integration tests */
|
||||
void test_ai_start_provider_autocomplete_only_on_exact(void** state);
|
||||
void test_ai_models_find_null_session(void** state);
|
||||
void test_ai_models_find_null_provider(void** state);
|
||||
void test_ai_providers_find_cycling(void** state);
|
||||
|
||||
/* Setup that also initializes prefs for round-trip persistence tests. */
|
||||
int ai_client_setup_with_prefs(void** state);
|
||||
int ai_client_teardown_with_prefs(void** state);
|
||||
|
||||
/* Chat response parser tests (ai_parse_response) */
|
||||
void test_ai_parse_response_openai_content(void** state);
|
||||
void test_ai_parse_response_perplexity_text(void** state);
|
||||
void test_ai_parse_response_text_preferred_over_content(void** state);
|
||||
void test_ai_parse_response_escaped_quote(void** state);
|
||||
void test_ai_parse_response_newline_escape(void** state);
|
||||
void test_ai_parse_response_empty_content(void** state);
|
||||
void test_ai_parse_response_missing_field(void** state);
|
||||
void test_ai_parse_response_null_input(void** state);
|
||||
void test_ai_parse_response_empty_input(void** state);
|
||||
void test_ai_parse_response_percent_signs_safe(void** state);
|
||||
void test_ai_parse_response_braces_in_content(void** state);
|
||||
void test_ai_parse_response_multiline_content(void** state);
|
||||
|
||||
/* Error response parser tests (ai_parse_error_message) */
|
||||
void test_ai_parse_error_standard_envelope(void** state);
|
||||
void test_ai_parse_error_with_escapes(void** state);
|
||||
void test_ai_parse_error_no_error_field(void** state);
|
||||
void test_ai_parse_error_no_message_field(void** state);
|
||||
void test_ai_parse_error_null_input(void** state);
|
||||
void test_ai_parse_error_empty_input(void** state);
|
||||
void test_ai_parse_error_tab_escape(void** state);
|
||||
void test_ai_parse_error_backslash_escape(void** state);
|
||||
|
||||
/* Extended JSON escape tests */
|
||||
void test_ai_json_escape_backspace(void** state);
|
||||
void test_ai_json_escape_formfeed(void** state);
|
||||
void test_ai_json_escape_carriage_return(void** state);
|
||||
void test_ai_json_escape_all_specials_combined(void** state);
|
||||
void test_ai_json_escape_passes_utf8_through(void** state);
|
||||
|
||||
/* Autocomplete cycling with many providers */
|
||||
void test_ai_providers_find_cycles_through_many(void** state);
|
||||
void test_ai_providers_find_previous_walks_backwards(void** state);
|
||||
void test_ai_providers_find_wraps_around(void** state);
|
||||
|
||||
/* Session edge cases */
|
||||
void test_ai_session_add_message_null_session_no_crash(void** state);
|
||||
void test_ai_session_add_message_null_role_no_crash(void** state);
|
||||
void test_ai_session_add_message_null_content_no_crash(void** state);
|
||||
void test_ai_session_history_preserves_large_order(void** state);
|
||||
void test_ai_session_clear_empty_history(void** state);
|
||||
void test_ai_session_set_model_null_keeps_old(void** state);
|
||||
void test_ai_session_unref_null_no_crash(void** state);
|
||||
void test_ai_session_ref_null_returns_null(void** state);
|
||||
|
||||
/* Provider edge cases */
|
||||
void test_ai_get_provider_null_returns_null(void** state);
|
||||
void test_ai_remove_provider_null_returns_false(void** state);
|
||||
void test_ai_remove_provider_twice_second_fails(void** state);
|
||||
void test_ai_provider_survives_via_session_after_removal(void** state);
|
||||
|
||||
/* Settings edge cases */
|
||||
void test_ai_settings_multiple_keys_independent(void** state);
|
||||
void test_ai_settings_get_missing_returns_null(void** state);
|
||||
void test_ai_settings_isolated_between_providers(void** state);
|
||||
|
||||
/* Model parsing edge cases */
|
||||
void test_ai_parse_models_data_not_array(void** state);
|
||||
void test_ai_parse_models_empty_data_array(void** state);
|
||||
void test_ai_parse_models_id_outside_data_ignored(void** state);
|
||||
void test_ai_parse_models_multiple_models(void** state);
|
||||
|
||||
/* Prefs round-trip tests */
|
||||
void test_ai_prefs_round_trip_api_key(void** state);
|
||||
void test_ai_prefs_round_trip_remove_key(void** state);
|
||||
void test_ai_prefs_multiple_providers_persist(void** state);
|
||||
41
tests/unittests/ui/stub_ai.c
Normal file
41
tests/unittests/ui/stub_ai.c
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
void
|
||||
win_print_outgoing(ProfWin* window, const char* show_char, const char* const id, const char* const replace_id, const char* const message)
|
||||
{
|
||||
/* Stub: do nothing */
|
||||
(void)window;
|
||||
(void)show_char;
|
||||
(void)id;
|
||||
(void)replace_id;
|
||||
(void)message;
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "test_form.h"
|
||||
#include "test_callbacks.h"
|
||||
#include "test_plugins_disco.h"
|
||||
#include "test_ai_client.h"
|
||||
#include "test_database_export.h"
|
||||
#include "test_database_stress.h"
|
||||
|
||||
@@ -659,6 +660,121 @@ main(int argc, char* argv[])
|
||||
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_insensitive, ai_client_setup, ai_client_teardown),
|
||||
/* Provider default model and settings tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_default_model, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_default_model, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_setting, ai_client_setup, ai_client_teardown),
|
||||
/* Model caching tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_are_fresh_initial, ai_client_setup, ai_client_teardown),
|
||||
/* Model parsing tests */
|
||||
cmocka_unit_test(test_ai_parse_models_openai_format),
|
||||
cmocka_unit_test(test_ai_parse_models_perplexity_format),
|
||||
cmocka_unit_test(test_ai_parse_models_array_format),
|
||||
cmocka_unit_test(test_ai_parse_models_empty_json),
|
||||
cmocka_unit_test(test_ai_parse_models_null_handling),
|
||||
cmocka_unit_test(test_ai_parse_models_escaped_quotes),
|
||||
cmocka_unit_test(test_ai_parse_models_with_whitespace),
|
||||
/* AI autocomplete integration tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_start_provider_autocomplete_only_on_exact, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_null_session, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_null_provider, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycling, 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),
|
||||
/* Chat response parser */
|
||||
cmocka_unit_test(test_ai_parse_response_openai_content),
|
||||
cmocka_unit_test(test_ai_parse_response_perplexity_text),
|
||||
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
|
||||
cmocka_unit_test(test_ai_parse_response_escaped_quote),
|
||||
cmocka_unit_test(test_ai_parse_response_newline_escape),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_content),
|
||||
cmocka_unit_test(test_ai_parse_response_missing_field),
|
||||
cmocka_unit_test(test_ai_parse_response_null_input),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_input),
|
||||
cmocka_unit_test(test_ai_parse_response_percent_signs_safe),
|
||||
cmocka_unit_test(test_ai_parse_response_braces_in_content),
|
||||
cmocka_unit_test(test_ai_parse_response_multiline_content),
|
||||
/* Error response parser */
|
||||
cmocka_unit_test(test_ai_parse_error_standard_envelope),
|
||||
cmocka_unit_test(test_ai_parse_error_with_escapes),
|
||||
cmocka_unit_test(test_ai_parse_error_no_error_field),
|
||||
cmocka_unit_test(test_ai_parse_error_no_message_field),
|
||||
cmocka_unit_test(test_ai_parse_error_null_input),
|
||||
cmocka_unit_test(test_ai_parse_error_empty_input),
|
||||
cmocka_unit_test(test_ai_parse_error_tab_escape),
|
||||
cmocka_unit_test(test_ai_parse_error_backslash_escape),
|
||||
/* Extended JSON escape coverage */
|
||||
cmocka_unit_test(test_ai_json_escape_backspace),
|
||||
cmocka_unit_test(test_ai_json_escape_formfeed),
|
||||
cmocka_unit_test(test_ai_json_escape_carriage_return),
|
||||
cmocka_unit_test(test_ai_json_escape_all_specials_combined),
|
||||
cmocka_unit_test(test_ai_json_escape_passes_utf8_through),
|
||||
/* Autocomplete cycling */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycles_through_many, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous_walks_backwards, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_wraps_around, ai_client_setup, ai_client_teardown),
|
||||
/* Session edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_session_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_role_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_content_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_history_preserves_large_order, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_clear_empty_history, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_set_model_null_keeps_old, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_unref_null_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_ref_null_returns_null, ai_client_setup, ai_client_teardown),
|
||||
/* Provider edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_null_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_null_returns_false, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_twice_second_fails, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_provider_survives_via_session_after_removal, ai_client_setup, ai_client_teardown),
|
||||
/* Settings */
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_multiple_keys_independent, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_get_missing_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_isolated_between_providers, ai_client_setup, ai_client_teardown),
|
||||
/* Model parsing edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_data_not_array, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_empty_data_array, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_id_outside_data_ignored, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_multiple_models, ai_client_setup, ai_client_teardown),
|
||||
/* Prefs round-trip (uses prefs+ai setup) */
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
// Flatfile export/import round-trip
|
||||
cmocka_unit_test(test_ff_roundtrip_simple_chat),
|
||||
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),
|
||||
|
||||
Reference in New Issue
Block a user