[#110] Add AI client with multi-provider support and UI #113
@@ -35,6 +35,9 @@ static Autocomplete providers_ac = NULL;
|
||||
static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url);
|
||||
static void _ai_load_models_for_provider(AIProvider* provider);
|
||||
static void _ai_save_models_for_provider(AIProvider* provider);
|
||||
static const gchar* _find_json_field(const gchar* json, const gchar* field);
|
||||
static gchar* _extract_json_string(const gchar* json, const gchar* field);
|
||||
static gchar* _parse_error_response(const gchar* error_json);
|
||||
|
||||
/* ========================================================================
|
||||
* Curl helpers
|
||||
@@ -184,6 +187,59 @@ ai_json_escape(const gchar* str)
|
||||
return g_string_free(result, FALSE);
|
||||
}
|
||||
|
||||
/* Decode JSON string escapes in [start, end) into a freshly allocated buffer.
|
||||
* Handles \" \\ \/ \n \t \r \b \f. \uXXXX sequences are passed through verbatim
|
||||
* since the rest of the AI pipeline already deals in UTF-8 byte streams. */
|
||||
static gchar*
|
||||
_json_unescape_substring(const gchar* start, const gchar* end)
|
||||
{
|
||||
gchar* out = g_new0(gchar, (end - start) + 1);
|
||||
gchar* w = out;
|
||||
for (const gchar* in = start; in < end;) {
|
||||
if (*in == '\\' && in + 1 < end) {
|
||||
switch (in[1]) {
|
||||
case '"': *w++ = '"'; in += 2; break;
|
||||
case '\\': *w++ = '\\'; in += 2; break;
|
||||
case '/': *w++ = '/'; in += 2; break;
|
||||
case 'n': *w++ = '\n'; in += 2; break;
|
||||
case 't': *w++ = '\t'; in += 2; break;
|
||||
case 'r': *w++ = '\r'; in += 2; break;
|
||||
case 'b': *w++ = '\b'; in += 2; break;
|
||||
case 'f': *w++ = '\f'; in += 2; break;
|
||||
default: *w++ = *in++; break;
|
||||
}
|
||||
} else {
|
||||
*w++ = *in++;
|
||||
}
|
||||
}
|
||||
*w = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Locate "field":"...value..." in json and return the unescaped value.
|
||||
* Skips JSON whitespace around the colon and treats any \X (X != \0) inside
|
||||
* the string body as an opaque escape so an embedded \" cannot be misread as
|
||||
* the closing quote. */
|
||||
static gchar*
|
||||
_extract_json_string(const gchar* json, const gchar* field)
|
||||
{
|
||||
const gchar* val = _find_json_field(json, field);
|
||||
if (!val || *val != '"')
|
||||
return NULL;
|
||||
|
||||
const gchar* start = val + 1;
|
||||
for (const gchar* p = start; *p; p++) {
|
||||
if (*p == '\\' && *(p + 1) != '\0') {
|
||||
p++; /* skip the escaped character; loop ++ advances past it */
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
return _json_unescape_substring(start, p);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider Management
|
||||
* ======================================================================== */
|
||||
@@ -616,8 +672,11 @@ _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers,
|
||||
log_error("Request failed for '%s': %s", provider->name, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else if (http_code >= 400) {
|
||||
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
|
||||
response->data ? response->data : "Unknown error");
|
||||
auto_gchar gchar* parsed = _parse_error_response(response->data);
|
||||
auto_gchar gchar* error_msg = parsed
|
||||
? g_strdup_printf("HTTP %ld: %s", http_code, parsed)
|
||||
: g_strdup_printf("HTTP %ld: %s", http_code,
|
||||
response->data && strlen(response->data) > 0 ? response->data : "Unknown error");
|
||||
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else {
|
||||
@@ -1272,139 +1331,30 @@ _parse_ai_response(const gchar* response_json)
|
||||
if (!response_json || strlen(response_json) == 0)
|
||||
return NULL;
|
||||
|
||||
/* Try Perplexity /v1/agent format first: look for "text":"..." inside output array
|
||||
* Response: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */
|
||||
const gchar* text_start = strstr(response_json, "\"text\":\"");
|
||||
if (text_start) {
|
||||
text_start += strlen("\"text\":\"");
|
||||
const gchar* p = text_start;
|
||||
while (*p) {
|
||||
if (*p == '\\' && *(p + 1) == '"') {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
gsize len = p - text_start;
|
||||
gchar* result = g_new0(gchar, len + 1);
|
||||
gchar* out = result;
|
||||
const gchar* in = text_start;
|
||||
while (in < p) {
|
||||
if (*in == '\\' && *(in + 1) == '"') {
|
||||
*out++ = '"';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
||||
*out++ = '\n';
|
||||
in += 2;
|
||||
} else {
|
||||
*out++ = *in++;
|
||||
}
|
||||
}
|
||||
*out = '\0';
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
}
|
||||
/* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]}
|
||||
* Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */
|
||||
gchar* text = _extract_json_string(response_json, "text");
|
||||
if (text)
|
||||
return text;
|
||||
|
||||
/* Try legacy OpenAI format: "content":"..."
|
||||
* Response: {"choices":[{"message":{"content":"..."}}]} */
|
||||
const gchar* content_start = strstr(response_json, "\"content\":\"");
|
||||
if (!content_start)
|
||||
return NULL;
|
||||
|
||||
content_start += strlen("\"content\":\"");
|
||||
|
||||
/* Find the closing quote, accounting for escaped quotes */
|
||||
const gchar* p = content_start;
|
||||
while (*p) {
|
||||
if (*p == '\\' && *(p + 1) == '"') {
|
||||
/* Escaped quote, skip both characters */
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
/* Found unescaped closing quote */
|
||||
gsize len = p - content_start;
|
||||
/* Unescape the content: convert \" back to " */
|
||||
gchar* result = g_new0(gchar, len + 1);
|
||||
gchar* out = result;
|
||||
const gchar* in = content_start;
|
||||
while (in < p) {
|
||||
if (*in == '\\' && *(in + 1) == '"') {
|
||||
*out++ = '"';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
||||
*out++ = '\n';
|
||||
in += 2;
|
||||
} else {
|
||||
*out++ = *in++;
|
||||
}
|
||||
}
|
||||
*out = '\0';
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
|
||||
return NULL;
|
||||
return _extract_json_string(response_json, "content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from an API error JSON response.
|
||||
* Handles format: {"error":{"message":"...","type":"...","code":...}}
|
||||
*/
|
||||
/* Extract the human-readable error.message from a provider error envelope:
|
||||
* {"error":{"message":"...","type":"...","code":...}}
|
||||
* Scoped to the substring after "error": so unrelated top-level "message"
|
||||
* fields do not get picked up. */
|
||||
static gchar*
|
||||
_parse_error_response(const gchar* error_json)
|
||||
{
|
||||
if (!error_json || strlen(error_json) == 0)
|
||||
return NULL;
|
||||
|
||||
/* Look for "message":"..." inside "error" object */
|
||||
const gchar* error_obj = strstr(error_json, "\"error\":");
|
||||
if (!error_obj)
|
||||
return NULL;
|
||||
|
||||
const gchar* message_key = strstr(error_obj, "\"message\":\"");
|
||||
if (!message_key)
|
||||
return NULL;
|
||||
|
||||
message_key += strlen("\"message\":\"");
|
||||
const gchar* msg_start = message_key;
|
||||
const gchar* p = msg_start;
|
||||
while (*p) {
|
||||
if (*p == '\\' && *(p + 1) == '"') {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == '"') {
|
||||
gsize len = p - msg_start;
|
||||
gchar* result = g_new0(gchar, len + 1);
|
||||
gchar* out = result;
|
||||
const gchar* in = msg_start;
|
||||
while (in < p) {
|
||||
if (*in == '\\' && *(in + 1) == '"') {
|
||||
*out++ = '"';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == 'n') {
|
||||
*out++ = '\n';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == '\\') {
|
||||
*out++ = '\\';
|
||||
in += 2;
|
||||
} else if (*in == '\\' && *(in + 1) == 't') {
|
||||
*out++ = '\t';
|
||||
in += 2;
|
||||
} else {
|
||||
*out++ = *in++;
|
||||
}
|
||||
}
|
||||
*out = '\0';
|
||||
return result;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
|
||||
return NULL;
|
||||
return _extract_json_string(error_obj, "message");
|
||||
}
|
||||
|
||||
static gpointer
|
||||
|
||||
Reference in New Issue
Block a user