Compare commits

..

11 Commits

Author SHA1 Message Date
ac7d6c31f0 fix(ui): address PR review on WIN_CHAT scroll handling
All checks were successful
CI Code / Check coding style (pull_request) Successful in 36s
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Code Coverage (pull_request) Successful in 2m40s
CI Code / Linux (debian) (pull_request) Successful in 4m38s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m53s
CI Code / Linux (arch) (pull_request) Successful in 5m41s
- Drop DB_RESPONSE_ERROR -> REACHED_BOTTOM in win_page_down: a transient
  backend error (e.g. sqlite lock) should not force the scroll state
  forward, or the user would get stuck mid-conversation expecting more
  rows that did not load.
- Trim the _win_printf comment to a single WHY line; the rest belongs in
  the originating commit message.
2026-05-16 17:57:28 +03:00
0f24910685 fix(ui): preserve WIN_CHAT messages when DB cannot replay them
All checks were successful
CI Code / Check spelling (pull_request) Successful in 16s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Successful in 2m45s
CI Code / Linux (debian) (pull_request) Successful in 4m39s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m56s
CI Code / Linux (arch) (pull_request) Successful in 5m45s
_win_printf skipped buffer/pad append for WIN_CHAT while the user was
scrolled, relying on chatwin_db_history() to replay missed messages on
scroll-down. That replay is unavailable when PREF_DBLOG is "off" (no
write) or "redact" ([REDACTED] body), or when the backend failed to
initialize — in those cases the message was lost.

Introduce log_database_can_recover_messages() and gate the WIN_CHAT
early return on it: when recovery is impossible, fall through and
append to the buffer just like non-chat windows do. Also treat
DB_RESPONSE_ERROR in win_page_down the same as DB_RESPONSE_EMPTY so
the [SCROLLED] indicator doesn't hang when the backend is dead.
2026-05-16 17:06:31 +03:00
506794474a fix(ui): handle ai window scrolling in titlebar
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 30s
CI Code / Code Coverage (pull_request) Successful in 2m45s
CI Code / Linux (debian) (pull_request) Successful in 4m37s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m52s
CI Code / Linux (arch) (pull_request) Successful in 5m40s
Add missing scroll rendering for AI windows in
the title bar draw function. This ensures AI
windows display their scrolled state consistently
with other non-chat window types.
2026-05-16 12:24:36 +00:00
ef0dd74dc1 fix(ui): preserve messages in non-chat windows while scrolled
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 27s
CI Code / Code Coverage (pull_request) Successful in 2m44s
CI Code / Linux (debian) (pull_request) Successful in 4m42s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m57s
CI Code / Linux (arch) (pull_request) Successful in 5m50s
_win_printf used to drop the buffer append and pad render for any window
in paged state, but only WIN_CHAT can recover lost messages via
chatwin_db_history on scroll-down. WIN_MUC, WIN_PRIVATE and WIN_AI have
no such fallback, so incoming and outgoing messages were silently lost
when the user was viewing history. Suppress the buffer/render only for
WIN_CHAT; for the rest, still bump unread_msg but let the message land
in the buffer so it becomes visible on scroll-down.

This also removes the manual paged/unread_msg reset before printing the
user message in cl_ev_send_ai_msg — it was a local workaround for the
same drop and is no longer needed.

The buffer-bottom reset in win_page_down (c167f487) is still required:
it is the only path that clears paged and unread_msg for non-chat
windows when the user scrolls back to the bottom (WIN_SCROLL_REACHED_BOTTOM
is set only on the is_chat DB branch).
2026-05-15 19:46:12 +03:00
c12a1bc355 fix(ui): reset paged flag at buffer bottom for non-chat windows 2026-05-15 19:46:12 +03:00
06b80bc89a fix(ai): fix memory leak of local provider in error paths
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 31s
CI Code / Code Coverage (pull_request) Successful in 2m44s
CI Code / Linux (debian) (pull_request) Successful in 4m42s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m56s
CI Code / Linux (arch) (pull_request) Successful in 5m49s
CI Code / Check spelling (push) Successful in 16s
CI Code / Check coding style (push) Successful in 31s
CI Code / Code Coverage (push) Successful in 2m42s
CI Code / Linux (debian) (push) Successful in 4m45s
CI Code / Linux (ubuntu) (push) Successful in 4m57s
CI Code / Linux (arch) (push) Successful in 5m50s
Add missing unref calls for local_provider in _ai_request_thread
error handling to prevent memory leaks.
2026-05-15 14:14:11 +00:00
f9e0ba9630 fix(ai): fix memory leaks in ai session handling
All checks were successful
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 38s
CI Code / Code Coverage (pull_request) Successful in 2m55s
CI Code / Linux (debian) (pull_request) Successful in 4m39s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m51s
CI Code / Linux (arch) (pull_request) Successful in 5m34s
Add missing `ai_session_unref` calls to prevent memory leaks.

