feat(ai): add model caching, settings, and commands

- Introduce model caching with persistence to preferences
- Add provider default model and custom settings management
- Implement `/ai switch`, `/ai models`, and improve `/ai start`
- Add model name autocomplete for chat commands
- Update command definitions and help text
- Add unit tests for new functionality
This commit is contained in:
2026-05-09 13:15:58 +00:00
parent 731b55fa19
commit acae543057
11 changed files with 1255 additions and 98 deletions

View File

@@ -70,6 +70,7 @@
static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* prefix);
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous);
@@ -285,12 +286,14 @@ static Autocomplete correction_ac;
static Autocomplete avatar_ac;
static Autocomplete ai_subcommands_ac;
static Autocomplete ai_set_subcommands_ac;
static Autocomplete ai_set_custom_subcommands_ac;
static Autocomplete ai_remove_subcommands_ac;
static Autocomplete url_ac;
static Autocomplete executable_ac;
static Autocomplete executable_param_ac;
static Autocomplete intype_ac;
static Autocomplete mood_ac;
static Autocomplete ai_models_ac;
static Autocomplete mood_type_ac;
static Autocomplete strophe_ac;
static Autocomplete strophe_sm_ac;
@@ -465,6 +468,7 @@ static Autocomplete* all_acs[] = {
&force_encryption_policy_ac,
&ai_subcommands_ac,
&ai_set_subcommands_ac,
&ai_set_custom_subcommands_ac,
&ai_remove_subcommands_ac
};
@@ -1178,18 +1182,26 @@ cmd_ac_init(void)
autocomplete_add(correction_ac, "off");
autocomplete_add(correction_ac, "char");
autocomplete_add(ai_subcommands_ac, "set");
autocomplete_add(ai_subcommands_ac, "remove");
autocomplete_add(ai_subcommands_ac, "start");
autocomplete_add(ai_subcommands_ac, "clear");
autocomplete_add(ai_subcommands_ac, "correct");
autocomplete_add(ai_subcommands_ac, "providers");
autocomplete_add_unsorted(ai_subcommands_ac, "set", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "remove", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "start", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "clear", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "providers", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "switch", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "models", FALSE);
autocomplete_add(ai_set_subcommands_ac, "provider");
autocomplete_add(ai_set_subcommands_ac, "token");
autocomplete_add(ai_set_subcommands_ac, "org");
autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE);
autocomplete_add(ai_remove_subcommands_ac, "provider");
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "tools", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "search", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "memory", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "plugins", FALSE);
autocomplete_add_unsorted(ai_remove_subcommands_ac, "provider", FALSE);
autocomplete_add(avatar_ac, "set");
autocomplete_add(avatar_ac, "disable");
@@ -4480,6 +4492,12 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
char* result = NULL;
/* Parse once for reuse - /ai <subcommand> [<arg1>] [<arg2>] */
gboolean parse_result = FALSE;
auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result);
gboolean space_at_end = g_str_has_suffix(input, " ");
int num_args = g_strv_length(args);
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL);
if (result) {
@@ -4492,12 +4510,36 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
return result;
}
// /ai set org <provider> <org_id> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set org", ai_providers_find, previous, NULL);
// /ai set default-provider <provider> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set default-provider", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set default-model <provider> <model> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set default-model", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set custom <provider> <setting> <value> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set custom <provider> <setting> - autocomplete settings
/* args[0]="set", args[1]="custom", args[2]=provider (if typed), args[3]=setting (if typed) */
if (num_args == 3 && g_strcmp0(args[1], "custom") == 0) {
/* /ai set custom <provider> - check if provider is valid */
if (ai_get_provider(args[2])) {
/* Valid provider, try settings autocomplete */
result = autocomplete_param_with_ac(input, "/ai set custom ", ai_set_custom_subcommands_ac, TRUE, previous);
if (result) {
return result;
}
}
}
// /ai set <subcommand> - autocomplete subcommands
result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous);
if (result) {
@@ -4515,25 +4557,95 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
return result;
}
// /ai start [<provider>/<model>] - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
// /ai start [<provider>] [<model>] - use shared parse_args
if (parse_result && args && args[0] != NULL && g_strcmp0(args[0], "start") == 0) {
/* args[0]="start", args[1]=provider (if typed), args[2]=model (if typed) */
if (num_args == 1 || (num_args == 2 && !space_at_end)) {
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
} else {
/* /ai start <provider> <model> - model autocomplete */
result = _ai_model_autocomplete(window, input, previous, "/ai start ");
}
if (result) {
return result;
}
}
// /ai switch <provider> <model> - autocomplete model names for specified provider
result = _ai_model_autocomplete(window, input, previous, "/ai switch");
if (result) {
return result;
}
// /ai clear [<provider>] - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai clear", ai_providers_find, previous, NULL);
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai correct <provider> <text>
result = autocomplete_param_with_func(input, "/ai correct", ai_providers_find, previous, NULL);
// /ai switch <model> - autocomplete model names from current session's provider
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
result = autocomplete_param_with_func(input, "/ai switch", ai_models_find, previous, aiwin);
if (result) {
return result;
}
// /ai models <provider> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai models", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai clear - no autocomplete
// /ai providers - no autocomplete
result = autocomplete_param_with_ac(input, "/ai", ai_subcommands_ac, FALSE, previous);
return result;
}
}
/**
* Autocomplete model names for /ai <cmd> <provider> <model> patterns.
* Extracts provider from input and provides model completions from that provider's cached models.
* Uses static ai_models_ac to preserve cycling state (last_found) across calls.
*/
static char*
_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
{
if (!g_str_has_prefix(input, cmd_prefix)) {
return NULL;
}
/* Extract provider name from input (after cmd_prefix) */
auto_gchar gchar* rest = g_strdup(input + strlen(cmd_prefix));
char* space = strchr(rest, ' ');
if (space) {
*space = '\0';
}
/* Look up the provider by name and get its models */
AIProvider* prov = ai_get_provider(rest);
if (!prov || !prov->models) {
return NULL;
}
if (!ai_models_ac) {
ai_models_ac = autocomplete_new();
}
/* Convert GList* to char** for autocomplete_update */
int model_count = g_list_length(prov->models);
char** model_array = g_new0(char*, model_count + 1);
GList* curr = prov->models;
int i = 0;
while (curr) {
model_array[i++] = curr->data;
curr = g_list_next(curr);
}
autocomplete_update(ai_models_ac, model_array);
g_free(model_array);
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
return result;
}

