Compare commits

..

15 Commits

Author SHA1 Message Date
befdd0ba10 test(ai): autocomplete tests — 5 expected failures documenting real bugs
Adds 10 tests for ai_models_find and additional edge cases on
ai_providers_find. 5 pass as sanity; 5 fail intentionally, documenting
two distinct bugs in the autocomplete integration:

BUG A — ai_models_find cycling is broken
  src/ai/ai_client.c ai_models_find() allocates a fresh Autocomplete on
  every call and frees it before returning. The Autocomplete's last_found
  cursor is therefore never persisted, so repeated calls with the same
  prefix always return the first match. Tab+Tab+Tab cannot reach models
  2, 3, ...
  Caught by:
    - test_ai_models_find_cycles_through_matches
    - test_ai_models_find_null_search_cycles_all

BUG B — providers_ac is never reset between user input cycles
  src/ai/ai_client.c keeps the static providers_ac AC, but
  src/command/cmd_ac.c's all_acs[] (which cmd_ac_reset() iterates) does
  not include &providers_ac. So when the input loop fires the global
  reset, providers_ac retains its last_found cursor from the previous
  prefix.
  Result: tab-completing a new prefix continues from the old cursor
  position instead of restarting at the head of the new prefix's matches.
  A provider added mid-session may also be missed depending on cursor
  position.
  Caught by:
    - test_ai_providers_find_state_resets_on_prefix_change
    - test_ai_providers_find_empty_then_prefix
    - test_ai_providers_find_after_add_includes_new

Sanity (5 passing):
  - test_ai_models_find_no_models_returns_null
  - test_ai_models_find_returns_match_for_prefix
  - test_ai_models_find_no_match_returns_null
  - test_ai_models_find_previous_direction
  - test_ai_providers_find_after_remove_skips_removed

The failing tests are checked in as-is — they go green when the bugs
are fixed. See PR description for suggested fixes.
2026-05-14 08:47:24 +00:00
d676f3b087 test(ai): expand unit and functional coverage for /ai feature
Unit tests (tests/unittests/test_ai_client.c): +50 cases on top of the
existing 50 — total 100. Covers:
  - chat response parser (ai_parse_response): OpenAI content + Perplexity
    text formats, escape decoding, empty/null/missing inputs, format-string
    safety, multiline content
  - error envelope parser (ai_parse_error_message): standard envelope,
    nested escapes, missing fields, null/empty
  - extended JSON escape: \b, \f, \r, all-specials, UTF-8 pass-through
  - provider autocomplete cycling with >=2 matches and wrap-around
  - session edge cases: NULL args, 100-message order preservation,
    set_model(NULL), ref/unref(NULL)
  - provider edge cases: get/remove with NULL, double-remove, survival
    via session ref after ai_remove_provider
  - settings: multi-key independence, missing key, cross-provider
    isolation
  - model parsing edges: data not array, empty data, "id" outside data,
    multiple models
  - prefs round-trip: set token -> shutdown -> init -> token reloaded
    from disk (uses load_preferences fixture)

Functional tests:
  - Console only (test_ai.c): 15 cases for the /ai command surface that
    don't need HTTP. Covers /ai help, providers list, set provider/token,
    start with/without key/unknown provider, clear, remove, default
    provider/model, switch without window, bad subcommand.

Infrastructure:
  - TEST_GROUPS bumped to 5 in proftest.c; AI tests live in their own
    Group 5 because mixing them with stabber-driven tests in Group 4
    poisoned stbbr_stop() teardown.
  - PROF_FUNC_TEST_AI macro in functionaltests.c registers AI tests
    with ai_init_test() which wraps init_prof_test with a prof_connect()
    so stabber sees a graceful disconnect at teardown.

Source-level changes to enable testing:
  - src/ai/ai_client.{c,h}: dropped 'static' from _parse_ai_response
    (renamed ai_parse_response) and _parse_error_response (renamed
    ai_parse_error_message); declared under a "Parsing helpers (exposed
    for testing)" section. Same approach as ai_parse_models_from_json.