- In `_ai_request_thread`, release session on error paths and
  after successful processing.
- In `cmd_ai_start`, release reference after passing ownership
  to the AI window.
2026-05-15 11:58:25 +00:00
7469f31c78 fix(ai): fix memory leaks in _ai_request_thread
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Code Coverage (pull_request) Successful in 2m43s
CI Code / Linux (debian) (pull_request) Successful in 4m45s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m55s
CI Code / Linux (arch) (pull_request) Successful in 5m45s
Replace manual g_free calls with auto_gchar for local_provider_name,
local_model, local_api_key, and response_data to ensure automatic
cleanup and prevent memory leaks.
2026-05-15 11:29:10 +00:00
5e329c77e1 fix(ai): fix memory leak in AI message stanza ID
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Successful in 2m43s
CI Code / Linux (debian) (pull_request) Successful in 4m41s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m55s
CI Code / Linux (arch) (pull_request) Successful in 5m44s
Store stanza ID in an auto_gchar variable to ensure automatic memory cleanup.
2026-05-15 08:55:56 +00:00
9e5dfb14f8 feat(ai): add AI client with multi-provider chat support
All checks were successful
CI Code / Check spelling (push) Successful in 18s
CI Code / Check coding style (push) Successful in 29s
CI Code / Code Coverage (push) Successful in 2m44s
CI Code / Linux (debian) (push) Successful in 4m46s
CI Code / Linux (ubuntu) (push) Successful in 4m59s
CI Code / Linux (arch) (push) Successful in 5m56s
Add an AI client module that integrates with OpenAI-compatible API
providers (OpenAI, Perplexity, and custom endpoints) to provide
AI-assisted chat within CProof. Users can start sessions with /ai start,
send prompts, receive responses in a dedicated AI window, switch between
providers and models, and manage API keys — all with tab-completion.

Providers are configured via /ai set commands with per-provider API keys,
endpoints, default models, and custom settings. Two default providers
(openai, perplexity) are seeded on first use. Provider state persists in
[ai/<name>] sections of the preferences keyfile with automatic migration
from the previous flat-key format.

The /ai command integrates into the existing command system with 8
subcommands covering provider management, session lifecycle, model
fetching, and conversation clearing. Autocomplete uses the standard
flat prefix-matching chain for reliable tab-completion at every nesting
level. A new ProfAiWin window type is added to the window system.

Architecture:

Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls
Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call
Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s)
Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training
Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM
JSON parsing uses unified helpers for both chat responses and error
envelopes with consistent escape decoding. The response parser tries
Perplexity /v1/responses "text" field first, then falls back to OpenAI
"content". Error parsing extracts provider error.message from the
standard envelope format. Model parsing handles multiple API response
formats (OpenAI list, Perplexity, array) including edge cases.

Tests include 470+ lines of unit tests covering provider management,
session lifecycle, JSON parsing (multiple formats), autocomplete cycling,
and error handling, plus functional tests for /ai command dispatch.
A stub_ai.c module isolates unit tests from UI dependencies.
2026-05-15 02:21:54 +00:00
1aaa382d5e fix(verify): per-contact context in output, demote duplicate stanza-id to debug
All checks were successful
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 31s
CI Code / Code Coverage (pull_request) Successful in 2m39s
CI Code / Linux (debian) (pull_request) Successful in 4m39s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m51s
CI Code / Linux (arch) (pull_request) Successful in 5m40s
CI Code / Check spelling (push) Successful in 20s
CI Code / Check coding style (push) Successful in 36s
CI Code / Code Coverage (push) Successful in 2m42s
CI Code / Linux (debian) (push) Successful in 4m43s
CI Code / Linux (ubuntu) (push) Successful in 4m58s
CI Code / Linux (arch) (push) Successful in 5m44s
2026-05-06 17:02:27 +03:00
13 changed files with 550 additions and 341 deletions

View File

