Compare commits
34 Commits
fix/scroll
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
2c74c0a058
|
|||
|
c167f48714
|
|||
|
010b2062a4
|
|||
|
ecb07fd00c
|
|||
|
a898cb212d
|
|||
|
07d267b5bc
|
|||
|
acae543057
|
|||
|
731b55fa19
|
|||
|
e229ed1281
|
|||
|
f4221e27ac
|
|||
|
cdcc45b7c6
|
|||
|
f133d81a05
|
|||
|
a16237d16b
|
|||
|
cfe6ea46e2
|
|||
|
2fc7f3d672
|
|||
|
fe0a46da58
|
|||
|
cead417e78
|
|||
|
00f11eb704
|
|||
|
4913a3d5a4
|
|||
|
634fb7d7eb
|
|||
|
461c0c32dd
|
|||
|
002a6ed15b
|
|||
|
dc75f16221
|
|||
|
da0bf43d73
|
|||
|
9e1f9b666e
|
|||
|
93ad7379e2
|
|||
|
caa0b3ccba
|
|||
|
c9a5239117
|
|||
|
25e0459979
|
|||
|
bccd3ecded
|
|||
|
cff05ca802
|
|||
|
9c8ad57b59
|
|||
|
1f1770bd58
|
|||
|
e43e8378b0
|
@@ -203,7 +203,6 @@ functionaltest_sources = \
|
||||
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
|
||||
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
|
||||
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
|
||||
tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \
|
||||
tests/functionaltests/functionaltests.c
|
||||
|
||||
main_source = src/main.c
|
||||
|
||||
@@ -21,9 +21,12 @@
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <glib.h>
|
||||
#include <pthread.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Default providers */
|
||||
#define DEFAULT_OPENAI_URL "https://api.openai.com/"
|
||||
#define DEFAULT_PERPLEXITY_URL "https://api.perplexity.ai/"
|
||||
|
||||
/* Global state */
|
||||
static GHashTable* providers = NULL;
|
||||
static GHashTable* provider_keys = NULL;
|
||||
@@ -32,11 +35,8 @@ static Autocomplete providers_ac = NULL;
|
||||
/* ========================================================================
|
||||
* Forward declarations
|
||||
* ======================================================================== */
|
||||
static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url);
|
||||
static void _ai_load_models_for_provider(AIProvider* provider);
|
||||
static void _ai_save_models_for_provider(AIProvider* provider);
|
||||
static const gchar* _find_json_field(const gchar* json, const gchar* field);
|
||||
static gchar* _extract_json_string(const gchar* json, const gchar* field);
|
||||
|
||||
/* ========================================================================
|
||||
* Curl helpers
|
||||
@@ -90,7 +90,9 @@ _aiwin_validate(gpointer user_data)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&lock);
|
||||
gboolean win_exists = wins_ai_exists((ProfAiWin*)user_data);
|
||||
pthread_mutex_unlock(&lock);
|
||||
if (!win_exists) {
|
||||
log_warning("[AI-THREAD] aiwin=%p no longer exists — window was closed", (void*)user_data);
|
||||
return NULL;
|
||||
@@ -108,13 +110,12 @@ _aiwin_validate(gpointer user_data)
|
||||
static void
|
||||
_aiwin_display_error(gpointer user_data, const gchar* error_msg)
|
||||
{
|
||||
pthread_mutex_lock(&lock);
|
||||
ProfAiWin* aiwin = _aiwin_validate(user_data);
|
||||
if (!aiwin) {
|
||||
log_warning("[AI-THREAD] Cannot display error: aiwin is invalid or window was closed (msg: %s)", error_msg);
|
||||
pthread_mutex_unlock(&lock);
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
aiwin_display_error(aiwin, error_msg);
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
@@ -132,14 +133,16 @@ _aiwin_display_response(gpointer user_data, const gchar* response)
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&lock);
|
||||
/* Try to display in AI window if available */
|
||||
ProfAiWin* aiwin = _aiwin_validate(user_data);
|
||||
if (!aiwin) {
|
||||
pthread_mutex_lock(&lock);
|
||||
cons_show("%s", response);
|
||||
pthread_mutex_unlock(&lock);
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&lock);
|
||||
aiwin_display_response(aiwin, response);
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
@@ -186,95 +189,17 @@ ai_json_escape(const gchar* str)
|
||||
return g_string_free(result, FALSE);
|
||||
}
|
||||
|
||||
/* Decode JSON string escapes in [start, end) into a freshly allocated buffer.
|
||||
* Handles \" \\ \/ \n \t \r \b \f. \uXXXX sequences are passed through verbatim
|
||||
* since the rest of the AI pipeline already deals in UTF-8 byte streams. */
|
||||
static gchar*
|
||||
_json_unescape_substring(const gchar* start, const gchar* end)
|
||||
{
|
||||
gchar* out = g_new0(gchar, (end - start) + 1);
|
||||
gchar* w = out;
|
||||
for (const gchar* in = start; in < end;) {
|
||||
if (*in == '\\' && in + 1 < end) {
|
||||
switch (in[1]) {
|
||||
case '"':
|
||||
*w++ = '"';
|
||||
in += 2;
|
||||
break;
|
||||
case '\\':
|
||||
*w++ = '\\';
|
||||
in += 2;
|
||||
break;
|
||||
case '/':
|
||||
*w++ = '/';
|
||||
in += 2;
|
||||
break;
|
||||
case 'n':
|
||||
*w++ = '\n';
|
||||
in += 2;
|
||||
break;
|
||||
case 't':
|
||||
*w++ = '\t';
|
||||
in += 2;
|
||||
break;
|
||||
case 'r':
|
||||
*w++ = '\r';
|
||||
in += 2;
|
||||
break;
|
||||
case 'b':
|
||||
*w++ = '\b';
|
||||
in += 2;
|
||||
break;
|
||||
case 'f':
|
||||
*w++ = '\f';
|
||||
in += 2;
|
||||
break;
|
||||
default:
|
||||
*w++ = *in++;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
*w++ = *in++;
|
||||
}
|
||||
}
|
||||
*w = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Locate "field":"...value..." in json and return the unescaped value.
|
||||
* Skips JSON whitespace around the colon and treats any \X (X != \0) inside
|
||||
* the string body as an opaque escape so an embedded \" cannot be misread as
|
||||
* the closing quote. */
|
||||
static gchar*
|
||||
_extract_json_string(const gchar* json, const gchar* field)
|
||||
{
|
||||
const gchar* val = _find_json_field(json, field);
|
||||
if (!val || *val != '"')
|
||||
return NULL;
|
||||
|
||||
const gchar* start = val + 1;
|
||||
for (const gchar* p = start; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1) != '\0') {
|
||||
p++; /* skip the escaped character; loop ++ advances past it */
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
return _json_unescape_substring(start, p);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management
|
||||
* ======================================================================== */
|
||||
|
||||
static AIProvider*
|
||||
ai_provider_new(const gchar* name, const gchar* api_url)
|
||||
ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
{
|
||||
AIProvider* provider = g_new0(AIProvider, 1);
|
||||
provider->name = g_strdup(name);
|
||||
provider->api_url = g_strdup(api_url ? api_url : "");
|
||||
provider->org_id = g_strdup(org_id);
|
||||
provider->project_id = NULL;
|
||||
provider->default_model = NULL;
|
||||
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
@@ -284,7 +209,7 @@ ai_provider_new(const gchar* name, const gchar* api_url)
|
||||
return provider;
|
||||
}
|
||||
|
||||
AIProvider*
|
||||
static AIProvider*
|
||||
ai_provider_ref(AIProvider* provider)
|
||||
{
|
||||
if (provider) {
|
||||
@@ -305,6 +230,7 @@ ai_provider_unref(AIProvider* provider)
|
||||
|
||||
g_free(provider->name);
|
||||
g_free(provider->api_url);
|
||||
g_free(provider->org_id);
|
||||
g_free(provider->project_id);
|
||||
g_free(provider->default_model);
|
||||
g_hash_table_destroy(provider->settings);
|
||||
@@ -361,51 +287,24 @@ ai_client_init(void)
|
||||
/* Create autocomplete for provider names */
|
||||
providers_ac = autocomplete_new();
|
||||
|
||||
/* Get all providers (defaults if nothing configured, otherwise config values) */
|
||||
GList* all_providers = prefs_ai_get_providers();
|
||||
for (GList* curr = all_providers; curr; curr = g_list_next(curr)) {
|
||||
/* List contains pairs: name, url, name, url, ... */
|
||||
gchar* name = (gchar*)curr->data;
|
||||
curr = g_list_next(curr);
|
||||
if (!curr)
|
||||
break;
|
||||
gchar* url = (gchar*)curr->data;
|
||||
_ai_add_provider_nopersist(name, url);
|
||||
}
|
||||
prefs_free_ai_providers(all_providers);
|
||||
/* Add default providers */
|
||||
ai_add_provider("openai", DEFAULT_OPENAI_URL, NULL);
|
||||
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL, NULL);
|
||||
|
||||
/* Load saved API keys from config */
|
||||
ai_load_keys();
|
||||
|
||||
/* Load cached models, persisted default-model and custom settings for
|
||||
* all providers. */
|
||||
/* Load cached models for all providers */
|
||||
GList* provider_list = ai_list_providers();
|
||||
for (GList* curr = provider_list; curr; curr = g_list_next(curr)) {
|
||||
GList* curr = provider_list;
|
||||
while (curr) {
|
||||
AIProvider* provider = (AIProvider*)curr->data;
|
||||
_ai_load_models_for_provider(provider);
|
||||
|
||||
auto_gchar gchar* dm = prefs_ai_get_default_model(provider->name);
|
||||
if (dm && dm[0] != '\0') {
|
||||
g_free(provider->default_model);
|
||||
provider->default_model = g_strdup(dm);
|
||||
}
|
||||
|
||||
GList* settings = prefs_ai_list_settings(provider->name);
|
||||
for (GList* s = settings; s; s = g_list_next(s)) {
|
||||
const gchar* name = (const gchar*)s->data;
|
||||
auto_gchar gchar* val = prefs_ai_get_setting(provider->name, name);
|
||||
if (val) {
|
||||
if (!provider->settings) {
|
||||
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
}
|
||||
g_hash_table_insert(provider->settings, g_strdup(name), g_strdup(val));
|
||||
}
|
||||
}
|
||||
prefs_free_ai_settings(settings);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(provider_list);
|
||||
|
||||
log_info("AI client initialized with providers from preferences");
|
||||
log_info("AI client initialized with default providers: openai, perplexity");
|
||||
}
|
||||
|
||||
void
|
||||
@@ -436,31 +335,8 @@ ai_get_provider(const gchar* name)
|
||||
return g_hash_table_lookup(providers, name);
|
||||
}
|
||||
|
||||
/* Internal helper to add provider without persisting to config.
|
||||
* Used for default providers and loading from config on startup. */
|
||||
static AIProvider*
|
||||
_ai_add_provider_nopersist(const gchar* name, const gchar* api_url)
|
||||
{
|
||||
/* Check if provider already exists */
|
||||
AIProvider* existing = g_hash_table_lookup(providers, name);
|
||||
if (existing) {
|
||||
g_free(existing->api_url);
|
||||
existing->api_url = g_strdup(api_url);
|
||||
return ai_provider_ref(existing);
|
||||
}
|
||||
|
||||
/* Create new provider (ref_count=1 owned by hash table) */
|
||||
AIProvider* provider = ai_provider_new(name, api_url);
|
||||
g_hash_table_insert(providers, g_strdup(name), provider);
|
||||
|
||||
/* Sync autocomplete */
|
||||
autocomplete_add(providers_ac, name);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
AIProvider*
|
||||
ai_add_provider(const gchar* name, const gchar* api_url)
|
||||
ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
{
|
||||
if (!name || !api_url)
|
||||
return NULL;
|
||||
@@ -475,17 +351,18 @@ ai_add_provider(const gchar* name, const gchar* api_url)
|
||||
/* Update existing provider */
|
||||
g_free(existing->api_url);
|
||||
existing->api_url = g_strdup(api_url);
|
||||
/* Persist the updated URL to config */
|
||||
prefs_ai_set_provider(name, api_url);
|
||||
g_free(existing->org_id);
|
||||
existing->org_id = g_strdup(org_id);
|
||||
log_info("Updated provider: %s", name);
|
||||
return ai_provider_ref(existing);
|
||||
}
|
||||
|
||||
/* Persist the new provider to config */
|
||||
prefs_ai_set_provider(name, api_url);
|
||||
/* Create new provider (ref_count=1 owned by hash table) */
|
||||
AIProvider* provider = ai_provider_new(name, api_url, org_id);
|
||||
g_hash_table_insert(providers, g_strdup(name), provider);
|
||||
|
||||
/* Add to hash table (reuse internal helper) */
|
||||
AIProvider* provider = _ai_add_provider_nopersist(name, api_url);
|
||||
/* Sync autocomplete */
|
||||
autocomplete_add(providers_ac, name);
|
||||
|
||||
log_info("Added provider: %s (URL: %s)", name, api_url);
|
||||
return provider; /* Caller gets non-owning pointer; hash table owns ref */
|
||||
@@ -497,10 +374,6 @@ ai_remove_provider(const gchar* name)
|
||||
if (!name || !providers)
|
||||
return FALSE;
|
||||
|
||||
/* Wipe the whole [ai/<name>] subsection from config — url, key,
|
||||
* default_model, models, custom settings — in one call. */
|
||||
prefs_ai_remove_section(name);
|
||||
|
||||
/* Sync autocomplete before removing */
|
||||
autocomplete_remove(providers_ac, name);
|
||||
|
||||
@@ -529,26 +402,53 @@ ai_list_providers(void)
|
||||
* Provider autocomplete state
|
||||
* ======================================================================== */
|
||||
|
||||
/* Stateful provider name finder with case-sensitive matching.
|
||||
* Maintains position in sorted list for deterministic tab-completion cycling.
|
||||
* The autocomplete is kept in sync via ai_add_provider/ai_remove_provider. */
|
||||
gchar*
|
||||
ai_providers_find(const char* const search_str, gboolean previous, void* context)
|
||||
{
|
||||
/* Initialize autocomplete on first use */
|
||||
if (!providers_ac) {
|
||||
log_debug("[AI-AC] Uninitialized call to ai_providers_find");
|
||||
providers_ac = autocomplete_new();
|
||||
}
|
||||
|
||||
/* NULL search_str is treated as empty string for cycling */
|
||||
const char* effective_search = (search_str != NULL) ? search_str : "";
|
||||
|
||||
/* Use stateful autocomplete */
|
||||
return autocomplete_complete(providers_ac, effective_search, FALSE, previous);
|
||||
}
|
||||
|
||||
/* Stateful model name finder for autocomplete.
|
||||
* Uses the current session's provider models for completion. */
|
||||
gchar*
|
||||
ai_models_find(const char* const search_str, gboolean previous, void* context)
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)context;
|
||||
if (!aiwin || !aiwin->session) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return autocomplete_complete(providers_ac, search_str, FALSE, previous);
|
||||
}
|
||||
|
||||
/* Reset the provider autocomplete state.
|
||||
* Called from cmd_ac_reset() to clear autocomplete state when the user presses Ctrl+C. */
|
||||
void
|
||||
ai_providers_reset_ac(void)
|
||||
{
|
||||
if (!providers_ac) {
|
||||
return;
|
||||
AIProvider* provider = aiwin->session->provider;
|
||||
if (!provider || !provider->models) {
|
||||
return NULL;
|
||||
}
|
||||
autocomplete_reset(providers_ac);
|
||||
|
||||
/* Create a temporary autocomplete for models */
|
||||
Autocomplete models_ac = autocomplete_new();
|
||||
GList* curr = provider->models;
|
||||
while (curr) {
|
||||
autocomplete_add(models_ac, curr->data);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
/* NULL search_str is treated as empty string for cycling */
|
||||
const char* effective_search = (search_str != NULL) ? search_str : "";
|
||||
|
||||
gchar* result = autocomplete_complete(models_ac, effective_search, FALSE, previous);
|
||||
autocomplete_free(models_ac);
|
||||
return result;
|
||||
}
|
||||
|
||||
gchar*
|
||||
@@ -601,7 +501,6 @@ ai_set_provider_default_model(const gchar* provider_name, const gchar* model)
|
||||
|
||||
g_free(provider->default_model);
|
||||
provider->default_model = g_strdup(model);
|
||||
prefs_ai_set_default_model(provider_name, model);
|
||||
log_info("Default model for provider '%s' set to: %s", provider_name, model);
|
||||
}
|
||||
|
||||
@@ -636,11 +535,9 @@ ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const
|
||||
|
||||
if (value) {
|
||||
g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value));
|
||||
prefs_ai_set_setting(provider_name, setting, value);
|
||||
log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value);
|
||||
} else {
|
||||
g_hash_table_remove(provider->settings, setting);
|
||||
prefs_ai_remove_setting(provider_name, setting);
|
||||
log_info("Setting '%s' removed for provider '%s'", setting, provider_name);
|
||||
}
|
||||
}
|
||||
@@ -674,7 +571,7 @@ typedef struct
|
||||
} ai_request_t;
|
||||
|
||||
/**
|
||||
* Build curl headers with auth.
|
||||
* Build curl headers with auth and optional org header.
|
||||
* Returns headers list (caller must free with curl_slist_free_all).
|
||||
*/
|
||||
static struct curl_slist*
|
||||
@@ -685,6 +582,11 @@ _build_curl_headers(AIProvider* provider, const gchar* api_key)
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
headers = curl_slist_append(headers, auth_header);
|
||||
|
||||
if (provider && provider->org_id && strlen(provider->org_id) > 0) {
|
||||
auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", provider->org_id);
|
||||
headers = curl_slist_append(headers, org_header);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -712,11 +614,8 @@ _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers,
|
||||
log_error("Request failed for '%s': %s", provider->name, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else if (http_code >= 400) {
|
||||
auto_gchar gchar* parsed = ai_parse_error_message(response->data);
|
||||
auto_gchar gchar* error_msg = parsed
|
||||
? g_strdup_printf("HTTP %ld: %s", http_code, parsed)
|
||||
: g_strdup_printf("HTTP %ld: %s", http_code,
|
||||
response->data && strlen(response->data) > 0 ? response->data : "Unknown error");
|
||||
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
|
||||
response->data ? response->data : "Unknown error");
|
||||
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else {
|
||||
@@ -739,10 +638,6 @@ _ai_generic_request_thread(gpointer data)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Keep the provider alive for the duration of this request. Without this, /ai remove provider X could free the provider
|
||||
* struct (via the hash table's destroy-notify) while curl_easy_perform is still using it — classic UAF. */
|
||||
ai_provider_ref(provider);
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
log_error("Failed to initialize curl for %s", req->provider_name);
|
||||
@@ -773,10 +668,6 @@ _ai_generic_request_thread(gpointer data)
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
g_free(response.data);
|
||||
|
||||
/* Release the reference taken after lookup — provider may now be freed. */
|
||||
ai_provider_unref(provider);
|
||||
|
||||
g_free(req->provider_name);
|
||||
g_free(req->request_url);
|
||||
g_free(req);
|
||||
@@ -1085,18 +976,9 @@ _models_response_handler(AIProvider* provider, const gchar* response, gpointer u
|
||||
{
|
||||
_parse_and_cache_models(provider, response);
|
||||
|
||||
gint count = g_list_length(provider->models);
|
||||
GString* msg = g_string_new(NULL);
|
||||
g_string_append_printf(msg, "Cached %d models for provider '%s':", count, provider->name);
|
||||
for (GList* curr = provider->models; curr; curr = g_list_next(curr)) {
|
||||
g_string_append_printf(msg, "\n %s", (const gchar*)curr->data);
|
||||
}
|
||||
if (count > 0) {
|
||||
g_string_append_printf(msg, "\nUse '/ai start %s <model>' to start a chat.", provider->name);
|
||||
}
|
||||
|
||||
auto_gchar gchar* out = g_string_free(msg, FALSE);
|
||||
_aiwin_display_response(user_data, out);
|
||||
auto_gchar gchar* msg = g_strdup_printf("Cached %d models for provider '%s'. Use '/ai start <provider> <model>' to start a chat.",
|
||||
g_list_length(provider->models), provider->name);
|
||||
_aiwin_display_response(user_data, msg);
|
||||
}
|
||||
|
||||
static gpointer
|
||||
@@ -1168,11 +1050,6 @@ ai_session_create(const gchar* provider_name, const gchar* model)
|
||||
}
|
||||
|
||||
AISession* session = g_new0(AISession, 1);
|
||||
if (pthread_mutex_init(&session->lock, NULL) != 0) {
|
||||
log_error("Failed to initialize session mutex");
|
||||
g_free(session);
|
||||
return NULL;
|
||||
}
|
||||
session->provider_name = g_strdup(provider_name);
|
||||
session->provider = ai_provider_ref(provider);
|
||||
session->model = g_strdup(model);
|
||||
@@ -1218,8 +1095,6 @@ ai_session_unref(AISession* session)
|
||||
}
|
||||
g_list_free(session->history);
|
||||
|
||||
pthread_mutex_destroy(&session->lock);
|
||||
|
||||
g_free(session);
|
||||
log_debug("AI session destroyed");
|
||||
}
|
||||
@@ -1230,12 +1105,10 @@ ai_session_add_message(AISession* session, const gchar* role, const gchar* conte
|
||||
if (!session || !role || !content)
|
||||
return;
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
AIMessage* msg = g_new0(AIMessage, 1);
|
||||
msg->role = g_strdup(role);
|
||||
msg->content = g_strdup(content);
|
||||
session->history = g_list_append(session->history, msg);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
log_debug("Added %s message to session (total: %d)", role, g_list_length(session->history));
|
||||
}
|
||||
@@ -1246,7 +1119,6 @@ ai_session_clear_history(AISession* session)
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
GList* curr = session->history;
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
@@ -1257,7 +1129,6 @@ ai_session_clear_history(AISession* session)
|
||||
}
|
||||
g_list_free(session->history);
|
||||
session->history = NULL;
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
log_info("AI session history cleared");
|
||||
}
|
||||
@@ -1276,64 +1147,24 @@ ai_session_set_model(AISession* session, const gchar* model)
|
||||
if (!session || !model)
|
||||
return;
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
g_free(session->model);
|
||||
session->model = g_strdup(model);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
log_info("Session model changed to: %s", model);
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_switch(AISession* session, const gchar* provider_name,
|
||||
const gchar* model, gchar* api_key)
|
||||
{
|
||||
if (!session || !provider_name || !model || !api_key)
|
||||
return;
|
||||
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
log_warning("Provider '%s' not found for session switch", provider_name);
|
||||
g_free(api_key);
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
g_free(session->provider_name);
|
||||
session->provider_name = g_strdup(provider_name);
|
||||
|
||||
ai_provider_unref(session->provider);
|
||||
session->provider = ai_provider_ref(provider);
|
||||
|
||||
g_free(session->model);
|
||||
session->model = g_strdup(model);
|
||||
|
||||
g_free(session->api_key);
|
||||
session->api_key = g_strdup(api_key);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
g_free(api_key);
|
||||
log_info("Session switched to %s/%s", provider_name, model);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* API Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Build JSON payload from a GList of AIMessage nodes and a prompt string.
|
||||
* This is the thread-safe variant used by _ai_request_thread() which holds
|
||||
* the session lock while calling it.
|
||||
*
|
||||
* @param model The model identifier
|
||||
* @param history GList of AIMessage* (must be held under session lock)
|
||||
* @param prompt The new user prompt to append
|
||||
* @return Newly allocated JSON string (caller must free)
|
||||
*/
|
||||
static gchar*
|
||||
_build_json_payload_from_list(const gchar* model, GList* history, const gchar* prompt)
|
||||
_build_json_payload(AISession* session, const gchar* prompt)
|
||||
{
|
||||
|
||||
/* OpenAI-compatible Responses API format:
|
||||
* {"model": "...", "input": [...], "stream": false, "store": false}
|
||||
* store:false prevents providers from storing/using requests for training */
|
||||
GString* messages_json = g_string_new("");
|
||||
GList* curr = history;
|
||||
GList* curr = session->history;
|
||||
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
@@ -1356,7 +1187,7 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
|
||||
}
|
||||
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
|
||||
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(model);
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(session->model);
|
||||
gchar* json_payload = g_strdup_printf(
|
||||
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
|
||||
escaped_model, messages_json->str);
|
||||
@@ -1365,36 +1196,145 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
|
||||
return json_payload;
|
||||
}
|
||||
|
||||
gchar*
|
||||
ai_parse_response(const gchar* response_json)
|
||||
static gchar*
|
||||
_parse_ai_response(const gchar* response_json)
|
||||
{
|
||||
if (!response_json || strlen(response_json) == 0)
|
||||
return NULL;
|
||||
|
||||
/* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]}
|
||||
* Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */
|
||||
gchar* text = _extract_json_string(response_json, "text");
|
||||
if (text)
|
||||
return text;
|
||||
/* Try Perplexity /v1/agent format first: look for "text":"..." inside output array
|
||||
* Response: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */
|
||||
const gchar* text_start = strstr(response_json, "\"text\":\"");
|
||||
if (text_start) {
|
||||
text_start += strlen("\"text\":\"");
|
||||
const gchar* p = text_start;
|
||||
while (*p) {
|
||||
if (*p == '\\' && *(p + 1) == '"') {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
gsize len = p - text_start;
|
||||
gchar* result = g_new0(gchar, len + 1);
|
||||
gchar* out = result;
|
||||
const gchar* in = text_start;
|
||||
while (in < p) {
|
||||
if (*in == '\\' && *(in + 1) == '"') {
|
||||
*out++ = '"';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
||||
*out++ = '\n';
|
||||
in += 2;
|
||||
} else {
|
||||
*out++ = *in++;
|
||||
}
|
||||
}
|
||||
*out = '\0';
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
}
|
||||
|
||||
return _extract_json_string(response_json, "content");
|
||||
/* Try legacy OpenAI format: "content":"..."
|
||||
* Response: {"choices":[{"message":{"content":"..."}}]} */
|
||||
const gchar* content_start = strstr(response_json, "\"content\":\"");
|
||||
if (!content_start)
|
||||
return NULL;
|
||||
|
||||
content_start += strlen("\"content\":\"");
|
||||
|
||||
/* Find the closing quote, accounting for escaped quotes */
|
||||
const gchar* p = content_start;
|
||||
while (*p) {
|
||||
if (*p == '\\' && *(p + 1) == '"') {
|
||||
/* Escaped quote, skip both characters */
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
/* Found unescaped closing quote */
|
||||
gsize len = p - content_start;
|
||||
/* Unescape the content: convert \" back to " */
|
||||
gchar* result = g_new0(gchar, len + 1);
|
||||
gchar* out = result;
|
||||
const gchar* in = content_start;
|
||||
while (in < p) {
|
||||
if (*in == '\\' && *(in + 1) == '"') {
|
||||
*out++ = '"';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
||||
*out++ = '\n';
|
||||
in += 2;
|
||||
} else {
|
||||
*out++ = *in++;
|
||||
}
|
||||
}
|
||||
*out = '\0';
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Extract the human-readable error.message from a provider error envelope:
|
||||
* {"error":{"message":"...","type":"...","code":...}}
|
||||
* Scoped to the substring after "error": so unrelated top-level "message"
|
||||
* fields do not get picked up. */
|
||||
gchar*
|
||||
ai_parse_error_message(const gchar* error_json)
|
||||
/**
|
||||
* Extract error message from an API error JSON response.
|
||||
* Handles format: {"error":{"message":"...","type":"...","code":...}}
|
||||
*/
|
||||
static gchar*
|
||||
_parse_error_response(const gchar* error_json)
|
||||
{
|
||||
if (!error_json || strlen(error_json) == 0)
|
||||
return NULL;
|
||||
|
||||
/* Look for "message":"..." inside "error" object */
|
||||
const gchar* error_obj = strstr(error_json, "\"error\":");
|
||||
if (!error_obj)
|
||||
return NULL;
|
||||
|
||||
return _extract_json_string(error_obj, "message");
|
||||
const gchar* message_key = strstr(error_obj, "\"message\":\"");
|
||||
if (!message_key)
|
||||
return NULL;
|
||||
|
||||
message_key += strlen("\"message\":\"");
|
||||
const gchar* msg_start = message_key;
|
||||
const gchar* p = msg_start;
|
||||
while (*p) {
|
||||
if (*p == '\\' && *(p + 1) == '"') {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
gsize len = p - msg_start;
|
||||
gchar* result = g_new0(gchar, len + 1);
|
||||
gchar* out = result;
|
||||
const gchar* in = msg_start;
|
||||
while (in < p) {
|
||||
if (*in == '\\' && *(in + 1) == '"') {
|
||||
*out++ = '"';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
||||
*out++ = '\n';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == '\\') {
|
||||
*out++ = '\\';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == 't') {
|
||||
*out++ = '\t';
|
||||
in += 2;
|
||||
} else {
|
||||
*out++ = *in++;
|
||||
}
|
||||
}
|
||||
*out = '\0';
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static gpointer
|
||||
@@ -1408,34 +1348,15 @@ _ai_request_thread(gpointer data)
|
||||
auto_gchar gchar* prompt = args[1];
|
||||
gpointer user_data = args[2];
|
||||
|
||||
/* =====================================================================
|
||||
* CRITICAL: Copy all session state under lock to prevent UAF races with
|
||||
* cmd_ai_switch() and ai_session_clear_history() which mutate session
|
||||
* fields on the main thread without holding a session ref.
|
||||
*
|
||||
* The worker holds a ref to the session (via ai_session_ref in
|
||||
* ai_send_prompt), which prevents the session struct from being freed,
|
||||
* but does NOT prevent field mutations (g_free/g_strdup of strings,
|
||||
* list modifications). We must snapshot all fields atomically.
|
||||
* ===================================================================== */
|
||||
pthread_mutex_lock(&session->lock);
|
||||
auto_gchar gchar* local_provider_name = g_strdup(session->provider_name);
|
||||
AIProvider* local_provider = ai_provider_ref(session->provider);
|
||||
auto_gchar gchar* local_model = g_strdup(session->model);
|
||||
auto_gchar gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
log_debug("[AI-THREAD] Session: %s/%s", local_provider_name, local_model);
|
||||
log_debug("[AI-THREAD] API key length: %zu", strlen(local_api_key));
|
||||
log_debug("[AI-THREAD] Session: %s/%s", session->provider_name, session->model);
|
||||
log_debug("[AI-THREAD] API key length: %zu", session->api_key ? strlen(session->api_key) : 0);
|
||||
|
||||
/* Check for API key first */
|
||||
if (!local_api_key || strlen(local_api_key) == 0) {
|
||||
if (!session->api_key || strlen(session->api_key) == 0) {
|
||||
auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s <key>' to configure.",
|
||||
local_provider_name, local_provider_name);
|
||||
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
|
||||
session->provider_name, session->provider_name);
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
ai_provider_unref(local_provider);
|
||||
ai_session_unref(session);
|
||||
g_free(args);
|
||||
return NULL;
|
||||
}
|
||||
@@ -1444,30 +1365,23 @@ _ai_request_thread(gpointer data)
|
||||
log_debug("[AI-THREAD] Curl initialized: %s", curl ? "OK" : "FAILED");
|
||||
if (!curl) {
|
||||
log_error("AI request failed for %s/%s: Failed to initialize curl",
|
||||
local_provider_name, local_model);
|
||||
session->provider_name, session->model);
|
||||
_aiwin_display_error(user_data, "Failed to initialize curl.");
|
||||
ai_provider_unref(local_provider);
|
||||
ai_session_unref(session);
|
||||
g_free(args);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Build JSON payload from history + prompt (under lock), then add user message to history */
|
||||
log_debug("[AI-THREAD] Building JSON payload...");
|
||||
pthread_mutex_lock(&session->lock);
|
||||
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
|
||||
|
||||
/* Add user message to history now (it's in JSON but not yet in history) */
|
||||
AIMessage* user_msg = g_new0(AIMessage, 1);
|
||||
user_msg->role = g_strdup("user");
|
||||
user_msg->content = g_strdup(prompt);
|
||||
session->history = g_list_append(session->history, user_msg);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
/* Add user message to history FIRST */
|
||||
ai_session_add_message(session, "user", prompt);
|
||||
log_debug("[AI-THREAD] Added user message to history");
|
||||
|
||||
/* Build JSON payload (includes the message we just added) */
|
||||
log_debug("[AI-THREAD] Building JSON payload...");
|
||||
auto_gchar gchar* json_payload = _build_json_payload(session, prompt);
|
||||
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
|
||||
|
||||
/* Build headers using shared helper */
|
||||
struct curl_slist* headers = _build_curl_headers(local_provider, local_api_key);
|
||||
struct curl_slist* headers = _build_curl_headers(session->provider, session->api_key);
|
||||
|
||||
/* Response buffer */
|
||||
struct curl_response_t response;
|
||||
@@ -1475,11 +1389,11 @@ _ai_request_thread(gpointer data)
|
||||
response.size = 0;
|
||||
|
||||
/* Build request URL */
|
||||
const gchar* api_url = local_provider->api_url;
|
||||
const gchar* api_url = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL;
|
||||
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
|
||||
log_debug("[AI-THREAD] API URL: %s", api_url);
|
||||
log_debug("[AI-THREAD] API Request URL: %s", request_url);
|
||||
log_debug("[AI-THREAD] Model: %s", local_model);
|
||||
log_debug("[AI-THREAD] Model: %s", session->model);
|
||||
|
||||
/* Set URL and execute request */
|
||||
curl_easy_setopt(curl, CURLOPT_URL, request_url);
|
||||
@@ -1496,36 +1410,33 @@ _ai_request_thread(gpointer data)
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
log_debug("[AI-THREAD] HTTP response code: %ld", http_code);
|
||||
auto_gchar gchar* response_data = g_steal_pointer(&response.data);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
|
||||
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else if (http_code >= 400) {
|
||||
/* Handle HTTP errors */
|
||||
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
|
||||
response.size, response_data);
|
||||
response.size, response.data ? response.data : "NULL");
|
||||
/* Try to extract the actual error message from the JSON response */
|
||||
auto_gchar gchar* parsed_error = ai_parse_error_message(response_data);
|
||||
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response_data && strlen(response_data) > 0 ? response_data : "Unknown error");
|
||||
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
|
||||
auto_gchar gchar* parsed_error = _parse_error_response(response.data);
|
||||
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response.data && strlen(response.data) > 0 ? response.data : "Unknown error");
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else {
|
||||
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response_data);
|
||||
auto_gchar gchar* content = ai_parse_response(response_data);
|
||||
/* Parse response - transfer ownership to auto_gchar for cleanup */
|
||||
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
|
||||
auto_gchar gchar* response_data = response.data;
|
||||
response.data = NULL;
|
||||
auto_gchar gchar* content = _parse_ai_response(response_data);
|
||||
if (content) {
|
||||
/* Add assistant response to history (under lock) */
|
||||
pthread_mutex_lock(&session->lock);
|
||||
AIMessage* assistant_msg = g_new0(AIMessage, 1);
|
||||
assistant_msg->role = g_strdup("assistant");
|
||||
assistant_msg->content = g_strdup(content);
|
||||
session->history = g_list_append(session->history, assistant_msg);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
/* Add assistant response to history */
|
||||
ai_session_add_message(session, "assistant", content);
|
||||
_aiwin_display_response(user_data, content);
|
||||
} else {
|
||||
log_error("AI response parse failed for %s/%s: %.200s...",
|
||||
local_provider_name, local_model, response_data);
|
||||
session->provider_name, session->model, response_data);
|
||||
_aiwin_display_error(user_data, "Failed to parse AI response.");
|
||||
}
|
||||
}
|
||||
@@ -1533,9 +1444,6 @@ _ai_request_thread(gpointer data)
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
ai_provider_unref(local_provider);
|
||||
|
||||
ai_session_unref(session);
|
||||
g_free(args);
|
||||
|
||||
return NULL;
|
||||
|
||||
@@ -19,6 +19,7 @@ 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) */
|
||||
@@ -32,7 +33,6 @@ typedef struct ai_provider_t
|
||||
*/
|
||||
typedef struct ai_session_t
|
||||
{
|
||||
pthread_mutex_t lock; /* Protects all session fields below */
|
||||
gchar* provider_name; /* Provider name */
|
||||
AIProvider* provider; /* Provider configuration */
|
||||
gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */
|
||||
@@ -66,9 +66,10 @@ 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);
|
||||
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
|
||||
|
||||
/**
|
||||
* Remove a provider by name.
|
||||
@@ -77,13 +78,6 @@ AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
|
||||
*/
|
||||
gboolean ai_remove_provider(const gchar* name);
|
||||
|
||||
/**
|
||||
* Increment the reference count of a provider.
|
||||
* @param provider The provider to reference
|
||||
* @return The same provider pointer
|
||||
*/
|
||||
AIProvider* ai_provider_ref(AIProvider* provider);
|
||||
|
||||
/**
|
||||
* Decrement the reference count of a provider.
|
||||
* @param provider The provider to unreference
|
||||
@@ -107,10 +101,13 @@ GList* ai_list_providers(void);
|
||||
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
|
||||
|
||||
/**
|
||||
* Reset the provider autocomplete state.
|
||||
* Called from cmd_ac_reset() to clear autocomplete state.
|
||||
* 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
|
||||
*/
|
||||
void ai_providers_reset_ac(void);
|
||||
gchar* ai_models_find(const char* const search_str, gboolean previous, void* context);
|
||||
|
||||
/**
|
||||
* Get the API key for a provider.
|
||||
@@ -172,7 +169,7 @@ gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data);
|
||||
gboolean ai_models_are_fresh(const gchar* provider_name);
|
||||
|
||||
/* ========================================================================
|
||||
* Parsing helpers (exposed for testing)
|
||||
* Model Parsing (for testing)
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
@@ -183,24 +180,6 @@ gboolean ai_models_are_fresh(const gchar* provider_name);
|
||||
*/
|
||||
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
|
||||
|
||||
/**
|
||||
* Extract assistant content from an LLM response body. Tries Perplexity
|
||||
* /v1/responses "text" first, falls back to OpenAI "content". Decodes
|
||||
* the \" and \n escape sequences only.
|
||||
* @param response_json The JSON body from the provider
|
||||
* @return Newly allocated content string, or NULL if not found (caller frees)
|
||||
*/
|
||||
gchar* ai_parse_response(const gchar* response_json);
|
||||
|
||||
/**
|
||||
* Extract human-readable error message from an API error envelope.
|
||||
* Expected format: {"error":{"message":"...","type":"...","code":...}}
|
||||
* Decodes \" \n \\ \t escapes.
|
||||
* @param error_json The JSON body of the error response
|
||||
* @return Newly allocated error message, or NULL if not parseable (caller frees)
|
||||
*/
|
||||
gchar* ai_parse_error_message(const gchar* error_json);
|
||||
|
||||
/* ========================================================================
|
||||
* Session Management
|
||||
* ======================================================================== */
|
||||
@@ -254,19 +233,6 @@ const gchar* ai_session_get_model(AISession* session);
|
||||
*/
|
||||
void ai_session_set_model(AISession* session, const gchar* model);
|
||||
|
||||
/**
|
||||
* Atomically switch session provider, model, and API key.
|
||||
* All mutations happen under the session lock to prevent races with
|
||||
* _ai_request_thread() which snapshots session state before making requests.
|
||||
*
|
||||
* @param session The session
|
||||
* @param provider_name New provider name
|
||||
* @param model New model identifier
|
||||
* @param api_key New API key (caller must free after calling this)
|
||||
*/
|
||||
void ai_session_switch(AISession* session, const gchar* provider_name,
|
||||
const gchar* model, gchar* api_key);
|
||||
|
||||
/* ========================================================================
|
||||
* Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
|
||||
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);
|
||||
@@ -468,8 +469,7 @@ static Autocomplete* all_acs[] = {
|
||||
&ai_subcommands_ac,
|
||||
&ai_set_subcommands_ac,
|
||||
&ai_set_custom_subcommands_ac,
|
||||
&ai_remove_subcommands_ac,
|
||||
&ai_models_ac
|
||||
&ai_remove_subcommands_ac
|
||||
};
|
||||
|
||||
static GHashTable* ac_funcs = NULL;
|
||||
@@ -1192,6 +1192,7 @@ cmd_ac_init(void)
|
||||
|
||||
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);
|
||||
|
||||
@@ -1696,7 +1697,6 @@ cmd_ac_reset(ProfWin* window)
|
||||
win_reset_search_attempts();
|
||||
win_close_reset_search_attempts();
|
||||
plugins_reset_autocomplete();
|
||||
ai_providers_reset_ac();
|
||||
}
|
||||
|
||||
void
|
||||
@@ -4487,10 +4487,6 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Forward declaration */
|
||||
static char* _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input,
|
||||
gboolean previous, const char* cmd);
|
||||
|
||||
static char*
|
||||
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
{
|
||||
@@ -4499,6 +4495,7 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
/* 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) */
|
||||
@@ -4513,6 +4510,12 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
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) {
|
||||
@@ -4554,20 +4557,34 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai start <provider> <model> - autocomplete provider names and model names
|
||||
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai start");
|
||||
// /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;
|
||||
}
|
||||
|
||||
// /ai switch <provider> <model> - autocomplete provider names and model names
|
||||
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai switch");
|
||||
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set default-model <provider> <model> - autocomplete provider names and model names
|
||||
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai set default-model");
|
||||
// /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;
|
||||
}
|
||||
@@ -4587,26 +4604,13 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete provider names and model names for /ai <cmd> <provider> <model> patterns.
|
||||
* First tries to autocomplete provider names, then model names if provider is valid.
|
||||
* 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.
|
||||
* The cmd_prefix should NOT include trailing space (e.g., "/ai start" not "/ai start ").
|
||||
*/
|
||||
static char*
|
||||
_ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd)
|
||||
_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
|
||||
{
|
||||
char* result;
|
||||
|
||||
/* First, try provider name autocomplete */
|
||||
result = autocomplete_param_with_func(input, cmd, ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Provider was specified, try model name autocomplete */
|
||||
/* Build the full command prefix with space */
|
||||
auto_gchar gchar* cmd_prefix = g_strdup_printf("%s ", cmd);
|
||||
|
||||
if (!g_str_has_prefix(input, cmd_prefix)) {
|
||||
return NULL;
|
||||
}
|
||||
@@ -4642,6 +4646,6 @@ _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gb
|
||||
|
||||
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
|
||||
|
||||
result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
|
||||
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2804,6 +2804,7 @@ static const struct cmd_t command_defs[] = {
|
||||
"/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>",
|
||||
@@ -2822,12 +2823,14 @@ static const struct cmd_t command_defs[] = {
|
||||
{ "", "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)" },
|
||||
{ "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(
|
||||
@@ -2835,6 +2838,7 @@ static const struct cmd_t command_defs[] = {
|
||||
"/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",
|
||||
|
||||
@@ -8516,8 +8516,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
auto_gchar gchar* stanza_id = connection_create_stanza_id();
|
||||
cl_ev_send_ai_msg(aiwin, inp, stanza_id);
|
||||
cl_ev_send_ai_msg(aiwin, inp, NULL);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -10818,6 +10817,7 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args)
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10839,7 +10839,7 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
if (ai_add_provider(args[2], args[3])) {
|
||||
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]);
|
||||
@@ -10876,6 +10876,23 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
|
||||
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);
|
||||
@@ -10944,11 +10961,8 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
|
||||
if (!model) {
|
||||
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>'.", provider_name);
|
||||
return TRUE;
|
||||
model = "gpt-4o"; // Fallback
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
@@ -10981,9 +10995,6 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
|
||||
}
|
||||
log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type);
|
||||
|
||||
// Release the reference held by cmd_ai_start, since the window now holds one
|
||||
ai_session_unref(session);
|
||||
|
||||
// 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);
|
||||
@@ -11013,12 +11024,12 @@ cmd_ai_model(ProfWin* window, const char* const command, gchar** args)
|
||||
|
||||
const gchar* model = args[1];
|
||||
|
||||
// Must be in an AI window to use this command
|
||||
if (!window || window->type != WIN_AI) {
|
||||
cons_show_error("Must be in an AI chat window to use this command.");
|
||||
// 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;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
@@ -11044,12 +11055,12 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Must be in an AI window to use this command
|
||||
if (!window || window->type != WIN_AI) {
|
||||
cons_show_error("Must be in an AI chat window to use this command.");
|
||||
// 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;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
@@ -11063,12 +11074,14 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
|
||||
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) {
|
||||
@@ -11084,10 +11097,6 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
} else {
|
||||
// arg1 is a model name, keep current provider
|
||||
provider_name = aiwin->session->provider_name;
|
||||
if (!provider_name) {
|
||||
cons_show_error("Session has no provider name.");
|
||||
return TRUE;
|
||||
}
|
||||
model = arg1;
|
||||
}
|
||||
|
||||
@@ -11099,8 +11108,19 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Atomically switch session provider, model, and API key (thread-safe)
|
||||
ai_session_switch(aiwin->session, provider_name, model, g_steal_pointer(&api_key));
|
||||
// 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);
|
||||
@@ -11147,7 +11167,8 @@ cmd_ai_models(ProfWin* window, const char* const command, gchar** args)
|
||||
cons_show("Use '/ai models %s' to fetch fresh models from the API.", provider_name);
|
||||
} else {
|
||||
// Default: fetch fresh models from API
|
||||
ai_fetch_models(provider_name, window);
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
ai_fetch_models(provider_name, aiwin);
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
@@ -11157,44 +11178,103 @@ gboolean
|
||||
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai clear
|
||||
// Must be in an AI window to use this command
|
||||
if (!window || window->type != WIN_AI) {
|
||||
cons_show_error("Must be in an AI chat window to use this command.");
|
||||
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;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
if (aiwin->session) {
|
||||
ai_session_clear_history(aiwin->session);
|
||||
// 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;
|
||||
}
|
||||
|
||||
win_clear(window);
|
||||
cons_show("AI Chat history cleared.");
|
||||
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();
|
||||
if (!providers) {
|
||||
cons_show(" (none configured)");
|
||||
cons_show("");
|
||||
cons_show("Add a provider with: /ai set provider <name> <url>");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||
AIProvider* provider = curr->data;
|
||||
auto_gchar gchar* key = ai_get_provider_key(provider->name);
|
||||
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);
|
||||
|
||||
|
||||
@@ -67,8 +67,6 @@
|
||||
#define PREF_GROUP_PLUGINS "plugins"
|
||||
#define PREF_GROUP_EXECUTABLES "executables"
|
||||
#define PREF_GROUP_AI "ai"
|
||||
#define PREF_GROUP_AI_PREFIX "ai/" /* per-provider subsection prefix: [ai/<provider>] */
|
||||
#define PREF_AI_SETTING_PREFIX "setting." /* per-provider custom setting key prefix */
|
||||
|
||||
#define INPBLOCK_DEFAULT 1000
|
||||
|
||||
@@ -245,69 +243,6 @@ _prefs_load(void)
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.jid", NULL);
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.name", NULL);
|
||||
}
|
||||
|
||||
/* Migrate legacy flat [ai] layout
|
||||
* openai_url=..., openai=<token>, openai_models=..., openai_default_model=...
|
||||
* into per-provider subsections
|
||||
* [ai/openai] url=..., key=..., models=..., default_model=...
|
||||
* Idempotent: once the flat keys are gone, this is a no-op. */
|
||||
if (g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
||||
gsize key_count = 0;
|
||||
auto_gcharv gchar** all_keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &key_count, NULL);
|
||||
|
||||
/* Collect provider names from <name>_url keys first so we can migrate
|
||||
* the whole bundle (url + token + models + default_model) atomically
|
||||
* per provider, without misclassifying unrelated flat keys. */
|
||||
GHashTable* to_migrate = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
for (gsize i = 0; i < key_count; i++) {
|
||||
const gchar* k = all_keys[i];
|
||||
if (g_str_has_suffix(k, "_url") && !g_str_has_suffix(k, "_models_url")) {
|
||||
gsize klen = strlen(k);
|
||||
if (klen > 4) {
|
||||
g_hash_table_add(to_migrate, g_strndup(k, klen - 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GList* provider_names = g_hash_table_get_keys(to_migrate);
|
||||
for (GList* curr = provider_names; curr; curr = g_list_next(curr)) {
|
||||
const gchar* name = (const gchar*)curr->data;
|
||||
auto_gchar gchar* group = g_strconcat(PREF_GROUP_AI_PREFIX, name, NULL);
|
||||
|
||||
const struct
|
||||
{
|
||||
const gchar* flat_suffix;
|
||||
const gchar* new_key;
|
||||
} map[] = {
|
||||
{ "_url", "url" },
|
||||
{ "_models", "models" },
|
||||
{ "_default_model", "default_model" },
|
||||
{ NULL, NULL },
|
||||
};
|
||||
for (int j = 0; map[j].flat_suffix; j++) {
|
||||
auto_gchar gchar* flat_key = g_strconcat(name, map[j].flat_suffix, NULL);
|
||||
if (g_key_file_has_key(prefs, PREF_GROUP_AI, flat_key, NULL)) {
|
||||
auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, flat_key, NULL);
|
||||
if (val) {
|
||||
g_key_file_set_string(prefs, group, map[j].new_key, val);
|
||||
}
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, flat_key, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
/* Token was stored as the bare provider name in [ai]. */
|
||||
if (g_key_file_has_key(prefs, PREF_GROUP_AI, name, NULL)) {
|
||||
auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
|
||||
if (val) {
|
||||
g_key_file_set_string(prefs, group, "key", val);
|
||||
}
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, name, NULL);
|
||||
}
|
||||
}
|
||||
g_list_free(provider_names);
|
||||
g_hash_table_destroy(to_migrate);
|
||||
}
|
||||
|
||||
_save_prefs();
|
||||
|
||||
boolean_choice_ac = autocomplete_new();
|
||||
@@ -1773,103 +1708,73 @@ _save_prefs(void)
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* AI provider preferences
|
||||
*
|
||||
* Each provider lives in its own [ai/<name>] subsection of the keyfile with:
|
||||
* url=...
|
||||
* key=<api token>
|
||||
* default_model=...
|
||||
* models=model1;model2;...
|
||||
* setting.<custom>=value (e.g. setting.tools=enabled)
|
||||
* The bare [ai] section keeps only the global "defaults_initialized" sentinel.
|
||||
* Legacy flat-key layouts are migrated on first load in _prefs_load().
|
||||
* AI Token Management
|
||||
* ======================================================================== */
|
||||
|
||||
static gchar*
|
||||
_ai_section(const char* const provider)
|
||||
{
|
||||
return g_strconcat(PREF_GROUP_AI_PREFIX, provider, NULL);
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_ai_set_string(const char* const provider, const char* const key, const char* const value)
|
||||
{
|
||||
if (!provider || !key || !value || !prefs)
|
||||
return FALSE;
|
||||
|
||||
auto_gchar gchar* group = _ai_section(provider);
|
||||
g_key_file_set_string(prefs, group, key, value);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static char*
|
||||
_ai_get_string(const char* const provider, const char* const key)
|
||||
{
|
||||
if (!provider || !key || !prefs)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* group = _ai_section(provider);
|
||||
if (!g_key_file_has_key(prefs, group, key, NULL))
|
||||
return NULL;
|
||||
return g_key_file_get_string(prefs, group, key, NULL);
|
||||
}
|
||||
|
||||
static gboolean
|
||||
_ai_remove_string(const char* const provider, const char* const key)
|
||||
{
|
||||
if (!provider || !key || !prefs)
|
||||
return FALSE;
|
||||
|
||||
auto_gchar gchar* group = _ai_section(provider);
|
||||
if (!g_key_file_has_key(prefs, group, key, NULL))
|
||||
return FALSE;
|
||||
|
||||
g_key_file_remove_key(prefs, group, key, NULL);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* --- Token --- */
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_token(const char* const provider, const char* const token)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
if (!provider)
|
||||
return FALSE;
|
||||
if (!token)
|
||||
|
||||
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);
|
||||
return _ai_set_string(provider, "key", token);
|
||||
}
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_token(const char* const provider)
|
||||
{
|
||||
return _ai_remove_string(provider, "key");
|
||||
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)
|
||||
{
|
||||
return _ai_get_string(provider, "key");
|
||||
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)
|
||||
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GList* result = NULL;
|
||||
GList* providers = prefs_ai_list_providers();
|
||||
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||
const gchar* name = (const gchar*)curr->data;
|
||||
auto_gchar gchar* tok = prefs_ai_get_token(name);
|
||||
if (tok && tok[0] != '\0') {
|
||||
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));
|
||||
}
|
||||
}
|
||||
prefs_free_ai_providers(providers);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1881,45 +1786,72 @@ prefs_free_ai_tokens(GList* tokens)
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Models cache --- */
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count)
|
||||
{
|
||||
if (!provider || count <= 0 || !prefs)
|
||||
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)
|
||||
if (i > 0) {
|
||||
g_string_append_c(model_str, ';');
|
||||
}
|
||||
g_string_append(model_str, models[i]);
|
||||
}
|
||||
|
||||
gboolean ok = _ai_set_string(provider, "models", model_str->str);
|
||||
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str);
|
||||
g_string_free(model_str, TRUE);
|
||||
return ok;
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_models(const char* const provider)
|
||||
{
|
||||
return _ai_remove_string(provider, "models");
|
||||
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)
|
||||
{
|
||||
auto_gchar gchar* model_str = _ai_get_string(provider, "models");
|
||||
if (!model_str || model_str[0] == '\0')
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1931,207 +1863,6 @@ prefs_free_ai_models(GList* models)
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Default model --- */
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_default_model(const char* const provider, const char* const model)
|
||||
{
|
||||
if (!provider || !model || !prefs)
|
||||
return FALSE;
|
||||
if (model[0] == '\0')
|
||||
return prefs_ai_remove_default_model(provider);
|
||||
return _ai_set_string(provider, "default_model", model);
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_default_model(const char* const provider)
|
||||
{
|
||||
return _ai_remove_string(provider, "default_model");
|
||||
}
|
||||
|
||||
char*
|
||||
prefs_ai_get_default_model(const char* const provider)
|
||||
{
|
||||
return _ai_get_string(provider, "default_model");
|
||||
}
|
||||
|
||||
/* --- Provider URL --- */
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_provider(const char* const provider, const char* const url)
|
||||
{
|
||||
if (!provider || !url)
|
||||
return FALSE;
|
||||
return _ai_set_string(provider, "url", url);
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_provider(const char* const provider)
|
||||
{
|
||||
/* Provider presence is identified by the url key; removing it is what
|
||||
* makes prefs_ai_list_providers stop returning this name. The remaining
|
||||
* sub-keys are wiped through prefs_ai_remove_section(). */
|
||||
return _ai_remove_string(provider, "url");
|
||||
}
|
||||
|
||||
char*
|
||||
prefs_ai_get_provider_url(const char* const provider)
|
||||
{
|
||||
return _ai_get_string(provider, "url");
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_section(const char* const provider)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
return FALSE;
|
||||
|
||||
auto_gchar gchar* group = _ai_section(provider);
|
||||
if (!g_key_file_has_group(prefs, group))
|
||||
return FALSE;
|
||||
|
||||
g_key_file_remove_group(prefs, group, NULL);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_list_providers(void)
|
||||
{
|
||||
if (!prefs)
|
||||
return NULL;
|
||||
|
||||
GList* result = NULL;
|
||||
gsize len = 0;
|
||||
auto_gcharv gchar** groups = g_key_file_get_groups(prefs, &len);
|
||||
if (!groups)
|
||||
return NULL;
|
||||
|
||||
gsize prefix_len = strlen(PREF_GROUP_AI_PREFIX);
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
const gchar* g = groups[i];
|
||||
if (g_str_has_prefix(g, PREF_GROUP_AI_PREFIX) && strlen(g) > prefix_len) {
|
||||
/* Only count groups that actually identify a provider (have url). */
|
||||
if (g_key_file_has_key(prefs, g, "url", NULL)) {
|
||||
result = g_list_append(result, g_strdup(g + prefix_len));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
prefs_free_ai_providers(GList* providers)
|
||||
{
|
||||
if (providers) {
|
||||
g_list_free_full(providers, g_free);
|
||||
}
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_get_providers(void)
|
||||
{
|
||||
GList* configured = prefs_ai_list_providers();
|
||||
GList* result = NULL;
|
||||
|
||||
for (GList* curr = configured; curr; curr = g_list_next(curr)) {
|
||||
const gchar* name = (const gchar*)curr->data;
|
||||
auto_gchar gchar* url = prefs_ai_get_provider_url(name);
|
||||
if (url && strlen(url) > 0) {
|
||||
result = g_list_append(result, g_strdup(name));
|
||||
result = g_list_append(result, g_strdup(url));
|
||||
}
|
||||
}
|
||||
prefs_free_ai_providers(configured);
|
||||
|
||||
/* Seed openai/perplexity into the in-memory result on very first init
|
||||
* only. In production the "defaults_initialized" sentinel in [ai] is what
|
||||
* tells us we've already seeded — a user who removes every provider must
|
||||
* not get them silently re-added on the next start. When prefs is NULL
|
||||
* (e.g. unit tests that bring up ai_client without prefs_load) we still
|
||||
* seed the in-memory list so behaviour matches a fresh first run, but
|
||||
* skip the persistence write. */
|
||||
gboolean already_seeded = prefs && g_key_file_get_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", NULL);
|
||||
if (!already_seeded && !result) {
|
||||
if (prefs) {
|
||||
prefs_ai_set_provider("openai", "https://api.openai.com/");
|
||||
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
|
||||
}
|
||||
result = g_list_append(result, g_strdup("openai"));
|
||||
result = g_list_append(result, g_strdup("https://api.openai.com/"));
|
||||
result = g_list_append(result, g_strdup("perplexity"));
|
||||
result = g_list_append(result, g_strdup("https://api.perplexity.ai/"));
|
||||
}
|
||||
if (prefs && !already_seeded) {
|
||||
g_key_file_set_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", TRUE);
|
||||
_save_prefs();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* --- Custom per-provider settings (setting.<name>) --- */
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value)
|
||||
{
|
||||
if (!provider || !setting)
|
||||
return FALSE;
|
||||
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
|
||||
if (!value)
|
||||
return _ai_remove_string(provider, key);
|
||||
return _ai_set_string(provider, key, value);
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_setting(const char* const provider, const char* const setting)
|
||||
{
|
||||
if (!provider || !setting)
|
||||
return FALSE;
|
||||
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
|
||||
return _ai_remove_string(provider, key);
|
||||
}
|
||||
|
||||
char*
|
||||
prefs_ai_get_setting(const char* const provider, const char* const setting)
|
||||
{
|
||||
if (!provider || !setting)
|
||||
return NULL;
|
||||
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
|
||||
return _ai_get_string(provider, key);
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_list_settings(const char* const provider)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* group = _ai_section(provider);
|
||||
if (!g_key_file_has_group(prefs, group))
|
||||
return NULL;
|
||||
|
||||
GList* result = NULL;
|
||||
gsize len = 0;
|
||||
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, group, &len, NULL);
|
||||
gsize prefix_len = strlen(PREF_AI_SETTING_PREFIX);
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
if (g_str_has_prefix(keys[i], PREF_AI_SETTING_PREFIX) && strlen(keys[i]) > prefix_len) {
|
||||
result = g_list_append(result, g_strdup(keys[i] + prefix_len));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
prefs_free_ai_settings(GList* settings)
|
||||
{
|
||||
if (settings) {
|
||||
g_list_free_full(settings, 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.
|
||||
|
||||
@@ -366,35 +366,10 @@ char* prefs_ai_get_token(const char* const provider);
|
||||
GList* prefs_ai_list_tokens(void);
|
||||
void prefs_free_ai_tokens(GList* tokens);
|
||||
|
||||
/* AI provider management */
|
||||
gboolean prefs_ai_set_provider(const char* const provider, const char* const url);
|
||||
gboolean prefs_ai_remove_provider(const char* const provider);
|
||||
char* prefs_ai_get_provider_url(const char* const provider);
|
||||
GList* prefs_ai_list_providers(void);
|
||||
void prefs_free_ai_providers(GList* providers);
|
||||
GList* prefs_ai_get_providers(void);
|
||||
|
||||
/* Wipe the whole [ai/<provider>] subsection (url, key, models, default_model,
|
||||
* custom settings) in one call. */
|
||||
gboolean prefs_ai_remove_section(const char* const provider);
|
||||
|
||||
/* 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);
|
||||
|
||||
/* AI default model per provider */
|
||||
gboolean prefs_ai_set_default_model(const char* const provider, const char* const model);
|
||||
gboolean prefs_ai_remove_default_model(const char* const provider);
|
||||
char* prefs_ai_get_default_model(const char* const provider);
|
||||
|
||||
/* AI per-provider custom settings (e.g. tools, search) — persisted as
|
||||
* setting.<name>=<value> inside [ai/<provider>]. Pass NULL value to remove. */
|
||||
gboolean prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value);
|
||||
gboolean prefs_ai_remove_setting(const char* const provider, const char* const setting);
|
||||
char* prefs_ai_get_setting(const char* const provider, const char* const setting);
|
||||
GList* prefs_ai_list_settings(const char* const provider);
|
||||
void prefs_free_ai_settings(GList* settings);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -61,22 +61,6 @@ integrity_issue_free(integrity_issue_t* issue)
|
||||
}
|
||||
}
|
||||
|
||||
gboolean
|
||||
log_database_can_recover_messages(void)
|
||||
{
|
||||
if (!active_db_backend) {
|
||||
return FALSE;
|
||||
}
|
||||
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
|
||||
if (g_strcmp0(pref_dblog, "off") == 0) {
|
||||
return FALSE;
|
||||
}
|
||||
if (g_strcmp0(pref_dblog, "redact") == 0) {
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
log_database_init(ProfAccount* account)
|
||||
{
|
||||
|
||||
@@ -109,9 +109,6 @@ ProfMessage* log_database_get_limits_info(const gchar* const contact_barejid, gb
|
||||
void log_database_close(void);
|
||||
GSList* log_database_verify_integrity(const gchar* const contact_barejid);
|
||||
|
||||
// FALSE if no backend, or PREF_DBLOG is "off" / "redact".
|
||||
gboolean log_database_can_recover_messages(void);
|
||||
|
||||
// Cross-backend export/import (requires HAVE_SQLITE)
|
||||
#ifdef HAVE_SQLITE
|
||||
int log_database_export_to_flatfile(const gchar* const contact_jid);
|
||||
|
||||
@@ -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,13 +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);
|
||||
auto_gcharv gchar** parts = g_strsplit(dir_name, "_at_", -1);
|
||||
auto_gchar gchar* contact_jid = g_strjoinv("@", parts);
|
||||
auto_gchar gchar* basename = g_strdup_printf("%s/history.log", contact_jid);
|
||||
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_printf("%s↔%s/chatlog.db",
|
||||
from_a ? from_a : "?", to_a ? to_a : "?");
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = id_a;
|
||||
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");
|
||||
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,13 +515,10 @@ _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_printf("%s↔%s/chatlog.db",
|
||||
from ? from : "?", to ? to : "?");
|
||||
issue->file = g_strdup("chatlog.db");
|
||||
issue->line = id;
|
||||
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
|
||||
id, replaces_id);
|
||||
|
||||
@@ -301,6 +301,11 @@ cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const
|
||||
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);
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
|
||||
FREE_SET_NULL(ac->search_str);
|
||||
}
|
||||
|
||||
ac->search_str = g_strdup(search_str);
|
||||
ac->search_str = strdup(search_str);
|
||||
found = _search(ac, ac->items, quote, NEXT);
|
||||
|
||||
return found;
|
||||
@@ -305,7 +305,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
|
||||
// autocomplete_func func is used -> autocomplete_param_with_func
|
||||
// Autocomplete ac, gboolean quote are used -> autocomplete_param_with_ac
|
||||
static char*
|
||||
_autocomplete_param_common(const char* const input, const char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
|
||||
_autocomplete_param_common(const char* const input, char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
|
||||
{
|
||||
int len;
|
||||
auto_gchar gchar* command_cpy = g_strdup_printf("%s ", command);
|
||||
@@ -344,7 +344,7 @@ _autocomplete_param_common(const char* const input, const char* command, autocom
|
||||
}
|
||||
|
||||
char*
|
||||
autocomplete_param_with_func(const char* const input, const char* command, autocomplete_func func, gboolean previous, void* context)
|
||||
autocomplete_param_with_func(const char* const input, char* command, autocomplete_func func, gboolean previous, void* context)
|
||||
{
|
||||
return _autocomplete_param_common(input, command, func, NULL, FALSE, previous, context);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ gchar* autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean
|
||||
GList* autocomplete_create_list(Autocomplete ac);
|
||||
gint autocomplete_length(Autocomplete ac);
|
||||
|
||||
char* autocomplete_param_with_func(const char* const input, const char* command,
|
||||
char* autocomplete_param_with_func(const char* const input, char* command,
|
||||
autocomplete_func func, gboolean previous, void* context);
|
||||
|
||||
char* autocomplete_param_with_ac(const char* const input, char* command,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
#include "ui/ui.h"
|
||||
#include "ui/titlebar.h"
|
||||
#include "ui/inputwin.h"
|
||||
#include "ui/win_types.h"
|
||||
#include "ui/window_list.h"
|
||||
#include "ui/window.h"
|
||||
#include "ui/screen.h"
|
||||
@@ -253,10 +252,6 @@ _title_bar_draw(void)
|
||||
_show_muc_privacy(mucwin);
|
||||
_show_attention(current, mucwin->has_attention);
|
||||
_show_scrolled(current);
|
||||
} else if (current && current->type == WIN_AI) {
|
||||
ProfAiWin* aiwin = (ProfAiWin*)current;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
_show_scrolled(current);
|
||||
}
|
||||
|
||||
_show_self_presence();
|
||||
@@ -288,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);
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ typedef struct prof_layout_t
|
||||
WINDOW* win;
|
||||
ProfBuff buffer;
|
||||
int y_pos;
|
||||
int paged;
|
||||
int unread_msg;
|
||||
} ProfLayout;
|
||||
|
||||
@@ -149,16 +148,24 @@ typedef enum {
|
||||
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;
|
||||
|
||||
141
src/ui/window.c
141
src/ui/window.c
@@ -84,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)
|
||||
{
|
||||
@@ -117,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);
|
||||
|
||||
@@ -135,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;
|
||||
@@ -150,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;
|
||||
@@ -162,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);
|
||||
@@ -196,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;
|
||||
|
||||
@@ -215,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;
|
||||
@@ -254,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;
|
||||
@@ -273,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;
|
||||
@@ -290,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;
|
||||
@@ -305,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);
|
||||
@@ -322,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;
|
||||
@@ -336,7 +353,7 @@ win_create_ai(AISession* session)
|
||||
{
|
||||
ProfAiWin* new_win = g_new0(ProfAiWin, 1);
|
||||
new_win->window.type = WIN_AI;
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->message_buffer = buffer_create();
|
||||
@@ -722,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;
|
||||
|
||||
@@ -736,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);
|
||||
@@ -777,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
|
||||
@@ -788,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;
|
||||
|
||||
@@ -805,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();
|
||||
@@ -814,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
|
||||
@@ -826,34 +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;
|
||||
window->layout->unread_msg = 0;
|
||||
}
|
||||
|
||||
// Non-chat windows have no DB to fetch from, so WIN_SCROLL_REACHED_BOTTOM
|
||||
// never fires; reset paged once the buffer's last line is on screen.
|
||||
if (window->type != WIN_CHAT
|
||||
&& (total_rows - *page_start) <= page_space + 1) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -862,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);
|
||||
@@ -886,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);
|
||||
|
||||
@@ -914,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);
|
||||
}
|
||||
@@ -1024,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;
|
||||
}
|
||||
@@ -1808,14 +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, ...)
|
||||
{
|
||||
|
||||
if (window->layout->paged && wins_is_current(window)) {
|
||||
window->layout->unread_msg++;
|
||||
// Skip buffer only when DB can replay the message on scroll-down.
|
||||
if (window->type == WIN_CHAT && log_database_can_recover_messages()) {
|
||||
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();
|
||||
@@ -1832,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);
|
||||
|
||||
@@ -867,6 +867,24 @@ wins_get_prune_wins(void)
|
||||
return result;
|
||||
}
|
||||
|
||||
ProfAiWin*
|
||||
wins_get_ai(void)
|
||||
{
|
||||
GList* curr = values;
|
||||
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (window->type == WIN_AI) {
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
return aiwin;
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
gboolean
|
||||
wins_ai_exists(ProfAiWin* const aiwin)
|
||||
{
|
||||
|
||||
@@ -63,6 +63,7 @@ ProfPrivateWin* wins_get_private(const char* const fulljid);
|
||||
ProfPluginWin* wins_get_plugin(const char* const tag);
|
||||
ProfXMLWin* wins_get_xmlconsole(void);
|
||||
ProfVcardWin* wins_get_vcard(void);
|
||||
ProfAiWin* wins_get_ai(void);
|
||||
gboolean wins_ai_exists(ProfAiWin* const aiwin);
|
||||
|
||||
void wins_close_plugin(char* tag);
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
#ifdef HAVE_SQLITE
|
||||
#include "test_export_import.h"
|
||||
#endif
|
||||
#include "test_ai.h"
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) \
|
||||
@@ -67,14 +66,6 @@
|
||||
#test, test, init_prof_test, close_prof_test, #test \
|
||||
}
|
||||
|
||||
/* AI tests use a custom init that primes stabber via prof_connect so
|
||||
* stbbr_stop() in close_prof_test() doesn't hang on a never-connected
|
||||
* server thread. See ai_init_test() in test_ai.c. */
|
||||
#define PROF_FUNC_TEST_AI(test) \
|
||||
{ \
|
||||
#test, test, ai_init_test, close_prof_test, #test \
|
||||
}
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
|
||||
static int
|
||||
@@ -93,9 +84,9 @@ main(int argc, char* argv[])
|
||||
int group = 0; /* 0 = all groups */
|
||||
if (argc > 1) {
|
||||
group = atoi(argv[1]);
|
||||
if (group < 1 || group > 5) {
|
||||
if (group < 1 || group > 4) {
|
||||
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
|
||||
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
|
||||
fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -316,31 +307,6 @@ main(int argc, char* argv[])
|
||||
#endif
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 5: AI command surface (console + HTTP stub)
|
||||
* Standalone because AI tests don't use stabber's XMPP path; mixing
|
||||
* them with XMPP-bound tests in the same group poisons stabber state
|
||||
* between fixture init/teardown cycles and hangs subsequent
|
||||
* prof_connect() calls.
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group5_tests[] = {
|
||||
/* Console-only — no network calls */
|
||||
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
|
||||
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
|
||||
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
|
||||
PROF_FUNC_TEST_AI(ai_set_provider_adds_custom),
|
||||
PROF_FUNC_TEST_AI(ai_set_token_marks_key_set),
|
||||
PROF_FUNC_TEST_AI(ai_start_unknown_provider_errors),
|
||||
PROF_FUNC_TEST_AI(ai_start_without_key_errors),
|
||||
PROF_FUNC_TEST_AI(ai_start_with_key_opens_window),
|
||||
PROF_FUNC_TEST_AI(ai_clear_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_remove_provider_works),
|
||||
PROF_FUNC_TEST_AI(ai_remove_provider_unknown_errors),
|
||||
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
|
||||
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
|
||||
};
|
||||
|
||||
/* Test group registry for easy extension */
|
||||
struct
|
||||
{
|
||||
@@ -352,7 +318,6 @@ main(int argc, char* argv[])
|
||||
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
|
||||
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
|
||||
{ "Group 5: AI command surface (console only)", group5_tests, ARRAY_SIZE(group5_tests) },
|
||||
};
|
||||
const int num_groups = ARRAY_SIZE(groups);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
#include "proftest.h"
|
||||
|
||||
/* Number of parallel test groups for CI builds */
|
||||
#define TEST_GROUPS 5
|
||||
#define TEST_GROUPS 4
|
||||
|
||||
/* Number of ports reserved per test group; allows skipping TIME_WAIT ports */
|
||||
#define PORTS_PER_GROUP 50
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
/*
|
||||
* test_ai.c
|
||||
*
|
||||
* Functional tests for the /ai command surface (Tier A — no network).
|
||||
*
|
||||
* These tests interact with profanity through its PTY console and exercise
|
||||
* paths that do not reach libcurl: command parsing, provider registration,
|
||||
* key/setting/default storage, error messages, and AI window creation.
|
||||
*
|
||||
* Tests that require an actual provider HTTP exchange (prompt -> response,
|
||||
* /ai models <provider>, HTTP error envelopes) are out of scope here and
|
||||
* belong in a separate suite backed by a local HTTP stub.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "proftest.h"
|
||||
#include "test_ai.h"
|
||||
|
||||
int
|
||||
ai_init_test(void** state)
|
||||
{
|
||||
int ret = init_prof_test(state);
|
||||
if (ret != 0) {
|
||||
return ret;
|
||||
}
|
||||
/* Connect to stabber even though AI tests don't use XMPP. Without this
|
||||
* stabber's accept loop has no client to disconnect on /quit, and
|
||||
* stbbr_stop() in close_prof_test() hangs uninterruptibly when joining
|
||||
* the server thread. */
|
||||
prof_connect();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
ai_no_args_shows_help(void** state)
|
||||
{
|
||||
/* `/ai` with no arguments lists the built-in providers and a usage hint.
|
||||
* We don't pin specific provider names — defaults may change. Verify the
|
||||
* header, the "Configured providers" section, that *some* provider line
|
||||
* carries an http(s) URL, and the usage hint. */
|
||||
prof_input("/ai");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client"));
|
||||
assert_true(prof_output_exact("Configured providers:"));
|
||||
assert_true(prof_output_regex("URL: https?://"));
|
||||
assert_true(prof_output_regex("Use '/ai start' to begin a chat"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_providers_lists_defaults(void** state)
|
||||
{
|
||||
/* `/ai providers` (no "list") shows the built-in list with URLs. */
|
||||
prof_input("/ai providers");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("Available AI providers:"));
|
||||
/* At least one URL line is rendered — exact name agnostic. */
|
||||
assert_true(prof_output_regex("https?://"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_providers_list_shows_key_status(void** state)
|
||||
{
|
||||
/* `/ai providers list` lists each configured provider with key status. */
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("Configured providers:"));
|
||||
/* No tokens have been set yet in this fresh session. */
|
||||
assert_true(prof_output_regex("Key: NOT configured"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_provider_adds_custom(void** state)
|
||||
{
|
||||
/* Adding a custom provider should make it appear in /ai providers list. */
|
||||
prof_input("/ai set provider mock http://127.0.0.1:1/");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'mock' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("mock"));
|
||||
assert_true(prof_output_regex("http://127.0.0.1:1/"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_token_marks_key_set(void** state)
|
||||
{
|
||||
/* Use a self-set-up provider so the test doesn't pin a default name. */
|
||||
prof_input("/ai set provider testprov https://example.test/");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'testprov' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai set token testprov sk-fake-test-key");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: testprov"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Key: configured"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_unknown_provider_errors(void** state)
|
||||
{
|
||||
/* Unknown provider name should error out without creating a window. */
|
||||
prof_input("/ai start nope_provider somemodel");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'nope_provider' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_without_key_errors(void** state)
|
||||
{
|
||||
/* Self-set-up provider with no token. /ai start must refuse. */
|
||||
prof_input("/ai set provider testprov https://example.test/");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'testprov' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai start testprov model-x");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No API key set for provider 'testprov'"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_with_key_opens_window(void** state)
|
||||
{
|
||||
/* Self-set-up provider with token; /ai start opens a WIN_AI window. */
|
||||
prof_input("/ai set provider testprov https://example.test/");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'testprov' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai set token testprov sk-fake-test-key");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: testprov"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai start testprov model-x");
|
||||
|
||||
prof_timeout(5);
|
||||
/* /ai start switches focus to the new WIN_AI window; the window prints
|
||||
* "AI Chat: <provider>/<model>" as its first line. */
|
||||
assert_true(prof_output_regex("AI Chat: testprov/model-x"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_clear_without_window_errors(void** state)
|
||||
{
|
||||
/* /ai clear from the console (no active AI window) refuses with the
|
||||
* shared "must be in AI chat window" guard used by /ai switch as well. */
|
||||
prof_input("/ai clear");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Must be in an AI chat window"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_remove_provider_works(void** state)
|
||||
{
|
||||
/* Round-trip: add -> verify present -> remove -> verify gone. */
|
||||
prof_input("/ai set provider tmpprov http://127.0.0.1:2/");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai remove provider tmpprov");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' removed"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai remove provider tmpprov");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_remove_provider_unknown_errors(void** state)
|
||||
{
|
||||
/* Removing a provider that was never added must report "not found". */
|
||||
prof_input("/ai remove provider nonexistent_xyz");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_default_model_updates_provider(void** state)
|
||||
{
|
||||
/* Setting a default model is acknowledged on the console. */
|
||||
prof_input("/ai set provider testprov https://example.test/");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'testprov' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai set default-model testprov model-preview");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Default model for provider 'testprov' set to: model-preview"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_switch_without_window_errors(void** state)
|
||||
{
|
||||
/* /ai switch with no active AI window refuses with the shared
|
||||
* "must be in AI chat window" guard. */
|
||||
prof_input("/ai switch testprov model-x");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Must be in an AI chat window"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_bad_subcommand_shows_usage(void** state)
|
||||
{
|
||||
/* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */
|
||||
prof_input("/ai not_a_subcommand");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Invalid usage, see '/help ai'"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
#ifndef __H_FUNC_TEST_AI
|
||||
#define __H_FUNC_TEST_AI
|
||||
|
||||
/*
|
||||
* AI tests don't exercise the XMPP path, but the test fixture's
|
||||
* stbbr_stop() hangs indefinitely when no client ever connected to
|
||||
* stabber. ai_init_test wraps init_prof_test with a prof_connect()
|
||||
* so stabber sees a graceful disconnect at teardown.
|
||||
*/
|
||||
int ai_init_test(void** state);
|
||||
|
||||
void ai_no_args_shows_help(void** state);
|
||||
void ai_providers_lists_defaults(void** state);
|
||||
void ai_providers_list_shows_key_status(void** state);
|
||||
void ai_set_provider_adds_custom(void** state);
|
||||
void ai_set_token_marks_key_set(void** state);
|
||||
void ai_start_unknown_provider_errors(void** state);
|
||||
void ai_start_without_key_errors(void** state);
|
||||
void ai_start_with_key_opens_window(void** state);
|
||||
void ai_clear_without_window_errors(void** state);
|
||||
void ai_remove_provider_works(void** state);
|
||||
void ai_remove_provider_unknown_errors(void** state);
|
||||
void ai_set_default_model_updates_provider(void** state);
|
||||
void ai_switch_without_window_errors(void** state);
|
||||
void ai_bad_subcommand_shows_usage(void** state);
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,13 +31,14 @@ 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_empty_search_str(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);
|
||||
@@ -56,85 +57,6 @@ void test_ai_parse_models_escaped_quotes(void** state);
|
||||
void test_ai_parse_models_with_whitespace(void** state);
|
||||
/* AI autocomplete integration tests */
|
||||
void test_ai_start_provider_autocomplete_only_on_exact(void** state);
|
||||
void test_ai_models_find_null_session(void** state);
|
||||
void test_ai_models_find_null_provider(void** state);
|
||||
void test_ai_providers_find_cycling(void** state);
|
||||
|
||||
/* Setup that also initializes prefs for round-trip persistence tests. */
|
||||
int ai_client_setup_with_prefs(void** state);
|
||||
int ai_client_teardown_with_prefs(void** state);
|
||||
|
||||
/* Chat response parser tests (ai_parse_response) */
|
||||
void test_ai_parse_response_openai_content(void** state);
|
||||
void test_ai_parse_response_perplexity_text(void** state);
|
||||
void test_ai_parse_response_text_preferred_over_content(void** state);
|
||||
void test_ai_parse_response_escaped_quote(void** state);
|
||||
void test_ai_parse_response_newline_escape(void** state);
|
||||
void test_ai_parse_response_empty_content(void** state);
|
||||
void test_ai_parse_response_missing_field(void** state);
|
||||
void test_ai_parse_response_null_input(void** state);
|
||||
void test_ai_parse_response_empty_input(void** state);
|
||||
void test_ai_parse_response_percent_signs_safe(void** state);
|
||||
void test_ai_parse_response_braces_in_content(void** state);
|
||||
void test_ai_parse_response_multiline_content(void** state);
|
||||
|
||||
/* Error response parser tests (ai_parse_error_message) */
|
||||
void test_ai_parse_error_standard_envelope(void** state);
|
||||
void test_ai_parse_error_with_escapes(void** state);
|
||||
void test_ai_parse_error_no_error_field(void** state);
|
||||
void test_ai_parse_error_no_message_field(void** state);
|
||||
void test_ai_parse_error_null_input(void** state);
|
||||
void test_ai_parse_error_empty_input(void** state);
|
||||
void test_ai_parse_error_tab_escape(void** state);
|
||||
void test_ai_parse_error_backslash_escape(void** state);
|
||||
|
||||
/* Extended JSON escape tests */
|
||||
void test_ai_json_escape_backspace(void** state);
|
||||
void test_ai_json_escape_formfeed(void** state);
|
||||
void test_ai_json_escape_carriage_return(void** state);
|
||||
void test_ai_json_escape_all_specials_combined(void** state);
|
||||
void test_ai_json_escape_passes_utf8_through(void** state);
|
||||
|
||||
/* Autocomplete cycling with many providers */
|
||||
void test_ai_providers_find_cycles_through_many(void** state);
|
||||
void test_ai_providers_find_previous_walks_backwards(void** state);
|
||||
void test_ai_providers_find_wraps_around(void** state);
|
||||
|
||||
/* Session edge cases */
|
||||
void test_ai_session_add_message_null_session_no_crash(void** state);
|
||||
void test_ai_session_add_message_null_role_no_crash(void** state);
|
||||
void test_ai_session_add_message_null_content_no_crash(void** state);
|
||||
void test_ai_session_history_preserves_large_order(void** state);
|
||||
void test_ai_session_clear_empty_history(void** state);
|
||||
void test_ai_session_set_model_null_keeps_old(void** state);
|
||||
void test_ai_session_unref_null_no_crash(void** state);
|
||||
void test_ai_session_ref_null_returns_null(void** state);
|
||||
|
||||
/* Provider edge cases */
|
||||
void test_ai_get_provider_null_returns_null(void** state);
|
||||
void test_ai_remove_provider_null_returns_false(void** state);
|
||||
void test_ai_remove_provider_twice_second_fails(void** state);
|
||||
void test_ai_provider_survives_via_session_after_removal(void** state);
|
||||
|
||||
/* Settings edge cases */
|
||||
void test_ai_settings_multiple_keys_independent(void** state);
|
||||
void test_ai_settings_get_missing_returns_null(void** state);
|
||||
void test_ai_settings_isolated_between_providers(void** state);
|
||||
|
||||
/* Model parsing edge cases */
|
||||
void test_ai_parse_models_data_not_array(void** state);
|
||||
void test_ai_parse_models_empty_data_array(void** state);
|
||||
void test_ai_parse_models_id_outside_data_ignored(void** state);
|
||||
void test_ai_parse_models_multiple_models(void** state);
|
||||
|
||||
/* Prefs round-trip tests */
|
||||
void test_ai_prefs_round_trip_api_key(void** state);
|
||||
void test_ai_prefs_round_trip_remove_key(void** state);
|
||||
void test_ai_prefs_multiple_providers_persist(void** state);
|
||||
|
||||
/* Autocomplete deeper coverage */
|
||||
void test_ai_providers_find_after_remove_skips_removed(void** state);
|
||||
|
||||
/* Reset hook + persistence */
|
||||
void test_ai_providers_reset_ac_restarts_cycle(void** state);
|
||||
void test_ai_add_provider_persisted_across_init(void** state);
|
||||
void test_ai_remove_provider_persisted_across_init(void** state);
|
||||
void test_ai_models_persisted_across_init(void** state);
|
||||
|
||||
@@ -1433,3 +1433,8 @@ cons_has_alerts(void)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
win_move_to_end(ProfWin* window)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -681,16 +681,14 @@ main(int argc, char* argv[])
|
||||
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),
|
||||
/* SIGSEGV on ai_providers_find(NULL, ...) — see test body and
|
||||
* AI_AUTOCOMPLETE_POSSIBLE_ISSUES.md, issue 1. Re-enable once the
|
||||
* NULL-search path is guarded. */
|
||||
/* 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_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),
|
||||
@@ -709,6 +707,8 @@ main(int argc, char* argv[])
|
||||
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),
|
||||
@@ -716,72 +716,6 @@ main(int argc, char* argv[])
|
||||
cmocka_unit_test(test_ai_json_escape_special_chars),
|
||||
cmocka_unit_test(test_ai_json_escape_percent_signs),
|
||||
cmocka_unit_test(test_ai_json_escape_backslash_quote),
|
||||
/* Chat response parser */
|
||||
cmocka_unit_test(test_ai_parse_response_openai_content),
|
||||
cmocka_unit_test(test_ai_parse_response_perplexity_text),
|
||||
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
|
||||
cmocka_unit_test(test_ai_parse_response_escaped_quote),
|
||||
cmocka_unit_test(test_ai_parse_response_newline_escape),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_content),
|
||||
cmocka_unit_test(test_ai_parse_response_missing_field),
|
||||
cmocka_unit_test(test_ai_parse_response_null_input),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_input),
|
||||
cmocka_unit_test(test_ai_parse_response_percent_signs_safe),
|
||||
cmocka_unit_test(test_ai_parse_response_braces_in_content),
|
||||
cmocka_unit_test(test_ai_parse_response_multiline_content),
|
||||
/* Error response parser */
|
||||
cmocka_unit_test(test_ai_parse_error_standard_envelope),
|
||||
cmocka_unit_test(test_ai_parse_error_with_escapes),
|
||||
cmocka_unit_test(test_ai_parse_error_no_error_field),
|
||||
cmocka_unit_test(test_ai_parse_error_no_message_field),
|
||||
cmocka_unit_test(test_ai_parse_error_null_input),
|
||||
cmocka_unit_test(test_ai_parse_error_empty_input),
|
||||
cmocka_unit_test(test_ai_parse_error_tab_escape),
|
||||
cmocka_unit_test(test_ai_parse_error_backslash_escape),
|
||||
/* Extended JSON escape coverage */
|
||||
cmocka_unit_test(test_ai_json_escape_backspace),
|
||||
cmocka_unit_test(test_ai_json_escape_formfeed),
|
||||
cmocka_unit_test(test_ai_json_escape_carriage_return),
|
||||
cmocka_unit_test(test_ai_json_escape_all_specials_combined),
|
||||
cmocka_unit_test(test_ai_json_escape_passes_utf8_through),
|
||||
/* Autocomplete cycling */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycles_through_many, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous_walks_backwards, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_wraps_around, ai_client_setup, ai_client_teardown),
|
||||
/* Session edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_session_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_role_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_content_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_history_preserves_large_order, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_clear_empty_history, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_set_model_null_keeps_old, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_unref_null_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_ref_null_returns_null, ai_client_setup, ai_client_teardown),
|
||||
/* Provider edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_null_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_null_returns_false, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_twice_second_fails, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_provider_survives_via_session_after_removal, ai_client_setup, ai_client_teardown),
|
||||
/* Settings */
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_multiple_keys_independent, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_get_missing_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_isolated_between_providers, ai_client_setup, ai_client_teardown),
|
||||
/* Model parsing edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_data_not_array, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_empty_data_array, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_id_outside_data_ignored, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_multiple_models, ai_client_setup, ai_client_teardown),
|
||||
/* Prefs round-trip (uses prefs+ai setup) */
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
/* Autocomplete deeper coverage */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_after_remove_skips_removed, ai_client_setup, ai_client_teardown),
|
||||
/* Reset hook + persistence */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_reset_ac_restarts_cycle, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_add_provider_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
// Flatfile export/import round-trip
|
||||
cmocka_unit_test(test_ff_roundtrip_simple_chat),
|
||||
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),
|
||||
|
||||
@@ -104,12 +104,6 @@ connection_create_uuid(void)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char*
|
||||
connection_create_stanza_id(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void
|
||||
connection_free_uuid(char* uuid)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user