Compare commits
10 Commits
test/ai-co
...
feat/ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
01b695dd70
|
|||
|
91695c0d4d
|
|||
|
360c5fb0a9
|
|||
|
3c021c754d
|
|||
|
33929e2ae7
|
|||
|
43d557f2dc
|
|||
|
0847394d34
|
|||
|
5104de40d3
|
|||
|
659b9d77df
|
|||
|
de2d21a0ac
|
@@ -35,6 +35,8 @@ static Autocomplete providers_ac = NULL;
|
|||||||
static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url);
|
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_load_models_for_provider(AIProvider* provider);
|
||||||
static void _ai_save_models_for_provider(AIProvider* provider);
|
static void _ai_save_models_for_provider(AIProvider* provider);
|
||||||
|
static const gchar* _find_json_field(const gchar* json, const gchar* field);
|
||||||
|
static gchar* _extract_json_string(const gchar* json, const gchar* field);
|
||||||
|
|
||||||
/* ========================================================================
|
/* ========================================================================
|
||||||
* Curl helpers
|
* Curl helpers
|
||||||
@@ -184,6 +186,85 @@ ai_json_escape(const gchar* str)
|
|||||||
return g_string_free(result, FALSE);
|
return g_string_free(result, FALSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Decode JSON string escapes in [start, end) into a freshly allocated buffer.
|
||||||
|
* Handles \" \\ \/ \n \t \r \b \f. \uXXXX sequences are passed through verbatim
|
||||||
|
* since the rest of the AI pipeline already deals in UTF-8 byte streams. */
|
||||||
|
static gchar*
|
||||||
|
_json_unescape_substring(const gchar* start, const gchar* end)
|
||||||
|
{
|
||||||
|
gchar* out = g_new0(gchar, (end - start) + 1);
|
||||||
|
gchar* w = out;
|
||||||
|
for (const gchar* in = start; in < end;) {
|
||||||
|
if (*in == '\\' && in + 1 < end) {
|
||||||
|
switch (in[1]) {
|
||||||
|
case '"':
|
||||||
|
*w++ = '"';
|
||||||
|
in += 2;
|
||||||
|
break;
|
||||||
|
case '\\':
|
||||||
|
*w++ = '\\';
|
||||||
|
in += 2;
|
||||||
|
break;
|
||||||
|
case '/':
|
||||||
|
*w++ = '/';
|
||||||
|
in += 2;
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
*w++ = '\n';
|
||||||
|
in += 2;
|
||||||
|
break;
|
||||||
|
case 't':
|
||||||
|
*w++ = '\t';
|
||||||
|
in += 2;
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
*w++ = '\r';
|
||||||
|
in += 2;
|
||||||
|
break;
|
||||||
|
case 'b':
|
||||||
|
*w++ = '\b';
|
||||||
|
in += 2;
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
*w++ = '\f';
|
||||||
|
in += 2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
*w++ = *in++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*w++ = *in++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*w = '\0';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Locate "field":"...value..." in json and return the unescaped value.
|
||||||
|
* Skips JSON whitespace around the colon and treats any \X (X != \0) inside
|
||||||
|
* the string body as an opaque escape so an embedded \" cannot be misread as
|
||||||
|
* the closing quote. */
|
||||||
|
static gchar*
|
||||||
|
_extract_json_string(const gchar* json, const gchar* field)
|
||||||
|
{
|
||||||
|
const gchar* val = _find_json_field(json, field);
|
||||||
|
if (!val || *val != '"')
|
||||||
|
return NULL;
|
||||||
|
|
||||||
|
const gchar* start = val + 1;
|
||||||
|
for (const gchar* p = start; *p; p++) {
|
||||||
|
if (*p == '\\' && *(p + 1) != '\0') {
|
||||||
|
p++; /* skip the escaped character; loop ++ advances past it */
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (*p == '"') {
|
||||||
|
return _json_unescape_substring(start, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================================================
|
/* ========================================================================
|
||||||
* Provider Management
|
* Provider Management
|
||||||
* ======================================================================== */
|
* ======================================================================== */
|
||||||
@@ -296,13 +377,31 @@ ai_client_init(void)
|
|||||||
/* Load saved API keys from config */
|
/* Load saved API keys from config */
|
||||||
ai_load_keys();
|
ai_load_keys();
|
||||||
|
|
||||||
/* Load cached models for all providers */
|
/* Load cached models, persisted default-model and custom settings for
|
||||||
|
* all providers. */
|
||||||
GList* provider_list = ai_list_providers();
|
GList* provider_list = ai_list_providers();
|
||||||
GList* models_curr = provider_list;
|
for (GList* curr = provider_list; curr; curr = g_list_next(curr)) {
|
||||||
while (models_curr) {
|
AIProvider* provider = (AIProvider*)curr->data;
|
||||||
AIProvider* provider = (AIProvider*)models_curr->data;
|
|
||||||
_ai_load_models_for_provider(provider);
|
_ai_load_models_for_provider(provider);
|
||||||
models_curr = g_list_next(models_curr);
|
|
||||||
|
auto_gchar gchar* dm = prefs_ai_get_default_model(provider->name);
|
||||||
|
if (dm && dm[0] != '\0') {
|
||||||
|
g_free(provider->default_model);
|
||||||
|
provider->default_model = g_strdup(dm);
|
||||||
|
}
|
||||||
|
|
||||||
|
GList* settings = prefs_ai_list_settings(provider->name);
|
||||||
|
for (GList* s = settings; s; s = g_list_next(s)) {
|
||||||
|
const gchar* name = (const gchar*)s->data;
|
||||||
|
auto_gchar gchar* val = prefs_ai_get_setting(provider->name, name);
|
||||||
|
if (val) {
|
||||||
|
if (!provider->settings) {
|
||||||
|
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||||
|
}
|
||||||
|
g_hash_table_insert(provider->settings, g_strdup(name), g_strdup(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs_free_ai_settings(settings);
|
||||||
}
|
}
|
||||||
g_list_free(provider_list);
|
g_list_free(provider_list);
|
||||||
|
|
||||||
@@ -398,8 +497,9 @@ ai_remove_provider(const gchar* name)
|
|||||||
if (!name || !providers)
|
if (!name || !providers)
|
||||||
return FALSE;
|
return FALSE;
|
||||||
|
|
||||||
/* Remove from config before removing from hash table */
|
/* Wipe the whole [ai/<name>] subsection from config — url, key,
|
||||||
prefs_ai_remove_provider(name);
|
* default_model, models, custom settings — in one call. */
|
||||||
|
prefs_ai_remove_section(name);
|
||||||
|
|
||||||
/* Sync autocomplete before removing */
|
/* Sync autocomplete before removing */
|
||||||
autocomplete_remove(providers_ac, name);
|
autocomplete_remove(providers_ac, name);
|
||||||
@@ -501,6 +601,7 @@ ai_set_provider_default_model(const gchar* provider_name, const gchar* model)
|
|||||||
|
|
||||||
g_free(provider->default_model);
|
g_free(provider->default_model);
|
||||||
provider->default_model = g_strdup(model);
|
provider->default_model = g_strdup(model);
|
||||||
|
prefs_ai_set_default_model(provider_name, model);
|
||||||
log_info("Default model for provider '%s' set to: %s", provider_name, model);
|
log_info("Default model for provider '%s' set to: %s", provider_name, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,9 +636,11 @@ ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const
|
|||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value));
|
g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value));
|
||||||
|
prefs_ai_set_setting(provider_name, setting, value);
|
||||||
log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value);
|
log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value);
|
||||||
} else {
|
} else {
|
||||||
g_hash_table_remove(provider->settings, setting);
|
g_hash_table_remove(provider->settings, setting);
|
||||||
|
prefs_ai_remove_setting(provider_name, setting);
|
||||||
log_info("Setting '%s' removed for provider '%s'", setting, provider_name);
|
log_info("Setting '%s' removed for provider '%s'", setting, provider_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -609,8 +712,11 @@ _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers,
|
|||||||
log_error("Request failed for '%s': %s", provider->name, error_msg);
|
log_error("Request failed for '%s': %s", provider->name, error_msg);
|
||||||
_aiwin_display_error(user_data, error_msg);
|
_aiwin_display_error(user_data, error_msg);
|
||||||
} else if (http_code >= 400) {
|
} else if (http_code >= 400) {
|
||||||
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
|
auto_gchar gchar* parsed = ai_parse_error_message(response->data);
|
||||||
response->data ? response->data : "Unknown error");
|
auto_gchar gchar* error_msg = parsed
|
||||||
|
? g_strdup_printf("HTTP %ld: %s", http_code, parsed)
|
||||||
|
: g_strdup_printf("HTTP %ld: %s", http_code,
|
||||||
|
response->data && strlen(response->data) > 0 ? response->data : "Unknown error");
|
||||||
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
|
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
|
||||||
_aiwin_display_error(user_data, error_msg);
|
_aiwin_display_error(user_data, error_msg);
|
||||||
} else {
|
} else {
|
||||||
@@ -979,9 +1085,18 @@ _models_response_handler(AIProvider* provider, const gchar* response, gpointer u
|
|||||||
{
|
{
|
||||||
_parse_and_cache_models(provider, response);
|
_parse_and_cache_models(provider, response);
|
||||||
|
|
||||||
auto_gchar gchar* msg = g_strdup_printf("Cached %d models for provider '%s'. Use '/ai start <provider> <model>' to start a chat.",
|
gint count = g_list_length(provider->models);
|
||||||
g_list_length(provider->models), provider->name);
|
GString* msg = g_string_new(NULL);
|
||||||
_aiwin_display_response(user_data, msg);
|
g_string_append_printf(msg, "Cached %d models for provider '%s':", count, provider->name);
|
||||||
|
for (GList* curr = provider->models; curr; curr = g_list_next(curr)) {
|
||||||
|
g_string_append_printf(msg, "\n %s", (const gchar*)curr->data);
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
g_string_append_printf(msg, "\nUse '/ai start %s <model>' to start a chat.", provider->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_gchar gchar* out = g_string_free(msg, FALSE);
|
||||||
|
_aiwin_display_response(user_data, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
static gpointer
|
static gpointer
|
||||||
@@ -1256,139 +1371,30 @@ ai_parse_response(const gchar* response_json)
|
|||||||
if (!response_json || strlen(response_json) == 0)
|
if (!response_json || strlen(response_json) == 0)
|
||||||
return NULL;
|
return NULL;
|
||||||
|
|
||||||
/* Try Perplexity /v1/agent format first: look for "text":"..." inside output array
|
/* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]}
|
||||||
* Response: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */
|
* Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */
|
||||||
const gchar* text_start = strstr(response_json, "\"text\":\"");
|
gchar* text = _extract_json_string(response_json, "text");
|
||||||
if (text_start) {
|
if (text)
|
||||||
text_start += strlen("\"text\":\"");
|
return text;
|
||||||
const gchar* p = text_start;
|
|
||||||
while (*p) {
|
|
||||||
if (*p == '\\' && *(p + 1) == '"') {
|
|
||||||
p += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (*p == '"') {
|
|
||||||
gsize len = p - text_start;
|
|
||||||
gchar* result = g_new0(gchar, len + 1);
|
|
||||||
gchar* out = result;
|
|
||||||
const gchar* in = text_start;
|
|
||||||
while (in < p) {
|
|
||||||
if (*in == '\\' && *(in + 1) == '"') {
|
|
||||||
*out++ = '"';
|
|
||||||
in += 2;
|
|
||||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
|
||||||
*out++ = '\n';
|
|
||||||
in += 2;
|
|
||||||
} else {
|
|
||||||
*out++ = *in++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*out = '\0';
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
p++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Try legacy OpenAI format: "content":"..."
|
return _extract_json_string(response_json, "content");
|
||||||
* Response: {"choices":[{"message":{"content":"..."}}]} */
|
|
||||||
const gchar* content_start = strstr(response_json, "\"content\":\"");
|
|
||||||
if (!content_start)
|
|
||||||
return NULL;
|
|
||||||
|
|
||||||
content_start += strlen("\"content\":\"");
|
|
||||||
|
|
||||||
/* Find the closing quote, accounting for escaped quotes */
|
|
||||||
const gchar* p = content_start;
|
|
||||||
while (*p) {
|
|
||||||
if (*p == '\\' && *(p + 1) == '"') {
|
|
||||||
/* Escaped quote, skip both characters */
|
|
||||||
p += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (*p == '"') {
|
|
||||||
/* Found unescaped closing quote */
|
|
||||||
gsize len = p - content_start;
|
|
||||||
/* Unescape the content: convert \" back to " */
|
|
||||||
gchar* result = g_new0(gchar, len + 1);
|
|
||||||
gchar* out = result;
|
|
||||||
const gchar* in = content_start;
|
|
||||||
while (in < p) {
|
|
||||||
if (*in == '\\' && *(in + 1) == '"') {
|
|
||||||
*out++ = '"';
|
|
||||||
in += 2;
|
|
||||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
|
||||||
*out++ = '\n';
|
|
||||||
in += 2;
|
|
||||||
} else {
|
|
||||||
*out++ = *in++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*out = '\0';
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
p++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NULL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/* Extract the human-readable error.message from a provider error envelope:
|
||||||
* Extract error message from an API error JSON response.
|
* {"error":{"message":"...","type":"...","code":...}}
|
||||||
* Handles format: {"error":{"message":"...","type":"...","code":...}}
|
* Scoped to the substring after "error": so unrelated top-level "message"
|
||||||
*/
|
* fields do not get picked up. */
|
||||||
gchar*
|
gchar*
|
||||||
ai_parse_error_message(const gchar* error_json)
|
ai_parse_error_message(const gchar* error_json)
|
||||||
{
|
{
|
||||||
if (!error_json || strlen(error_json) == 0)
|
if (!error_json || strlen(error_json) == 0)
|
||||||
return NULL;
|
return NULL;
|
||||||
|
|
||||||
/* Look for "message":"..." inside "error" object */
|
|
||||||
const gchar* error_obj = strstr(error_json, "\"error\":");
|
const gchar* error_obj = strstr(error_json, "\"error\":");
|
||||||
if (!error_obj)
|
if (!error_obj)
|
||||||
return NULL;
|
return NULL;
|
||||||
|
|
||||||
const gchar* message_key = strstr(error_obj, "\"message\":\"");
|
return _extract_json_string(error_obj, "message");
|
||||||
if (!message_key)
|
|
||||||
return NULL;
|
|
||||||
|
|
||||||
message_key += strlen("\"message\":\"");
|
|
||||||
const gchar* msg_start = message_key;
|
|
||||||
const gchar* p = msg_start;
|
|
||||||
while (*p) {
|
|
||||||
if (*p == '\\' && *(p + 1) == '"') {
|
|
||||||
p += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (*p == '"') {
|
|
||||||
gsize len = p - msg_start;
|
|
||||||
gchar* result = g_new0(gchar, len + 1);
|
|
||||||
gchar* out = result;
|
|
||||||
const gchar* in = msg_start;
|
|
||||||
while (in < p) {
|
|
||||||
if (*in == '\\' && *(in + 1) == '"') {
|
|
||||||
*out++ = '"';
|
|
||||||
in += 2;
|
|
||||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
|
||||||
*out++ = '\n';
|
|
||||||
in += 2;
|
|
||||||
} else if (*in == '\\' && *(in + 1) == '\\') {
|
|
||||||
*out++ = '\\';
|
|
||||||
in += 2;
|
|
||||||
} else if (*in == '\\' && *(in + 1) == 't') {
|
|
||||||
*out++ = '\t';
|
|
||||||
in += 2;
|
|
||||||
} else {
|
|
||||||
*out++ = *in++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*out = '\0';
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
p++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NULL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static gpointer
|
static gpointer
|
||||||
|
|||||||
@@ -106,15 +106,6 @@ GList* ai_list_providers(void);
|
|||||||
*/
|
*/
|
||||||
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
|
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a model name for autocomplete.
|
|
||||||
* @param search_str The search string
|
|
||||||
* @param previous Whether to go to previous match
|
|
||||||
* @param context ProfAiWin* for the current session
|
|
||||||
* @return Model name, or NULL if not found
|
|
||||||
*/
|
|
||||||
gchar* ai_models_find(const char* const search_str, gboolean previous, void* context);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset the provider autocomplete state.
|
* Reset the provider autocomplete state.
|
||||||
* Called from cmd_ac_reset() to clear autocomplete state.
|
* Called from cmd_ac_reset() to clear autocomplete state.
|
||||||
|
|||||||
@@ -10817,7 +10817,6 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args)
|
|||||||
|
|
||||||
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
|
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
|
||||||
cons_show("Use '/ai models <provider>' to fetch available models.");
|
cons_show("Use '/ai models <provider>' to fetch available models.");
|
||||||
cons_show("Available models: https://models.litellm.ai/");
|
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11174,30 +11173,24 @@ cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
|
|||||||
gboolean
|
gboolean
|
||||||
cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
|
cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
|
||||||
{
|
{
|
||||||
if (args[1] == NULL || g_strcmp0(args[1], "list") != 0) {
|
|
||||||
// List all available providers
|
|
||||||
cons_show("Available AI providers:");
|
|
||||||
cons_show("");
|
|
||||||
cons_show(" openai - OpenAI API (https://api.openai.com)");
|
|
||||||
cons_show(" perplexity - Perplexity API (https://api.perplexity.ai)");
|
|
||||||
cons_show("");
|
|
||||||
cons_show("Add custom providers with: /ai set provider <name> <url>");
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// List configured providers with key status
|
|
||||||
cons_show("Configured providers:");
|
cons_show("Configured providers:");
|
||||||
cons_show("");
|
cons_show("");
|
||||||
|
|
||||||
GList* providers = ai_list_providers();
|
GList* providers = ai_list_providers();
|
||||||
|
if (!providers) {
|
||||||
|
cons_show(" (none configured)");
|
||||||
|
cons_show("");
|
||||||
|
cons_show("Add a provider with: /ai set provider <name> <url>");
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||||
AIProvider* provider = curr->data;
|
AIProvider* provider = curr->data;
|
||||||
gchar* key = ai_get_provider_key(provider->name);
|
auto_gchar gchar* key = ai_get_provider_key(provider->name);
|
||||||
cons_show(" %s", provider->name);
|
cons_show(" %s", provider->name);
|
||||||
cons_show(" URL: %s", provider->api_url);
|
cons_show(" URL: %s", provider->api_url);
|
||||||
cons_show(" Key: %s", key ? "configured" : "NOT configured");
|
cons_show(" Key: %s", key ? "configured" : "NOT configured");
|
||||||
cons_show("");
|
cons_show("");
|
||||||
g_free(key);
|
|
||||||
}
|
}
|
||||||
g_list_free(providers);
|
g_list_free(providers);
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
#define PREF_GROUP_PLUGINS "plugins"
|
#define PREF_GROUP_PLUGINS "plugins"
|
||||||
#define PREF_GROUP_EXECUTABLES "executables"
|
#define PREF_GROUP_EXECUTABLES "executables"
|
||||||
#define PREF_GROUP_AI "ai"
|
#define PREF_GROUP_AI "ai"
|
||||||
|
#define PREF_GROUP_AI_PREFIX "ai/" /* per-provider subsection prefix: [ai/<provider>] */
|
||||||
|
#define PREF_AI_SETTING_PREFIX "setting." /* per-provider custom setting key prefix */
|
||||||
|
|
||||||
#define INPBLOCK_DEFAULT 1000
|
#define INPBLOCK_DEFAULT 1000
|
||||||
|
|
||||||
@@ -243,6 +245,69 @@ _prefs_load(void)
|
|||||||
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.jid", NULL);
|
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.jid", NULL);
|
||||||
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.name", NULL);
|
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.name", NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Migrate legacy flat [ai] layout
|
||||||
|
* openai_url=..., openai=<token>, openai_models=..., openai_default_model=...
|
||||||
|
* into per-provider subsections
|
||||||
|
* [ai/openai] url=..., key=..., models=..., default_model=...
|
||||||
|
* Idempotent: once the flat keys are gone, this is a no-op. */
|
||||||
|
if (g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
||||||
|
gsize key_count = 0;
|
||||||
|
auto_gcharv gchar** all_keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &key_count, NULL);
|
||||||
|
|
||||||
|
/* Collect provider names from <name>_url keys first so we can migrate
|
||||||
|
* the whole bundle (url + token + models + default_model) atomically
|
||||||
|
* per provider, without misclassifying unrelated flat keys. */
|
||||||
|
GHashTable* to_migrate = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||||
|
for (gsize i = 0; i < key_count; i++) {
|
||||||
|
const gchar* k = all_keys[i];
|
||||||
|
if (g_str_has_suffix(k, "_url") && !g_str_has_suffix(k, "_models_url")) {
|
||||||
|
gsize klen = strlen(k);
|
||||||
|
if (klen > 4) {
|
||||||
|
g_hash_table_add(to_migrate, g_strndup(k, klen - 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GList* provider_names = g_hash_table_get_keys(to_migrate);
|
||||||
|
for (GList* curr = provider_names; curr; curr = g_list_next(curr)) {
|
||||||
|
const gchar* name = (const gchar*)curr->data;
|
||||||
|
auto_gchar gchar* group = g_strconcat(PREF_GROUP_AI_PREFIX, name, NULL);
|
||||||
|
|
||||||
|
const struct
|
||||||
|
{
|
||||||
|
const gchar* flat_suffix;
|
||||||
|
const gchar* new_key;
|
||||||
|
} map[] = {
|
||||||
|
{ "_url", "url" },
|
||||||
|
{ "_models", "models" },
|
||||||
|
{ "_default_model", "default_model" },
|
||||||
|
{ NULL, NULL },
|
||||||
|
};
|
||||||
|
for (int j = 0; map[j].flat_suffix; j++) {
|
||||||
|
auto_gchar gchar* flat_key = g_strconcat(name, map[j].flat_suffix, NULL);
|
||||||
|
if (g_key_file_has_key(prefs, PREF_GROUP_AI, flat_key, NULL)) {
|
||||||
|
auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, flat_key, NULL);
|
||||||
|
if (val) {
|
||||||
|
g_key_file_set_string(prefs, group, map[j].new_key, val);
|
||||||
|
}
|
||||||
|
g_key_file_remove_key(prefs, PREF_GROUP_AI, flat_key, NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Token was stored as the bare provider name in [ai]. */
|
||||||
|
if (g_key_file_has_key(prefs, PREF_GROUP_AI, name, NULL)) {
|
||||||
|
auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
|
||||||
|
if (val) {
|
||||||
|
g_key_file_set_string(prefs, group, "key", val);
|
||||||
|
}
|
||||||
|
g_key_file_remove_key(prefs, PREF_GROUP_AI, name, NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g_list_free(provider_names);
|
||||||
|
g_hash_table_destroy(to_migrate);
|
||||||
|
}
|
||||||
|
|
||||||
_save_prefs();
|
_save_prefs();
|
||||||
|
|
||||||
boolean_choice_ac = autocomplete_new();
|
boolean_choice_ac = autocomplete_new();
|
||||||
@@ -1708,73 +1773,103 @@ _save_prefs(void)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================================
|
/* ========================================================================
|
||||||
* AI Token Management
|
* AI provider preferences
|
||||||
|
*
|
||||||
|
* Each provider lives in its own [ai/<name>] subsection of the keyfile with:
|
||||||
|
* url=...
|
||||||
|
* key=<api token>
|
||||||
|
* default_model=...
|
||||||
|
* models=model1;model2;...
|
||||||
|
* setting.<custom>=value (e.g. setting.tools=enabled)
|
||||||
|
* The bare [ai] section keeps only the global "defaults_initialized" sentinel.
|
||||||
|
* Legacy flat-key layouts are migrated on first load in _prefs_load().
|
||||||
* ======================================================================== */
|
* ======================================================================== */
|
||||||
|
|
||||||
|
static gchar*
|
||||||
|
_ai_section(const char* const provider)
|
||||||
|
{
|
||||||
|
return g_strconcat(PREF_GROUP_AI_PREFIX, provider, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean
|
||||||
|
_ai_set_string(const char* const provider, const char* const key, const char* const value)
|
||||||
|
{
|
||||||
|
if (!provider || !key || !value || !prefs)
|
||||||
|
return FALSE;
|
||||||
|
|
||||||
|
auto_gchar gchar* group = _ai_section(provider);
|
||||||
|
g_key_file_set_string(prefs, group, key, value);
|
||||||
|
_save_prefs();
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char*
|
||||||
|
_ai_get_string(const char* const provider, const char* const key)
|
||||||
|
{
|
||||||
|
if (!provider || !key || !prefs)
|
||||||
|
return NULL;
|
||||||
|
|
||||||
|
auto_gchar gchar* group = _ai_section(provider);
|
||||||
|
if (!g_key_file_has_key(prefs, group, key, NULL))
|
||||||
|
return NULL;
|
||||||
|
return g_key_file_get_string(prefs, group, key, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean
|
||||||
|
_ai_remove_string(const char* const provider, const char* const key)
|
||||||
|
{
|
||||||
|
if (!provider || !key || !prefs)
|
||||||
|
return FALSE;
|
||||||
|
|
||||||
|
auto_gchar gchar* group = _ai_section(provider);
|
||||||
|
if (!g_key_file_has_key(prefs, group, key, NULL))
|
||||||
|
return FALSE;
|
||||||
|
|
||||||
|
g_key_file_remove_key(prefs, group, key, NULL);
|
||||||
|
_save_prefs();
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Token --- */
|
||||||
|
|
||||||
gboolean
|
gboolean
|
||||||
prefs_ai_set_token(const char* const provider, const char* const token)
|
prefs_ai_set_token(const char* const provider, const char* const token)
|
||||||
{
|
{
|
||||||
if (!provider)
|
if (!provider || !prefs)
|
||||||
return FALSE;
|
return FALSE;
|
||||||
|
if (!token)
|
||||||
if (!prefs)
|
|
||||||
return FALSE;
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
g_key_file_set_string(prefs, PREF_GROUP_AI, provider, token);
|
|
||||||
_save_prefs();
|
|
||||||
return TRUE;
|
|
||||||
} else {
|
|
||||||
return prefs_ai_remove_token(provider);
|
return prefs_ai_remove_token(provider);
|
||||||
}
|
return _ai_set_string(provider, "key", token);
|
||||||
}
|
}
|
||||||
|
|
||||||
gboolean
|
gboolean
|
||||||
prefs_ai_remove_token(const char* const provider)
|
prefs_ai_remove_token(const char* const provider)
|
||||||
{
|
{
|
||||||
if (!provider)
|
return _ai_remove_string(provider, "key");
|
||||||
return FALSE;
|
|
||||||
|
|
||||||
if (!prefs)
|
|
||||||
return FALSE;
|
|
||||||
|
|
||||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, provider, NULL)) {
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, provider, NULL);
|
|
||||||
_save_prefs();
|
|
||||||
return TRUE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
char*
|
char*
|
||||||
prefs_ai_get_token(const char* const provider)
|
prefs_ai_get_token(const char* const provider)
|
||||||
{
|
{
|
||||||
if (!provider || !prefs)
|
return _ai_get_string(provider, "key");
|
||||||
return NULL;
|
|
||||||
|
|
||||||
return g_key_file_get_string(prefs, PREF_GROUP_AI, provider, NULL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GList*
|
GList*
|
||||||
prefs_ai_list_tokens(void)
|
prefs_ai_list_tokens(void)
|
||||||
{
|
{
|
||||||
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
if (!prefs)
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
|
||||||
|
|
||||||
GList* result = NULL;
|
GList* result = NULL;
|
||||||
gsize len;
|
GList* providers = prefs_ai_list_providers();
|
||||||
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
|
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
||||||
|
const gchar* name = (const gchar*)curr->data;
|
||||||
for (gsize i = 0; i < len; i++) {
|
auto_gchar gchar* tok = prefs_ai_get_token(name);
|
||||||
char* name = keys[i];
|
if (tok && tok[0] != '\0') {
|
||||||
auto_gchar gchar* value = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
|
|
||||||
if (value) {
|
|
||||||
result = g_list_append(result, g_strdup(name));
|
result = g_list_append(result, g_strdup(name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
prefs_free_ai_providers(providers);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1786,72 +1881,45 @@ prefs_free_ai_tokens(GList* tokens)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Models cache --- */
|
||||||
|
|
||||||
gboolean
|
gboolean
|
||||||
prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count)
|
prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count)
|
||||||
{
|
{
|
||||||
if (!provider || count <= 0)
|
if (!provider || count <= 0 || !prefs)
|
||||||
return FALSE;
|
return FALSE;
|
||||||
|
|
||||||
if (!prefs)
|
|
||||||
return FALSE;
|
|
||||||
|
|
||||||
/* Build semicolon-separated model list */
|
|
||||||
GString* model_str = g_string_new("");
|
GString* model_str = g_string_new("");
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
if (i > 0) {
|
if (i > 0)
|
||||||
g_string_append_c(model_str, ';');
|
g_string_append_c(model_str, ';');
|
||||||
}
|
|
||||||
g_string_append(model_str, models[i]);
|
g_string_append(model_str, models[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str);
|
gboolean ok = _ai_set_string(provider, "models", model_str->str);
|
||||||
g_string_free(model_str, TRUE);
|
g_string_free(model_str, TRUE);
|
||||||
_save_prefs();
|
return ok;
|
||||||
return TRUE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gboolean
|
gboolean
|
||||||
prefs_ai_remove_models(const char* const provider)
|
prefs_ai_remove_models(const char* const provider)
|
||||||
{
|
{
|
||||||
if (!provider)
|
return _ai_remove_string(provider, "models");
|
||||||
return FALSE;
|
|
||||||
|
|
||||||
if (!prefs)
|
|
||||||
return FALSE;
|
|
||||||
|
|
||||||
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
|
|
||||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, key, NULL);
|
|
||||||
_save_prefs();
|
|
||||||
return TRUE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GList*
|
GList*
|
||||||
prefs_ai_get_models(const char* const provider)
|
prefs_ai_get_models(const char* const provider)
|
||||||
{
|
{
|
||||||
if (!provider || !prefs)
|
auto_gchar gchar* model_str = _ai_get_string(provider, "models");
|
||||||
|
if (!model_str || model_str[0] == '\0')
|
||||||
return NULL;
|
return NULL;
|
||||||
|
|
||||||
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
|
|
||||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto_gchar gchar* model_str = g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
|
|
||||||
if (!model_str || strlen(model_str) == 0) {
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
GList* result = NULL;
|
GList* result = NULL;
|
||||||
gchar** models = g_strsplit(model_str, ";", -1);
|
gchar** models = g_strsplit(model_str, ";", -1);
|
||||||
for (int i = 0; models[i] != NULL; i++) {
|
for (int i = 0; models[i] != NULL; i++) {
|
||||||
result = g_list_append(result, g_strdup(models[i]));
|
result = g_list_append(result, g_strdup(models[i]));
|
||||||
}
|
}
|
||||||
g_strfreev(models);
|
g_strfreev(models);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1863,78 +1931,89 @@ prefs_free_ai_models(GList* models)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================================
|
/* --- Default model --- */
|
||||||
* AI Provider Management
|
|
||||||
* ======================================================================== */
|
gboolean
|
||||||
|
prefs_ai_set_default_model(const char* const provider, const char* const model)
|
||||||
|
{
|
||||||
|
if (!provider || !model || !prefs)
|
||||||
|
return FALSE;
|
||||||
|
if (model[0] == '\0')
|
||||||
|
return prefs_ai_remove_default_model(provider);
|
||||||
|
return _ai_set_string(provider, "default_model", model);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean
|
||||||
|
prefs_ai_remove_default_model(const char* const provider)
|
||||||
|
{
|
||||||
|
return _ai_remove_string(provider, "default_model");
|
||||||
|
}
|
||||||
|
|
||||||
|
char*
|
||||||
|
prefs_ai_get_default_model(const char* const provider)
|
||||||
|
{
|
||||||
|
return _ai_get_string(provider, "default_model");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Provider URL --- */
|
||||||
|
|
||||||
gboolean
|
gboolean
|
||||||
prefs_ai_set_provider(const char* const provider, const char* const url)
|
prefs_ai_set_provider(const char* const provider, const char* const url)
|
||||||
{
|
{
|
||||||
if (!provider || !url)
|
if (!provider || !url)
|
||||||
return FALSE;
|
return FALSE;
|
||||||
|
return _ai_set_string(provider, "url", url);
|
||||||
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
|
gboolean
|
||||||
prefs_ai_remove_provider(const char* const provider)
|
prefs_ai_remove_provider(const char* const provider)
|
||||||
{
|
{
|
||||||
if (!provider)
|
/* Provider presence is identified by the url key; removing it is what
|
||||||
return FALSE;
|
* makes prefs_ai_list_providers stop returning this name. The remaining
|
||||||
|
* sub-keys are wiped through prefs_ai_remove_section(). */
|
||||||
if (!prefs)
|
return _ai_remove_string(provider, "url");
|
||||||
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*
|
char*
|
||||||
prefs_ai_get_provider_url(const char* const provider)
|
prefs_ai_get_provider_url(const char* const provider)
|
||||||
|
{
|
||||||
|
return _ai_get_string(provider, "url");
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean
|
||||||
|
prefs_ai_remove_section(const char* const provider)
|
||||||
{
|
{
|
||||||
if (!provider || !prefs)
|
if (!provider || !prefs)
|
||||||
return NULL;
|
return FALSE;
|
||||||
|
|
||||||
auto_gchar gchar* key = g_strconcat(provider, "_url", NULL);
|
auto_gchar gchar* group = _ai_section(provider);
|
||||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
if (!g_key_file_has_group(prefs, group))
|
||||||
return NULL;
|
return FALSE;
|
||||||
}
|
|
||||||
|
|
||||||
return g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
|
g_key_file_remove_group(prefs, group, NULL);
|
||||||
|
_save_prefs();
|
||||||
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
GList*
|
GList*
|
||||||
prefs_ai_list_providers(void)
|
prefs_ai_list_providers(void)
|
||||||
{
|
{
|
||||||
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
if (!prefs)
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
|
||||||
|
|
||||||
GList* result = NULL;
|
GList* result = NULL;
|
||||||
gsize len = 0;
|
gsize len = 0;
|
||||||
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
|
auto_gcharv gchar** groups = g_key_file_get_groups(prefs, &len);
|
||||||
|
if (!groups)
|
||||||
|
return NULL;
|
||||||
|
|
||||||
|
gsize prefix_len = strlen(PREF_GROUP_AI_PREFIX);
|
||||||
for (gsize i = 0; i < len; i++) {
|
for (gsize i = 0; i < len; i++) {
|
||||||
char* key = keys[i];
|
const gchar* g = groups[i];
|
||||||
/* Match keys ending with "_url" that are not "_models" suffix */
|
if (g_str_has_prefix(g, PREF_GROUP_AI_PREFIX) && strlen(g) > prefix_len) {
|
||||||
if (g_str_has_suffix(key, "_url") && !g_str_has_suffix(key, "_models_url")) {
|
/* Only count groups that actually identify a provider (have url). */
|
||||||
/* Extract provider name by removing "_url" suffix */
|
if (g_key_file_has_key(prefs, g, "url", NULL)) {
|
||||||
gsize key_len = strlen(key);
|
result = g_list_append(result, g_strdup(g + prefix_len));
|
||||||
if (key_len > 4) {
|
|
||||||
gchar* provider_name = g_strndup(key, key_len - 4);
|
|
||||||
result = g_list_append(result, provider_name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1953,38 +2032,106 @@ prefs_free_ai_providers(GList* providers)
|
|||||||
GList*
|
GList*
|
||||||
prefs_ai_get_providers(void)
|
prefs_ai_get_providers(void)
|
||||||
{
|
{
|
||||||
/* If user has configured any providers, return those */
|
|
||||||
GList* configured = prefs_ai_list_providers();
|
GList* configured = prefs_ai_list_providers();
|
||||||
if (configured && g_list_length(configured) > 0) {
|
GList* result = NULL;
|
||||||
/* Build result list with name, url pairs */
|
|
||||||
GList* result = NULL;
|
for (GList* curr = configured; curr; curr = g_list_next(curr)) {
|
||||||
GList* curr = configured;
|
const gchar* name = (const gchar*)curr->data;
|
||||||
while (curr) {
|
auto_gchar gchar* url = prefs_ai_get_provider_url(name);
|
||||||
gchar* name = (gchar*)curr->data;
|
if (url && strlen(url) > 0) {
|
||||||
gchar* url = prefs_ai_get_provider_url(name);
|
result = g_list_append(result, g_strdup(name));
|
||||||
if (url && strlen(url) > 0) {
|
result = g_list_append(result, g_strdup(url));
|
||||||
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_free_ai_providers(configured);
|
||||||
|
|
||||||
prefs_ai_set_provider("openai", "https://api.openai.com/");
|
/* Seed openai/perplexity into the in-memory result on very first init
|
||||||
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
|
* only. In production the "defaults_initialized" sentinel in [ai] is what
|
||||||
|
* tells us we've already seeded — a user who removes every provider must
|
||||||
|
* not get them silently re-added on the next start. When prefs is NULL
|
||||||
|
* (e.g. unit tests that bring up ai_client without prefs_load) we still
|
||||||
|
* seed the in-memory list so behaviour matches a fresh first run, but
|
||||||
|
* skip the persistence write. */
|
||||||
|
gboolean already_seeded = prefs && g_key_file_get_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", NULL);
|
||||||
|
if (!already_seeded && !result) {
|
||||||
|
if (prefs) {
|
||||||
|
prefs_ai_set_provider("openai", "https://api.openai.com/");
|
||||||
|
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
|
||||||
|
}
|
||||||
|
result = g_list_append(result, g_strdup("openai"));
|
||||||
|
result = g_list_append(result, g_strdup("https://api.openai.com/"));
|
||||||
|
result = g_list_append(result, g_strdup("perplexity"));
|
||||||
|
result = g_list_append(result, g_strdup("https://api.perplexity.ai/"));
|
||||||
|
}
|
||||||
|
if (prefs && !already_seeded) {
|
||||||
|
g_key_file_set_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", TRUE);
|
||||||
|
_save_prefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Custom per-provider settings (setting.<name>) --- */
|
||||||
|
|
||||||
|
gboolean
|
||||||
|
prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value)
|
||||||
|
{
|
||||||
|
if (!provider || !setting)
|
||||||
|
return FALSE;
|
||||||
|
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
|
||||||
|
if (!value)
|
||||||
|
return _ai_remove_string(provider, key);
|
||||||
|
return _ai_set_string(provider, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean
|
||||||
|
prefs_ai_remove_setting(const char* const provider, const char* const setting)
|
||||||
|
{
|
||||||
|
if (!provider || !setting)
|
||||||
|
return FALSE;
|
||||||
|
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
|
||||||
|
return _ai_remove_string(provider, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
char*
|
||||||
|
prefs_ai_get_setting(const char* const provider, const char* const setting)
|
||||||
|
{
|
||||||
|
if (!provider || !setting)
|
||||||
|
return NULL;
|
||||||
|
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
|
||||||
|
return _ai_get_string(provider, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
GList*
|
||||||
|
prefs_ai_list_settings(const char* const provider)
|
||||||
|
{
|
||||||
|
if (!provider || !prefs)
|
||||||
|
return NULL;
|
||||||
|
|
||||||
|
auto_gchar gchar* group = _ai_section(provider);
|
||||||
|
if (!g_key_file_has_group(prefs, group))
|
||||||
|
return NULL;
|
||||||
|
|
||||||
GList* result = NULL;
|
GList* result = NULL;
|
||||||
result = g_list_append(result, g_strdup("openai"));
|
gsize len = 0;
|
||||||
result = g_list_append(result, g_strdup("https://api.openai.com/"));
|
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, group, &len, NULL);
|
||||||
result = g_list_append(result, g_strdup("perplexity"));
|
gsize prefix_len = strlen(PREF_AI_SETTING_PREFIX);
|
||||||
result = g_list_append(result, g_strdup("https://api.perplexity.ai/"));
|
for (gsize i = 0; i < len; i++) {
|
||||||
|
if (g_str_has_prefix(keys[i], PREF_AI_SETTING_PREFIX) && strlen(keys[i]) > prefix_len) {
|
||||||
|
result = g_list_append(result, g_strdup(keys[i] + prefix_len));
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
prefs_free_ai_settings(GList* settings)
|
||||||
|
{
|
||||||
|
if (settings) {
|
||||||
|
g_list_free_full(settings, g_free);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// get the preference group for a specific preference
|
// get the preference group for a specific preference
|
||||||
// for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs
|
// for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs
|
||||||
// to the [ui] section.
|
// to the [ui] section.
|
||||||
|
|||||||
@@ -374,10 +374,27 @@ GList* prefs_ai_list_providers(void);
|
|||||||
void prefs_free_ai_providers(GList* providers);
|
void prefs_free_ai_providers(GList* providers);
|
||||||
GList* prefs_ai_get_providers(void);
|
GList* prefs_ai_get_providers(void);
|
||||||
|
|
||||||
|
/* Wipe the whole [ai/<provider>] subsection (url, key, models, default_model,
|
||||||
|
* custom settings) in one call. */
|
||||||
|
gboolean prefs_ai_remove_section(const char* const provider);
|
||||||
|
|
||||||
/* AI model cache management */
|
/* AI model cache management */
|
||||||
gboolean prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count);
|
gboolean prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count);
|
||||||
gboolean prefs_ai_remove_models(const char* const provider);
|
gboolean prefs_ai_remove_models(const char* const provider);
|
||||||
GList* prefs_ai_get_models(const char* const provider);
|
GList* prefs_ai_get_models(const char* const provider);
|
||||||
void prefs_free_ai_models(GList* models);
|
void prefs_free_ai_models(GList* models);
|
||||||
|
|
||||||
|
/* AI default model per provider */
|
||||||
|
gboolean prefs_ai_set_default_model(const char* const provider, const char* const model);
|
||||||
|
gboolean prefs_ai_remove_default_model(const char* const provider);
|
||||||
|
char* prefs_ai_get_default_model(const char* const provider);
|
||||||
|
|
||||||
|
/* AI per-provider custom settings (e.g. tools, search) — persisted as
|
||||||
|
* setting.<name>=<value> inside [ai/<provider>]. Pass NULL value to remove. */
|
||||||
|
gboolean prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value);
|
||||||
|
gboolean prefs_ai_remove_setting(const char* const provider, const char* const setting);
|
||||||
|
char* prefs_ai_get_setting(const char* const provider, const char* const setting);
|
||||||
|
GList* prefs_ai_list_settings(const char* const provider);
|
||||||
|
void prefs_free_ai_settings(GList* settings);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#include "glib.h"
|
||||||
#include "prof_cmocka.h"
|
#include "prof_cmocka.h"
|
||||||
#include "common.h"
|
#include "common.h"
|
||||||
#include "ai/ai_client.h"
|
#include "ai/ai_client.h"
|
||||||
@@ -703,7 +704,6 @@ test_ai_parse_models_openai_format(void** state)
|
|||||||
models = g_list_next(models);
|
models = g_list_next(models);
|
||||||
assert_string_equal("google/gemini-3-flash-preview", models->data);
|
assert_string_equal("google/gemini-3-flash-preview", models->data);
|
||||||
|
|
||||||
ai_provider_unref(provider);
|
|
||||||
ai_client_shutdown();
|
ai_client_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,7 +776,6 @@ test_ai_parse_models_perplexity_format(void** state)
|
|||||||
models = g_list_last(models);
|
models = g_list_last(models);
|
||||||
assert_string_equal("xai/grok-4.3", models->data);
|
assert_string_equal("xai/grok-4.3", models->data);
|
||||||
|
|
||||||
ai_provider_unref(provider);
|
|
||||||
ai_client_shutdown();
|
ai_client_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,7 +800,6 @@ test_ai_parse_models_array_format(void** state)
|
|||||||
models = g_list_next(models);
|
models = g_list_next(models);
|
||||||
assert_string_equal("model3", models->data);
|
assert_string_equal("model3", models->data);
|
||||||
|
|
||||||
ai_provider_unref(provider);
|
|
||||||
ai_client_shutdown();
|
ai_client_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,7 +819,6 @@ test_ai_parse_models_empty_json(void** state)
|
|||||||
assert_null(provider->models);
|
assert_null(provider->models);
|
||||||
assert_int_equal(0, g_list_length(provider->models));
|
assert_int_equal(0, g_list_length(provider->models));
|
||||||
|
|
||||||
ai_provider_unref(provider);
|
|
||||||
ai_client_shutdown();
|
ai_client_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +834,6 @@ test_ai_parse_models_null_handling(void** state)
|
|||||||
ai_parse_models_from_json(provider, NULL);
|
ai_parse_models_from_json(provider, NULL);
|
||||||
assert_null(provider->models);
|
assert_null(provider->models);
|
||||||
|
|
||||||
ai_provider_unref(provider);
|
|
||||||
ai_client_shutdown();
|
ai_client_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,7 +857,6 @@ test_ai_parse_models_escaped_quotes(void** state)
|
|||||||
GList* models = provider->models;
|
GList* models = provider->models;
|
||||||
assert_non_null(models);
|
assert_non_null(models);
|
||||||
|
|
||||||
ai_provider_unref(provider);
|
|
||||||
ai_client_shutdown();
|
ai_client_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +880,6 @@ test_ai_parse_models_with_whitespace(void** state)
|
|||||||
GList* models = provider->models;
|
GList* models = provider->models;
|
||||||
assert_string_equal("model-with-spaces", models->data);
|
assert_string_equal("model-with-spaces", models->data);
|
||||||
|
|
||||||
ai_provider_unref(provider);
|
|
||||||
ai_client_shutdown();
|
ai_client_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,7 +1136,7 @@ test_ai_providers_find_cycles_through_many(void** state)
|
|||||||
ai_add_provider("alpine", "https://al.example/");
|
ai_add_provider("alpine", "https://al.example/");
|
||||||
|
|
||||||
/* Three providers share prefix "alp": alpha, alphabet, alpine. */
|
/* Three providers share prefix "alp": alpha, alphabet, alpine. */
|
||||||
GHashTable* seen = g_hash_table_new(g_str_hash, g_str_equal);
|
GHashTable* seen = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||||
|
|
||||||
for (int i = 0; i < 3; i++) {
|
for (int i = 0; i < 3; i++) {
|
||||||
auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL);
|
auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL);
|
||||||
|
|||||||
Reference in New Issue
Block a user