@@ -35,6 +35,8 @@ static Autocomplete providers_ac = NULL;
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
@@ -184,6 +186,85 @@ 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
* ======================================================================== */
@@ -296,13 +377,31 @@ ai_client_init(void)
/* Load saved API keys from config */
ai_load_keys();
/* Load cached models for all providers */
/* Load cached models, persisted default-model and custom settings for
* all providers. */
GList* provider_list = ai_list_providers();
GList* models_curr = provider_list;
while (models_curr) {
AIProvider* provider = (AIProvider*)models_curr->data;
for (GList* curr = provider_list; curr; curr = g_list_next(curr)) {
AIProvider* provider = (AIProvider*)curr->data;
_ai_load_models_for_provider(provider);
models_curr = g_list_next(models_curr);
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);
}
g_list_free(provider_list);
@@ -398,8 +497,9 @@ ai_remove_provider(const gchar* name)
if (!name || !providers)
return FALSE;
/* Remove from config before removing from hash table */
prefs_ai_remove_provider(name);
/* 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);
@@ -501,6 +601,7 @@ 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);
}
@@ -535,9 +636,11 @@ 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);
}
}
@@ -609,8 +712,11 @@ _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* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
response->data ? response->data : "Unknown error");
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");
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
@@ -979,9 +1085,18 @@ _models_response_handler(AIProvider* provider, const gchar* response, gpointer u
{
_parse_and_cache_models(provider, response);
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);
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);
}
static gpointer
@@ -1256,139 +1371,30 @@ ai_parse_response(const gchar* response_json)
if (!response_json || strlen(response_json) == 0)
return NULL;
/* 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++;
}
}
/* 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 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;
return _extract_json_string(response_json, "content");
}
/**
* Extract error message from an API error JSON response.
* Handles format: {"error":{"message":"...","type":"...","code":...}}
*/
/* 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)
{
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;
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;
return _extract_json_string(error_obj, "message");
}
static gpointer
@@ -1413,10 +1419,10 @@ _ai_request_thread(gpointer data)
* list modifications). We must snapshot all fields atomically.
* ===================================================================== */
pthread_mutex_lock(&session->lock);
gchar* local_provider_name = g_strdup(session->provider_name);
auto_gchar gchar* local_provider_name = g_strdup(session->provider_name);
AIProvider* local_provider = ai_provider_ref(session->provider);
gchar* local_model = g_strdup(session->model);
gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
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);
@@ -1428,6 +1434,8 @@ _ai_request_thread(gpointer data)
local_provider_name, local_provider_name);
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
_aiwin_display_error(user_data, error_msg);
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;
}
@@ -1438,6 +1446,8 @@ _ai_request_thread(gpointer data)
log_error("AI request failed for %s/%s: Failed to initialize curl",
local_provider_name, local_model);
_aiwin_display_error(user_data, "Failed to initialize curl.");
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;
}
@@ -1486,6 +1496,7 @@ _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));
@@ -1494,17 +1505,14 @@ _ai_request_thread(gpointer data)
} else if (http_code >= 400) {
/* Handle HTTP errors */
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
response.size, response.data ? response.data : "NULL");
response.size, response_data);
/* 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");
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);
_aiwin_display_error(user_data, error_msg);
} else {
/* 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;
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response_data);
auto_gchar gchar* content = ai_parse_response(response_data);
if (content) {
/* Add assistant response to history (under lock) */
@@ -1525,12 +1533,9 @@ _ai_request_thread(gpointer data)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
/* Cleanup local copies */
g_free(local_provider_name);
ai_provider_unref(local_provider);
g_free(local_model);
g_free(local_api_key);
ai_session_unref(session);
g_free(args);
return NULL;

View File