Results in Docker (cproof-debian image):
  - 595/595 unit tests pass
  - 15/15 AI functional tests pass (Group 5)
2026-05-14 08:47:24 +00:00
30ad6e2a6e fix(ac): use g_strdup instead of strdup for NULL-safety
Some checks failed
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m55s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m7s
CI Code / Linux (arch) (pull_request) Failing after 3m47s
2026-05-14 03:57:08 +00:00
7cf82c99f0 fix(ai): destroy mutex and remove locking in unref
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m48s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m56s
CI Code / Linux (arch) (pull_request) Failing after 3m35s
2026-05-14 03:54:30 +00:00
f93f7cdf2c fix(ai): unlock mutex before early return in error callback
Some checks failed
CI Code / Check coding style (pull_request) Successful in 36s
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m45s
CI Code / Linux (arch) (pull_request) Failing after 3m32s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m53s
The mutex was not released when the aiwin pointer was invalid, leading to a potential deadlock in the AI thread.
2026-05-14 03:52:20 +00:00
a9f0153c0f fix(ai): prevent duplicate user message in AI JSON payload
Some checks failed
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) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m53s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m59s
CI Code / Linux (arch) (pull_request) Failing after 3m39s
The user message was being added to session->history before calling
_build_json_payload_from_list(), which independently appends the prompt
to the JSON payload. This caused the same message to appear twice in
the API request.

Fix: Build JSON payload first, then add user message to history under
the same lock to maintain atomicity.
2026-05-14 03:50:12 +00:00
5a074b280d refactor(ai): remove hardcoded gpt-4o fallback model
Some checks failed
CI Code / Check coding style (pull_request) Successful in 38s
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Linux (arch) (pull_request) Failing after 3m33s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m46s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m53s
Remove the hardcoded "gpt-4o" fallback in cmd_ai_start to ensure
model selection relies exclusively on provider configuration defaults.
2026-05-14 03:37:59 +00:00
fba6a040ee refactor(cmd_ac): DRY AI autocomplete and fix trailing space API
Some checks failed
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m53s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m0s
CI Code / Linux (arch) (pull_request) Failing after 3m40s
- Replace _ai_model_autocomplete() with new _ai_provider_and_model_autocomplete()
  that handles both provider and model autocomplete in one function
