Compare commits
15 Commits
refactor/s
...
21147f525c
| Author | SHA1 | Date | |
|---|---|---|---|
|
21147f525c
|
|||
|
fba6a040ee
|
|||
|
89a224b017
|
|||
|
b28b719d11
|
|||
|
b21d537e80
|
|||
|
2f1637ca8e
|
|||
|
277c82fb5d
|
|||
|
1ecaceb59f
|
|||
|
18fb7fa733
|
|||
|
a75a06b4a2
|
|||
|
9f4621883a
|
|||
|
6fe8c370df
|
|||
|
b1dbe714e8
|
|||
|
107f7778e2
|
|||
|
b634eed5af
|
@@ -21,12 +21,9 @@
|
||||
|
||||
#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;
|
||||
@@ -35,6 +32,7 @@ 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);
|
||||
|
||||
@@ -90,9 +88,7 @@ _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;
|
||||
@@ -110,12 +106,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);
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
aiwin_display_error(aiwin, error_msg);
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
@@ -133,16 +129,14 @@ _aiwin_display_response(gpointer user_data, const gchar* response)
|
||||
return;
|
||||
}
|
||||
|
||||
/* Try to display in AI window if available */
|
||||
pthread_mutex_lock(&lock);
|
||||
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);
|
||||
}
|
||||
@@ -194,12 +188,11 @@ ai_json_escape(const gchar* str)
|
||||
* ======================================================================== */
|
||||
|
||||
static AIProvider*
|
||||
ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
ai_provider_new(const gchar* name, const gchar* api_url)
|
||||
{
|
||||
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);
|
||||
@@ -209,7 +202,7 @@ ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
return provider;
|
||||
}
|
||||
|
||||
static AIProvider*
|
||||
AIProvider*
|
||||
ai_provider_ref(AIProvider* provider)
|
||||
{
|
||||
if (provider) {
|
||||
@@ -230,7 +223,6 @@ 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);
|
||||
@@ -287,24 +279,33 @@ ai_client_init(void)
|
||||
/* Create autocomplete for provider names */
|
||||
providers_ac = autocomplete_new();
|
||||
|
||||
/* Add default providers */
|
||||
ai_add_provider("openai", DEFAULT_OPENAI_URL, NULL);
|
||||
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL, NULL);
|
||||
/* 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);
|
||||
|
||||
/* Load saved API keys from config */
|
||||
ai_load_keys();
|
||||
|
||||
/* Load cached models for all providers */
|
||||
GList* provider_list = ai_list_providers();
|
||||
GList* curr = provider_list;
|
||||
while (curr) {
|
||||
AIProvider* provider = (AIProvider*)curr->data;
|
||||
GList* models_curr = provider_list;
|
||||
while (models_curr) {
|
||||
AIProvider* provider = (AIProvider*)models_curr->data;
|
||||
_ai_load_models_for_provider(provider);
|
||||
curr = g_list_next(curr);
|
||||
models_curr = g_list_next(models_curr);
|
||||
}
|
||||
g_list_free(provider_list);
|
||||
|
||||
log_info("AI client initialized with default providers: openai, perplexity");
|
||||
log_info("AI client initialized with providers from preferences");
|
||||
}
|
||||
|
||||
void
|
||||
@@ -335,8 +336,31 @@ 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, const gchar* org_id)
|
||||
ai_add_provider(const gchar* name, const gchar* api_url)
|
||||
{
|
||||
if (!name || !api_url)
|
||||
return NULL;
|
||||
@@ -351,18 +375,17 @@ ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
/* Update existing provider */
|
||||
g_free(existing->api_url);
|
||||
existing->api_url = g_strdup(api_url);
|
||||
g_free(existing->org_id);
|
||||
existing->org_id = g_strdup(org_id);
|
||||
/* Persist the updated URL to config */
|
||||
prefs_ai_set_provider(name, api_url);
|
||||
log_info("Updated provider: %s", name);
|
||||
return ai_provider_ref(existing);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
/* Persist the new provider to config */
|
||||
prefs_ai_set_provider(name, api_url);
|
||||
|
||||
/* Sync autocomplete */
|
||||
autocomplete_add(providers_ac, name);
|
||||
/* Add to hash table (reuse internal helper) */
|
||||
AIProvider* provider = _ai_add_provider_nopersist(name, api_url);
|
||||
|
||||
log_info("Added provider: %s (URL: %s)", name, api_url);
|
||||
return provider; /* Caller gets non-owning pointer; hash table owns ref */
|
||||
@@ -374,6 +397,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);
|
||||
|
||||
/* Sync autocomplete before removing */
|
||||
autocomplete_remove(providers_ac, name);
|
||||
|
||||
@@ -402,53 +428,26 @@ 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) {
|
||||
providers_ac = autocomplete_new();
|
||||
log_debug("[AI-AC] Uninitialized call to ai_providers_find");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
return autocomplete_complete(providers_ac, search_str, 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)
|
||||
/* 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)
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)context;
|
||||
if (!aiwin || !aiwin->session) {
|
||||
return NULL;
|
||||
if (!providers_ac) {
|
||||
return;
|
||||
}
|
||||
|
||||
AIProvider* provider = aiwin->session->provider;
|
||||
if (!provider || !provider->models) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
autocomplete_reset(providers_ac);
|
||||
}
|
||||
|
||||
gchar*
|
||||
@@ -571,7 +570,7 @@ typedef struct
|
||||
} ai_request_t;
|
||||
|
||||
/**
|
||||
* Build curl headers with auth and optional org header.
|
||||
* Build curl headers with auth.
|
||||
* Returns headers list (caller must free with curl_slist_free_all).
|
||||
*/
|
||||
static struct curl_slist*
|
||||
@@ -582,11 +581,6 @@ _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;
|
||||
}
|
||||
|
||||
@@ -638,6 +632,10 @@ _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);
|
||||
@@ -668,6 +666,10 @@ _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);
|
||||
@@ -1050,6 +1052,11 @@ 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);
|
||||
@@ -1080,6 +1087,7 @@ ai_session_unref(AISession* session)
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
g_free(session->provider_name);
|
||||
ai_provider_unref(session->provider);
|
||||
g_free(session->model);
|
||||
@@ -1094,6 +1102,7 @@ ai_session_unref(AISession* session)
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(session->history);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
g_free(session);
|
||||
log_debug("AI session destroyed");
|
||||
@@ -1105,10 +1114,12 @@ 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));
|
||||
}
|
||||
@@ -1119,6 +1130,7 @@ ai_session_clear_history(AISession* session)
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
GList* curr = session->history;
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
@@ -1129,6 +1141,7 @@ 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");
|
||||
}
|
||||
@@ -1147,24 +1160,64 @@ 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(AISession* session, const gchar* prompt)
|
||||
_build_json_payload_from_list(const gchar* model, GList* history, 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 = session->history;
|
||||
GList* curr = history;
|
||||
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
@@ -1187,7 +1240,7 @@ _build_json_payload(AISession* session, const gchar* prompt)
|
||||
}
|
||||
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
|
||||
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(session->model);
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(model);
|
||||
gchar* json_payload = g_strdup_printf(
|
||||
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
|
||||
escaped_model, messages_json->str);
|
||||
@@ -1348,14 +1401,31 @@ _ai_request_thread(gpointer data)
|
||||
auto_gchar gchar* prompt = args[1];
|
||||
gpointer user_data = args[2];
|
||||
|
||||
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);
|
||||
/* =====================================================================
|
||||
* 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);
|
||||
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 : "");
|
||||
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));
|
||||
|
||||
/* Check for API key first */
|
||||
if (!session->api_key || strlen(session->api_key) == 0) {
|
||||
if (!local_api_key || strlen(local_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.",
|
||||
session->provider_name, session->provider_name);
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
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);
|
||||
g_free(args);
|
||||
return NULL;
|
||||
@@ -1365,23 +1435,30 @@ _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",
|
||||
session->provider_name, session->model);
|
||||
local_provider_name, local_model);
|
||||
_aiwin_display_error(user_data, "Failed to initialize curl.");
|
||||
g_free(args);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Add user message to history FIRST */
|
||||
ai_session_add_message(session, "user", prompt);
|
||||
/* Add user message to history FIRST (under lock) */
|
||||
pthread_mutex_lock(&session->lock);
|
||||
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);
|
||||
log_debug("[AI-THREAD] Added user message to history");
|
||||
|
||||
/* Build JSON payload (includes the message we just added) */
|
||||
/* Build JSON payload from local history copy (under lock) */
|
||||
log_debug("[AI-THREAD] Building JSON payload...");
|
||||
auto_gchar gchar* json_payload = _build_json_payload(session, prompt);
|
||||
pthread_mutex_lock(&session->lock);
|
||||
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
|
||||
|
||||
/* Build headers using shared helper */
|
||||
struct curl_slist* headers = _build_curl_headers(session->provider, session->api_key);
|
||||
struct curl_slist* headers = _build_curl_headers(local_provider, local_api_key);
|
||||
|
||||
/* Response buffer */
|
||||
struct curl_response_t response;
|
||||
@@ -1389,11 +1466,11 @@ _ai_request_thread(gpointer data)
|
||||
response.size = 0;
|
||||
|
||||
/* Build request URL */
|
||||
const gchar* api_url = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL;
|
||||
const gchar* api_url = local_provider->api_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", session->model);
|
||||
log_debug("[AI-THREAD] Model: %s", local_model);
|
||||
|
||||
/* Set URL and execute request */
|
||||
curl_easy_setopt(curl, CURLOPT_URL, request_url);
|
||||
@@ -1413,7 +1490,7 @@ _ai_request_thread(gpointer 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", session->provider_name, session->model, error_msg);
|
||||
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else if (http_code >= 400) {
|
||||
/* Handle HTTP errors */
|
||||
@@ -1422,7 +1499,7 @@ _ai_request_thread(gpointer data)
|
||||
/* Try to extract the actual error message from the JSON response */
|
||||
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);
|
||||
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 */
|
||||
@@ -1431,12 +1508,17 @@ _ai_request_thread(gpointer data)
|
||||
response.data = NULL;
|
||||
auto_gchar gchar* content = _parse_ai_response(response_data);
|
||||
if (content) {
|
||||
/* Add assistant response to history */
|
||||
ai_session_add_message(session, "assistant", 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);
|
||||
_aiwin_display_response(user_data, content);
|
||||
} else {
|
||||
log_error("AI response parse failed for %s/%s: %.200s...",
|
||||
session->provider_name, session->model, response_data);
|
||||
local_provider_name, local_model, response_data);
|
||||
_aiwin_display_error(user_data, "Failed to parse AI response.");
|
||||
}
|
||||
}
|
||||
@@ -1444,6 +1526,12 @@ _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);
|
||||
|
||||
g_free(args);
|
||||
|
||||
return NULL;
|
||||
|
||||
@@ -19,7 +19,6 @@ 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) */
|
||||
@@ -33,6 +32,7 @@ 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,10 +66,9 @@ AIProvider* ai_get_provider(const gchar* name);
|
||||
* Add or update a provider configuration.
|
||||
* @param name The provider name
|
||||
* @param api_url The API endpoint URL
|
||||
* @param org_id Optional organization ID (can be NULL)
|
||||
* @return New AIProvider* (caller must unref when done)
|
||||
*/
|
||||
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
|
||||
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
|
||||
|
||||
/**
|
||||
* Remove a provider by name.
|
||||
@@ -78,6 +77,13 @@ AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar
|
||||
*/
|
||||
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
|
||||
@@ -109,6 +115,12 @@ gchar* ai_providers_find(const char* const search_str, gboolean previous, void*
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
void ai_providers_reset_ac(void);
|
||||
|
||||
/**
|
||||
* Get the API key for a provider.
|
||||
* @param provider_name The provider name
|
||||
@@ -233,6 +245,19 @@ 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,7 +70,6 @@
|
||||
|
||||
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);
|
||||
@@ -469,7 +468,8 @@ static Autocomplete* all_acs[] = {
|
||||
&ai_subcommands_ac,
|
||||
&ai_set_subcommands_ac,
|
||||
&ai_set_custom_subcommands_ac,
|
||||
&ai_remove_subcommands_ac
|
||||
&ai_remove_subcommands_ac,
|
||||
&ai_models_ac
|
||||
};
|
||||
|
||||
static GHashTable* ac_funcs = NULL;
|
||||
@@ -1192,7 +1192,6 @@ 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);
|
||||
|
||||
@@ -1697,6 +1696,7 @@ cmd_ac_reset(ProfWin* window)
|
||||
win_reset_search_attempts();
|
||||
win_close_reset_search_attempts();
|
||||
plugins_reset_autocomplete();
|
||||
ai_providers_reset_ac();
|
||||
}
|
||||
|
||||
void
|
||||
@@ -4487,6 +4487,10 @@ _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)
|
||||
{
|
||||
@@ -4495,7 +4499,6 @@ _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) */
|
||||
@@ -4510,12 +4513,6 @@ _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) {
|
||||
@@ -4557,34 +4554,20 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai start [<provider>] [<model>] - use shared parse_args
|
||||
if (parse_result && args && args[0] != NULL && g_strcmp0(args[0], "start") == 0) {
|
||||
/* args[0]="start", args[1]=provider (if typed), args[2]=model (if typed) */
|
||||
if (num_args == 1 || (num_args == 2 && !space_at_end)) {
|
||||
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
|
||||
} else {
|
||||
/* /ai start <provider> <model> - model autocomplete */
|
||||
result = _ai_model_autocomplete(window, input, previous, "/ai start ");
|
||||
}
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// /ai switch <provider> <model> - autocomplete model names for specified provider
|
||||
result = _ai_model_autocomplete(window, input, previous, "/ai switch");
|
||||
// /ai start <provider> <model> - autocomplete provider names and model names
|
||||
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai start");
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
|
||||
// /ai switch <provider> <model> - autocomplete provider names and model names
|
||||
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai switch");
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai switch <model> - autocomplete model names from current session's provider
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
result = autocomplete_param_with_func(input, "/ai switch", ai_models_find, previous, aiwin);
|
||||
// /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");
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
@@ -4604,13 +4587,26 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete model names for /ai <cmd> <provider> <model> patterns.
|
||||
* Extracts provider from input and provides model completions from that provider's cached models.
|
||||
* 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.
|
||||
* 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_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
|
||||
_ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -4646,6 +4642,6 @@ _ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previo
|
||||
|
||||
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
|
||||
|
||||
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
|
||||
result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2804,7 +2804,6 @@ 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>",
|
||||
@@ -2823,14 +2822,12 @@ 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(
|
||||
@@ -2838,7 +2835,6 @@ 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,7 +8516,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
cl_ev_send_ai_msg(aiwin, inp, NULL);
|
||||
cl_ev_send_ai_msg(aiwin, inp, connection_create_stanza_id());
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -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], NULL)) {
|
||||
if (ai_add_provider(args[2], args[3])) {
|
||||
cons_show("Provider '%s' configured with URL: %s", args[2], args[3]);
|
||||
} else {
|
||||
cons_show_error("Failed to configure provider '%s'.", args[2]);
|
||||
@@ -10876,23 +10876,6 @@ 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);
|
||||
@@ -10961,8 +10944,11 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
|
||||
if (!model) {
|
||||
model = ai_get_provider_default_model(provider_name);
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
model = "gpt-4o"; // Fallback
|
||||
cons_show_error("No model specified and no default model set for provider '%s'.", provider_name);
|
||||
cons_show("Use '/ai set default-model %s <model>' or '/ai switch %s <model>'.", provider_name, provider_name);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
@@ -11024,12 +11010,12 @@ cmd_ai_model(ProfWin* window, const char* const command, gchar** args)
|
||||
|
||||
const gchar* model = args[1];
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
if (!aiwin) {
|
||||
cons_show("No active AI chat window. Use '/ai start' first.");
|
||||
// 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.");
|
||||
return TRUE;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
@@ -11055,12 +11041,12 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
if (!aiwin) {
|
||||
cons_show("No active AI chat window. Use '/ai start' first.");
|
||||
// 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.");
|
||||
return TRUE;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
@@ -11074,14 +11060,12 @@ 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) {
|
||||
@@ -11097,6 +11081,10 @@ 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;
|
||||
}
|
||||
|
||||
@@ -11108,19 +11096,8 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Update the existing session's provider and model
|
||||
if (changed_provider) {
|
||||
AIProvider* new_provider = ai_get_provider(provider_name);
|
||||
g_free(aiwin->session->provider_name);
|
||||
aiwin->session->provider_name = g_strdup(provider_name);
|
||||
ai_provider_unref(aiwin->session->provider);
|
||||
aiwin->session->provider = new_provider;
|
||||
aiwin->session->provider->ref_count++;
|
||||
}
|
||||
g_free(aiwin->session->model);
|
||||
aiwin->session->model = g_strdup(model);
|
||||
g_free(aiwin->session->api_key);
|
||||
aiwin->session->api_key = g_strdup(api_key);
|
||||
// Atomically switch session provider, model, and API key (thread-safe)
|
||||
ai_session_switch(aiwin->session, provider_name, model, g_steal_pointer(&api_key));
|
||||
|
||||
// Update window title
|
||||
win_println((ProfWin*)aiwin, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
|
||||
@@ -11167,8 +11144,7 @@ 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
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
ai_fetch_models(provider_name, aiwin);
|
||||
ai_fetch_models(provider_name, window);
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
@@ -11178,70 +11154,20 @@ gboolean
|
||||
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai clear
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
|
||||
if (aiwin) {
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
if (aiwin->session) {
|
||||
ai_session_clear_history(aiwin->session);
|
||||
}
|
||||
cons_show("Chat history cleared.");
|
||||
} else {
|
||||
cons_show("No active AI chat window to clear.");
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_correct(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai correct <message>
|
||||
// Join all arguments from args[1] onwards to support multi-word prompts
|
||||
auto_gchar gchar* prompt = g_strjoinv(" ", &args[1]);
|
||||
if (prompt == NULL || strlen(prompt) == 0) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = wins_get_ai();
|
||||
if (!aiwin) {
|
||||
cons_show("No active AI chat window. Use '/ai start <provider>/<model>' first.");
|
||||
// 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.");
|
||||
return TRUE;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
cons_show("No active session in this chat window.");
|
||||
return TRUE;
|
||||
if (aiwin->session) {
|
||||
ai_session_clear_history(aiwin->session);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
win_clear(window);
|
||||
cons_show("AI Chat history cleared.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
@@ -11270,9 +11196,6 @@ cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1863,6 +1863,128 @@ prefs_free_ai_models(GList* models)
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* AI Provider Management
|
||||
* ======================================================================== */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
char*
|
||||
prefs_ai_get_provider_url(const char* const provider)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* key = g_strconcat(provider, "_url", NULL);
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_list_providers(void)
|
||||
{
|
||||
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GList* result = NULL;
|
||||
gsize len = 0;
|
||||
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
prefs_free_ai_providers(GList* providers)
|
||||
{
|
||||
if (providers) {
|
||||
g_list_free_full(providers, g_free);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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/");
|
||||
|
||||
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/"));
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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,6 +366,14 @@ 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);
|
||||
|
||||
/* 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);
|
||||
|
||||
@@ -301,10 +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);
|
||||
/* 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);
|
||||
|
||||
@@ -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, char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
|
||||
_autocomplete_param_common(const char* const input, const 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, char* command, autocomplete_
|
||||
}
|
||||
|
||||
char*
|
||||
autocomplete_param_with_func(const char* const input, char* command, autocomplete_func func, gboolean previous, void* context)
|
||||
autocomplete_param_with_func(const char* const input, const 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, char* command,
|
||||
char* autocomplete_param_with_func(const char* const input, const 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 (!win_is_paged(current)) {
|
||||
if (current->layout->paged == 0) {
|
||||
win_move_to_end(current);
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ _show_attention(ProfWin* current, gboolean attention)
|
||||
static void
|
||||
_show_scrolled(ProfWin* current)
|
||||
{
|
||||
if (win_is_paged(current)) {
|
||||
if (current && current->layout->paged == 1) {
|
||||
int bracket_attrs = theme_attrs(THEME_TITLE_BRACKET);
|
||||
int scrolled_attrs = theme_attrs(THEME_TITLE_SCROLLED);
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ typedef struct prof_layout_t
|
||||
WINDOW* win;
|
||||
ProfBuff buffer;
|
||||
int y_pos;
|
||||
int paged;
|
||||
int unread_msg;
|
||||
} ProfLayout;
|
||||
|
||||
@@ -148,24 +149,16 @@ typedef enum {
|
||||
WIN_AI
|
||||
} win_type_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 enum {
|
||||
WIN_SCROLL_INNER,
|
||||
WIN_SCROLL_REACHED_TOP,
|
||||
WIN_SCROLL_REACHED_BOTTOM
|
||||
} win_scroll_state_t;
|
||||
|
||||
typedef struct prof_win_t
|
||||
{
|
||||
win_type_t type;
|
||||
ScrollEdges scroll_edges;
|
||||
win_scroll_state_t scroll_state;
|
||||
ProfLayout* layout;
|
||||
Autocomplete urls_ac;
|
||||
Autocomplete quotes_ac;
|
||||
|
||||
131
src/ui/window.c
131
src/ui/window.c
@@ -84,26 +84,6 @@ _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)
|
||||
{
|
||||
@@ -137,6 +117,7 @@ _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);
|
||||
|
||||
@@ -154,6 +135,7 @@ _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;
|
||||
@@ -168,7 +150,7 @@ win_create_console(void)
|
||||
{
|
||||
ProfConsoleWin* new_win = malloc(sizeof(ProfConsoleWin));
|
||||
new_win->window.type = WIN_CONSOLE;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_split_layout();
|
||||
|
||||
return &new_win->window;
|
||||
@@ -180,7 +162,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_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->barejid = strdup(barejid);
|
||||
@@ -214,7 +196,7 @@ win_create_muc(const char* const roomjid)
|
||||
int cols = getmaxx(stdscr);
|
||||
|
||||
new_win->window.type = WIN_MUC;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
ProfLayoutSplit* layout = malloc(sizeof(ProfLayoutSplit));
|
||||
layout->base.type = LAYOUT_SPLIT;
|
||||
|
||||
@@ -233,6 +215,7 @@ 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;
|
||||
@@ -271,7 +254,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_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
new_win->roomjid = strdup(roomjid);
|
||||
new_win->form = form;
|
||||
@@ -290,7 +273,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_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
new_win->fulljid = strdup(fulljid);
|
||||
new_win->unread = 0;
|
||||
@@ -307,7 +290,7 @@ win_create_xmlconsole(void)
|
||||
{
|
||||
ProfXMLWin* new_win = malloc(sizeof(ProfXMLWin));
|
||||
new_win->window.type = WIN_XML;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->memcheck = PROFXMLWIN_MEMCHECK;
|
||||
@@ -322,7 +305,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_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->tag = strdup(tag);
|
||||
@@ -339,7 +322,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_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->vcard = vcard;
|
||||
@@ -353,7 +336,7 @@ win_create_ai(AISession* session)
|
||||
{
|
||||
ProfAiWin* new_win = g_new0(ProfAiWin, 1);
|
||||
new_win->window.type = WIN_AI;
|
||||
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
|
||||
new_win->window.scroll_state = WIN_SCROLL_INNER;
|
||||
new_win->window.layout = _win_create_simple_layout();
|
||||
|
||||
new_win->message_buffer = buffer_create();
|
||||
@@ -739,13 +722,11 @@ 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 = win_page_space();
|
||||
int page_space = getmaxy(stdscr) - 4;
|
||||
if (scroll_size == 0)
|
||||
scroll_size = page_space;
|
||||
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;
|
||||
win_scroll_state_t* scroll_state = &window->scroll_state;
|
||||
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) ? WIN_SCROLL_INNER : *scroll_state;
|
||||
*page_start -= scroll_size;
|
||||
gboolean has_scroll_past_buffer = *page_start < 0;
|
||||
|
||||
@@ -755,18 +736,16 @@ 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) {
|
||||
// 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) {
|
||||
// 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_history_result_t db_response = chatwin_db_history(chatwin, NULL, NULL, TRUE);
|
||||
is_db_offset_jump_needed = db_response == DB_RESPONSE_SUCCESS;
|
||||
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);
|
||||
*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");
|
||||
}
|
||||
|
||||
if (edges->db_exhausted_above) {
|
||||
if (*scroll_state == WIN_SCROLL_REACHED_TOP) {
|
||||
*page_start = 0;
|
||||
if (prefs_get_boolean(PREF_MAM)) {
|
||||
win_print_loading_history(window);
|
||||
@@ -798,7 +777,9 @@ win_page_up(ProfWin* window, int scroll_size)
|
||||
win_update_virtual(window);
|
||||
}
|
||||
|
||||
// paged is derived on demand from y_pos / total_rows; nothing to set here.
|
||||
// Update scrolling state
|
||||
int total_rows = getcury(window->layout->win);
|
||||
window->layout->paged = (total_rows) - *page_start > page_space;
|
||||
}
|
||||
|
||||
void
|
||||
@@ -807,15 +788,13 @@ 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 = win_page_space();
|
||||
int page_space = getmaxy(stdscr) - 4;
|
||||
int page_start_initial = *page_start;
|
||||
|
||||
if (scroll_size == 0)
|
||||
scroll_size = page_space;
|
||||
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;
|
||||
win_scroll_state_t* scroll_state = &window->scroll_state;
|
||||
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_TOP) ? WIN_SCROLL_INNER : *scroll_state;
|
||||
|
||||
*page_start += scroll_size;
|
||||
|
||||
@@ -826,7 +805,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 && !edges->db_exhausted_below) {
|
||||
if (bf_size > 0 && *scroll_state != WIN_SCROLL_REACHED_BOTTOM) {
|
||||
// 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();
|
||||
@@ -835,7 +814,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)
|
||||
edges->db_exhausted_below = TRUE;
|
||||
*scroll_state = WIN_SCROLL_REACHED_BOTTOM;
|
||||
|
||||
// similar to page_up (see explanation there)
|
||||
// Get offset of the message that was the latest prior to loading messages from the DB
|
||||
@@ -847,22 +826,26 @@ win_page_down(ProfWin* window, int scroll_size)
|
||||
|
||||
total_rows = getcury(window->layout->win);
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
window->layout->paged = 1;
|
||||
|
||||
// update only if position has changed
|
||||
if ((page_start_initial != *page_start) || window->layout->unread_msg) {
|
||||
win_update_virtual(window);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -871,7 +854,8 @@ void
|
||||
win_sub_page_down(ProfWin* window)
|
||||
{
|
||||
if (window->layout->type == LAYOUT_SPLIT) {
|
||||
int page_space = win_page_space();
|
||||
int rows = getmaxy(stdscr);
|
||||
int page_space = rows - 4;
|
||||
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
|
||||
int sub_y = getcury(split_layout->subwin);
|
||||
int* sub_y_pos = &(split_layout->sub_y_pos);
|
||||
@@ -894,7 +878,8 @@ void
|
||||
win_sub_page_up(ProfWin* window)
|
||||
{
|
||||
if (window->layout->type == LAYOUT_SPLIT) {
|
||||
int page_space = win_page_space();
|
||||
int rows = getmaxy(stdscr);
|
||||
int page_space = rows - 4;
|
||||
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
|
||||
int* sub_y_pos = &(split_layout->sub_y_pos);
|
||||
|
||||
@@ -921,6 +906,7 @@ 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);
|
||||
}
|
||||
@@ -1030,12 +1016,14 @@ 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 page_space = win_page_space();
|
||||
int size = rows - 3;
|
||||
|
||||
window->layout->y_pos = y - page_space;
|
||||
window->layout->y_pos = y - (size - 1);
|
||||
if (window->layout->y_pos < 0) {
|
||||
window->layout->y_pos = 0;
|
||||
}
|
||||
@@ -1812,13 +1800,12 @@ 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, ...)
|
||||
{
|
||||
/* 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);
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
if (timestamp == NULL) {
|
||||
timestamp = g_date_time_new_now_local();
|
||||
@@ -1835,10 +1822,6 @@ _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,8 +81,6 @@ 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,24 +867,6 @@ 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,7 +63,6 @@ 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);
|
||||
|
||||
@@ -44,17 +44,15 @@ void
|
||||
test_ai_add_provider(void** state)
|
||||
{
|
||||
/* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */
|
||||
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1", "my-org");
|
||||
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("custom", provider->name);
|
||||
assert_string_equal("https://custom.api.com/v1", provider->api_url);
|
||||
assert_string_equal("my-org", provider->org_id);
|
||||
|
||||
/* Update existing provider (returns ref; caller owns it) */
|
||||
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1", NULL);
|
||||
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1");
|
||||
assert_non_null(updated);
|
||||
assert_string_equal("https://new.api.com/v1", updated->api_url);
|
||||
assert_null(updated->org_id);
|
||||
|
||||
ai_provider_unref(updated);
|
||||
}
|
||||
@@ -66,7 +64,7 @@ test_ai_remove_provider(void** state)
|
||||
assert_false(ai_remove_provider("nonexistent"));
|
||||
|
||||
/* Add and remove custom provider */
|
||||
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
|
||||
ai_add_provider("temp", "https://temp.api.com/v1");
|
||||
assert_true(ai_remove_provider("temp"));
|
||||
assert_null(ai_get_provider("temp"));
|
||||
}
|
||||
@@ -82,7 +80,7 @@ test_ai_list_providers(void** state)
|
||||
g_list_free(providers);
|
||||
|
||||
/* Add another provider */
|
||||
ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
ai_add_provider("test", "https://test.api.com/v1");
|
||||
providers = ai_list_providers();
|
||||
assert_int_equal(3, g_list_length(providers));
|
||||
|
||||
@@ -301,29 +299,27 @@ void
|
||||
test_ai_add_provider_update_existing(void** state)
|
||||
{
|
||||
/* Add a provider (hash table owns ref) */
|
||||
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1", "org1");
|
||||
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://first.api.com/v1", provider->api_url);
|
||||
assert_string_equal("org1", provider->org_id);
|
||||
|
||||
/* Update the same provider (returns ref) */
|
||||
provider = ai_add_provider("custom", "https://second.api.com/v1", "org2");
|
||||
provider = ai_add_provider("custom", "https://second.api.com/v1");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://second.api.com/v1", provider->api_url);
|
||||
assert_string_equal("org2", provider->org_id);
|
||||
ai_provider_unref(provider);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_null_name_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_add_provider(NULL, "https://api.com/v1", NULL));
|
||||
assert_null(ai_add_provider(NULL, "https://api.com/v1"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_null_url_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_add_provider("test", NULL, NULL));
|
||||
assert_null(ai_add_provider("test", NULL));
|
||||
}
|
||||
|
||||
void
|
||||
@@ -374,7 +370,7 @@ void
|
||||
test_ai_providers_find_forward_custom(void** state)
|
||||
{
|
||||
/* Add a custom provider and test */
|
||||
ai_add_provider("custom", "https://custom.api.com/v1", NULL);
|
||||
ai_add_provider("custom", "https://custom.api.com/v1");
|
||||
|
||||
auto_gchar gchar* result = ai_providers_find("c", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
@@ -484,7 +480,7 @@ test_ai_models_find_null_session(void** state)
|
||||
void
|
||||
test_ai_models_find_null_provider(void** state)
|
||||
{
|
||||
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
|
||||
ai_add_provider("temp", "https://temp.api.com/v1");
|
||||
AISession* session = ai_session_create("temp", "gpt-4");
|
||||
assert_non_null(session);
|
||||
ai_session_unref(session);
|
||||
@@ -656,7 +652,7 @@ test_ai_parse_models_openai_format(void** state)
|
||||
/* Format: {"object":"list","data":[{"id":"model1",...},...]} */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
|
||||
/* JSON response from Perplexity/OpenAI API */
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
@@ -692,7 +688,7 @@ test_ai_parse_models_perplexity_format(void** state)
|
||||
/* Test parsing actual Perplexity API response with all models from curl test */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("perplexity", "https://api.perplexity.ai/", NULL);
|
||||
AIProvider* provider = ai_add_provider("perplexity", "https://api.perplexity.ai/");
|
||||
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
@@ -765,7 +761,7 @@ test_ai_parse_models_array_format(void** state)
|
||||
/* Test parsing simple array format: ["model1", "model2", ...] */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
|
||||
const gchar* json = "[\"model1\", \"model2\", \"model3\"]";
|
||||
|
||||
@@ -790,7 +786,7 @@ test_ai_parse_models_empty_json(void** state)
|
||||
/* Test parsing empty/invalid JSON */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
|
||||
ai_parse_models_from_json(provider, "");
|
||||
assert_null(provider->models);
|
||||
@@ -810,7 +806,7 @@ test_ai_parse_models_null_handling(void** state)
|
||||
/* Test NULL handling */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
|
||||
/* NULL json should not crash */
|
||||
ai_parse_models_from_json(provider, NULL);
|
||||
@@ -826,7 +822,7 @@ test_ai_parse_models_escaped_quotes(void** state)
|
||||
/* Test parsing model IDs with escaped quotes */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
@@ -850,7 +846,7 @@ test_ai_parse_models_with_whitespace(void** state)
|
||||
/* Test parsing JSON with whitespace after colons */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
|
||||
/* JSON with spaces after colons (common in formatted JSON) */
|
||||
const gchar* json = "{ \"object\": \"list\", "
|
||||
|
||||
@@ -1433,8 +1433,3 @@ cons_has_alerts(void)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
win_move_to_end(ProfWin* window)
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user