@@ -106,15 +106,6 @@ GList* ai_list_providers(void);
*/
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
/**
* Find a model name for autocomplete.
* @param search_str The search string
* @param previous Whether to go to previous match
* @param context ProfAiWin* for the current session
* @return Model name, or NULL if not found
*/
gchar* ai_models_find(const char* const search_str, gboolean previous, void* context);
/**
* Reset the provider autocomplete state.
* Called from cmd_ac_reset() to clear autocomplete state.

View File

@@ -8516,7 +8516,8 @@ _cmd_execute_default(ProfWin* window, const char* inp)
case WIN_AI:
{
ProfAiWin* aiwin = (ProfAiWin*)window;
cl_ev_send_ai_msg(aiwin, inp, connection_create_stanza_id());
auto_gchar gchar* stanza_id = connection_create_stanza_id();
cl_ev_send_ai_msg(aiwin, inp, stanza_id);
break;
}
default:
@@ -10817,7 +10818,6 @@ 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;
}
@@ -10981,6 +10981,9 @@ 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);
@@ -11174,30 +11177,24 @@ cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
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;
gchar* key = ai_get_provider_key(provider->name);
auto_gchar 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");
cons_show("");
g_free(key);
}
g_list_free(providers);

View File

@@ -67,6 +67,8 @@
#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
@@ -243,6 +245,69 @@ _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();
@@ -1708,73 +1773,103 @@ _save_prefs(void)
}
/* ========================================================================
* AI Token Management
* 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().
* ======================================================================== */
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)
if (!provider || !prefs)
return FALSE;
if (!prefs)
return FALSE;
if (token) {
g_key_file_set_string(prefs, PREF_GROUP_AI, provider, token);
_save_prefs();
return TRUE;
} else {
if (!token)
return prefs_ai_remove_token(provider);
}
return _ai_set_string(provider, "key", token);
}
gboolean
prefs_ai_remove_token(const char* const provider)
{
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, provider, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, provider, NULL);
_save_prefs();
return TRUE;
return _ai_remove_string(provider, "key");
}
char*
prefs_ai_get_token(const char* const provider)
{
if (!provider || !prefs)
return NULL;
return g_key_file_get_string(prefs, PREF_GROUP_AI, provider, NULL);
return _ai_get_string(provider, "key");
}
GList*
prefs_ai_list_tokens(void)
{
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
if (!prefs)
return NULL;
}
GList* result = NULL;
gsize len;
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
for (gsize i = 0; i < len; i++) {
char* name = keys[i];
auto_gchar gchar* value = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
if (value) {
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') {
result = g_list_append(result, g_strdup(name));
}
}
prefs_free_ai_providers(providers);
return result;
}
@@ -1786,72 +1881,45 @@ 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)
if (!provider || count <= 0 || !prefs)
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]);
}
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str);
gboolean ok = _ai_set_string(provider, "models", model_str->str);
g_string_free(model_str, TRUE);
_save_prefs();
return TRUE;
return ok;
}
gboolean
prefs_ai_remove_models(const char* const provider)
{
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, key, NULL);
_save_prefs();
return TRUE;
return _ai_remove_string(provider, "models");
}
GList*
prefs_ai_get_models(const char* const provider)
{
if (!provider || !prefs)
auto_gchar gchar* model_str = _ai_get_string(provider, "models");
if (!model_str || model_str[0] == '\0')
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;
}
@@ -1863,78 +1931,89 @@ prefs_free_ai_models(GList* models)
}
}
/* ========================================================================
* AI Provider Management
* ======================================================================== */
/* --- 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;
if (!prefs)
return FALSE;
/* Store URL with "_url" suffix to avoid collision with API key storage */
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_url", NULL), url);
_save_prefs();
return TRUE;
return _ai_set_string(provider, "url", url);
}
gboolean
prefs_ai_remove_provider(const char* const provider)
{
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
auto_gchar gchar* url_key = g_strconcat(provider, "_url", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, url_key, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, url_key, NULL);
_save_prefs();
return TRUE;
/* 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 NULL;
return FALSE;
auto_gchar gchar* key = g_strconcat(provider, "_url", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
return NULL;
}
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_group(prefs, group))
return FALSE;
return g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
g_key_file_remove_group(prefs, group, NULL);
_save_prefs();
return TRUE;
}
GList*
prefs_ai_list_providers(void)
{
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
if (!prefs)
return NULL;
}
GList* result = NULL;
gsize len = 0;
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
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++) {
char* key = keys[i];
/* Match keys ending with "_url" that are not "_models" suffix */
if (g_str_has_suffix(key, "_url") && !g_str_has_suffix(key, "_models_url")) {
/* Extract provider name by removing "_url" suffix */
gsize key_len = strlen(key);
if (key_len > 4) {
gchar* provider_name = g_strndup(key, key_len - 4);
result = g_list_append(result, provider_name);
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));
}
}
}
@@ -1953,38 +2032,106 @@ prefs_free_ai_providers(GList* providers)
GList*
prefs_ai_get_providers(void)
{
/* If user has configured any providers, return those */
GList* configured = prefs_ai_list_providers();
if (configured && g_list_length(configured) > 0) {
/* Build result list with name, url pairs */
GList* result = NULL;
GList* curr = configured;
while (curr) {
gchar* name = (gchar*)curr->data;
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));
}
g_free(url);
curr = g_list_next(curr);
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);
return result;
}
prefs_free_ai_providers(configured);
prefs_ai_set_provider("openai", "https://api.openai.com/");
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
/* 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;
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/"));
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.

View File

@@ -374,10 +374,27 @@ 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

View File

@@ -61,6 +61,22 @@ 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)
{

View File

@@ -109,6 +109,9 @@ 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);

View File

@@ -211,9 +211,13 @@ _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)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate stanza-id \"%s\"", 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);
} else {
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
}
@@ -302,7 +306,13 @@ _verify_contact_dir(const char* cdir_path, GSList** issues)
return;
}
const char* basename = "history.log";
// 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);
_check_permissions(filepath, basename, issues);

View File

@@ -466,23 +466,36 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
sqlite3_finalize(stmt);
}
// Check timestamp ordering for a specific contact or all
// 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.
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`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"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` "
"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`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"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` "
"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");
}
@@ -490,15 +503,19 @@ _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);
int id_b = sqlite3_column_int(stmt, 2);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 3);
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);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_WARNING;
issue->file = g_strdup("chatlog.db");
issue->file = g_strdup_printf("%s↔%s/chatlog.db",
from_a ? from_a : "?", to_a ? to_a : "?");
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);
@@ -506,7 +523,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` FROM `ChatLogs` A "
"SELECT A.`id`, A.`replaces_db_id`, A.`from_jid`, A.`to_jid` 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");
@@ -515,10 +532,13 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id = sqlite3_column_int(stmt, 0);
int replaces_id = sqlite3_column_int(stmt, 1);
const char* from = (const char*)sqlite3_column_text(stmt, 2);
const char* to = (const char*)sqlite3_column_text(stmt, 3);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_ERROR;
issue->file = g_strdup("chatlog.db");
issue->file = g_strdup_printf("%s↔%s/chatlog.db",
from ? from : "?", to ? to : "?");
issue->line = id;
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
id, replaces_id);

View File

@@ -301,12 +301,6 @@ cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const
return;
}
/* Reset paged flag before printing user message.
* If the user scrolled up to view history, paged=1 would suppress
* the message in _win_printf(). Reset it here so the message displays. */
aiwin->window.layout->paged = 0;
aiwin->window.layout->unread_msg = 0;
// Display user message in AI window.
win_print_outgoing(&aiwin->window, ">>", id, NULL, message);