View File

@@ -2794,8 +2794,9 @@ static const struct cmd_t command_defs[] = {
{ "remove", cmd_ai_remove },
{ "start", cmd_ai_start },
{ "clear", cmd_ai_clear },
{ "correct", cmd_ai_correct },
{ "providers", cmd_ai_providers })
{ "providers", cmd_ai_providers },
{ "switch", cmd_ai_switch },
{ "models", cmd_ai_models })
CMD_MAINFUNC(cmd_ai)
CMD_TAGS(
CMD_TAG_CHAT)
@@ -2803,43 +2804,52 @@ static const struct cmd_t command_defs[] = {
"/ai",
"/ai set provider <name> <url>",
"/ai set token <provider> <token>",
"/ai set org <provider> <org_id>",
"/ai set default-provider <provider>",
"/ai set default-model <provider> <model>",
"/ai set custom <provider> <setting> <value>",
"/ai remove provider <name>",
"/ai providers",
"/ai providers-list",
"/ai start [<provider>/<model>]",
"/ai model <model>",
"/ai clear",
"/ai correct <message>")
"/ai start [<provider>] [<model>]",
"/ai switch <provider> [<model>]",
"/ai switch <model>",
"/ai models <provider>",
"/ai clear")
CMD_DESC(
"Interact with AI models via OpenAI-compatible APIs. "
"Supports multiple providers (openai, perplexity, custom). "
"Each provider has its own API key and endpoint configuration. "
"Each provider has its own API key, endpoint, default model, and settings. "
"Chat history is maintained per session and not persisted locally.")
CMD_ARGS(
{ "", "Display current AI settings and configured providers" },
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
{ "set org <provider> <org_id>", "Set organization ID for a provider (optional)" },
{ "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 all available providers" },
{ "providers-list", "List configured providers with keys status" },
{ "start [<provider>/<model>]", "Start new AI chat (e.g., openai/gpt-4o)" },
{ "model <model>", "Change model in current chat window" },
{ "clear", "Clear current chat history" },
{ "correct <message>", "Correct last user message and get new response" })
{ "providers", "List configured providers with full details" },
{ "start [<provider>] [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
{ "switch <provider> [<model>]", "Switch to different provider (and optionally model)" },
{ "switch <model>", "Change model in current session (keeps provider)" },
{ "models <provider>", "Fetch and display available models for provider" },
{ "clear", "Clear current chat history" })
CMD_EXAMPLES(
"/ai",
"/ai set token openai sk-xxx",
"/ai set token perplexity pplx-xxx",
"/ai set provider custom https://my-api.com/v1/chat/completions",
"/ai set org openai my-org-id",
"/ai set default-provider perplexity",
"/ai set default-model perplexity sonar",
"/ai set custom perplexity tools enabled",
"/ai remove provider custom",
"/ai start openai/gpt-4o",
"/ai start perplexity/sonar",
"/ai providers-list",
"/ai clear",
"/ai correct I meant something else")
"/ai start",
"/ai start perplexity",
"/ai start perplexity sonar",
"/ai start openai gpt-4o",
"/ai switch gpt-4o",
"/ai switch openai gpt-5.4-nano",
"/ai models perplexity",
"/ai clear")
},
// NEXT-COMMAND (search helper)