- Add /ai set default-model model autocomplete support
- Remove trailing space requirement from cmd_prefix parameter (use \"/ai start\"
  instead of \"/ai start \")
- Fix const correctness in autocomplete_param_with_func() signature
- Remove unused space_at_end variable
2026-05-14 03:34:48 +00:00
89a224b017 feat(ai): allow /ai models command from console
Remove window type check from cmd_ai_models() so fetching models works
from console. Response is shown via cons_show() when called from console,
or in the AI window when called from there.
2026-05-13 22:02:02 +00:00
b28b719d11 feat(ai): persist default providers to config on first use
Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 38s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (ubuntu) (pull_request) Failing after 2h57m20s
CI Code / Linux (debian) (pull_request) Failing after 2h57m22s
CI Code / Linux (arch) (pull_request) Failing after 2h57m25s
Fix /ai set provider <name> <url> not persisting across restarts, and
default providers (openai, perplexity) disappearing when user adds a
custom provider.

- Add prefs_ai_set_provider(), prefs_ai_remove_provider(),
  prefs_ai_get_provider_url(), prefs_ai_list_providers(),
  prefs_free_ai_providers() for provider persistence.

- Implement prefs_ai_get_providers() in preferences.c: if no providers
  are configured, write default providers to config and return them.
  This ensures defaults are persisted so users can modify them directly.

- Move default provider URL definitions from ai_client.c to
  preferences.c. ai_client.c no longer knows about specific provider
  names.

- Simplify ai_client_init() to call prefs_ai_get_providers() and
  initialize with whatever it receives.

- ai_add_provider() persists to config; ai_remove_provider() removes
  from config.

Behavior: On first run, default providers are written to config. User
can then modify, remove, or add providers freely and they persist.
2026-05-13 21:44:02 +00:00
b21d537e80 feat(ai): persist custom providers to config and move defaults to preferences
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Code Coverage (pull_request) Failing after 1m12s
CI Code / Linux (debian) (pull_request) Failing after 3m13s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m21s
CI Code / Linux (arch) (pull_request) Failing after 4m1s
CI Code / Check coding style (pull_request) Failing after 13m40s
Fix /ai set provider <name> <url> not persisting across restarts.

- Add prefs_ai_set_provider(), prefs_ai_remove_provider(),
  prefs_ai_get_provider_url(), prefs_ai_list_providers(),
  prefs_free_ai_providers(), and prefs_ai_get_default_provider_url()
  to preferences.h/c for provider persistence.

- Move default provider URL definitions (openai, perplexity) from
  ai_client.c to preferences.c, following the established pattern
  where defaults are returned as fallbacks when no config value exists.

- Modify ai_add_provider() to persist new/updated providers to config.

- Modify ai_remove_provider() to remove provider from config.

- Add internal _ai_add_provider_nopersist() helper for adding default
  providers and loading from config without double-persisting.

- Update ai_client_init() to load custom providers from config on
  startup, allowing users to override defaults or add new ones.

Users now have full control: default providers remain as fallbacks,
user-added providers persist to config, and users can override or
remove defaults freely.
2026-05-13 18:28:35 +00:00
2f1637ca8e fix(ai): add reset for models_ac and streamline model autocomplete handling
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 36s
CI Code / Code Coverage (pull_request) Failing after 1m11s
CI Code / Linux (debian) (pull_request) Failing after 3m10s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m20s
CI Code / Linux (arch) (pull_request) Failing after 4m3s
Replace the custom ai_models_find function with a dedicated
ai_models_ac autocomplete list. Update the /ai switch trigger to
include a trailing space for consistent parsing.
2026-05-13 12:59:29 +00:00
277c82fb5d refactor(ai): remove default command and enforce strict window context
Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m5s
CI Code / Linux (arch) (pull_request) Failing after 3m51s
CI Code / Linux (debian) (pull_request) Failing after 5m58s
- Remove `/ai set default-provider` command and related autocomplete logic
- Remove global `wins_get_ai()` window accessor function
- Enforce strict requirement that `/ai model`, `/ai switch`, `/ai models`, and `/ai clear` commands must be executed from within an active AI chat window
- Update `/ai start` to require `<provider>` argument

BREAKING CHANGE: `/ai model`, `/ai switch`, `/ai models`, and `/ai clear` commands now require an active AI window context. The `/ai set default-provider` command has been removed.
2026-05-13 12:41:20 +00:00
1ecaceb59f fix(ai): reset provider autocomplete state on search string change
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 12m29s
CI Code / Check spelling (pull_request) Failing after 13m4s
CI Code / Check coding style (pull_request) Failing after 13m36s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m13s
CI Code / Linux (debian) (pull_request) Failing after 14m48s
CI Code / Linux (arch) (pull_request) Failing after 15m25s
When typing /ai start <prefix><tab>, the autocomplete was not properly
handling search string changes. For example, typing "/ai start o<tab>"
would return "openai", but then typing "/ai start p<tab>" would still
return "openai" because the autocomplete state was not reset.

This commit:
- Adds ai_providers_reset_ac() function to reset provider autocomplete
  state, following the same pattern as accounts_reset_all_search()
- Integrates ai_providers_reset_ac() into cmd_ac_reset() for proper
  state cleanup when user presses Ctrl+C or switches contexts
2026-05-13 09:40:52 +00:00
18fb7fa733 fix(ai): pass stanza ID to AI message send function
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 12m1s
CI Code / Check spelling (pull_request) Failing after 12m26s
CI Code / Check coding style (pull_request) Failing after 13m0s
CI Code / Linux (ubuntu) (pull_request) Failing after 13m34s
CI Code / Linux (debian) (pull_request) Failing after 14m1s
CI Code / Linux (arch) (pull_request) Failing after 14m23s
2026-05-12 17:08:30 +00:00
11 changed files with 267 additions and 166 deletions

View File

@@ -24,10 +24,6 @@
#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;
@@ -36,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);
@@ -113,6 +110,7 @@ _aiwin_display_error(gpointer user_data, const gchar* error_msg)
ProfAiWin* aiwin = _aiwin_validate(user_data);
if (!aiwin) {
log_warning("[AI-THREAD] Cannot display error: aiwin is invalid or window was closed (msg: %s)", error_msg);
pthread_mutex_unlock(&lock);
return;
}
aiwin_display_error(aiwin, error_msg);
@@ -282,24 +280,33 @@ ai_client_init(void)
/* Create autocomplete for provider names */
providers_ac = autocomplete_new();
/* Add default providers */
ai_add_provider("openai", DEFAULT_OPENAI_URL);
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL);
/* 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
@@ -330,6 +337,29 @@ 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)
{
@@ -346,16 +376,17 @@ ai_add_provider(const gchar* name, const gchar* api_url)
/* Update existing provider */
g_free(existing->api_url);
existing->api_url = g_strdup(api_url);
/* Persist the updated URL to config */
prefs_ai_set_provider(name, api_url);
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);
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 */
@@ -367,6 +398,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);
@@ -395,53 +429,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*
@@ -1081,7 +1088,6 @@ 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);
@@ -1096,7 +1102,8 @@ ai_session_unref(AISession* session)
curr = g_list_next(curr);
}
g_list_free(session->history);
pthread_mutex_unlock(&session->lock);
pthread_mutex_destroy(&session->lock);
g_free(session);
log_debug("AI session destroyed");
@@ -1435,20 +1442,18 @@ _ai_request_thread(gpointer data)
return NULL;
}
/* Add user message to history FIRST (under lock) */
/* Build JSON payload from history + prompt (under lock), then add user message to history */
log_debug("[AI-THREAD] Building JSON payload...");
pthread_mutex_lock(&session->lock);
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
/* Add user message to history now (it's in JSON but not yet in history) */
AIMessage* user_msg = g_new0(AIMessage, 1);
user_msg->role = g_strdup("user");
user_msg->content = g_strdup(prompt);
session->history = g_list_append(session->history, user_msg);
pthread_mutex_unlock(&session->lock);
log_debug("[AI-THREAD] Added user message to history");
/* Build JSON payload from local history copy (under lock) */
log_debug("[AI-THREAD] Building JSON payload...");
pthread_mutex_lock(&session->lock);
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
pthread_mutex_unlock(&session->lock);
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
/* Build headers using shared helper */
@@ -1460,7 +1465,7 @@ _ai_request_thread(gpointer data)
response.size = 0;
/* Build request URL */
const gchar* api_url = local_provider ? local_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);

View File

@@ -115,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

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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:
@@ -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>'.", 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);
@@ -11158,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;
@@ -11169,18 +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.");
// 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;
}
cons_show("");
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (aiwin->session) {
ai_session_clear_history(aiwin->session);
}
win_clear(window);
cons_show("AI Chat history cleared.");
return TRUE;
}

View File

@@ -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.

View File

@@ -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);

View File

@@ -260,7 +260,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
FREE_SET_NULL(ac->search_str);
}
ac->search_str = strdup(search_str);
ac->search_str = g_strdup(search_str);
found = _search(ac, ac->items, quote, NEXT);
return found;
@@ -305,7 +305,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
// autocomplete_func func is used -> autocomplete_param_with_func
// Autocomplete ac, gboolean quote are used -> autocomplete_param_with_ac
static char*
_autocomplete_param_common(const char* const input, 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);
}

View File

@@ -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,

View File

@@ -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)
{

View File

@@ -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);