Compare commits
37 Commits
feat/no-db
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
2c74c0a058
|
|||
|
c167f48714
|
|||
|
010b2062a4
|
|||
|
ecb07fd00c
|
|||
|
a898cb212d
|
|||
|
07d267b5bc
|
|||
|
acae543057
|
|||
|
731b55fa19
|
|||
|
3f36c303c2
|
|||
|
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
|
|||
|
0feacbc9da
|
|||
|
0722dc9e36
|
@@ -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
|
||||
|
||||
@@ -204,6 +204,13 @@ case "$ARCH" in
|
||||
""
|
||||
)
|
||||
source /etc/profile.d/debuginfod.sh 2>/dev/null || true
|
||||
|
||||
if grep -q 'ID=arch' /etc/os-release 2>/dev/null && [ -f /etc/makepkg.conf ]; then
|
||||
echo "--> [Parity Mode] Simulating Pikaur collision..."
|
||||
set -a
|
||||
source /etc/makepkg.conf
|
||||
set +a
|
||||
fi
|
||||
;;
|
||||
darwin*)
|
||||
# 4 configurations for parallel CI
|
||||
|
||||
@@ -396,12 +396,11 @@ AC_SUBST([FORKPTY_LIB])
|
||||
## Default parameters
|
||||
AM_CFLAGS="$AM_CFLAGS -Wall -Wextra -Wformat=2 -Wno-format-zero-length"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wno-deprecated-declarations -Wno-unused-parameter -Wno-missing-field-initializers -Wno-sign-compare -Wno-cast-function-type"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wnull-dereference -Wpointer-arith"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wpointer-arith"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wimplicit-function-declaration"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wundef"
|
||||
AM_CFLAGS="$AM_CFLAGS -Wfloat-equal -Wredundant-decls"
|
||||
AM_CFLAGS="$AM_CFLAGS -fstack-protector-strong -fno-common"
|
||||
AM_CFLAGS="$AM_CFLAGS -D_FORTIFY_SOURCE=2"
|
||||
AM_CFLAGS="$AM_CFLAGS -std=gnu99 -ggdb3"
|
||||
|
||||
# GCC-specific warnings (not supported by clang) — test each one
|
||||
|
||||
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
257
src/ai/ai_client.h
Normal file
257
src/ai/ai_client.h
Normal file
@@ -0,0 +1,257 @@
|
||||
#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);
|
||||
|
||||
/* ========================================================================
|
||||
* Model Parsing (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);
|
||||
|
||||
/* ========================================================================
|
||||
* 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
|
||||
|
||||
@@ -211,13 +211,9 @@ _first_pass(FILE* fp, const char* basename,
|
||||
|
||||
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
|
||||
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
|
||||
// Older clients (Pidgin, Adium, even early Profanity)
|
||||
// sometimes reuse client-generated stanza-ids across
|
||||
// distinct messages, so a collision is not a real
|
||||
// integrity defect — log it for diagnostics but don't
|
||||
// surface it through /history verify.
|
||||
log_debug("flatfile verify: duplicate stanza-id \"%s\" at %s:%d",
|
||||
pl->stanza_id, basename, lineno);
|
||||
*issues = g_slist_prepend(*issues,
|
||||
_issue_new(INTEGRITY_WARNING, basename, lineno,
|
||||
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
|
||||
} else {
|
||||
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
|
||||
}
|
||||
@@ -306,15 +302,7 @@ _verify_contact_dir(const char* cdir_path, GSList** issues)
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconstruct the contact JID from the directory name so issue reports
|
||||
// identify which contact the problem belongs to (every contact has its
|
||||
// own history.log, so the bare filename is ambiguous).
|
||||
auto_gchar gchar* dir_name = g_path_get_basename(cdir_path);
|
||||
char* dir_unescaped = str_replace(dir_name, "_at_", "@");
|
||||
auto_gchar gchar* basename_owned = g_strdup_printf("%s/history.log",
|
||||
dir_unescaped ? dir_unescaped : dir_name);
|
||||
free(dir_unescaped);
|
||||
const char* basename = basename_owned;
|
||||
const char* basename = "history.log";
|
||||
|
||||
_check_permissions(filepath, basename, issues);
|
||||
|
||||
|
||||
@@ -466,36 +466,23 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// Check timestamp ordering, restricted to consecutive rows belonging
|
||||
// to the same conversation pair so adjacency does not span unrelated
|
||||
// contacts. Adjacent rows by id from different conversations are
|
||||
// legitimately out-of-order and should not be reported.
|
||||
// Check timestamp ordering for a specific contact or all
|
||||
const Jid* myjid = connection_get_jid();
|
||||
if (myjid && myjid->barejid) {
|
||||
auto_sqlite char* query = NULL;
|
||||
if (contact_barejid) {
|
||||
query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
|
||||
"FROM `ChatLogs` A "
|
||||
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
|
||||
" AND B.`id` > A.`id` "
|
||||
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
|
||||
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
|
||||
"WHERE A.`timestamp` > B.`timestamp` "
|
||||
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
|
||||
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
|
||||
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
|
||||
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
|
||||
"LIMIT 50",
|
||||
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
|
||||
} else {
|
||||
query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
|
||||
"FROM `ChatLogs` A "
|
||||
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
|
||||
" AND B.`id` > A.`id` "
|
||||
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
|
||||
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
|
||||
"WHERE A.`timestamp` > B.`timestamp` "
|
||||
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
|
||||
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
|
||||
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
|
||||
"LIMIT 50");
|
||||
}
|
||||
|
||||
@@ -503,19 +490,15 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int id_a = sqlite3_column_int(stmt, 0);
|
||||
const char* ts_a = (const char*)sqlite3_column_text(stmt, 1);
|
||||
const char* from_a = (const char*)sqlite3_column_text(stmt, 2);
|
||||
const char* to_a = (const char*)sqlite3_column_text(stmt, 3);
|
||||
int id_b = sqlite3_column_int(stmt, 4);
|
||||
const char* ts_b = (const char*)sqlite3_column_text(stmt, 5);
|
||||
int id_b = sqlite3_column_int(stmt, 2);
|
||||
const char* ts_b = (const char*)sqlite3_column_text(stmt, 3);
|
||||
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_WARNING;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = id_a;
|
||||
issue->message = g_strdup_printf("Timestamp out of order in %s↔%s: row %d (%s) > row %d (%s)",
|
||||
from_a ? from_a : "?", to_a ? to_a : "?",
|
||||
id_a, ts_a ? ts_a : "NULL",
|
||||
id_b, ts_b ? ts_b : "NULL");
|
||||
issue->message = g_strdup_printf("Timestamp out of order: row %d (%s) > row %d (%s)",
|
||||
id_a, ts_a ? ts_a : "NULL", id_b, ts_b ? ts_b : "NULL");
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
@@ -523,7 +506,7 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
|
||||
|
||||
// Check broken LMC references
|
||||
auto_sqlite char* lmc_query = sqlite3_mprintf(
|
||||
"SELECT A.`id`, A.`replaces_db_id`, A.`from_jid`, A.`to_jid` FROM `ChatLogs` A "
|
||||
"SELECT A.`id`, A.`replaces_db_id` FROM `ChatLogs` A "
|
||||
"WHERE A.`replaces_db_id` IS NOT NULL "
|
||||
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` B WHERE B.`id` = A.`replaces_db_id`) "
|
||||
"LIMIT 50");
|
||||
@@ -532,15 +515,12 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int id = sqlite3_column_int(stmt, 0);
|
||||
int replaces_id = sqlite3_column_int(stmt, 1);
|
||||
const char* from = (const char*)sqlite3_column_text(stmt, 2);
|
||||
const char* to = (const char*)sqlite3_column_text(stmt, 3);
|
||||
|
||||
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
|
||||
issue->level = INTEGRITY_ERROR;
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = id;
|
||||
issue->message = g_strdup_printf("Broken LMC reference in %s↔%s: row %d references non-existent row %d",
|
||||
from ? from : "?", to ? to : "?",
|
||||
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
|
||||
id, replaces_id);
|
||||
issues = g_slist_append(issues, issue);
|
||||
}
|
||||
|
||||
@@ -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,29 @@ 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;
|
||||
}
|
||||
|
||||
// Snap viewport to bottom so the user's own message is visible even if
|
||||
// they had scrolled up to read history; otherwise win_is_paged() in
|
||||
// _win_printf would suppress the print.
|
||||
win_move_to_end(&aiwin->window);
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -147,7 +147,7 @@ void
|
||||
ui_update(void)
|
||||
{
|
||||
ProfWin* current = wins_get_current();
|
||||
if (current->layout->paged == 0) {
|
||||
if (!win_is_paged(current)) {
|
||||
win_move_to_end(current);
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ _show_attention(ProfWin* current, gboolean attention)
|
||||
static void
|
||||
_show_scrolled(ProfWin* current)
|
||||
{
|
||||
if (current && current->layout->paged == 1) {
|
||||
if (win_is_paged(current)) {
|
||||
int bracket_attrs = theme_attrs(THEME_TITLE_BRACKET);
|
||||
int scrolled_attrs = theme_attrs(THEME_TITLE_SCROLLED);
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -119,7 +120,6 @@ typedef struct prof_layout_t
|
||||
WINDOW* win;
|
||||
ProfBuff buffer;
|
||||
int y_pos;
|
||||
int paged;
|
||||
int unread_msg;
|
||||
} ProfLayout;
|
||||
|
||||
@@ -144,19 +144,28 @@ typedef enum {
|
||||
WIN_PRIVATE,
|
||||
WIN_XML,
|
||||
WIN_PLUGIN,
|
||||
WIN_VCARD
|
||||
WIN_VCARD,
|
||||
WIN_AI
|
||||
} win_type_t;
|
||||
|
||||
typedef enum {
|
||||
WIN_SCROLL_INNER,
|
||||
WIN_SCROLL_REACHED_TOP,
|
||||
WIN_SCROLL_REACHED_BOTTOM
|
||||
} win_scroll_state_t;
|
||||
// ScrollEdges tracks the two end-of-history conditions independently,
|
||||
// disentangling buffer-layout state (handled implicitly via y_pos) from
|
||||
// DB-content state. db_exhausted_above means a previous page-up fetch came
|
||||
// back empty: there is nothing older in the chat database, so the next
|
||||
// page-up should not re-issue the SQL/flatfile read. db_exhausted_below
|
||||
// is the analogous flag for newer rows. Both default to FALSE, are flipped
|
||||
// to TRUE on a respective DB_RESPONSE_EMPTY fetch, and are cleared by
|
||||
// scrolling in the opposite direction so a re-attempt happens after new
|
||||
// activity (e.g. MAM late delivery, /history switch).
|
||||
typedef struct {
|
||||
gboolean db_exhausted_above;
|
||||
gboolean db_exhausted_below;
|
||||
} ScrollEdges;
|
||||
|
||||
typedef struct prof_win_t
|
||||
{
|
||||
win_type_t type;
|
||||
win_scroll_state_t scroll_state;
|
||||
ScrollEdges scroll_edges;
|
||||
ProfLayout* layout;
|
||||
Autocomplete urls_ac;
|
||||
Autocomplete quotes_ac;
|
||||
@@ -258,4 +267,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
|
||||
|
||||
175
src/ui/window.c
175
src/ui/window.c
@@ -36,6 +36,7 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "database.h"
|
||||
#include "ui/win_types.h"
|
||||
#include "ui/window_list.h"
|
||||
|
||||
#include <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.";
|
||||
@@ -82,6 +84,26 @@ _check_subwin_width(int cols, int width)
|
||||
return cols <= 1 ? 1 : CLAMP(width, 1, cols - 1);
|
||||
}
|
||||
|
||||
// Visible chat-area height in lines: stdscr minus the title bar (1), the
|
||||
// status bar (1), the input prompt area (1), and the separator (1).
|
||||
int
|
||||
win_page_space(void)
|
||||
{
|
||||
return getmaxy(stdscr) - 4;
|
||||
}
|
||||
|
||||
// True when the user has scrolled away from the live tail and there is
|
||||
// content below the visible viewport. Derived from y_pos and the current
|
||||
// height of the pad — no sticky flag, no possibility of staleness.
|
||||
gboolean
|
||||
win_is_paged(const ProfWin* window)
|
||||
{
|
||||
if (!window || !window->layout || !window->layout->win)
|
||||
return FALSE;
|
||||
int total_rows = getcury(window->layout->win);
|
||||
return (total_rows - window->layout->y_pos) > win_page_space();
|
||||
}
|
||||
|
||||
int
|
||||
win_roster_cols(void)
|
||||
{
|
||||
@@ -115,7 +137,6 @@ _win_create_simple_layout(void)
|
||||
wbkgd(layout->base.win, theme_attrs(THEME_TEXT));
|
||||
layout->base.buffer = buffer_create();
|
||||
layout->base.y_pos = 0;
|
||||
layout->base.paged = 0;
|
||||
layout->base.unread_msg = 0;
|
||||
scrollok(layout->base.win, TRUE);
|
||||
|
||||
@@ -133,7 +154,6 @@ _win_create_split_layout(void)
|
||||
wbkgd(layout->base.win, theme_attrs(THEME_TEXT));
|
||||
layout->base.buffer = buffer_create();
|
||||
layout->base.y_pos = 0;
|
||||
layout->base.paged = 0;
|
||||
layout->base.unread_msg = 0;
|
||||
scrollok(layout->base.win, TRUE);
|
||||
layout->subwin = NULL;
|
||||
@@ -148,7 +168,7 @@ win_create_console(void)
|
||||
{
|
||||
ProfConsoleWin* new_win = malloc(sizeof(ProfConsoleWin));
|
||||
new_win->window.type = WIN_CONSOLE;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.layout = _win_create_split_layout();
|
||||
|
||||
return &new_win->window;
|
||||
@@ -160,7 +180,7 @@ win_create_chat(const char* const barejid)
|
||||
assert(barejid != NULL);
|
||||
ProfChatWin* new_win = malloc(sizeof(ProfChatWin));
|
||||
new_win->window.type = WIN_CHAT;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->barejid = strdup(barejid);
|
||||
@@ -194,7 +214,7 @@ win_create_muc(const char* const roomjid)
|
||||
int cols = getmaxx(stdscr);
|
||||
|
||||
new_win->window.type = WIN_MUC;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
ProfLayoutSplit* layout = malloc(sizeof(ProfLayoutSplit));
|
||||
layout->base.type = LAYOUT_SPLIT;
|
||||
|
||||
@@ -213,7 +233,6 @@ win_create_muc(const char* const roomjid)
|
||||
layout->memcheck = LAYOUT_SPLIT_MEMCHECK;
|
||||
layout->base.buffer = buffer_create();
|
||||
layout->base.y_pos = 0;
|
||||
layout->base.paged = 0;
|
||||
layout->base.unread_msg = 0;
|
||||
scrollok(layout->base.win, TRUE);
|
||||
new_win->window.layout = (ProfLayout*)layout;
|
||||
@@ -252,7 +271,7 @@ win_create_config(const char* const roomjid, DataForm* form, ProfConfWinCallback
|
||||
assert(form != NULL);
|
||||
ProfConfWin* new_win = malloc(sizeof(ProfConfWin));
|
||||
new_win->window.type = WIN_CONFIG;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
new_win->roomjid = strdup(roomjid);
|
||||
new_win->form = form;
|
||||
@@ -271,7 +290,7 @@ win_create_private(const char* const fulljid)
|
||||
assert(fulljid != NULL);
|
||||
ProfPrivateWin* new_win = malloc(sizeof(ProfPrivateWin));
|
||||
new_win->window.type = WIN_PRIVATE;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
new_win->fulljid = strdup(fulljid);
|
||||
new_win->unread = 0;
|
||||
@@ -288,7 +307,7 @@ win_create_xmlconsole(void)
|
||||
{
|
||||
ProfXMLWin* new_win = malloc(sizeof(ProfXMLWin));
|
||||
new_win->window.type = WIN_XML;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->memcheck = PROFXMLWIN_MEMCHECK;
|
||||
@@ -303,7 +322,7 @@ win_create_plugin(const char* const plugin_name, const char* const tag)
|
||||
assert(tag != NULL);
|
||||
ProfPluginWin* new_win = malloc(sizeof(ProfPluginWin));
|
||||
new_win->window.type = WIN_PLUGIN;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->tag = strdup(tag);
|
||||
@@ -320,7 +339,7 @@ win_create_vcard(vCard* vcard)
|
||||
assert(vcard != NULL);
|
||||
ProfVcardWin* new_win = malloc(sizeof(ProfVcardWin));
|
||||
new_win->window.type = WIN_VCARD;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->vcard = vcard;
|
||||
@@ -329,6 +348,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_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
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 +442,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 +496,10 @@ win_get_tab_identifier(ProfWin* window)
|
||||
{
|
||||
return strdup("vcard");
|
||||
}
|
||||
case WIN_AI:
|
||||
{
|
||||
return strdup("ai");
|
||||
}
|
||||
}
|
||||
assert(FALSE);
|
||||
}
|
||||
@@ -536,6 +582,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 +714,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;
|
||||
}
|
||||
@@ -676,11 +739,13 @@ win_page_up(ProfWin* window, int scroll_size)
|
||||
int* page_start = &(window->layout->y_pos);
|
||||
int page_start_initial = *page_start;
|
||||
// Size of the visible chat page
|
||||
int page_space = getmaxy(stdscr) - 4;
|
||||
int page_space = win_page_space();
|
||||
if (scroll_size == 0)
|
||||
scroll_size = page_space;
|
||||
win_scroll_state_t* scroll_state = &window->scroll_state;
|
||||
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) ? WIN_SCROLL_INNER : *scroll_state;
|
||||
ScrollEdges* edges = &window->scroll_edges;
|
||||
// Going up means the bottom of the DB may have new content again; allow
|
||||
// a re-fetch on the next page-down.
|
||||
edges->db_exhausted_below = FALSE;
|
||||
*page_start -= scroll_size;
|
||||
gboolean has_scroll_past_buffer = *page_start < 0;
|
||||
|
||||
@@ -690,16 +755,18 @@ win_page_up(ProfWin* window, int scroll_size)
|
||||
|
||||
gboolean is_still_fetching_mam = first_entry && (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, LOADING_MESSAGE) == 0);
|
||||
if (!is_still_fetching_mam) {
|
||||
// WIN_SCROLL_REACHED_TOP means that we reached top of DB and there is no need to refetch data from it
|
||||
gboolean is_db_offset_jump_needed = false;
|
||||
if (*scroll_state != WIN_SCROLL_REACHED_TOP) {
|
||||
// db_exhausted_above suppresses further upward DB fetches; once
|
||||
// a previous fetch returned empty, there is nothing older.
|
||||
gboolean is_db_offset_jump_needed = FALSE;
|
||||
if (!edges->db_exhausted_above) {
|
||||
db_history_result_t db_response = chatwin_db_history(chatwin, NULL, NULL, TRUE);
|
||||
is_db_offset_jump_needed = db_response == DB_RESPONSE_SUCCESS;
|
||||
*scroll_state = db_response == DB_RESPONSE_EMPTY ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER;
|
||||
log_debug("Scroll state after DB history fetch: %s", *scroll_state ? "WIN_SCROLL_REACHED_TOP" : "WIN_SCROLL_INNER");
|
||||
edges->db_exhausted_above = (db_response == DB_RESPONSE_EMPTY);
|
||||
log_debug("Scroll edges after DB history fetch: above=%d below=%d",
|
||||
edges->db_exhausted_above, edges->db_exhausted_below);
|
||||
}
|
||||
|
||||
if (*scroll_state == WIN_SCROLL_REACHED_TOP) {
|
||||
if (edges->db_exhausted_above) {
|
||||
*page_start = 0;
|
||||
if (prefs_get_boolean(PREF_MAM)) {
|
||||
win_print_loading_history(window);
|
||||
@@ -731,9 +798,7 @@ win_page_up(ProfWin* window, int scroll_size)
|
||||
win_update_virtual(window);
|
||||
}
|
||||
|
||||
// Update scrolling state
|
||||
int total_rows = getcury(window->layout->win);
|
||||
window->layout->paged = (total_rows) - *page_start > page_space;
|
||||
// paged is derived on demand from y_pos / total_rows; nothing to set here.
|
||||
}
|
||||
|
||||
void
|
||||
@@ -742,13 +807,15 @@ win_page_down(ProfWin* window, int scroll_size)
|
||||
int total_rows = getcury(window->layout->win);
|
||||
int total_rows_with_unread = total_rows + window->layout->unread_msg;
|
||||
int* page_start = &(window->layout->y_pos);
|
||||
int page_space = getmaxy(stdscr) - 4;
|
||||
int page_space = win_page_space();
|
||||
int page_start_initial = *page_start;
|
||||
|
||||
if (scroll_size == 0)
|
||||
scroll_size = page_space;
|
||||
win_scroll_state_t* scroll_state = &window->scroll_state;
|
||||
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_TOP) ? WIN_SCROLL_INNER : *scroll_state;
|
||||
ScrollEdges* edges = &window->scroll_edges;
|
||||
// Going down means the top of the DB may have new content again; allow
|
||||
// a re-fetch on the next page-up (e.g. MAM late delivery).
|
||||
edges->db_exhausted_above = FALSE;
|
||||
|
||||
*page_start += scroll_size;
|
||||
|
||||
@@ -759,7 +826,7 @@ win_page_down(ProfWin* window, int scroll_size)
|
||||
|
||||
if ((past_bottom || at_page_space_and_past_unread) && is_chat) {
|
||||
int bf_size = buffer_size(window->layout->buffer);
|
||||
if (bf_size > 0 && *scroll_state != WIN_SCROLL_REACHED_BOTTOM) {
|
||||
if (bf_size > 0 && !edges->db_exhausted_below) {
|
||||
// How many lines are left until end of the screen
|
||||
int current_offset = total_rows - *page_start;
|
||||
GDateTime* now = g_date_time_new_now_local();
|
||||
@@ -768,7 +835,7 @@ win_page_down(ProfWin* window, int scroll_size)
|
||||
auto_gchar gchar* end_date = g_date_time_format_iso8601(now);
|
||||
db_history_result_t db_response = chatwin_db_history((ProfChatWin*)window, start, end_date, FALSE);
|
||||
if (db_response == DB_RESPONSE_EMPTY)
|
||||
*scroll_state = WIN_SCROLL_REACHED_BOTTOM;
|
||||
edges->db_exhausted_below = TRUE;
|
||||
|
||||
// similar to page_up (see explanation there)
|
||||
// Get offset of the message that was the latest prior to loading messages from the DB
|
||||
@@ -780,26 +847,22 @@ win_page_down(ProfWin* window, int scroll_size)
|
||||
|
||||
total_rows = getcury(window->layout->win);
|
||||
|
||||
// near the end, but will display only part of the page space, move a bit back to show full page
|
||||
if ((total_rows - *page_start) < page_space) {
|
||||
*page_start = total_rows - page_space;
|
||||
// went past end, show last page
|
||||
} else if (*page_start >= total_rows) {
|
||||
*page_start = total_rows - page_space - 1;
|
||||
// Clamp to the last full page so the viewport never goes past the buffer
|
||||
// and always shows a full page when one is available.
|
||||
int last_page_start = total_rows > page_space ? total_rows - page_space : 0;
|
||||
if (*page_start > last_page_start) {
|
||||
*page_start = last_page_start;
|
||||
}
|
||||
|
||||
window->layout->paged = 1;
|
||||
|
||||
// update only if position has changed
|
||||
if ((page_start_initial != *page_start) || window->layout->unread_msg) {
|
||||
win_update_virtual(window);
|
||||
}
|
||||
|
||||
/* Switch off page if no messages left to read.
|
||||
* TODO: update buffer end handling to check messages just after last entry.
|
||||
*/
|
||||
if (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) {
|
||||
window->layout->paged = 0;
|
||||
// Once the user is back at the buffer's last page the unread badge no
|
||||
// longer reflects anything they have not seen — clear it. paged itself
|
||||
// is derived from y_pos so there is no flag to flip.
|
||||
if (*page_start >= last_page_start) {
|
||||
window->layout->unread_msg = 0;
|
||||
}
|
||||
}
|
||||
@@ -808,8 +871,7 @@ void
|
||||
win_sub_page_down(ProfWin* window)
|
||||
{
|
||||
if (window->layout->type == LAYOUT_SPLIT) {
|
||||
int rows = getmaxy(stdscr);
|
||||
int page_space = rows - 4;
|
||||
int page_space = win_page_space();
|
||||
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
|
||||
int sub_y = getcury(split_layout->subwin);
|
||||
int* sub_y_pos = &(split_layout->sub_y_pos);
|
||||
@@ -832,8 +894,7 @@ void
|
||||
win_sub_page_up(ProfWin* window)
|
||||
{
|
||||
if (window->layout->type == LAYOUT_SPLIT) {
|
||||
int rows = getmaxy(stdscr);
|
||||
int page_space = rows - 4;
|
||||
int page_space = win_page_space();
|
||||
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
|
||||
int* sub_y_pos = &(split_layout->sub_y_pos);
|
||||
|
||||
@@ -860,7 +921,6 @@ win_clear(ProfWin* window)
|
||||
int y = getcury(window->layout->win);
|
||||
int* page_start = &(window->layout->y_pos);
|
||||
*page_start = y;
|
||||
window->layout->paged = 1;
|
||||
window->layout->unread_msg = 0;
|
||||
win_update_virtual(window);
|
||||
}
|
||||
@@ -970,14 +1030,12 @@ win_refresh_with_subwin(ProfWin* window)
|
||||
void
|
||||
win_move_to_end(ProfWin* window)
|
||||
{
|
||||
window->layout->paged = 0;
|
||||
window->layout->unread_msg = 0;
|
||||
|
||||
int rows = getmaxy(stdscr);
|
||||
int y = getcury(window->layout->win);
|
||||
int size = rows - 3;
|
||||
int page_space = win_page_space();
|
||||
|
||||
window->layout->y_pos = y - (size - 1);
|
||||
window->layout->y_pos = y - page_space;
|
||||
if (window->layout->y_pos < 0) {
|
||||
window->layout->y_pos = 0;
|
||||
}
|
||||
@@ -1754,12 +1812,13 @@ win_newline(ProfWin* window)
|
||||
static void
|
||||
_win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* timestamp, int flags, theme_item_t theme_item, const char* const display_from, const char* const from_jid, const char* const message_id, const char* const message, ...)
|
||||
{
|
||||
|
||||
/* Prevent printing and buffer update when user is viewing message history [SCROLLING]*/
|
||||
if (window->layout->paged && wins_is_current(window)) {
|
||||
window->layout->unread_msg++;
|
||||
return;
|
||||
}
|
||||
/* Append the message to the curses pad and the in-memory buffer
|
||||
* unconditionally. When the user is scrolled up (paged), the new line
|
||||
* lands on rows below the visible viewport so it does not disturb what
|
||||
* they are reading; they encounter it naturally on page-down. The
|
||||
* unread badge is bumped so the title bar still reflects new activity.
|
||||
*/
|
||||
const gboolean is_paged_now = win_is_paged(window) && wins_is_current(window);
|
||||
|
||||
if (timestamp == NULL) {
|
||||
timestamp = g_date_time_new_now_local();
|
||||
@@ -1776,6 +1835,10 @@ _win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* t
|
||||
_win_print_internal(window, show_char, pad_indent, timestamp, flags, theme_item, display_from, msg, NULL);
|
||||
buffer_append(window->layout->buffer, show_char, pad_indent, timestamp, flags, theme_item, display_from, from_jid, msg, NULL, message_id, y_start_pos, getcury(window->layout->win));
|
||||
|
||||
if (is_paged_now) {
|
||||
window->layout->unread_msg++;
|
||||
}
|
||||
|
||||
inp_nonblocking(TRUE);
|
||||
g_date_time_unref(timestamp);
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ void win_redraw(ProfWin* window);
|
||||
void win_print_loading_history(ProfWin* window);
|
||||
int win_roster_cols(void);
|
||||
int win_occpuants_cols(void);
|
||||
int win_page_space(void);
|
||||
gboolean win_is_paged(const ProfWin* window);
|
||||
void win_sub_print(WINDOW* win, char* msg, gboolean newline, gboolean wrap, int indent);
|
||||
void win_sub_newline_lazy(WINDOW* win);
|
||||
void win_mark_received(ProfWin* window, const char* const id);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -70,9 +70,12 @@ const char*
|
||||
bench_volume_name(bench_volume_t v)
|
||||
{
|
||||
switch (v) {
|
||||
case BENCH_VOLUME_SMALL: return "small";
|
||||
case BENCH_VOLUME_MEDIUM: return "medium";
|
||||
case BENCH_VOLUME_MAX: return "max";
|
||||
case BENCH_VOLUME_SMALL:
|
||||
return "small";
|
||||
case BENCH_VOLUME_MEDIUM:
|
||||
return "medium";
|
||||
case BENCH_VOLUME_MAX:
|
||||
return "max";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ char* bench_fmt_bytes(uint64_t bytes);
|
||||
char* bench_fmt_ms(double ms);
|
||||
|
||||
// Resolve BENCH_VOLUME env: "small" / "medium" / "max" / unset.
|
||||
typedef enum
|
||||
{
|
||||
typedef enum {
|
||||
BENCH_VOLUME_SMALL, // 10k lines / 1 contact / 1 year
|
||||
BENCH_VOLUME_MEDIUM, // 500k lines / 1 contact / 5 years
|
||||
BENCH_VOLUME_MAX, // 5M lines / 1 contact / 10 years
|
||||
|
||||
@@ -105,26 +105,25 @@ db_path_for(const char* account_jid)
|
||||
// Schema (lifted from database_sqlite.c so the seeder can populate without
|
||||
// going through _sqlite_init's full machinery)
|
||||
|
||||
static const char* SCHEMA_DDL =
|
||||
"CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
"`to_jid` TEXT NOT NULL, "
|
||||
"`from_resource` TEXT, `to_resource` TEXT, "
|
||||
"`message` TEXT, `timestamp` TEXT, `type` TEXT, "
|
||||
"`stanza_id` TEXT, `archive_id` TEXT, "
|
||||
"`encryption` TEXT, `marked_read` INTEGER, "
|
||||
"`replace_id` TEXT, "
|
||||
"`replaces_db_id` INTEGER, `replaced_by_db_id` INTEGER);"
|
||||
"CREATE TABLE IF NOT EXISTS `DbVersion` ("
|
||||
"`dv_id` INTEGER PRIMARY KEY, `version` INTEGER UNIQUE);"
|
||||
"INSERT OR IGNORE INTO `DbVersion` (`version`) VALUES ('2');"
|
||||
"CREATE INDEX IF NOT EXISTS ChatLogs_timestamp_IDX ON `ChatLogs` (`timestamp`);"
|
||||
"CREATE INDEX IF NOT EXISTS ChatLogs_to_from_jid_IDX ON `ChatLogs` (`to_jid`, `from_jid`);"
|
||||
"CREATE TRIGGER IF NOT EXISTS update_corrected_message "
|
||||
"AFTER INSERT ON ChatLogs FOR EACH ROW WHEN NEW.replaces_db_id IS NOT NULL "
|
||||
"BEGIN UPDATE ChatLogs SET replaced_by_db_id = NEW.id "
|
||||
"WHERE id = NEW.replaces_db_id; END;";
|
||||
static const char* SCHEMA_DDL = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"`from_jid` TEXT NOT NULL, "
|
||||
"`to_jid` TEXT NOT NULL, "
|
||||
"`from_resource` TEXT, `to_resource` TEXT, "
|
||||
"`message` TEXT, `timestamp` TEXT, `type` TEXT, "
|
||||
"`stanza_id` TEXT, `archive_id` TEXT, "
|
||||
"`encryption` TEXT, `marked_read` INTEGER, "
|
||||
"`replace_id` TEXT, "
|
||||
"`replaces_db_id` INTEGER, `replaced_by_db_id` INTEGER);"
|
||||
"CREATE TABLE IF NOT EXISTS `DbVersion` ("
|
||||
"`dv_id` INTEGER PRIMARY KEY, `version` INTEGER UNIQUE);"
|
||||
"INSERT OR IGNORE INTO `DbVersion` (`version`) VALUES ('2');"
|
||||
"CREATE INDEX IF NOT EXISTS ChatLogs_timestamp_IDX ON `ChatLogs` (`timestamp`);"
|
||||
"CREATE INDEX IF NOT EXISTS ChatLogs_to_from_jid_IDX ON `ChatLogs` (`to_jid`, `from_jid`);"
|
||||
"CREATE TRIGGER IF NOT EXISTS update_corrected_message "
|
||||
"AFTER INSERT ON ChatLogs FOR EACH ROW WHEN NEW.replaces_db_id IS NOT NULL "
|
||||
"BEGIN UPDATE ChatLogs SET replaced_by_db_id = NEW.id "
|
||||
"WHERE id = NEW.replaces_db_id; END;";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed: open sqlite3 directly, build schema, batched-INSERT N rows.
|
||||
@@ -160,14 +159,15 @@ do_seed(const seed_opts_t* o)
|
||||
|
||||
if (sqlite3_exec(db, "BEGIN TRANSACTION;", NULL, NULL, &err) != SQLITE_OK) {
|
||||
fprintf(stderr, "seed: BEGIN failed: %s\n", err);
|
||||
sqlite3_free(err); sqlite3_close(db); return 2;
|
||||
sqlite3_free(err);
|
||||
sqlite3_close(db);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const char* INSERT_SQL =
|
||||
"INSERT INTO ChatLogs ("
|
||||
"from_jid, to_jid, from_resource, to_resource, message, timestamp, type, "
|
||||
"stanza_id, archive_id, encryption, marked_read, replace_id"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, 'chat', ?, ?, 'none', -1, ?);";
|
||||
const char* INSERT_SQL = "INSERT INTO ChatLogs ("
|
||||
"from_jid, to_jid, from_resource, to_resource, message, timestamp, type, "
|
||||
"stanza_id, archive_id, encryption, marked_read, replace_id"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, 'chat', ?, ?, 'none', -1, ?);";
|
||||
sqlite3_stmt* stmt = NULL;
|
||||
if (sqlite3_prepare_v2(db, INSERT_SQL, -1, &stmt, NULL) != SQLITE_OK) {
|
||||
fprintf(stderr, "seed: prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
@@ -180,7 +180,8 @@ do_seed(const seed_opts_t* o)
|
||||
int64_t span_secs = (int64_t)5 * 365 * 24 * 3600; // 5 years
|
||||
char* prev_sid_per_contact[1024] = { 0 };
|
||||
int n_contacts = o->contacts > 0 ? o->contacts : 1;
|
||||
if (n_contacts > 1024) n_contacts = 1024;
|
||||
if (n_contacts > 1024)
|
||||
n_contacts = 1024;
|
||||
|
||||
double t0 = bench_now_ms();
|
||||
|
||||
@@ -209,10 +210,14 @@ do_seed(const seed_opts_t* o)
|
||||
sqlite3_bind_text(stmt, 5, body, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, iso, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 7, sid, -1, SQLITE_TRANSIENT);
|
||||
if (aid) sqlite3_bind_text(stmt, 8, aid, -1, SQLITE_TRANSIENT);
|
||||
else sqlite3_bind_null(stmt, 8);
|
||||
if (is_lmc) sqlite3_bind_text(stmt, 9, prev_sid_per_contact[ci], -1, SQLITE_TRANSIENT);
|
||||
else sqlite3_bind_null(stmt, 9);
|
||||
if (aid)
|
||||
sqlite3_bind_text(stmt, 8, aid, -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(stmt, 8);
|
||||
if (is_lmc)
|
||||
sqlite3_bind_text(stmt, 9, prev_sid_per_contact[ci], -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(stmt, 9);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||
fprintf(stderr, "seed: insert failed at row %" PRId64 ": %s\n",
|
||||
@@ -309,21 +314,29 @@ db_row_count(const char* db_path)
|
||||
static int64_t
|
||||
db_diff_full(const char* a_path, const char* b_path, int64_t* total_a, int64_t* total_b)
|
||||
{
|
||||
sqlite3* da = NULL; sqlite3* db = NULL;
|
||||
if (sqlite3_open(a_path, &da) != SQLITE_OK) return -1;
|
||||
if (sqlite3_open(b_path, &db) != SQLITE_OK) { sqlite3_close(da); return -1; }
|
||||
sqlite3* da = NULL;
|
||||
sqlite3* db = NULL;
|
||||
if (sqlite3_open(a_path, &da) != SQLITE_OK)
|
||||
return -1;
|
||||
if (sqlite3_open(b_path, &db) != SQLITE_OK) {
|
||||
sqlite3_close(da);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* SQL =
|
||||
"SELECT IFNULL(from_jid,''), IFNULL(to_jid,''), IFNULL(message,''), "
|
||||
"IFNULL(timestamp,''), IFNULL(type,''), IFNULL(stanza_id,''), "
|
||||
"IFNULL(archive_id,''), IFNULL(encryption,''), IFNULL(replace_id,'') "
|
||||
"FROM ChatLogs ORDER BY timestamp ASC, stanza_id ASC;";
|
||||
sqlite3_stmt* sa = NULL; sqlite3_stmt* sb = NULL;
|
||||
const char* SQL = "SELECT IFNULL(from_jid,''), IFNULL(to_jid,''), IFNULL(message,''), "
|
||||
"IFNULL(timestamp,''), IFNULL(type,''), IFNULL(stanza_id,''), "
|
||||
"IFNULL(archive_id,''), IFNULL(encryption,''), IFNULL(replace_id,'') "
|
||||
"FROM ChatLogs ORDER BY timestamp ASC, stanza_id ASC;";
|
||||
sqlite3_stmt* sa = NULL;
|
||||
sqlite3_stmt* sb = NULL;
|
||||
if (sqlite3_prepare_v2(da, SQL, -1, &sa, NULL) != SQLITE_OK
|
||||
|| sqlite3_prepare_v2(db, SQL, -1, &sb, NULL) != SQLITE_OK) {
|
||||
if (sa) sqlite3_finalize(sa);
|
||||
if (sb) sqlite3_finalize(sb);
|
||||
sqlite3_close(da); sqlite3_close(db);
|
||||
if (sa)
|
||||
sqlite3_finalize(sa);
|
||||
if (sb)
|
||||
sqlite3_finalize(sb);
|
||||
sqlite3_close(da);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -332,14 +345,16 @@ db_diff_full(const char* a_path, const char* b_path, int64_t* total_a, int64_t*
|
||||
while (1) {
|
||||
int ra = sqlite3_step(sa);
|
||||
int rb = sqlite3_step(sb);
|
||||
if (ra != SQLITE_ROW && rb != SQLITE_ROW) break;
|
||||
if (ra == SQLITE_ROW) na++;
|
||||
if (rb == SQLITE_ROW) nb++;
|
||||
if (ra != SQLITE_ROW && rb != SQLITE_ROW)
|
||||
break;
|
||||
if (ra == SQLITE_ROW)
|
||||
na++;
|
||||
if (rb == SQLITE_ROW)
|
||||
nb++;
|
||||
if (ra != rb) {
|
||||
mismatches++;
|
||||
if (shown < 5) {
|
||||
fprintf(stderr, " diff: row count drift at ra_row=%" PRId64
|
||||
" rb_row=%" PRId64 " ra=%d rb=%d\n", na, nb, ra, rb);
|
||||
fprintf(stderr, " diff: row count drift at ra_row=%" PRId64 " rb_row=%" PRId64 " ra=%d rb=%d\n", na, nb, ra, rb);
|
||||
shown++;
|
||||
}
|
||||
continue;
|
||||
@@ -364,8 +379,10 @@ db_diff_full(const char* a_path, const char* b_path, int64_t* total_a, int64_t*
|
||||
sqlite3_finalize(sb);
|
||||
sqlite3_close(da);
|
||||
sqlite3_close(db);
|
||||
if (total_a) *total_a = na;
|
||||
if (total_b) *total_b = nb;
|
||||
if (total_a)
|
||||
*total_a = na;
|
||||
if (total_b)
|
||||
*total_b = nb;
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
@@ -399,15 +416,23 @@ cmd_seed(int argc, char** argv)
|
||||
char* db_path = NULL;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--db=", 5) == 0) db_path = g_strdup(a + 5);
|
||||
else if (strncmp(a, "--rows=", 7) == 0) o.rows = strtoll(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--contacts=", 11) == 0) o.contacts = atoi(a + 11);
|
||||
else if (strncmp(a, "--lmc=", 6) == 0) o.lmc_pct = atoi(a + 6);
|
||||
else if (strncmp(a, "--body=", 7) == 0) o.avg_body = strtoull(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--seed=", 7) == 0) o.seed = strtoull(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--account=", 10) == 0) o.account_jid = a + 10;
|
||||
if (strncmp(a, "--db=", 5) == 0)
|
||||
db_path = g_strdup(a + 5);
|
||||
else if (strncmp(a, "--rows=", 7) == 0)
|
||||
o.rows = strtoll(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--contacts=", 11) == 0)
|
||||
o.contacts = atoi(a + 11);
|
||||
else if (strncmp(a, "--lmc=", 6) == 0)
|
||||
o.lmc_pct = atoi(a + 6);
|
||||
else if (strncmp(a, "--body=", 7) == 0)
|
||||
o.avg_body = strtoull(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--seed=", 7) == 0)
|
||||
o.seed = strtoull(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--account=", 10) == 0)
|
||||
o.account_jid = a + 10;
|
||||
}
|
||||
if (!db_path) db_path = db_path_for(o.account_jid);
|
||||
if (!db_path)
|
||||
db_path = db_path_for(o.account_jid);
|
||||
o.db_path = db_path;
|
||||
int r = do_seed(&o);
|
||||
g_free(db_path);
|
||||
@@ -423,10 +448,14 @@ cmd_export(int argc, char** argv)
|
||||
const char* volume = "export";
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--account=", 10) == 0) account = a + 10;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0) label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0) volume = a + 9;
|
||||
if (strncmp(a, "--account=", 10) == 0)
|
||||
account = a + 10;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0)
|
||||
label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0)
|
||||
volume = a + 9;
|
||||
}
|
||||
if (!init_sqlite_backend(account))
|
||||
return 2;
|
||||
@@ -461,10 +490,14 @@ cmd_import(int argc, char** argv)
|
||||
const char* volume = "import";
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--account=", 10) == 0) account = a + 10;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0) label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0) volume = a + 9;
|
||||
if (strncmp(a, "--account=", 10) == 0)
|
||||
account = a + 10;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0)
|
||||
label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0)
|
||||
volume = a + 9;
|
||||
}
|
||||
if (!init_sqlite_backend(account))
|
||||
return 2;
|
||||
@@ -507,24 +540,36 @@ cmd_roundtrip(int argc, char** argv)
|
||||
int do_diff = 0;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--rows=", 7) == 0) rows = strtoll(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--csv=", 6) == 0) csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0) label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0) volume = a + 9;
|
||||
else if (strncmp(a, "--lmc=", 6) == 0) lmc = atoi(a + 6);
|
||||
else if (strncmp(a, "--body=", 7) == 0) body = strtoull(a + 7, NULL, 10);
|
||||
else if (strcmp(a, "--full-diff") == 0) do_diff = 1;
|
||||
if (strncmp(a, "--rows=", 7) == 0)
|
||||
rows = strtoll(a + 7, NULL, 10);
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
csv = a + 6;
|
||||
else if (strncmp(a, "--label=", 8) == 0)
|
||||
label = a + 8;
|
||||
else if (strncmp(a, "--volume=", 9) == 0)
|
||||
volume = a + 9;
|
||||
else if (strncmp(a, "--lmc=", 6) == 0)
|
||||
lmc = atoi(a + 6);
|
||||
else if (strncmp(a, "--body=", 7) == 0)
|
||||
body = strtoull(a + 7, NULL, 10);
|
||||
else if (strcmp(a, "--full-diff") == 0)
|
||||
do_diff = 1;
|
||||
}
|
||||
|
||||
const char* account_a = "rt-a@bench.example";
|
||||
const char* account_b = "rt-b@bench.example";
|
||||
auto_gchar gchar* db_a = db_path_for(account_a);
|
||||
auto_gchar gchar* db_b = db_path_for(account_b);
|
||||
unlink(db_a); unlink(db_b);
|
||||
auto_gchar gchar* wal_a = g_strdup_printf("%s-wal", db_a); unlink(wal_a);
|
||||
auto_gchar gchar* shm_a = g_strdup_printf("%s-shm", db_a); unlink(shm_a);
|
||||
auto_gchar gchar* wal_b = g_strdup_printf("%s-wal", db_b); unlink(wal_b);
|
||||
auto_gchar gchar* shm_b = g_strdup_printf("%s-shm", db_b); unlink(shm_b);
|
||||
unlink(db_a);
|
||||
unlink(db_b);
|
||||
auto_gchar gchar* wal_a = g_strdup_printf("%s-wal", db_a);
|
||||
unlink(wal_a);
|
||||
auto_gchar gchar* shm_a = g_strdup_printf("%s-shm", db_a);
|
||||
unlink(shm_a);
|
||||
auto_gchar gchar* wal_b = g_strdup_printf("%s-wal", db_b);
|
||||
unlink(wal_b);
|
||||
auto_gchar gchar* shm_b = g_strdup_printf("%s-shm", db_b);
|
||||
unlink(shm_b);
|
||||
|
||||
fprintf(stderr, "===== roundtrip: rows=%" PRId64 " account_a=%s account_b=%s =====\n",
|
||||
rows, account_a, account_b);
|
||||
@@ -539,11 +584,13 @@ cmd_roundtrip(int argc, char** argv)
|
||||
so.seed = 42;
|
||||
so.account_jid = account_a;
|
||||
double seed_ms_t0 = bench_now_ms();
|
||||
if (do_seed(&so) != 0) return 2;
|
||||
if (do_seed(&so) != 0)
|
||||
return 2;
|
||||
double seed_ms = bench_now_ms() - seed_ms_t0;
|
||||
|
||||
// 2) Export DB_A → flatlog under account_a
|
||||
if (!init_sqlite_backend(account_a)) return 2;
|
||||
if (!init_sqlite_backend(account_a))
|
||||
return 2;
|
||||
double export_t0 = bench_now_ms();
|
||||
int exported = log_database_export_to_flatfile(NULL);
|
||||
double export_ms = bench_now_ms() - export_t0;
|
||||
@@ -552,7 +599,8 @@ cmd_roundtrip(int argc, char** argv)
|
||||
|
||||
// 3) Cross-mount the flatlog tree at account_b's expected location
|
||||
const char* base = getenv("BENCH_DATA_DIR");
|
||||
if (!base || !base[0]) base = "/tmp/cproof-bench-export";
|
||||
if (!base || !base[0])
|
||||
base = "/tmp/cproof-bench-export";
|
||||
auto_gchar gchar* dir_a = g_strdup_printf("%s/flatlog/%s", base, ff_jid_to_dir(account_a));
|
||||
auto_gchar gchar* dir_b = g_strdup_printf("%s/flatlog/%s", base, ff_jid_to_dir(account_b));
|
||||
auto_gchar gchar* parent_b = g_path_get_dirname(dir_b);
|
||||
@@ -565,7 +613,8 @@ cmd_roundtrip(int argc, char** argv)
|
||||
}
|
||||
|
||||
// 4) Import flatlog → DB_B
|
||||
if (!init_sqlite_backend(account_b)) return 2;
|
||||
if (!init_sqlite_backend(account_b))
|
||||
return 2;
|
||||
double import_t0 = bench_now_ms();
|
||||
int imported = log_database_import_from_flatfile(NULL);
|
||||
double import_ms = bench_now_ms() - import_t0;
|
||||
@@ -583,8 +632,7 @@ cmd_roundtrip(int argc, char** argv)
|
||||
int64_t na = 0, nb = 0;
|
||||
mismatches = db_diff_full(db_a, db_b, &na, &nb);
|
||||
diff_ms = bench_now_ms() - diff_t0;
|
||||
fprintf(stderr, " full content diff: a_rows=%" PRId64 " b_rows=%" PRId64
|
||||
" mismatches=%" PRId64 " (%.2fs)\n",
|
||||
fprintf(stderr, " full content diff: a_rows=%" PRId64 " b_rows=%" PRId64 " mismatches=%" PRId64 " (%.2fs)\n",
|
||||
na, nb, mismatches, diff_ms / 1000.0);
|
||||
}
|
||||
|
||||
@@ -614,13 +662,19 @@ cmd_roundtrip(int argc, char** argv)
|
||||
static int
|
||||
cmd_verify(int argc, char** argv)
|
||||
{
|
||||
const char* a = NULL; const char* b = NULL;
|
||||
const char* a = NULL;
|
||||
const char* b = NULL;
|
||||
for (int i = 0; i < argc; i++) {
|
||||
const char* x = argv[i];
|
||||
if (strncmp(x, "--db-a=", 7) == 0) a = x + 7;
|
||||
else if (strncmp(x, "--db-b=", 7) == 0) b = x + 7;
|
||||
if (strncmp(x, "--db-a=", 7) == 0)
|
||||
a = x + 7;
|
||||
else if (strncmp(x, "--db-b=", 7) == 0)
|
||||
b = x + 7;
|
||||
}
|
||||
if (!a || !b) {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
if (!a || !b) { usage(); return 2; }
|
||||
int64_t na = 0, nb = 0;
|
||||
int64_t m = db_diff_full(a, b, &na, &nb);
|
||||
fprintf(stderr, "verify: a_rows=%" PRId64 " b_rows=%" PRId64 " mismatches=%" PRId64 "\n",
|
||||
@@ -631,15 +685,23 @@ cmd_verify(int argc, char** argv)
|
||||
int
|
||||
main(int argc, char** argv)
|
||||
{
|
||||
if (argc < 2) { usage(); return 2; }
|
||||
if (argc < 2) {
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
const char* sub = argv[1];
|
||||
int sub_argc = argc - 2;
|
||||
char** sub_argv = argv + 2;
|
||||
if (g_strcmp0(sub, "seed") == 0) return cmd_seed(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "export") == 0) return cmd_export(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "import") == 0) return cmd_import(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "roundtrip") == 0) return cmd_roundtrip(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "verify") == 0) return cmd_verify(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "seed") == 0)
|
||||
return cmd_seed(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "export") == 0)
|
||||
return cmd_export(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "import") == 0)
|
||||
return cmd_import(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "roundtrip") == 0)
|
||||
return cmd_roundtrip(sub_argc, sub_argv);
|
||||
if (g_strcmp0(sub, "verify") == 0)
|
||||
return cmd_verify(sub_argc, sub_argv);
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
#include "config/account.h"
|
||||
#include "database_flatfile.h"
|
||||
|
||||
#define ACCOUNT_JID "fail@bench.example"
|
||||
#define CONTACT_JID "peer@bench.example"
|
||||
#define ACCOUNT_JID "fail@bench.example"
|
||||
#define CONTACT_JID "peer@bench.example"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test framework
|
||||
@@ -109,7 +109,8 @@ typedef struct
|
||||
static void
|
||||
verify_summary_free(verify_summary_t* s)
|
||||
{
|
||||
if (!s) return;
|
||||
if (!s)
|
||||
return;
|
||||
g_free(s->first_err);
|
||||
g_free(s->first_warn);
|
||||
}
|
||||
@@ -124,15 +125,18 @@ run_verify(verify_summary_t* out)
|
||||
out->total = g_slist_length(issues);
|
||||
for (GSList* l = issues; l; l = l->next) {
|
||||
integrity_issue_t* i = (integrity_issue_t*)l->data;
|
||||
if (!i) continue;
|
||||
if (!i)
|
||||
continue;
|
||||
switch (i->level) {
|
||||
case INTEGRITY_ERROR:
|
||||
out->errors++;
|
||||
if (!out->first_err) out->first_err = g_strdup(i->message ? i->message : "");
|
||||
if (!out->first_err)
|
||||
out->first_err = g_strdup(i->message ? i->message : "");
|
||||
break;
|
||||
case INTEGRITY_WARNING:
|
||||
out->warnings++;
|
||||
if (!out->first_warn) out->first_warn = g_strdup(i->message ? i->message : "");
|
||||
if (!out->first_warn)
|
||||
out->first_warn = g_strdup(i->message ? i->message : "");
|
||||
break;
|
||||
case INTEGRITY_INFO:
|
||||
out->infos++;
|
||||
@@ -148,7 +152,10 @@ report(fail_ctx_t* ctx, const char* tag, gboolean ok, double wall_ms,
|
||||
{
|
||||
fprintf(stderr, " %s %-3s %.1fms %s\n",
|
||||
ok ? "PASS" : "FAIL", tag, wall_ms, note ? note : "");
|
||||
if (ok) ctx->passed++; else ctx->failed++;
|
||||
if (ok)
|
||||
ctx->passed++;
|
||||
else
|
||||
ctx->failed++;
|
||||
if (ctx->csv_path) {
|
||||
struct stat st;
|
||||
uint64_t sz = (path && stat(path, &st) == 0) ? (uint64_t)st.st_size : 0;
|
||||
@@ -182,7 +189,8 @@ write_normal_line(FILE* fp, int seq, const char* body)
|
||||
static void
|
||||
test_F1(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F1")) return;
|
||||
if (!test_enabled(ctx->tests, "F1"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F1");
|
||||
|
||||
// Write 10 normal lines; chop the trailing \n off the last.
|
||||
@@ -227,7 +235,8 @@ test_F1(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F2(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F2")) return;
|
||||
if (!test_enabled(ctx->tests, "F2"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F2");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -262,14 +271,17 @@ test_F2(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F3(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F3")) return;
|
||||
if (!test_enabled(ctx->tests, "F3"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F3");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
write_normal_line(fp, 0, "before bom");
|
||||
// Inject BOM bytes at the start of a line in the middle of the file.
|
||||
fputc(0xEF, fp); fputc(0xBB, fp); fputc(0xBF, fp);
|
||||
fputc(0xEF, fp);
|
||||
fputc(0xBB, fp);
|
||||
fputc(0xBF, fp);
|
||||
fprintf(fp,
|
||||
"2025-06-15T12:00:01Z [chat|none|id:after-bom] peer@bench.example/phone: after bom\n");
|
||||
write_normal_line(fp, 2, "trailing");
|
||||
@@ -296,7 +308,8 @@ test_F3(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F7(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F7")) return;
|
||||
if (!test_enabled(ctx->tests, "F7"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F7");
|
||||
|
||||
// A: stanza_id="A", no replace
|
||||
@@ -337,7 +350,8 @@ test_F7(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F8(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F8")) return;
|
||||
if (!test_enabled(ctx->tests, "F8"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F8");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -376,7 +390,8 @@ test_F8(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F9(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F9")) return;
|
||||
if (!test_enabled(ctx->tests, "F9"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F9");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -412,7 +427,8 @@ test_F9(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F10(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F10")) return;
|
||||
if (!test_enabled(ctx->tests, "F10"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F10");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -450,7 +466,8 @@ test_F10(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F11(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F11")) return;
|
||||
if (!test_enabled(ctx->tests, "F11"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F11");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -488,7 +505,8 @@ test_F11(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F12(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F12")) return;
|
||||
if (!test_enabled(ctx->tests, "F12"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F12");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -518,7 +536,8 @@ test_F12(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F14(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F14")) return;
|
||||
if (!test_enabled(ctx->tests, "F14"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F14");
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
@@ -563,7 +582,8 @@ test_F14(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F15(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F15")) return;
|
||||
if (!test_enabled(ctx->tests, "F15"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F15");
|
||||
FILE* fp = fopen(path, "w");
|
||||
fclose(fp);
|
||||
@@ -600,7 +620,8 @@ test_F15(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F16(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F16")) return;
|
||||
if (!test_enabled(ctx->tests, "F16"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F16");
|
||||
|
||||
// Write enough messages that we cross at least 3 index entries
|
||||
@@ -715,7 +736,8 @@ test_F16(fail_ctx_t* ctx)
|
||||
static void
|
||||
test_F17(fail_ctx_t* ctx)
|
||||
{
|
||||
if (!test_enabled(ctx->tests, "F17")) return;
|
||||
if (!test_enabled(ctx->tests, "F17"))
|
||||
return;
|
||||
auto_gchar gchar* path = setup_test_dir(ctx->tmp_dir, "F17");
|
||||
|
||||
const int total_msgs = 2000;
|
||||
@@ -812,9 +834,12 @@ main(int argc, char** argv)
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--tmp=", 6) == 0) ctx.tmp_dir = a + 6;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) ctx.csv_path = a + 6;
|
||||
else if (strncmp(a, "--tests=", 8) == 0) ctx.tests = a + 8;
|
||||
if (strncmp(a, "--tmp=", 6) == 0)
|
||||
ctx.tmp_dir = a + 6;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
ctx.csv_path = a + 6;
|
||||
else if (strncmp(a, "--tests=", 8) == 0)
|
||||
ctx.tests = a + 8;
|
||||
else {
|
||||
fprintf(stderr, "unknown option: %s\n", a);
|
||||
return 2;
|
||||
|
||||
@@ -68,9 +68,12 @@ parse_args(int argc, char** argv, cli_t* c)
|
||||
c->tests = "all";
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--tmp=", 6) == 0) c->tmp_dir = a + 6;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) c->csv_path = a + 6;
|
||||
else if (strncmp(a, "--tests=", 8) == 0) c->tests = a + 8;
|
||||
if (strncmp(a, "--tmp=", 6) == 0)
|
||||
c->tmp_dir = a + 6;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
c->csv_path = a + 6;
|
||||
else if (strncmp(a, "--tests=", 8) == 0)
|
||||
c->tests = a + 8;
|
||||
else {
|
||||
fprintf(stderr, "unknown option: %s\n", a);
|
||||
return -1;
|
||||
@@ -95,8 +98,7 @@ test_enabled(const char* list, const char* name)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body factories
|
||||
|
||||
typedef enum
|
||||
{
|
||||
typedef enum {
|
||||
PAT_FILLER, // deterministic alpha-num
|
||||
PAT_EMBEDDED_LF, // filler with newlines every 100 bytes
|
||||
PAT_EMBEDDED_PIPE, // filler with '|' sprinkled
|
||||
@@ -155,7 +157,8 @@ typedef struct
|
||||
static void
|
||||
roundtrip_cleanup(roundtrip_result_t* r)
|
||||
{
|
||||
if (!r) return;
|
||||
if (!r)
|
||||
return;
|
||||
if (r->path && r->path[0])
|
||||
unlink(r->path);
|
||||
g_free(r->path);
|
||||
@@ -167,7 +170,8 @@ write_n_messages(const char* path, int n, size_t body_len, pattern_t pat,
|
||||
int64_t* bytes_written_out)
|
||||
{
|
||||
FILE* fp = fopen(path, "w");
|
||||
if (!fp) return -1;
|
||||
if (!fp)
|
||||
return -1;
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
|
||||
GDateTime* base = g_date_time_new_utc(2025, 6, 15, 12, 0, 0.0);
|
||||
@@ -176,7 +180,7 @@ write_n_messages(const char* path, int n, size_t body_len, pattern_t pat,
|
||||
auto_gchar gchar* iso = g_date_time_format_iso8601(t);
|
||||
g_date_time_unref(t);
|
||||
auto_gchar gchar* sid = g_strdup_printf("long-%d-%08x", i,
|
||||
(unsigned)(rand_r(&(unsigned){i + 1})));
|
||||
(unsigned)(rand_r(&(unsigned){ i + 1 })));
|
||||
auto_gchar gchar* body = make_body(body_len, pat);
|
||||
ff_write_line(fp, iso, "chat", "none",
|
||||
sid, NULL, NULL,
|
||||
@@ -200,7 +204,8 @@ read_and_validate(const char* path, size_t expected_body_len, int n,
|
||||
int64_t* parsed_out, int64_t* mismatched_out, int64_t* parse_fail_out)
|
||||
{
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) return -1;
|
||||
if (!fp)
|
||||
return -1;
|
||||
ff_skip_bom(fp);
|
||||
|
||||
int64_t parsed = 0, mismatched = 0, failures = 0;
|
||||
@@ -224,9 +229,12 @@ read_and_validate(const char* path, size_t expected_body_len, int n,
|
||||
ff_parsed_line_free(pl);
|
||||
}
|
||||
fclose(fp);
|
||||
if (parsed_out) *parsed_out = parsed;
|
||||
if (mismatched_out) *mismatched_out = mismatched;
|
||||
if (parse_fail_out) *parse_fail_out = failures;
|
||||
if (parsed_out)
|
||||
*parsed_out = parsed;
|
||||
if (mismatched_out)
|
||||
*mismatched_out = mismatched;
|
||||
if (parse_fail_out)
|
||||
*parse_fail_out = failures;
|
||||
(void)n;
|
||||
return 0;
|
||||
}
|
||||
@@ -257,7 +265,8 @@ static void
|
||||
csv_emit(const char* csv_path, const char* tag, size_t body_len, int n,
|
||||
const roundtrip_result_t* r, const char* note)
|
||||
{
|
||||
if (!csv_path) return;
|
||||
if (!csv_path)
|
||||
return;
|
||||
auto_gchar gchar* full_note = g_strdup_printf(
|
||||
"n=%d body=%zu write=%.1fms read=%.1fms parsed=%" PRId64
|
||||
" mismatch=%" PRId64 " parse_fail=%" PRId64 " %s",
|
||||
@@ -337,8 +346,11 @@ run_oversized(const char* tmp, const char* csv)
|
||||
}
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (pl) { parsed++; ff_parsed_line_free(pl); }
|
||||
else failures++;
|
||||
if (pl) {
|
||||
parsed++;
|
||||
ff_parsed_line_free(pl);
|
||||
} else
|
||||
failures++;
|
||||
}
|
||||
fclose(fp);
|
||||
double read_ms = bench_now_ms() - t0;
|
||||
@@ -373,7 +385,8 @@ run_pagination_with_huge(const char* tmp, const char* csv)
|
||||
int huge_indices[5] = { 950, 970, 985, 992, 998 }; // five 1MB bodies near end
|
||||
|
||||
FILE* fp = fopen(path, "w");
|
||||
if (!fp) return;
|
||||
if (!fp)
|
||||
return;
|
||||
fprintf(fp, "%s", FLATFILE_HEADER);
|
||||
GDateTime* base = g_date_time_new_utc(2025, 6, 15, 12, 0, 0.0);
|
||||
for (int i = 0; i < total; i++) {
|
||||
@@ -381,7 +394,11 @@ run_pagination_with_huge(const char* tmp, const char* csv)
|
||||
auto_gchar gchar* iso = g_date_time_format_iso8601(t);
|
||||
g_date_time_unref(t);
|
||||
size_t blen = 100;
|
||||
for (int k = 0; k < 5; k++) if (huge_indices[k] == i) { blen = MB; break; }
|
||||
for (int k = 0; k < 5; k++)
|
||||
if (huge_indices[k] == i) {
|
||||
blen = MB;
|
||||
break;
|
||||
}
|
||||
auto_gchar gchar* sid = g_strdup_printf("page-%d", i);
|
||||
auto_gchar gchar* body = make_body(blen, PAT_FILLER);
|
||||
ff_write_line(fp, iso, "chat", "none",
|
||||
@@ -413,12 +430,17 @@ run_pagination_with_huge(const char* tmp, const char* csv)
|
||||
int64_t parsed = 0;
|
||||
char* buf;
|
||||
while ((buf = ff_readline(fp, NULL)) != NULL) {
|
||||
if (buf[0] == '\0' || buf[0] == '#') { free(buf); continue; }
|
||||
if (buf[0] == '\0' || buf[0] == '#') {
|
||||
free(buf);
|
||||
continue;
|
||||
}
|
||||
ff_parsed_line_t* pl = ff_parse_line(buf);
|
||||
free(buf);
|
||||
if (!pl) continue;
|
||||
if (!pl)
|
||||
continue;
|
||||
parsed++;
|
||||
if (ring[rpos]) g_free(ring[rpos]);
|
||||
if (ring[rpos])
|
||||
g_free(ring[rpos]);
|
||||
ring[rpos] = g_strdup(pl->message ? pl->message : "");
|
||||
rpos = (rpos + 1) % 100;
|
||||
ff_parsed_line_free(pl);
|
||||
@@ -426,7 +448,8 @@ run_pagination_with_huge(const char* tmp, const char* csv)
|
||||
fclose(fp);
|
||||
double dt = bench_now_ms() - t0;
|
||||
long rss = bench_peak_rss_kb();
|
||||
for (int i = 0; i < 100; i++) g_free(ring[i]);
|
||||
for (int i = 0; i < 100; i++)
|
||||
g_free(ring[i]);
|
||||
|
||||
fprintf(stderr,
|
||||
" L12 scrollback w/ 5×1MB in last 100 read=%.1fms parsed=%" PRId64 " rss=%ldKB\n",
|
||||
|
||||
@@ -58,7 +58,7 @@ typedef struct
|
||||
const char* csv_path;
|
||||
const char* scenarios; // comma-separated list, or "all"
|
||||
const char* account_jid;
|
||||
int extend_lines; // for S5
|
||||
int extend_lines; // for S5
|
||||
} cli_t;
|
||||
|
||||
static void
|
||||
@@ -77,11 +77,16 @@ parse_args(int argc, char** argv, cli_t* c)
|
||||
cli_default(c);
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, "--data=", 7) == 0) c->data_dir = a + 7;
|
||||
else if (strncmp(a, "--csv=", 6) == 0) c->csv_path = a + 6;
|
||||
else if (strncmp(a, "--scenarios=", 12) == 0) c->scenarios = a + 12;
|
||||
else if (strncmp(a, "--account=", 10) == 0) c->account_jid = a + 10;
|
||||
else if (strncmp(a, "--extend-lines=", 15) == 0) c->extend_lines = atoi(a + 15);
|
||||
if (strncmp(a, "--data=", 7) == 0)
|
||||
c->data_dir = a + 7;
|
||||
else if (strncmp(a, "--csv=", 6) == 0)
|
||||
c->csv_path = a + 6;
|
||||
else if (strncmp(a, "--scenarios=", 12) == 0)
|
||||
c->scenarios = a + 12;
|
||||
else if (strncmp(a, "--account=", 10) == 0)
|
||||
c->account_jid = a + 10;
|
||||
else if (strncmp(a, "--extend-lines=", 15) == 0)
|
||||
c->extend_lines = atoi(a + 15);
|
||||
else {
|
||||
fprintf(stderr, "unknown option: %s\n", a);
|
||||
return -1;
|
||||
@@ -121,7 +126,8 @@ static int64_t
|
||||
count_lines(const char* path)
|
||||
{
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) return -1;
|
||||
if (!fp)
|
||||
return -1;
|
||||
int64_t n = 0;
|
||||
int c;
|
||||
while ((c = fgetc(fp)) != EOF) {
|
||||
@@ -191,7 +197,8 @@ run_tail_access(const char* path, int drop_cache, long* peak_rss_kb, int64_t* li
|
||||
ff_contact_state_t* state = ff_state_new(path);
|
||||
if (!ff_state_ensure_fresh(state)) {
|
||||
ff_state_free(state);
|
||||
if (lines_read) *lines_read = 0;
|
||||
if (lines_read)
|
||||
*lines_read = 0;
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
@@ -229,7 +236,8 @@ run_tail_access(const char* path, int drop_cache, long* peak_rss_kb, int64_t* li
|
||||
if (!pl)
|
||||
continue;
|
||||
parsed++;
|
||||
if (ring[ring_pos]) g_free(ring[ring_pos]);
|
||||
if (ring[ring_pos])
|
||||
g_free(ring[ring_pos]);
|
||||
ring[ring_pos] = g_strdup(pl->message ? pl->message : "");
|
||||
ring_pos = (ring_pos + 1) % TAIL_PAGE;
|
||||
ff_parsed_line_free(pl);
|
||||
@@ -297,8 +305,10 @@ run_first_build(const char* path, long* peak_rss_kb, size_t* idx_entries)
|
||||
ff_contact_state_t* state = ff_state_new(path);
|
||||
int ok = ff_state_ensure_fresh(state);
|
||||
double dt = bench_now_ms() - t0;
|
||||
if (idx_entries) *idx_entries = ok ? state->n_entries : 0;
|
||||
if (peak_rss_kb) *peak_rss_kb = bench_peak_rss_kb();
|
||||
if (idx_entries)
|
||||
*idx_entries = ok ? state->n_entries : 0;
|
||||
if (peak_rss_kb)
|
||||
*peak_rss_kb = bench_peak_rss_kb();
|
||||
ff_state_free(state);
|
||||
return ok ? dt : -1.0;
|
||||
}
|
||||
@@ -330,7 +340,8 @@ run_incremental_extend(const char* path, int n_lines, long* peak_rss_kb,
|
||||
int64_t before_size = -1;
|
||||
{
|
||||
struct stat st;
|
||||
if (stat(path, &st) == 0) before_size = st.st_size;
|
||||
if (stat(path, &st) == 0)
|
||||
before_size = st.st_size;
|
||||
}
|
||||
GDateTime* now = g_date_time_new_now_utc();
|
||||
for (int i = 0; i < n_lines; i++) {
|
||||
@@ -359,7 +370,8 @@ run_incremental_extend(const char* path, int n_lines, long* peak_rss_kb,
|
||||
else
|
||||
*added_bytes = -1;
|
||||
}
|
||||
if (peak_rss_kb) *peak_rss_kb = bench_peak_rss_kb();
|
||||
if (peak_rss_kb)
|
||||
*peak_rss_kb = bench_peak_rss_kb();
|
||||
|
||||
fprintf(stderr, " S5: lines before=%zu after=%zu (delta=%zu)\n",
|
||||
before_lines, state->total_lines,
|
||||
@@ -380,7 +392,8 @@ _count_level(GSList* issues, integrity_level_t level)
|
||||
int n = 0;
|
||||
for (GSList* l = issues; l; l = l->next) {
|
||||
integrity_issue_t* i = (integrity_issue_t*)l->data;
|
||||
if (i && i->level == level) n++;
|
||||
if (i && i->level == level)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -406,7 +419,8 @@ run_verify(long* peak_rss_kb, int64_t* total_issues, int* errors, int* warnings,
|
||||
int shown_err = 0, shown_warn = 0;
|
||||
for (GSList* l = issues; l; l = l->next) {
|
||||
integrity_issue_t* iss = (integrity_issue_t*)l->data;
|
||||
if (!iss) continue;
|
||||
if (!iss)
|
||||
continue;
|
||||
if (iss->level == INTEGRITY_ERROR && shown_err < 5) {
|
||||
fprintf(stderr, " ERR %s:%d %s\n",
|
||||
iss->file ? iss->file : "?", iss->line, iss->message ? iss->message : "");
|
||||
@@ -419,11 +433,16 @@ run_verify(long* peak_rss_kb, int64_t* total_issues, int* errors, int* warnings,
|
||||
}
|
||||
g_slist_free_full(issues, (GDestroyNotify)integrity_issue_free);
|
||||
|
||||
if (peak_rss_kb) *peak_rss_kb = bench_peak_rss_kb();
|
||||
if (total_issues) *total_issues = total;
|
||||
if (errors) *errors = e;
|
||||
if (warnings) *warnings = w;
|
||||
if (infos) *infos = i;
|
||||
if (peak_rss_kb)
|
||||
*peak_rss_kb = bench_peak_rss_kb();
|
||||
if (total_issues)
|
||||
*total_issues = total;
|
||||
if (errors)
|
||||
*errors = e;
|
||||
if (warnings)
|
||||
*warnings = w;
|
||||
if (infos)
|
||||
*infos = i;
|
||||
return dt;
|
||||
}
|
||||
|
||||
@@ -445,7 +464,8 @@ main(int argc, char** argv)
|
||||
cli.data_dir, account_dir);
|
||||
if (!g_file_test(contacts_root, G_FILE_TEST_IS_DIR)) {
|
||||
fprintf(stderr, "ERROR: %s does not exist. Run gen_history first "
|
||||
"(make sure --account matches).\n", contacts_root);
|
||||
"(make sure --account matches).\n",
|
||||
contacts_root);
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -454,7 +474,8 @@ main(int argc, char** argv)
|
||||
return 2;
|
||||
|
||||
const char* volume_name = getenv("BENCH_VOLUME");
|
||||
if (!volume_name) volume_name = "medium";
|
||||
if (!volume_name)
|
||||
volume_name = "medium";
|
||||
|
||||
fprintf(stderr, "===== bench_runner: data=%s volume=%s =====\n", cli.data_dir, volume_name);
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
#include "config/files.h"
|
||||
#include "config/preferences.h"
|
||||
#include "database.h"
|
||||
#include "database_flatfile.h" // for ff_jid_to_dir() in stub
|
||||
#include "database_flatfile.h" // for ff_jid_to_dir() in stub
|
||||
#include "xmpp/xmpp.h"
|
||||
#include "xmpp/jid.h"
|
||||
#include "xmpp/message.h"
|
||||
@@ -100,14 +100,50 @@ log_error(const char* const msg, ...)
|
||||
va_end(ap);
|
||||
}
|
||||
|
||||
void log_init(log_level_t filter, const char* const log_file) { (void)filter; (void)log_file; }
|
||||
void log_close(void) {}
|
||||
void log_msg(log_level_t level, const char* const area, const char* const msg) { (void)level; (void)area; (void)msg; }
|
||||
const char* get_log_file_location(void) { return ""; }
|
||||
log_level_t log_get_filter(void) { return PROF_LEVEL_INFO; }
|
||||
int log_level_from_string(char* log_level, log_level_t* level) { (void)log_level; if (level) *level = PROF_LEVEL_INFO; return 0; }
|
||||
void log_stderr_init(log_level_t level) { (void)level; }
|
||||
void log_stderr_handler(void) {}
|
||||
void
|
||||
log_init(log_level_t filter, const char* const log_file)
|
||||
{
|
||||
(void)filter;
|
||||
(void)log_file;
|
||||
}
|
||||
void
|
||||
log_close(void)
|
||||
{
|
||||
}
|
||||
void
|
||||
log_msg(log_level_t level, const char* const area, const char* const msg)
|
||||
{
|
||||
(void)level;
|
||||
(void)area;
|
||||
(void)msg;
|
||||
}
|
||||
const char*
|
||||
get_log_file_location(void)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
log_level_t
|
||||
log_get_filter(void)
|
||||
{
|
||||
return PROF_LEVEL_INFO;
|
||||
}
|
||||
int
|
||||
log_level_from_string(char* log_level, log_level_t* level)
|
||||
{
|
||||
(void)log_level;
|
||||
if (level)
|
||||
*level = PROF_LEVEL_INFO;
|
||||
return 0;
|
||||
}
|
||||
void
|
||||
log_stderr_init(log_level_t level)
|
||||
{
|
||||
(void)level;
|
||||
}
|
||||
void
|
||||
log_stderr_handler(void)
|
||||
{
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prefs / files / xmpp / ui — never reached by the harness, but database_flatfile.c
|
||||
|
||||
@@ -57,16 +57,14 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI options
|
||||
|
||||
typedef enum
|
||||
{
|
||||
typedef enum {
|
||||
SID_UUID,
|
||||
SID_LIBPURPLE,
|
||||
SID_CONVERSATIONS,
|
||||
SID_MIXED,
|
||||
} sid_mode_t;
|
||||
|
||||
typedef enum
|
||||
{
|
||||
typedef enum {
|
||||
LEN_SHORT,
|
||||
LEN_MIXED,
|
||||
LEN_LONG,
|
||||
@@ -109,20 +107,44 @@ opts_default(opts_t* o)
|
||||
static int
|
||||
parse_sid_mode(const char* s, sid_mode_t* out)
|
||||
{
|
||||
if (g_strcmp0(s, "uuid") == 0) { *out = SID_UUID; return 0; }
|
||||
if (g_strcmp0(s, "libpurple") == 0) { *out = SID_LIBPURPLE; return 0; }
|
||||
if (g_strcmp0(s, "conversations") == 0) { *out = SID_CONVERSATIONS; return 0; }
|
||||
if (g_strcmp0(s, "mixed") == 0) { *out = SID_MIXED; return 0; }
|
||||
if (g_strcmp0(s, "uuid") == 0) {
|
||||
*out = SID_UUID;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "libpurple") == 0) {
|
||||
*out = SID_LIBPURPLE;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "conversations") == 0) {
|
||||
*out = SID_CONVERSATIONS;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "mixed") == 0) {
|
||||
*out = SID_MIXED;
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int
|
||||
parse_len_profile(const char* s, len_profile_t* out)
|
||||
{
|
||||
if (g_strcmp0(s, "short") == 0) { *out = LEN_SHORT; return 0; }
|
||||
if (g_strcmp0(s, "mixed") == 0) { *out = LEN_MIXED; return 0; }
|
||||
if (g_strcmp0(s, "long") == 0) { *out = LEN_LONG; return 0; }
|
||||
if (g_strcmp0(s, "extreme") == 0) { *out = LEN_EXTREME; return 0; }
|
||||
if (g_strcmp0(s, "short") == 0) {
|
||||
*out = LEN_SHORT;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "mixed") == 0) {
|
||||
*out = LEN_MIXED;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "long") == 0) {
|
||||
*out = LEN_LONG;
|
||||
return 0;
|
||||
}
|
||||
if (g_strcmp0(s, "extreme") == 0) {
|
||||
*out = LEN_EXTREME;
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -204,8 +226,26 @@ print_help(void)
|
||||
// Body / stanza-id / resource generators (deterministic)
|
||||
|
||||
static const char* MSG_BANK_TINY[] = {
|
||||
"ok", "yes", "no", "ack", "thx", "ttyl", "k", "lol", "+1", "yep", "nope",
|
||||
"sure", "got it", "great", "afk", "brb", "bye", "hi", "hello", "hey",
|
||||
"ok",
|
||||
"yes",
|
||||
"no",
|
||||
"ack",
|
||||
"thx",
|
||||
"ttyl",
|
||||
"k",
|
||||
"lol",
|
||||
"+1",
|
||||
"yep",
|
||||
"nope",
|
||||
"sure",
|
||||
"got it",
|
||||
"great",
|
||||
"afk",
|
||||
"brb",
|
||||
"bye",
|
||||
"hi",
|
||||
"hello",
|
||||
"hey",
|
||||
};
|
||||
static const int MSG_BANK_TINY_N = sizeof(MSG_BANK_TINY) / sizeof(MSG_BANK_TINY[0]);
|
||||
|
||||
@@ -265,24 +305,35 @@ pick_body_len(uint64_t* rng, len_profile_t profile)
|
||||
return 5 + (xorshift64(rng) % 96);
|
||||
}
|
||||
if (profile == LEN_LONG) {
|
||||
if (r < 200) return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 600) return 100 + (xorshift64(rng) % 400);
|
||||
if (r < 850) return 1000 + (xorshift64(rng) % 9000); // 1-10 KB
|
||||
if (r < 980) return 10000 + (xorshift64(rng) % 90000); // 10-100 KB
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
if (r < 200)
|
||||
return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 600)
|
||||
return 100 + (xorshift64(rng) % 400);
|
||||
if (r < 850)
|
||||
return 1000 + (xorshift64(rng) % 9000); // 1-10 KB
|
||||
if (r < 980)
|
||||
return 10000 + (xorshift64(rng) % 90000); // 10-100 KB
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
}
|
||||
if (profile == LEN_EXTREME) {
|
||||
if (r < 100) return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 400) return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
if (r < 800) return 1000000 + (xorshift64(rng) % 4000000); // 1-5 MB
|
||||
return 5000000 + (xorshift64(rng) % 4000000); // 5-9 MB
|
||||
if (r < 100)
|
||||
return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 400)
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
if (r < 800)
|
||||
return 1000000 + (xorshift64(rng) % 4000000); // 1-5 MB
|
||||
return 5000000 + (xorshift64(rng) % 4000000); // 5-9 MB
|
||||
}
|
||||
// LEN_MIXED — realistic distribution
|
||||
if (r < 500) return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 900) return 100 + (xorshift64(rng) % 400);
|
||||
if (r < 980) return 500 + (xorshift64(rng) % 4500); // 500B-5KB
|
||||
if (r < 995) return 5000 + (xorshift64(rng) % 95000); // 5-100 KB
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
if (r < 500)
|
||||
return 5 + (xorshift64(rng) % 96);
|
||||
if (r < 900)
|
||||
return 100 + (xorshift64(rng) % 400);
|
||||
if (r < 980)
|
||||
return 500 + (xorshift64(rng) % 4500); // 500B-5KB
|
||||
if (r < 995)
|
||||
return 5000 + (xorshift64(rng) % 95000); // 5-100 KB
|
||||
return 100000 + (xorshift64(rng) % 900000); // 100KB-1MB
|
||||
}
|
||||
|
||||
static char*
|
||||
@@ -341,13 +392,15 @@ make_stanza_id(uint64_t* rng, sid_mode_t mode, int64_t global_seq, int contact_i
|
||||
sid_mode_t effective = mode;
|
||||
if (mode == SID_MIXED) {
|
||||
int r = (int)(xorshift64(rng) % 3);
|
||||
effective = (r == 0) ? SID_UUID : (r == 1) ? SID_LIBPURPLE : SID_CONVERSATIONS;
|
||||
effective = (r == 0) ? SID_UUID : (r == 1) ? SID_LIBPURPLE
|
||||
: SID_CONVERSATIONS;
|
||||
}
|
||||
switch (effective) {
|
||||
case SID_LIBPURPLE:
|
||||
// Per-contact incremental — collides across contacts (intentional, mirrors libpurple).
|
||||
return g_strdup_printf("%" PRId64, global_seq);
|
||||
case SID_CONVERSATIONS: {
|
||||
case SID_CONVERSATIONS:
|
||||
{
|
||||
// Conversations-style: 22 base32-ish chars
|
||||
static const char alpha[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
char buf[24];
|
||||
@@ -358,7 +411,8 @@ make_stanza_id(uint64_t* rng, sid_mode_t mode, int64_t global_seq, int contact_i
|
||||
}
|
||||
case SID_UUID:
|
||||
case SID_MIXED:
|
||||
default: {
|
||||
default:
|
||||
{
|
||||
// RFC4122-ish UUID v4 (deterministic from RNG, not cryptographic)
|
||||
uint64_t a = xorshift64(rng);
|
||||
uint64_t b = xorshift64(rng);
|
||||
@@ -379,12 +433,12 @@ make_stanza_id(uint64_t* rng, sid_mode_t mode, int64_t global_seq, int contact_i
|
||||
typedef struct
|
||||
{
|
||||
char* jid;
|
||||
char** resources; // n = opts.resources_per_contact
|
||||
char** resources; // n = opts.resources_per_contact
|
||||
int n_resources;
|
||||
char* dir;
|
||||
char* path;
|
||||
FILE* fp;
|
||||
int64_t seq; // libpurple-style counter
|
||||
int64_t seq; // libpurple-style counter
|
||||
int64_t lines_written;
|
||||
int64_t bytes_written;
|
||||
char* last_stanza_id; // for LMC corrections
|
||||
|
||||
@@ -61,7 +61,10 @@
|
||||
#endif
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) { #test, test, init_prof_test, close_prof_test, #test }
|
||||
#define PROF_FUNC_TEST(test) \
|
||||
{ \
|
||||
#test, test, init_prof_test, close_prof_test, #test \
|
||||
}
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
|
||||
|
||||
@@ -26,41 +26,41 @@
|
||||
#define PORTS_PER_GROUP 50
|
||||
|
||||
/* Base port and fallback scan range for stabber */
|
||||
#define BASE_PORT 5230
|
||||
#define FALLBACK_PORT_RANGE (TEST_GROUPS * PORTS_PER_GROUP)
|
||||
#define BASE_PORT 5230
|
||||
#define FALLBACK_PORT_RANGE (TEST_GROUPS * PORTS_PER_GROUP)
|
||||
|
||||
/* Shutdown polling: interval and maximum attempts before escalating signals */
|
||||
#define MS_TO_US 1000
|
||||
#define SHUTDOWN_POLL_MS 50
|
||||
#define SHUTDOWN_POLL_MAX 100 /* 100 x 50ms = 5s */
|
||||
#define SIGTERM_POLL_MAX 20 /* 20 x 50ms = 1s */
|
||||
#define QUIT_GRACE_MS 100 /* grace for /quit to be read from pty */
|
||||
#define INIT_WAIT_MS 50 /* wait for child process to initialize */
|
||||
#define INPUT_DELAY_MS 10 /* let profanity process input */
|
||||
#define OUTPUT_POLL_MS 50 /* polling interval for output checks */
|
||||
#define READ_TIMEOUT_MS 100 /* select() timeout for pty reads */
|
||||
#define MS_TO_US 1000
|
||||
#define SHUTDOWN_POLL_MS 50
|
||||
#define SHUTDOWN_POLL_MAX 100 /* 100 x 50ms = 5s */
|
||||
#define SIGTERM_POLL_MAX 20 /* 20 x 50ms = 1s */
|
||||
#define QUIT_GRACE_MS 100 /* grace for /quit to be read from pty */
|
||||
#define INIT_WAIT_MS 50 /* wait for child process to initialize */
|
||||
#define INPUT_DELAY_MS 10 /* let profanity process input */
|
||||
#define OUTPUT_POLL_MS 50 /* polling interval for output checks */
|
||||
#define READ_TIMEOUT_MS 100 /* select() timeout for pty reads */
|
||||
|
||||
/* Expect timeouts (seconds) */
|
||||
#define EXPECT_TIMEOUT_DEFAULT 30
|
||||
#define EXPECT_TIMEOUT_CONNECT 60
|
||||
#define EXPECT_TIMEOUT_DEFAULT 30
|
||||
#define EXPECT_TIMEOUT_CONNECT 60
|
||||
|
||||
/* Test duration thresholds (seconds) */
|
||||
#define SLOW_TEST_THRESHOLD_S 20
|
||||
#define FAST_TEST_THRESHOLD_S 0.3
|
||||
#define SLOW_TEST_THRESHOLD_S 20
|
||||
#define FAST_TEST_THRESHOLD_S 0.3
|
||||
|
||||
/* Sentinel "infinity" for min-elapsed tracking (any real test is faster) */
|
||||
#define MIN_ELAPSED_INIT 1e9
|
||||
#define MIN_ELAPSED_INIT 1e9
|
||||
|
||||
/* Terminal dimensions for forkpty */
|
||||
#define PTY_ROWS 24
|
||||
#define PTY_COLS 300
|
||||
#define PTY_ROWS 24
|
||||
#define PTY_COLS 300
|
||||
|
||||
/* Preprocessor stringification (for setenv from numeric #define) */
|
||||
#define STRINGIFY_(x) #x
|
||||
#define STRINGIFY(x) STRINGIFY_(x)
|
||||
|
||||
char *config_orig;
|
||||
char *data_orig;
|
||||
char* config_orig;
|
||||
char* data_orig;
|
||||
|
||||
int fd = 0;
|
||||
int stub_port = BASE_PORT;
|
||||
@@ -87,19 +87,19 @@ static int expect_timeout = EXPECT_TIMEOUT_DEFAULT;
|
||||
|
||||
/* Per-test wall-clock timer */
|
||||
static struct timespec test_start_ts;
|
||||
static const char *current_test_name;
|
||||
static const char* current_test_name;
|
||||
static double min_elapsed = MIN_ELAPSED_INIT;
|
||||
static const char *min_test_name = "(none)";
|
||||
static const char* min_test_name = "(none)";
|
||||
|
||||
/* Per-test threshold overrides (0 = use default) */
|
||||
double prof_test_slow_threshold = 0;
|
||||
double prof_test_fast_threshold = 0;
|
||||
|
||||
/* Timezone for profanity child process (see proftest.h) */
|
||||
const char *prof_test_tz = "UTC";
|
||||
const char* prof_test_tz = "UTC";
|
||||
|
||||
gboolean
|
||||
_create_dir(const char *name)
|
||||
_create_dir(const char* name)
|
||||
{
|
||||
struct stat sb;
|
||||
|
||||
@@ -117,14 +117,14 @@ _create_dir(const char *name)
|
||||
}
|
||||
|
||||
gboolean
|
||||
_mkdir_recursive(const char *dir)
|
||||
_mkdir_recursive(const char* dir)
|
||||
{
|
||||
int i;
|
||||
gboolean result = TRUE;
|
||||
|
||||
for (i = 1; i <= strlen(dir); i++) {
|
||||
if (dir[i] == '/' || dir[i] == '\0') {
|
||||
gchar *next_dir = g_strndup(dir, i);
|
||||
gchar* next_dir = g_strndup(dir, i);
|
||||
result = _create_dir(next_dir);
|
||||
g_free(next_dir);
|
||||
if (!result) {
|
||||
@@ -139,7 +139,7 @@ _mkdir_recursive(const char *dir)
|
||||
void
|
||||
_create_config_dir(void)
|
||||
{
|
||||
GString *profanity_dir = g_string_new(xdg_config_home);
|
||||
GString* profanity_dir = g_string_new(xdg_config_home);
|
||||
g_string_append(profanity_dir, "/profanity");
|
||||
|
||||
if (!_mkdir_recursive(profanity_dir->str)) {
|
||||
@@ -152,7 +152,7 @@ _create_config_dir(void)
|
||||
void
|
||||
_create_data_dir(void)
|
||||
{
|
||||
GString *profanity_dir = g_string_new(xdg_data_home);
|
||||
GString* profanity_dir = g_string_new(xdg_data_home);
|
||||
g_string_append(profanity_dir, "/profanity");
|
||||
|
||||
if (!_mkdir_recursive(profanity_dir->str)) {
|
||||
@@ -165,7 +165,7 @@ _create_data_dir(void)
|
||||
void
|
||||
_create_chatlogs_dir(void)
|
||||
{
|
||||
GString *chatlogs_dir = g_string_new(xdg_data_home);
|
||||
GString* chatlogs_dir = g_string_new(xdg_data_home);
|
||||
g_string_append(chatlogs_dir, "/profanity/chatlogs");
|
||||
|
||||
if (!_mkdir_recursive(chatlogs_dir->str)) {
|
||||
@@ -178,7 +178,7 @@ _create_chatlogs_dir(void)
|
||||
void
|
||||
_create_logs_dir(void)
|
||||
{
|
||||
GString *logs_dir = g_string_new(xdg_data_home);
|
||||
GString* logs_dir = g_string_new(xdg_data_home);
|
||||
g_string_append(logs_dir, "/profanity/logs");
|
||||
|
||||
if (!_mkdir_recursive(logs_dir->str)) {
|
||||
@@ -191,12 +191,12 @@ _create_logs_dir(void)
|
||||
void
|
||||
_cleanup_dirs(void)
|
||||
{
|
||||
const char *group_env = getenv("PROF_TEST_GROUP");
|
||||
const char* group_env = getenv("PROF_TEST_GROUP");
|
||||
int group = group_env ? atoi(group_env) : 0;
|
||||
int dir_id = (group >= 1 && group <= TEST_GROUPS) ? group : stub_port;
|
||||
|
||||
|
||||
printf("[PROF_TEST] Cleaning up directories for group %d (dir_id %d)\n", group, dir_id);
|
||||
|
||||
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf ./test-files/%d", dir_id);
|
||||
int res = system(cmd);
|
||||
@@ -220,26 +220,26 @@ _read_output(int timeout_ms)
|
||||
{
|
||||
fd_set readfds;
|
||||
struct timeval tv;
|
||||
|
||||
|
||||
FD_ZERO(&readfds);
|
||||
FD_SET(fd, &readfds);
|
||||
|
||||
|
||||
tv.tv_sec = timeout_ms / MS_TO_US;
|
||||
tv.tv_usec = (timeout_ms % MS_TO_US) * MS_TO_US;
|
||||
|
||||
|
||||
int ret = select(fd + 1, &readfds, NULL, NULL, &tv);
|
||||
if (ret <= 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
size_t space = OUTPUT_BUF_SIZE - output_len - 1;
|
||||
if (space <= 0) {
|
||||
/* Buffer full, shift content */
|
||||
memmove(output_buffer, output_buffer + OUTPUT_BUF_SIZE/2, OUTPUT_BUF_SIZE/2);
|
||||
output_len = OUTPUT_BUF_SIZE/2;
|
||||
memmove(output_buffer, output_buffer + OUTPUT_BUF_SIZE / 2, OUTPUT_BUF_SIZE / 2);
|
||||
output_len = OUTPUT_BUF_SIZE / 2;
|
||||
space = OUTPUT_BUF_SIZE - output_len - 1;
|
||||
}
|
||||
|
||||
|
||||
ssize_t n = read(fd, output_buffer + output_len, space);
|
||||
if (n > 0) {
|
||||
output_len += n;
|
||||
@@ -260,7 +260,7 @@ prof_start(void)
|
||||
ws.ws_col = PTY_COLS;
|
||||
ws.ws_xpixel = 0;
|
||||
ws.ws_ypixel = 0;
|
||||
|
||||
|
||||
/* Reset output buffer */
|
||||
output_len = 0;
|
||||
output_buffer[0] = '\0';
|
||||
@@ -272,48 +272,48 @@ prof_start(void)
|
||||
tzset();
|
||||
|
||||
child_pid = forkpty(&fd, NULL, NULL, &ws);
|
||||
|
||||
|
||||
if (child_pid < 0) {
|
||||
fd = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (child_pid == 0) {
|
||||
/* Child process */
|
||||
setenv("COLUMNS", STRINGIFY(PTY_COLS), 1);
|
||||
setenv("TERM", "xterm", 1);
|
||||
|
||||
|
||||
execl("./profanity", "./profanity", "-l", "DEBUG", NULL);
|
||||
|
||||
|
||||
/* If exec fails */
|
||||
fprintf(stderr, "execl failed: %s\n", strerror(errno));
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
|
||||
/* Parent process */
|
||||
/* Set non-blocking mode for reading */
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
|
||||
/* Brief wait for process to initialize */
|
||||
sleep_ms(INIT_WAIT_MS);
|
||||
}
|
||||
|
||||
int
|
||||
init_prof_test(void **state)
|
||||
init_prof_test(void** state)
|
||||
{
|
||||
clock_gettime(CLOCK_MONOTONIC, &test_start_ts);
|
||||
current_test_name = state && *state ? (const char *)*state : "unknown";
|
||||
current_test_name = state && *state ? (const char*)*state : "unknown";
|
||||
prof_test_slow_threshold = 0;
|
||||
prof_test_fast_threshold = 0;
|
||||
/* Get test group from environment for static resource allocation */
|
||||
const char *group_env = getenv("PROF_TEST_GROUP");
|
||||
const char* group_env = getenv("PROF_TEST_GROUP");
|
||||
int group = group_env ? atoi(group_env) : 0;
|
||||
|
||||
|
||||
/* Get build index for port offset (for parallel CI builds) */
|
||||
const char *build_env = getenv("PROF_BUILD_INDEX");
|
||||
const char* build_env = getenv("PROF_BUILD_INDEX");
|
||||
int build_idx = build_env ? atoi(build_env) : 0;
|
||||
|
||||
|
||||
/* Calculate port base: each build uses a different range of
|
||||
* TEST_GROUPS * PORTS_PER_GROUP ports.
|
||||
* Build 0 (local/default): 5230-5429, Full: 5230-5429, Minimal: 5430-5629, etc.
|
||||
@@ -321,7 +321,7 @@ init_prof_test(void **state)
|
||||
* or sequential run (no parallel builds), while Full/Minimal/NoEncrypt/Default
|
||||
* are used in CI where they run in parallel. */
|
||||
int port_base = BASE_PORT + ((build_idx > 0 ? build_idx - 1 : 0) * TEST_GROUPS * PORTS_PER_GROUP);
|
||||
|
||||
|
||||
/* Each group gets a dedicated range of ports for parallel execution.
|
||||
* Group 1: port_base..port_base+PORTS_PER_GROUP-1
|
||||
* Group 2: port_base+PORTS_PER_GROUP..port_base+2*PORTS_PER_GROUP-1, etc.
|
||||
@@ -353,7 +353,7 @@ init_prof_test(void **state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!started) {
|
||||
fprintf(stderr, "[PROF_TEST] ERROR: could not start stabber on any port\n");
|
||||
return -1;
|
||||
@@ -366,8 +366,8 @@ init_prof_test(void **state)
|
||||
"./test-files/%d/xdg_config_home", dir_id);
|
||||
snprintf(xdg_data_home, sizeof(xdg_data_home),
|
||||
"./test-files/%d/xdg_data_home", dir_id);
|
||||
|
||||
printf("[PROF_TEST] Group %d using directories: config=%s, data=%s\n",
|
||||
|
||||
printf("[PROF_TEST] Group %d using directories: config=%s, data=%s\n",
|
||||
group, xdg_config_home, xdg_data_home);
|
||||
|
||||
config_orig = getenv("XDG_CONFIG_HOME");
|
||||
@@ -418,7 +418,7 @@ init_prof_test(void **state)
|
||||
}
|
||||
|
||||
int
|
||||
close_prof_test(void **state)
|
||||
close_prof_test(void** state)
|
||||
{
|
||||
if (fd > 0 && child_pid > 0) {
|
||||
prof_input("/quit");
|
||||
@@ -439,7 +439,10 @@ close_prof_test(void **state)
|
||||
if (!exited) {
|
||||
kill(child_pid, SIGTERM);
|
||||
for (int i = 0; i < SIGTERM_POLL_MAX; i++) {
|
||||
if (waitpid(child_pid, NULL, WNOHANG) != 0) { exited = 1; break; }
|
||||
if (waitpid(child_pid, NULL, WNOHANG) != 0) {
|
||||
exited = 1;
|
||||
break;
|
||||
}
|
||||
sleep_ms(SHUTDOWN_POLL_MS);
|
||||
}
|
||||
if (!exited) {
|
||||
@@ -462,7 +465,7 @@ close_prof_test(void **state)
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
double elapsed = (now.tv_sec - test_start_ts.tv_sec)
|
||||
+ (now.tv_nsec - test_start_ts.tv_nsec) / 1e9;
|
||||
+ (now.tv_nsec - test_start_ts.tv_nsec) / 1e9;
|
||||
if (elapsed < min_elapsed) {
|
||||
min_elapsed = elapsed;
|
||||
min_test_name = current_test_name;
|
||||
@@ -483,14 +486,14 @@ close_prof_test(void **state)
|
||||
}
|
||||
|
||||
void
|
||||
prof_input(const char *input)
|
||||
prof_input(const char* input)
|
||||
{
|
||||
GString *inp_str = g_string_new(input);
|
||||
GString* inp_str = g_string_new(input);
|
||||
g_string_append(inp_str, "\r");
|
||||
ssize_t _wn = write(fd, inp_str->str, inp_str->len);
|
||||
(void)_wn;
|
||||
g_string_free(inp_str, TRUE);
|
||||
|
||||
|
||||
/* Small delay to let profanity process input */
|
||||
sleep_ms(INPUT_DELAY_MS);
|
||||
}
|
||||
@@ -500,24 +503,24 @@ prof_input(const char *input)
|
||||
* Returns 1 if found, 0 if timeout.
|
||||
*/
|
||||
int
|
||||
prof_output_exact(const char *text)
|
||||
prof_output_exact(const char* text)
|
||||
{
|
||||
time_t start = time(NULL);
|
||||
|
||||
|
||||
while (time(NULL) - start < expect_timeout) {
|
||||
/* Read any available output */
|
||||
while (_read_output(READ_TIMEOUT_MS) > 0) {
|
||||
/* Keep reading while data available */
|
||||
}
|
||||
|
||||
|
||||
/* Check if text is in buffer */
|
||||
if (strstr(output_buffer, text) != NULL) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
sleep_ms(OUTPUT_POLL_MS);
|
||||
}
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -526,34 +529,34 @@ prof_output_exact(const char *text)
|
||||
* Returns 1 if found, 0 if timeout.
|
||||
*/
|
||||
int
|
||||
prof_output_regex(const char *pattern)
|
||||
prof_output_regex(const char* pattern)
|
||||
{
|
||||
regex_t regex;
|
||||
int ret;
|
||||
|
||||
|
||||
ret = regcomp(®ex, pattern, REG_EXTENDED | REG_NOSUB);
|
||||
if (ret != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
time_t start = time(NULL);
|
||||
|
||||
|
||||
while (time(NULL) - start < expect_timeout) {
|
||||
/* Read any available output */
|
||||
while (_read_output(READ_TIMEOUT_MS) > 0) {
|
||||
/* Keep reading while data available */
|
||||
}
|
||||
|
||||
|
||||
/* Check if pattern matches */
|
||||
ret = regexec(®ex, output_buffer, 0, NULL, 0);
|
||||
if (ret == 0) {
|
||||
regfree(®ex);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
sleep_ms(OUTPUT_POLL_MS);
|
||||
}
|
||||
|
||||
|
||||
/* Timeout reached - log diagnostic info */
|
||||
fprintf(stderr, "Timeout waiting for regex '%s' after %d seconds. Last output:\n", pattern, expect_timeout);
|
||||
size_t len = strlen(output_buffer);
|
||||
@@ -563,23 +566,21 @@ prof_output_regex(const char *pattern)
|
||||
fprintf(stderr, "%s", output_buffer);
|
||||
}
|
||||
fprintf(stderr, "\n");
|
||||
|
||||
|
||||
regfree(®ex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
prof_connect_with_roster(const char *roster)
|
||||
prof_connect_with_roster(const char* roster)
|
||||
{
|
||||
GString *roster_str = g_string_new(
|
||||
GString* roster_str = g_string_new(
|
||||
"<iq type='result' to='stabber@localhost/profanity'>"
|
||||
"<query xmlns='jabber:iq:roster' ver='362'>"
|
||||
);
|
||||
"<query xmlns='jabber:iq:roster' ver='362'>");
|
||||
g_string_append(roster_str, roster);
|
||||
g_string_append(roster_str,
|
||||
"</query>"
|
||||
"</iq>"
|
||||
);
|
||||
"</query>"
|
||||
"</iq>");
|
||||
|
||||
stbbr_for_query("jabber:iq:roster", roster_str->str);
|
||||
g_string_free(roster_str, TRUE);
|
||||
@@ -589,7 +590,7 @@ prof_connect_with_roster(const char *roster)
|
||||
char connect_cmd[128];
|
||||
snprintf(connect_cmd, sizeof(connect_cmd), "/connect stabber@localhost server 127.0.0.1 port %d tls disable auth legacy", stub_port);
|
||||
prof_input(connect_cmd);
|
||||
|
||||
|
||||
assert_true(prof_output_regex("password:"));
|
||||
prof_input("password");
|
||||
|
||||
@@ -597,15 +598,14 @@ prof_connect_with_roster(const char *roster)
|
||||
assert_true(prof_output_regex("Connecting as stabber@localhost"));
|
||||
assert_true(prof_output_regex("logged in successfully"));
|
||||
assert_true(prof_output_regex(".+online.+ \\(priority 0\\)\\."));
|
||||
|
||||
|
||||
expect_timeout = EXPECT_TIMEOUT_CONNECT;
|
||||
// Wait for presence stanza to be sent (content-based, not ID-based)
|
||||
// Match the actual attribute order from stanza_attach_caps
|
||||
assert_true(stbbr_received(
|
||||
"<presence id=\"*\">"
|
||||
"<c xmlns=\"http://jabber.org/protocol/caps\" hash=\"sha-1\" node=\"http://profanity-im.github.io\" ver=\"*\"/>"
|
||||
"</presence>"
|
||||
));
|
||||
"<c xmlns=\"http://jabber.org/protocol/caps\" hash=\"sha-1\" node=\"http://profanity-im.github.io\" ver=\"*\"/>"
|
||||
"</presence>"));
|
||||
}
|
||||
|
||||
void
|
||||
@@ -625,6 +625,5 @@ prof_connect(void)
|
||||
{
|
||||
prof_connect_with_roster(
|
||||
"<item jid='buddy1@localhost' subscription='both' name='Buddy1'/>"
|
||||
"<item jid='buddy2@localhost' subscription='both' name='Buddy2'/>"
|
||||
);
|
||||
"<item jid='buddy2@localhost' subscription='both' name='Buddy2'/>");
|
||||
}
|
||||
|
||||
@@ -33,6 +33,6 @@ extern double prof_test_slow_threshold;
|
||||
extern double prof_test_fast_threshold;
|
||||
|
||||
/* Timezone used for profanity child process (default "UTC") */
|
||||
extern const char *prof_test_tz;
|
||||
extern const char* prof_test_tz;
|
||||
|
||||
#endif
|
||||
|
||||
869
tests/unittests/test_ai_client.c
Normal file
869
tests/unittests/test_ai_client.c
Normal file
@@ -0,0 +1,869 @@
|
||||
#include "prof_cmocka.h"
|
||||
#include "common.h"
|
||||
#include "ai/ai_client.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ========================================================================
|
||||
* Setup/Teardown
|
||||
* ======================================================================== */
|
||||
|
||||
int
|
||||
ai_client_setup(void** state)
|
||||
{
|
||||
ai_client_init();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
ai_client_teardown(void** state)
|
||||
{
|
||||
ai_client_shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_client_init(void** state)
|
||||
{
|
||||
/* After init, default providers should exist */
|
||||
AIProvider* openai = ai_get_provider("openai");
|
||||
assert_non_null(openai);
|
||||
assert_string_equal("openai", openai->name);
|
||||
assert_string_equal("https://api.openai.com/", openai->api_url);
|
||||
|
||||
AIProvider* perplexity = ai_get_provider("perplexity");
|
||||
assert_non_null(perplexity);
|
||||
assert_string_equal("perplexity", perplexity->name);
|
||||
assert_string_equal("https://api.perplexity.ai/", perplexity->api_url);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider(void** state)
|
||||
{
|
||||
/* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */
|
||||
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1", "my-org");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("custom", provider->name);
|
||||
assert_string_equal("https://custom.api.com/v1", provider->api_url);
|
||||
assert_string_equal("my-org", provider->org_id);
|
||||
|
||||
/* Update existing provider (returns ref; caller owns it) */
|
||||
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1", NULL);
|
||||
assert_non_null(updated);
|
||||
assert_string_equal("https://new.api.com/v1", updated->api_url);
|
||||
assert_null(updated->org_id);
|
||||
|
||||
ai_provider_unref(updated);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_remove_provider(void** state)
|
||||
{
|
||||
/* Remove default provider should fail */
|
||||
assert_false(ai_remove_provider("nonexistent"));
|
||||
|
||||
/* Add and remove custom provider */
|
||||
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
|
||||
assert_true(ai_remove_provider("temp"));
|
||||
assert_null(ai_get_provider("temp"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_list_providers(void** state)
|
||||
{
|
||||
GList* providers = ai_list_providers();
|
||||
assert_non_null(providers);
|
||||
assert_int_equal(2, g_list_length(providers)); /* openai and perplexity */
|
||||
|
||||
/* Free list (ai_list_providers returns non-ref'd providers; caller must not unref) */
|
||||
g_list_free(providers);
|
||||
|
||||
/* Add another provider */
|
||||
ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
providers = ai_list_providers();
|
||||
assert_int_equal(3, g_list_length(providers));
|
||||
|
||||
/* Free list */
|
||||
g_list_free(providers);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* API Key Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_set_provider_key(void** state)
|
||||
{
|
||||
ai_set_provider_key("openai", "sk-test-key-123");
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_non_null(key);
|
||||
assert_string_equal("sk-test-key-123", key);
|
||||
}
|
||||
|
||||
/* Update key */
|
||||
ai_set_provider_key("openai", "sk-new-key-456");
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_string_equal("sk-new-key-456", key);
|
||||
}
|
||||
|
||||
/* Remove key */
|
||||
ai_set_provider_key("openai", NULL);
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_null(key);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_get_provider_key(void** state)
|
||||
{
|
||||
/* No key set initially */
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_null(key);
|
||||
}
|
||||
|
||||
/* Set and get key */
|
||||
ai_set_provider_key("perplexity", "pplx-abc123");
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("perplexity");
|
||||
assert_non_null(key);
|
||||
assert_string_equal("pplx-abc123", key);
|
||||
}
|
||||
|
||||
/* Wrong provider returns null */
|
||||
{
|
||||
auto_gchar gchar* key = ai_get_provider_key("openai");
|
||||
assert_null(key);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Session Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_session_create(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
assert_string_equal("openai", session->provider_name);
|
||||
assert_string_equal("gpt-4", session->model);
|
||||
assert_null(session->api_key); /* No key set */
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_ref_unref(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
|
||||
/* Reference */
|
||||
AISession* ref = ai_session_ref(session);
|
||||
assert_true(ref == session);
|
||||
|
||||
/* Unreference twice */
|
||||
ai_session_unref(session);
|
||||
ai_session_unref(ref); /* Should free here */
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_add_message(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
|
||||
ai_session_add_message(session, "user", "Hello");
|
||||
ai_session_add_message(session, "assistant", "Hi there!");
|
||||
|
||||
assert_int_equal(2, g_list_length(session->history));
|
||||
|
||||
AIMessage* first = session->history->data;
|
||||
assert_string_equal("user", first->role);
|
||||
assert_string_equal("Hello", first->content);
|
||||
|
||||
AIMessage* second = g_list_next(session->history)->data;
|
||||
assert_string_equal("assistant", second->role);
|
||||
assert_string_equal("Hi there!", second->content);
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_clear_history(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
|
||||
ai_session_add_message(session, "user", "Message 1");
|
||||
ai_session_add_message(session, "user", "Message 2");
|
||||
ai_session_add_message(session, "assistant", "Response");
|
||||
|
||||
assert_int_equal(3, g_list_length(session->history));
|
||||
|
||||
ai_session_clear_history(session);
|
||||
assert_int_equal(0, g_list_length(session->history));
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_set_model(void** state)
|
||||
{
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_string_equal("gpt-4", session->model);
|
||||
|
||||
ai_session_set_model(session, "gpt-3.5-turbo");
|
||||
assert_string_equal("gpt-3.5-turbo", session->model);
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* JSON Escape Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_json_escape(void** state)
|
||||
{
|
||||
gchar* escaped = ai_json_escape("hello \"world\"");
|
||||
assert_string_equal("hello \\\"world\\\"", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_null(void** state)
|
||||
{
|
||||
gchar* escaped = ai_json_escape(NULL);
|
||||
assert_string_equal("", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_empty(void** state)
|
||||
{
|
||||
gchar* escaped = ai_json_escape("");
|
||||
assert_string_equal("", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_special_chars(void** state)
|
||||
{
|
||||
gchar* escaped = ai_json_escape("line1\nline2\ttab\\backslash\"quote");
|
||||
assert_string_equal("line1\\nline2\\ttab\\\\backslash\\\"quote", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_percent_signs(void** state)
|
||||
{
|
||||
/* Critical: % characters in content must be escaped for JSON, not treated as format specifiers */
|
||||
gchar* escaped = ai_json_escape("100% complete with %s and %d format strings");
|
||||
assert_string_equal("100% complete with %s and %d format strings", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_backslash_quote(void** state)
|
||||
{
|
||||
/* Test escaped quote handling */
|
||||
gchar* escaped = ai_json_escape("He said \"hello\" and \\ goodbye");
|
||||
assert_string_equal("He said \\\"hello\\\" and \\\\ goodbye", escaped);
|
||||
g_free(escaped);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_api_key_is_copied(void** state)
|
||||
{
|
||||
/* Verify that session owns its own copy of the API key */
|
||||
ai_set_provider_key("openai", "sk-test-key-123");
|
||||
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
assert_string_equal("sk-test-key-123", session->api_key);
|
||||
|
||||
/* Remove the provider key - session should still have its copy */
|
||||
ai_set_provider_key("openai", NULL);
|
||||
assert_non_null(session->api_key);
|
||||
assert_string_equal("sk-test-key-123", session->api_key);
|
||||
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_update_existing(void** state)
|
||||
{
|
||||
/* Add a provider (hash table owns ref) */
|
||||
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1", "org1");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://first.api.com/v1", provider->api_url);
|
||||
assert_string_equal("org1", provider->org_id);
|
||||
|
||||
/* Update the same provider (returns ref) */
|
||||
provider = ai_add_provider("custom", "https://second.api.com/v1", "org2");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://second.api.com/v1", provider->api_url);
|
||||
assert_string_equal("org2", provider->org_id);
|
||||
ai_provider_unref(provider);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_null_name_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_add_provider(NULL, "https://api.com/v1", NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_null_url_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_add_provider("test", NULL, NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_create_null_provider_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_session_create("nonexistent", "gpt-4"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_create_null_model_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_session_create("openai", NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_api_key_null_when_no_key_set(void** state)
|
||||
{
|
||||
/* openai has no key set by default */
|
||||
AISession* session = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(session);
|
||||
assert_null(session->api_key);
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Autocomplete Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward(void** state)
|
||||
{
|
||||
/* Test forward iteration - should return first match */
|
||||
auto_gchar gchar* result = ai_providers_find("o", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward_perplexity(void** state)
|
||||
{
|
||||
/* Test forward iteration for perplexity */
|
||||
auto_gchar gchar* result = ai_providers_find("p", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("perplexity", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward_custom(void** state)
|
||||
{
|
||||
/* Add a custom provider and test */
|
||||
ai_add_provider("custom", "https://custom.api.com/v1", NULL);
|
||||
|
||||
auto_gchar gchar* result = ai_providers_find("c", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("custom", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward_no_match(void** state)
|
||||
{
|
||||
/* Test no match */
|
||||
auto_gchar gchar* result = ai_providers_find("z", FALSE, NULL);
|
||||
assert_null(result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_forward_partial_match(void** state)
|
||||
{
|
||||
/* Test partial match - should return providers starting with "ope" */
|
||||
auto_gchar gchar* result = ai_providers_find("ope", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_next(void** state)
|
||||
{
|
||||
/* Test that stateless implementation returns same result each call */
|
||||
auto_gchar gchar* result1 = ai_providers_find("o", FALSE, NULL);
|
||||
assert_non_null(result1);
|
||||
assert_string_equal("openai", result1);
|
||||
|
||||
/* Second call with same params returns same result (stateless) */
|
||||
auto_gchar gchar* result2 = ai_providers_find("o", FALSE, NULL);
|
||||
assert_non_null(result2);
|
||||
assert_string_equal("openai", result2);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_previous(void** state)
|
||||
{
|
||||
/* Test that previous=TRUE returns last match in list */
|
||||
/* With only "openai" starting with "o", both FALSE and TRUE return same result */
|
||||
auto_gchar gchar* result1 = ai_providers_find("o", FALSE, NULL);
|
||||
assert_non_null(result1);
|
||||
assert_string_equal("openai", result1);
|
||||
|
||||
/* previous=TRUE also returns "openai" (only one match, so first==last) */
|
||||
auto_gchar gchar* result2 = ai_providers_find("o", TRUE, NULL);
|
||||
assert_non_null(result2);
|
||||
assert_string_equal("openai", result2);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_null_search_str(void** state)
|
||||
{
|
||||
/* NULL search_str triggers cycling: returns first provider in list */
|
||||
auto_gchar gchar* result = ai_providers_find(NULL, FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_empty_search_str(void** state)
|
||||
{
|
||||
/* Empty search_str triggers cycling: returns first provider in list */
|
||||
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_case_insensitive(void** state)
|
||||
{
|
||||
/* Test that matching is case-insensitive (via g_ascii_strdown) */
|
||||
auto_gchar gchar* result = ai_providers_find("OPENAI", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
|
||||
result = ai_providers_find("OpenAI", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
|
||||
result = ai_providers_find("openai", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* AI Autocomplete Integration Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_start_provider_autocomplete_only_on_exact(void** state)
|
||||
{
|
||||
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
assert_string_equal("openai", result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_models_find_null_session(void** state)
|
||||
{
|
||||
auto_gchar gchar* result = ai_models_find("gpt", FALSE, NULL);
|
||||
assert_null(result);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_models_find_null_provider(void** state)
|
||||
{
|
||||
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
|
||||
AISession* session = ai_session_create("temp", "gpt-4");
|
||||
assert_non_null(session);
|
||||
ai_session_unref(session);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_cycling(void** state)
|
||||
{
|
||||
/* NULL search_str should cycle through providers */
|
||||
auto_gchar gchar* result1 = ai_providers_find(NULL, FALSE, NULL);
|
||||
assert_non_null(result1);
|
||||
|
||||
auto_gchar gchar* result2 = ai_providers_find(NULL, FALSE, NULL);
|
||||
assert_non_null(result2);
|
||||
|
||||
/* Cycling: consecutive calls with NULL should return different providers */
|
||||
assert_string_not_equal(result1, result2);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Default Model and Settings Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_set_provider_default_model(void** state)
|
||||
{
|
||||
/* Set default model for openai provider */
|
||||
ai_set_provider_default_model("openai", "gpt-5");
|
||||
|
||||
/* Verify the model was set */
|
||||
const gchar* model = ai_get_provider_default_model("openai");
|
||||
assert_non_null(model);
|
||||
assert_string_equal("gpt-5", model);
|
||||
|
||||
/* Update default model */
|
||||
ai_set_provider_default_model("openai", "gpt-4o");
|
||||
model = ai_get_provider_default_model("openai");
|
||||
assert_non_null(model);
|
||||
assert_string_equal("gpt-4o", model);
|
||||
|
||||
/* Set default model for perplexity */
|
||||
ai_set_provider_default_model("perplexity", "sonar-pro");
|
||||
model = ai_get_provider_default_model("perplexity");
|
||||
assert_non_null(model);
|
||||
assert_string_equal("sonar-pro", model);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_get_provider_default_model(void** state)
|
||||
{
|
||||
/* NULL provider returns NULL */
|
||||
assert_null(ai_get_provider_default_model(NULL));
|
||||
|
||||
/* Non-existent provider returns NULL */
|
||||
assert_null(ai_get_provider_default_model("nonexistent"));
|
||||
|
||||
/* Default providers may or may not have default models set initially */
|
||||
/* After setting, they should return the model */
|
||||
ai_set_provider_default_model("openai", "test-model");
|
||||
assert_string_equal("test-model", ai_get_provider_default_model("openai"));
|
||||
|
||||
/* NULL model argument should be ignored (no change) */
|
||||
ai_set_provider_default_model("openai", NULL);
|
||||
/* After setting NULL, the model should still be "test-model" because
|
||||
* ai_set_provider_default_model returns early on NULL model */
|
||||
assert_string_equal("test-model", ai_get_provider_default_model("openai"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_set_provider_setting(void** state)
|
||||
{
|
||||
/* NULL provider should be ignored */
|
||||
ai_set_provider_setting(NULL, "tools", "enabled");
|
||||
|
||||
/* NULL setting should be ignored */
|
||||
ai_set_provider_setting("openai", NULL, "enabled");
|
||||
|
||||
/* Non-existent provider should log warning but not crash */
|
||||
ai_set_provider_setting("nonexistent", "tools", "enabled");
|
||||
|
||||
/* Set a setting for openai */
|
||||
ai_set_provider_setting("openai", "tools", "enabled");
|
||||
ai_set_provider_setting("openai", "search", "disabled");
|
||||
|
||||
/* Verify settings were set */
|
||||
gchar* tools = ai_get_provider_setting("openai", "tools");
|
||||
assert_non_null(tools);
|
||||
assert_string_equal("enabled", tools);
|
||||
g_free(tools);
|
||||
|
||||
gchar* search = ai_get_provider_setting("openai", "search");
|
||||
assert_non_null(search);
|
||||
assert_string_equal("disabled", search);
|
||||
g_free(search);
|
||||
|
||||
/* NULL value removes the setting */
|
||||
ai_set_provider_setting("openai", "tools", NULL);
|
||||
tools = ai_get_provider_setting("openai", "tools");
|
||||
assert_null(tools);
|
||||
|
||||
/* Setting that was never set returns NULL */
|
||||
assert_null(ai_get_provider_setting("openai", "nonexistent_setting"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_get_provider_setting(void** state)
|
||||
{
|
||||
/* NULL provider returns NULL */
|
||||
assert_null(ai_get_provider_setting(NULL, "tools"));
|
||||
|
||||
/* NULL setting returns NULL */
|
||||
assert_null(ai_get_provider_setting("openai", NULL));
|
||||
|
||||
/* Non-existent provider returns NULL */
|
||||
assert_null(ai_get_provider_setting("nonexistent", "tools"));
|
||||
|
||||
/* Provider without settings returns NULL */
|
||||
assert_null(ai_get_provider_setting("perplexity", "tools"));
|
||||
|
||||
/* Set and get setting */
|
||||
ai_set_provider_setting("openai", "temperature", "0.7");
|
||||
gchar* temp = ai_get_provider_setting("openai", "temperature");
|
||||
assert_non_null(temp);
|
||||
assert_string_equal("0.7", temp);
|
||||
g_free(temp);
|
||||
|
||||
/* Setting value is a copy (caller must free) */
|
||||
ai_set_provider_setting("openai", "max_tokens", "2048");
|
||||
temp = ai_get_provider_setting("openai", "max_tokens");
|
||||
assert_non_null(temp);
|
||||
assert_string_equal("2048", temp);
|
||||
g_free(temp);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Model Caching Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_models_are_fresh_initial(void** state)
|
||||
{
|
||||
/* After init, models should be fresh (or at least not crash) */
|
||||
/* The ai_client_init() loads models from prefs, which may be empty initially */
|
||||
/* So we just verify the function works correctly */
|
||||
|
||||
/* NULL provider returns FALSE */
|
||||
assert_false(ai_models_are_fresh(NULL));
|
||||
|
||||
/* Non-existent provider returns FALSE */
|
||||
assert_false(ai_models_are_fresh("nonexistent"));
|
||||
|
||||
/* Default providers - check that function doesn't crash */
|
||||
/* Freshness depends on whether models were loaded from prefs */
|
||||
gboolean fresh_openai = ai_models_are_fresh("openai");
|
||||
assert_true(fresh_openai == TRUE || fresh_openai == FALSE); /* Just verify no crash */
|
||||
|
||||
gboolean fresh_perplexity = ai_models_are_fresh("perplexity");
|
||||
assert_true(fresh_perplexity == TRUE || fresh_perplexity == FALSE); /* Just verify no crash */
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Model Parsing Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_parse_models_openai_format(void** state)
|
||||
{
|
||||
/* Test parsing OpenAI-compatible API response format */
|
||||
/* Format: {"object":"list","data":[{"id":"model1",...},...]} */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* JSON response from Perplexity/OpenAI API */
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
"{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}"
|
||||
"]}";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
/* Verify models were parsed correctly */
|
||||
assert_int_equal(4, g_list_length(provider->models));
|
||||
|
||||
/* Check specific models */
|
||||
GList* models = provider->models;
|
||||
assert_string_equal("anthropic/claude-haiku-4-5", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("anthropic/claude-opus-4-5", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("openai/gpt-5", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("google/gemini-3-flash-preview", models->data);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_perplexity_format(void** state)
|
||||
{
|
||||
/* Test parsing actual Perplexity API response with all models from curl test */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("perplexity", "https://api.perplexity.ai/", NULL);
|
||||
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
"{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-opus-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-opus-4-7\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-sonnet-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-sonnet-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"},"
|
||||
"{\"id\":\"google/gemini-3.1-pro-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"},"
|
||||
"{\"id\":\"nvidia/nemotron-3-super-120b-a12b\",\"object\":\"model\",\"created\":0,\"owned_by\":\"nvidia\"},"
|
||||
"{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.1\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.2\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.4\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.4-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.4-nano\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"perplexity/sonar\",\"object\":\"model\",\"created\":0,\"owned_by\":\"perplexity\"},"
|
||||
"{\"id\":\"xai/grok-4-1-fast-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
||||
"{\"id\":\"xai/grok-4.20-multi-agent\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
||||
"{\"id\":\"xai/grok-4.20-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
||||
"{\"id\":\"xai/grok-4.20-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
||||
"{\"id\":\"xai/grok-4.3\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}"
|
||||
"]}";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
/* Verify all 23 models were parsed correctly */
|
||||
assert_int_equal(23, g_list_length(provider->models));
|
||||
|
||||
/* Verify NO field names or owned_by values are in the model list */
|
||||
/* The bug was that "id", "object", "model", "created", "owned_by" and provider names
|
||||
* like "anthropic", "google", "openai", "nvidia", "perplexity", "xai" were extracted */
|
||||
GList* models = provider->models;
|
||||
while (models) {
|
||||
const gchar* model = (const gchar*)models->data;
|
||||
/* These should never be model IDs */
|
||||
assert_false(g_strcmp0(model, "id") == 0);
|
||||
assert_false(g_strcmp0(model, "object") == 0);
|
||||
assert_false(g_strcmp0(model, "model") == 0);
|
||||
assert_false(g_strcmp0(model, "created") == 0);
|
||||
assert_false(g_strcmp0(model, "owned_by") == 0);
|
||||
assert_false(g_strcmp0(model, "list") == 0);
|
||||
/* These are owned_by values, not model IDs */
|
||||
assert_false(g_strcmp0(model, "anthropic") == 0);
|
||||
assert_false(g_strcmp0(model, "google") == 0);
|
||||
assert_false(g_strcmp0(model, "openai") == 0);
|
||||
assert_false(g_strcmp0(model, "nvidia") == 0);
|
||||
assert_false(g_strcmp0(model, "perplexity") == 0);
|
||||
assert_false(g_strcmp0(model, "xai") == 0);
|
||||
models = g_list_next(models);
|
||||
}
|
||||
|
||||
/* Verify first and last model IDs */
|
||||
models = provider->models;
|
||||
assert_string_equal("anthropic/claude-haiku-4-5", models->data);
|
||||
models = g_list_last(models);
|
||||
assert_string_equal("xai/grok-4.3", models->data);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_array_format(void** state)
|
||||
{
|
||||
/* Test parsing simple array format: ["model1", "model2", ...] */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
const gchar* json = "[\"model1\", \"model2\", \"model3\"]";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
assert_int_equal(3, g_list_length(provider->models));
|
||||
|
||||
GList* models = provider->models;
|
||||
assert_string_equal("model1", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("model2", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("model3", models->data);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_empty_json(void** state)
|
||||
{
|
||||
/* Test parsing empty/invalid JSON */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
ai_parse_models_from_json(provider, "");
|
||||
assert_null(provider->models);
|
||||
assert_int_equal(0, g_list_length(provider->models));
|
||||
|
||||
ai_parse_models_from_json(provider, "{\"invalid\": true}");
|
||||
assert_null(provider->models);
|
||||
assert_int_equal(0, g_list_length(provider->models));
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_null_handling(void** state)
|
||||
{
|
||||
/* Test NULL handling */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* NULL json should not crash */
|
||||
ai_parse_models_from_json(provider, NULL);
|
||||
assert_null(provider->models);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_escaped_quotes(void** state)
|
||||
{
|
||||
/* Test parsing model IDs with escaped quotes */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
"{\"id\":\"test/model\\\"with\\\"quotes\",\"object\":\"model\",\"created\":0,\"owned_by\":\"test\"}"
|
||||
"]}";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
assert_int_equal(1, g_list_length(provider->models));
|
||||
/* Model ID should have the escaped quotes handled */
|
||||
GList* models = provider->models;
|
||||
assert_non_null(models);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_with_whitespace(void** state)
|
||||
{
|
||||
/* Test parsing JSON with whitespace after colons */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* JSON with spaces after colons (common in formatted JSON) */
|
||||
const gchar* json = "{ \"object\": \"list\", "
|
||||
"\"data\": [ "
|
||||
"{ \"id\": \"model-with-spaces\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"test\" }"
|
||||
"]}";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
assert_int_equal(1, g_list_length(provider->models));
|
||||
GList* models = provider->models;
|
||||
assert_string_equal("model-with-spaces", models->data);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
62
tests/unittests/test_ai_client.h
Normal file
62
tests/unittests/test_ai_client.h
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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);
|
||||
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;
|
||||
}
|
||||
@@ -1433,3 +1433,8 @@ cons_has_alerts(void)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
win_move_to_end(ProfWin* window)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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,62 @@ 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),
|
||||
// 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