View File

@@ -10795,21 +10795,28 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args)
cons_show("AI Chat - OpenAI-compatible API client");
cons_show("");
// List available providers
// List configured providers with full details
GList* providers = ai_list_providers();
cons_show("Available providers:");
cons_show("Configured providers:");
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
AIProvider* provider = curr->data;
auto_gchar gchar* key = ai_get_provider_key(provider->name);
cons_show(" %s (URL: %s, Key: %s)",
provider->name,
provider->api_url,
key ? "set" : "not set");
const gchar* default_model = ai_get_provider_default_model(provider->name);
cons_show(" %s", provider->name);
cons_show(" URL: %s", provider->api_url);
cons_show(" Key: %s", key ? "configured" : "NOT configured");
if (default_model) {
cons_show(" Default model: %s", default_model);
}
if (provider->models && g_list_length(provider->models) > 0) {
cons_show(" Cached models: %d", g_list_length(provider->models));
}
cons_show("");
}
g_list_free(providers);
cons_show("");
cons_show("Use '/ai start <provider>/<model>' to begin a chat.");
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
cons_show("Use '/ai models <provider>' to fetch available models.");
cons_show("Available models: https://models.litellm.ai/");
return TRUE;
}
@@ -10849,20 +10856,41 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
cons_show("API token set for provider: %s", args[2]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "org") == 0) {
// /ai set org <provider> <org_id>
} else if (g_strcmp0(args[1], "default-model") == 0) {
// /ai set default-model <provider> <model>
if (g_strv_length(args) < 4) {
cons_bad_cmd_usage(command);
return TRUE;
}
AIProvider* provider = ai_get_provider(args[2]);
if (provider) {
g_free(provider->org_id);
provider->org_id = g_strdup(args[3]);
cons_show("Organization ID set for provider: %s", args[2]);
} else {
cons_show("Provider '%s' not found. Add it first with '/ai set provider'.", args[2]);
ai_set_provider_default_model(args[2], args[3]);
cons_show("Default model for provider '%s' set to: %s", args[2], args[3]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "custom") == 0) {
// /ai set custom <provider> <setting> <value>
if (g_strv_length(args) < 5) {
cons_bad_cmd_usage(command);
return TRUE;
}
ai_set_provider_setting(args[2], args[3], args[4]);
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "default-provider") == 0) {
// /ai set default-provider <provider>
if (g_strv_length(args) < 3) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gchar* provider_name = args[2];
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
cons_show_error("Provider '%s' not found. Use '/ai set provider %s <url>' to add it.",
provider_name, provider_name);
return TRUE;
}
prefs_set_string(PREF_AI_PROVIDER, provider_name);
cons_show("Default provider set to: %s", provider_name);
cons_show("");
return TRUE;
}
@@ -10897,26 +10925,31 @@ cmd_ai_remove(ProfWin* window, const char* const command, gchar** args)
gboolean
cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
{
// /ai start [<provider>/<model>]
const gchar* provider_model = (g_strv_length(args) >= 2) ? args[1] : NULL;
// /ai start [<provider>] [<model>]
// Space-separated, no slash syntax
const gchar* provider_name = NULL;
const gchar* model = NULL;
auto_gchar gchar* owned_provider_name = NULL;
const gchar* provider_name = "openai";
const gchar* model = "gpt-4o";
if (provider_model) {
const gchar* slash = strchr(provider_model, '/');
if (slash) {
owned_provider_name = g_strndup(provider_model, slash - provider_model);
provider_name = owned_provider_name;
model = slash + 1;
} else {
// Just a model name, use default provider
model = provider_model;
}
if (g_strv_length(args) >= 2) {
provider_name = args[1];
}
if (g_strv_length(args) >= 3) {
model = args[2];
}
// Resolve defaults
auto_gchar gchar* owned_provider_name = NULL;
if (!provider_name) {
// Use default provider from preferences
auto_gchar gchar* default_provider = prefs_get_string(PREF_AI_PROVIDER);
if (default_provider && strlen(default_provider) > 0) {
provider_name = default_provider;
}
}
if (!provider_name) {
provider_name = "openai"; // Fallback
}
// Check if provider exists
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.",
@@ -10924,6 +10957,14 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
// Get model: explicit > provider default > hardcoded fallback
if (!model) {
model = ai_get_provider_default_model(provider_name);
}
if (!model) {
model = "gpt-4o"; // Fallback
}
// Check for API key
gchar* api_key = ai_get_provider_key(provider_name);
if (!api_key || strlen(api_key) == 0) {
@@ -10972,6 +11013,161 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
gboolean
cmd_ai_model(ProfWin* window, const char* const command, gchar** args)
{
// /ai model <model>
if (args[1] == NULL) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gchar* model = args[1];
// Get current AI window
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start' first.");
return TRUE;
}
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (!aiwin->session) {
cons_show("No active session in this chat window.");
return TRUE;
}
ai_session_set_model(aiwin->session, model);
cons_show("Session model changed to: %s", model);
cons_show("");
return TRUE;
}
gboolean
cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
{
// /ai switch <provider> [<model>] - Change provider and optionally model
// /ai switch <model> - Change model only (keeps current provider)
// Modifies the existing session's provider and model instead of recreating it
if (g_strv_length(args) < 2) {
cons_bad_cmd_usage(command);
return TRUE;
}
// Get current AI window
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start' first.");
return TRUE;
}
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (!aiwin->session) {
cons_show("No active session in this chat window.");
return TRUE;
}
const gchar* arg1 = args[1];
const gchar* arg2 = (g_strv_length(args) >= 3) ? args[2] : NULL;
const gchar* provider_name;
const gchar* model;
gboolean changed_provider = FALSE;
// Check if arg1 is a known provider
AIProvider* provider = ai_get_provider(arg1);
if (provider) {
// arg1 is a provider name
provider_name = arg1;
changed_provider = TRUE;
// Get model: arg2 > provider default > error
if (arg2) {
model = arg2;
} else {
model = ai_get_provider_default_model(provider_name);
}
if (!model) {
cons_show_error("No model specified and no default model set for provider '%s'.", provider_name);
cons_show("Use '/ai set default-model %s <model>' or '/ai switch %s <model>'.", provider_name, provider_name);
return TRUE;
}
} else {
// arg1 is a model name, keep current provider
provider_name = aiwin->session->provider_name;
model = arg1;
}
// Check for API key
auto_gchar gchar* api_key = ai_get_provider_key(provider_name);
if (!api_key || strlen(api_key) == 0) {
cons_show_error("No API key set for provider '%s'. Use '/ai set token %s <key>' first.",
provider_name, provider_name);
return TRUE;
}
// Update the existing session's provider and model
if (changed_provider) {
AIProvider* new_provider = ai_get_provider(provider_name);
g_free(aiwin->session->provider_name);
aiwin->session->provider_name = g_strdup(provider_name);
ai_provider_unref(aiwin->session->provider);
aiwin->session->provider = new_provider;
aiwin->session->provider->ref_count++;
}
g_free(aiwin->session->model);
aiwin->session->model = g_strdup(model);
g_free(aiwin->session->api_key);
aiwin->session->api_key = g_strdup(api_key);
// Update window title
win_println((ProfWin*)aiwin, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
cons_show("Switched to %s/%s", provider_name, model);
cons_show("");
return TRUE;
}
gboolean
cmd_ai_models(ProfWin* window, const char* const command, gchar** args)
{
// /ai models <provider> [--refresh]
if (args[1] == NULL) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gchar* provider_name = args[1];
gboolean refresh = (g_strv_length(args) >= 3) && (g_strcmp0(args[2], "--refresh") == 0);
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
cons_show_error("Provider '%s' not found.", provider_name);
return TRUE;
}
if (refresh || !ai_models_are_fresh(provider_name)) {
// Fetch models from API
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
ai_fetch_models(provider_name, aiwin);
} else {
// Display cached models
cons_show("Cached models for provider '%s':", provider_name);
GList* curr = provider->models;
while (curr) {
cons_show(" %s", (gchar*)curr->data);
curr = g_list_next(curr);
}
cons_show("");
cons_show("Use '/ai models %s --refresh' to fetch fresh models from the API.", provider_name);
}
return TRUE;
}
gboolean
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
{

View File

@@ -171,8 +171,9 @@ gboolean cmd_ai_set(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_remove(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_start(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_clear(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_correct(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_providers(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_switch(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_models(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_plugins(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_plugins_sourcepath(ProfWin* window, const char* const command, gchar** args);