View File

@@ -46,6 +46,7 @@
#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"
@@ -252,6 +253,10 @@ _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();

View File

@@ -848,6 +848,14 @@ win_page_down(ProfWin* window, int scroll_size)
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;
window->layout->unread_msg = 0;
}
}
void
@@ -1801,10 +1809,12 @@ static void
_win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* timestamp, int flags, theme_item_t theme_item, const char* const display_from, const char* const from_jid, const char* const message_id, const char* const message, ...)
{
/* Prevent printing and buffer update when user is viewing message history [SCROLLING]*/
if (window->layout->paged && wins_is_current(window)) {
window->layout->unread_msg++;
return;
// Skip buffer only when DB can replay the message on scroll-down.
if (window->type == WIN_CHAT && log_database_can_recover_messages()) {
return;
}
}
if (timestamp == NULL) {

View File

@@ -1,3 +1,4 @@
#include "glib.h"
#include "prof_cmocka.h"
#include "common.h"
#include "ai/ai_client.h"
@@ -703,7 +704,6 @@ test_ai_parse_models_openai_format(void** state)
models = g_list_next(models);
assert_string_equal("google/gemini-3-flash-preview", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -776,7 +776,6 @@ test_ai_parse_models_perplexity_format(void** state)
models = g_list_last(models);
assert_string_equal("xai/grok-4.3", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -801,7 +800,6 @@ test_ai_parse_models_array_format(void** state)
models = g_list_next(models);
assert_string_equal("model3", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -821,7 +819,6 @@ test_ai_parse_models_empty_json(void** state)
assert_null(provider->models);
assert_int_equal(0, g_list_length(provider->models));
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -837,7 +834,6 @@ test_ai_parse_models_null_handling(void** state)
ai_parse_models_from_json(provider, NULL);
assert_null(provider->models);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -861,7 +857,6 @@ test_ai_parse_models_escaped_quotes(void** state)
GList* models = provider->models;
assert_non_null(models);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -885,7 +880,6 @@ test_ai_parse_models_with_whitespace(void** state)
GList* models = provider->models;
assert_string_equal("model-with-spaces", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -1142,7 +1136,7 @@ test_ai_providers_find_cycles_through_many(void** state)
ai_add_provider("alpine", "https://al.example/");
/* Three providers share prefix "alp": alpha, alphabet, alpine. */
GHashTable* seen = g_hash_table_new(g_str_hash, g_str_equal);
GHashTable* seen = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
for (int i = 0; i < 3; i++) {
auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL);