From 7c3a271feea4b3693bf93a47924f7c682585e179 Mon Sep 17 00:00:00 2001 From: Aldrik Ramaekers Date: Sat, 27 Dec 2025 17:25:41 +0100 Subject: mail provider settings ui --- TODO | 3 - include/administration.hpp | 16 +++ include/file_templates.hpp | 4 + include/importer.hpp | 9 +- run_linux64.sh | 4 +- src/administration.cpp | 13 +- src/administration_reader.cpp | 5 + src/administration_writer.cpp | 4 + src/ai_providers/DeepSeek.cpp | 120 ------------------ src/ai_providers/openAI.cpp | 276 ------------------------------------------ src/importer.cpp | 13 ++ src/providers/DeepSeek.cpp | 120 ++++++++++++++++++ src/providers/MailerSend.cpp | 35 ++++++ src/providers/openAI.cpp | 276 ++++++++++++++++++++++++++++++++++++++++++ src/ui/imgui_extensions.cpp | 2 +- src/ui/ui_invoices.cpp | 4 +- src/ui/ui_settings.cpp | 74 ++++++++--- 17 files changed, 554 insertions(+), 424 deletions(-) delete mode 100644 src/ai_providers/DeepSeek.cpp delete mode 100644 src/ai_providers/openAI.cpp create mode 100644 src/providers/DeepSeek.cpp create mode 100644 src/providers/MailerSend.cpp create mode 100644 src/providers/openAI.cpp diff --git a/TODO b/TODO index 723dbf3..cdf8f16 100644 --- a/TODO +++ b/TODO @@ -10,9 +10,6 @@ Testing: - write tests for strops.hpp - write tests that check error handling for corrupt files. (e.g. references to tax rates, project and cost center that failed to load) -Improvements: -- "price includes tax" toggle that recalculates billing item tax, net and total - Features: - Timeline for invoice modifications (e.g. edited, status changed, paid) - When creating a new openbooks file, user should only be able to set company info before anything else. diff --git a/include/administration.hpp b/include/administration.hpp index 31044f5..4f276ad 100644 --- a/include/administration.hpp +++ b/include/administration.hpp @@ -400,6 +400,19 @@ typedef struct char api_key_public[MAX_LEN_API_KEY]; } ai_service; +typedef enum +{ + EMAIL_PROVIDER_MAILERSEND = 0, + + EMAIL_PROVIDER_END, +} email_provider; + +typedef struct +{ + email_provider provider; + char api_key[MAX_LEN_API_KEY]; +} email_service; + typedef struct { time_t timestamp; @@ -433,6 +446,7 @@ typedef struct // Service providers. ai_service ai_service; + email_service email_service; } ledger; // Add/Update result codes. @@ -501,10 +515,12 @@ namespace administration { char* get_default_currency(); time_t get_default_invoice_expire_duration(); ai_service get_ai_service(); + email_service get_email_service(); void set_file_path(char* path); void set_next_id(s32 nr); void set_next_sequence_number(s32 nr); void set_ai_service(ai_service provider); + void set_email_service(email_service provider); void create_income_statement(income_statement* statement); bool can_create_invoices(); diff --git a/include/file_templates.hpp b/include/file_templates.hpp index 15637a7..c5e0191 100644 --- a/include/file_templates.hpp +++ b/include/file_templates.hpp @@ -71,6 +71,10 @@ namespace file_template { " {{AI_SERVICE_PUBLIC_KEY}}\n" " {{AI_SERVICE_MODEL}}\n" " \n" + " \n" + " {{EMAIL_SERVICE_PROVIDER}}\n" + " {{EMAIL_SERVICE_KEY}}\n" + " \n" ""; static const char* peppol_invoice_tax_subtotal_template = diff --git a/include/importer.hpp b/include/importer.hpp index 1d97c12..8db70ef 100644 --- a/include/importer.hpp +++ b/include/importer.hpp @@ -30,7 +30,6 @@ typedef uint32_t i_err; namespace importer { - typedef enum { IMPORT_STARTING, @@ -71,10 +70,18 @@ namespace importer { bool (*get_available_models)(model_list_request* buffer); } ai_provider_impl; + typedef struct + { + char* provider_name; + bool (*send_email)(char* sender, char* recipients, u32 recipients_count, const char* subject, const char* text); + } email_provider_impl; + const char* error_to_string(i_err error); const char* status_to_string(status status); ai_provider_impl get_ai_provider_implementation(ai_provider provider); + email_provider_impl get_email_provider_implementation(email_provider provider); + invoice_request* ai_document_to_invoice(char* file_path); model_list_request* ai_get_available_models(ai_provider service); } \ No newline at end of file diff --git a/run_linux64.sh b/run_linux64.sh index b84875b..0cc70b3 100755 --- a/run_linux64.sh +++ b/run_linux64.sh @@ -12,7 +12,7 @@ libs/zip/src/*.c \ libs/xml.c/src/*.c \ libs/timer_lib/*.c \ libs/tinyfiledialogs/tinyfiledialogs.c" -SOURCES="src/*.cpp src/ui/*.cpp src/locales/*.cpp src/ai_providers/*.cpp" +SOURCES="src/*.cpp src/ui/*.cpp src/locales/*.cpp src/providers/*.cpp" LIBS="-lstdc++ -lglfw -lGL -lm -lssl -lcrypto" FLAGS="-Wall -Wno-changes-meaning -Wno-write-strings -Wno-attributes -Wno-unused-variable -fpermissive -Wno-format-zero-length -g" INCLUDE_DIRS="-Ilibs/imgui-1.92.1 \ @@ -31,7 +31,7 @@ DEFINITIONS="-D_PLATFORM_=\"linux64\"" # Check for test flag if [ "$1" == "-t" ]; then - SOURCES="tests/main.cpp src/administration.cpp src/administration_writer.cpp src/administration_reader.cpp src/strops.cpp src/logger.cpp src/locales.cpp src/locales/*.cpp src/ai_providers/*.cpp src/importer.cpp src/memops.cpp src/countries.cpp" + SOURCES="tests/main.cpp src/administration.cpp src/administration_writer.cpp src/administration_reader.cpp src/strops.cpp src/logger.cpp src/locales.cpp src/locales/*.cpp src/providers/*.cpp src/importer.cpp src/memops.cpp src/countries.cpp" OUT_EXE="accounting_tests" DEFINITIONS="-D_PLATFORM_=\"linux64\" -D_TESTING_MODE_" fi diff --git a/src/administration.cpp b/src/administration.cpp index b4f626e..61e5b3e 100644 --- a/src/administration.cpp +++ b/src/administration.cpp @@ -237,6 +237,17 @@ ai_service administration::get_ai_service() return g_administration.ai_service; } +email_service administration::get_email_service() +{ + return g_administration.email_service; +} + +void administration::set_email_service(email_service provider) +{ + g_administration.email_service = provider; + if (administration_data_changed_event_callback) administration_data_changed_event_callback(); +} + void administration::set_ai_service(ai_service provider) { g_administration.ai_service = provider; @@ -1899,8 +1910,6 @@ a_err administration::billing_item_add_to_invoice(invoice* invoice, billing_item memops::copy(tb, &item, sizeof(billing_item)); strops::format(tb->id, sizeof(tb->id), "B/%d", create_id()); strops::copy(tb->currency, invoice->currency, MAX_LEN_CURRENCY); // Set billing item currency to invoice currency. - - logger::info("XD: %s\n", tb->tax_internal_code); administration_recalculate_billing_item_totals(tb); if (!list_append(&invoice->billing_items, tb)) { diff --git a/src/administration_reader.cpp b/src/administration_reader.cpp index 34cfe8f..7528711 100644 --- a/src/administration_reader.cpp +++ b/src/administration_reader.cpp @@ -426,6 +426,11 @@ bool administration_reader::import_administration_info(char* buffer, size_t buff xml_get_str_x(root, ai_service.model_name, MAX_LEN_SHORT_DESC, "AIService", "Model", 0); administration::set_ai_service(ai_service); + email_service email_service; + email_service.provider = (email_provider)xml_get_s32_x(root, "EmailService", "Provider", 0); + xml_get_str_x(root, email_service.api_key, MAX_LEN_API_KEY, "EmailService", "PublicKey", 0); + administration::set_email_service(email_service); + logger::info("Loaded administration info in %.3fms. next_id=%d next_sequence_number=%d", STOPWATCH_TIME, administration::get_next_id(), administration::get_next_sequence_number()); diff --git a/src/administration_writer.cpp b/src/administration_writer.cpp index 45c8cdd..ef604b1 100644 --- a/src/administration_writer.cpp +++ b/src/administration_writer.cpp @@ -887,6 +887,10 @@ bool administration_writer::save_administration_info_blocking() strops::replace(file_content, buf_length, "{{AI_SERVICE_PUBLIC_KEY}}", ai_service.api_key_public); strops::replace(file_content, buf_length, "{{AI_SERVICE_MODEL}}", ai_service.model_name); + email_service email_service = administration::get_email_service(); + strops::replace_int32(file_content, buf_length, "{{EMAIL_SERVICE_PROVIDER}}", (s32)email_service.provider); + strops::replace(file_content, buf_length, "{{EMAIL_SERVICE_KEY}}", email_service.api_key); + //// Write to Disk. int final_length = (int)strops::length(file_content); if (!xml_string_is_valid((uint8_t*)file_content, final_length)) result = 0; diff --git a/src/ai_providers/DeepSeek.cpp b/src/ai_providers/DeepSeek.cpp deleted file mode 100644 index c34e299..0000000 --- a/src/ai_providers/DeepSeek.cpp +++ /dev/null @@ -1,120 +0,0 @@ -/* -* Copyright (c) 2025 Aldrik Ramaekers -* -* Permission to use, copy, modify, and/or distribute this software for any -* purpose with or without fee is hereby granted, provided that the above -* copyright notice and this permission notice appear in all copies. -* -* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -*/ - -#define CPPHTTPLIB_OPENSSL_SUPPORT -#include "httplib.h" -#include "strops.hpp" -#include "memops.hpp" -#include "logger.hpp" -#include "importer.hpp" - -#define QUERY_BUFFER_SIZE 1000000 - -char* query_buffer = 0; -static bool _DeepSeek_query_with_file(const char* query, size_t query_length, char* file_id, char** response) -{ - (void)file_id; - (void)query_length; - assert(query_buffer); - - const char *api_key = administration::get_ai_service().api_key_public; - - httplib::SSLClient cli("api.deepseek.com"); - //cli.enable_server_certificate_verification(false); - - //char* query_escaped = strops::prep_str_for_json(query, query_length); - //memops::unalloc(query); // TODO why?? - - size_t file_size = strops::length(query_buffer); - sprintf(query_buffer + file_size, "%s", query); - - char* query_escaped = strops::prep_str_for_json(query_buffer, strops::length(query_buffer)); - - size_t body_size = file_size + QUERY_BUFFER_SIZE; - char* body = (char*)memops::alloc(body_size); - strops::format(body, body_size, - "{\"model\":\"%s\", \"messages\": [ { \"role\": \"user\", \"content\": \"%s\" } ] }", administration::get_ai_service().model_name, query_escaped); - - httplib::Headers headers; - headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); - headers.insert(std::make_pair("Content-Type", "application/json")); - headers.insert(std::make_pair("Accept", "application/json")); - - httplib::Result res = cli.Post("/chat/completions", headers, body, "application/json"); - memops::unalloc(body); - - if (!res || res->status != 200) { - logger::error("ERROR Failed to query API."); - logger::error(res->body.c_str()); - return 0; - } - - char* response_body = (char*)res->body.c_str(); - *response = (char*)memops::alloc(100000); - memops::zero(*response, 100000); - strops::copy(*response, response_body, 100000); - - strops::get_json_value(*response, "content", *response, 100000); - *response = strops::unprep_str_from_json(*response); - - return 1; -} - -static bool _DeepSeek_upload_file(char* file_path, char* file_id, size_t file_id_len) -{ - (void)file_id; - (void)file_id_len; - const char *filename = strops::get_filename(file_path); - - FILE* orig_file = fopen(file_path, "r"); - if (orig_file == NULL) { - logger::error("ERROR: file to upload could not be opened."); - return 0; - } - - fseek(orig_file, 0L, SEEK_END); - long sz = ftell(orig_file); - fseek(orig_file, 0, SEEK_SET); - - size_t buffer_size = sz + QUERY_BUFFER_SIZE; - char* file_content_buffer = (char*)memops::alloc(buffer_size); - memops::zero(file_content_buffer, buffer_size); - - query_buffer = file_content_buffer; - - file_content_buffer += sprintf(file_content_buffer, "[file name]: %s\n", filename); - file_content_buffer += sprintf(file_content_buffer, "[file content begin]\n"); - - fread(file_content_buffer, sz, 1, orig_file); - file_content_buffer += sz; - - for (int i = 0; i < file_content_buffer-query_buffer; i++) if (query_buffer[i] <= 0x1f) query_buffer[i] = ' '; - - file_content_buffer += sprintf(file_content_buffer, "\n[file content end]\n"); - file_content_buffer[0] = 0; - - fclose(orig_file); - - return 1; -} - -importer::ai_provider_impl _deepseek_api_provider = { - "DeekSeek", - "deepseek-reasoner", - _DeepSeek_upload_file, - _DeepSeek_query_with_file, - 0, -}; \ No newline at end of file diff --git a/src/ai_providers/openAI.cpp b/src/ai_providers/openAI.cpp deleted file mode 100644 index d1495dc..0000000 --- a/src/ai_providers/openAI.cpp +++ /dev/null @@ -1,276 +0,0 @@ -/* -* Copyright (c) 2025 Aldrik Ramaekers -* -* Permission to use, copy, modify, and/or distribute this software for any -* purpose with or without fee is hereby granted, provided that the above -* copyright notice and this permission notice appear in all copies. -* -* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -*/ - -#include - -#define CPPHTTPLIB_OPENSSL_SUPPORT -#include "httplib.h" - -#include "memops.hpp" -#include "strops.hpp" -#include "logger.hpp" -#include "importer.hpp" - -static bool _openAI_batch_query_with_file(const char** queries, size_t query_count, char* file_id, invoice* buffer, importer::batch_query_response_handler response_handler) -{ - const char *api_key = administration::get_ai_service().api_key_public; - httplib::SSLClient cli("api.openai.com", 443); - - thrd_t threads[20]; - assert(query_count <= 20); - - for (u32 i = 0; i < query_count; i++) - { - auto* func = new auto([&api_key, &cli, i, &file_id, &response_handler, &buffer, &queries]() { - char* query_escaped = strops::prep_str_for_json(queries[i], 1000); - - size_t body_size = 1000; // Ballpark - char* body = (char*)memops::alloc(body_size); - strops::format(body, body_size, - "{ \"model\":\"%s\", " - " \"input\": [ " - " { \"role\": \"user\", " - " \"content\": [ " - " { \"type\": \"input_file\", \"file_id\": \"%s\" }, " - " { \"type\": \"input_text\", \"text\": \"%s\" } " - " ] " - " }" - "], " - " \"text\": { \"format\": { \"type\": \"json_object\" } } " - "}", administration::get_ai_service().model_name, file_id, query_escaped); - - httplib::Headers headers; - headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); - - httplib::Result res = cli.Post("/v1/responses", headers, body, "application/json"); - memops::unalloc(body); - - if (!res || res->status != 200) { - logger::error("ERROR Failed to query API."); - logger::error(res->body.c_str()); - return 0; - } - - char* response_body = (char*)res->body.c_str(); - char* response = (char*)memops::alloc(5000); - memops::zero(response, 5000); - strops::copy(response, response_body, 5000); - - strops::get_json_value(response, "text", response, 5000); - strops::unprep_str_from_json(response); - - response_handler(buffer, response); - - memops::unalloc(response); - memops::unalloc(query_escaped); - return 1; - }); - - auto trampoline = [](void* arg) -> int { - auto* f = static_cast(arg); - (*f)(); - delete f; - return 0; - }; - - thrd_create(&threads[i], trampoline, func); - } - - for (u32 i = 0; i < query_count; i++) thrd_join(threads[i], nullptr); - - return 1; -} - -static bool _openAI_query_with_file(const char* query, size_t query_length, char* file_id, char** response) -{ - const char *api_key = administration::get_ai_service().api_key_public; - - httplib::SSLClient cli("api.openai.com", 443); - //cli.enable_server_certificate_verification(false); - - char* query_escaped = strops::prep_str_for_json(query, query_length); - //memops::unalloc(query); - - size_t body_size = query_length + 200; - char* body = (char*)memops::alloc(body_size); - strops::format(body, body_size, - "{\"model\":\"%s\", \"input\": [ { \"role\": \"user\", \"content\": [ { \"type\": \"input_file\", \"file_id\": \"%s\" }, " - "{ \"type\": \"input_text\", \"text\": \"%s\" } ] } ] }", administration::get_ai_service().model_name, file_id, query_escaped); - - httplib::Headers headers; - headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); - - httplib::Result res = cli.Post("/v1/responses", headers, body, "application/json"); - memops::unalloc(body); - - if (!res || res->status != 200) { - logger::error("ERROR Failed to query API."); - logger::error(res->body.c_str()); - return 0; - } - - char* response_body = (char*)res->body.c_str(); - *response = (char*)memops::alloc(100000); - memops::zero(*response, 100000); - strops::copy(*response, response_body, 100000); - - strops::get_json_value(*response, "text", *response, 100000); - *response = strops::unprep_str_from_json(*response); - - return 1; -} - -static bool _openAI_upload_file(char* file_path, char* file_id, size_t file_id_len) -{ - const char *api_key = administration::get_ai_service().api_key_public; - const char *filename = strops::get_filename(file_path); - - FILE* orig_file = fopen(file_path, "rb"); - if (orig_file == NULL) { - logger::error("ERROR: file to upload could not be opened."); - return 0; - } - - fseek(orig_file, 0L, SEEK_END); - long sz = ftell(orig_file); - fseek(orig_file, 0, SEEK_SET); - - httplib::SSLClient cli("api.openai.com", 443); - //cli.enable_server_certificate_verification(false); - - char body[512]; - strops::format(body, sizeof(body), "{\"filename\":\"%s\",\"purpose\":\"user_data\", \"bytes\": %d, \"mime_type\": \"application/pdf\", \"expires_after\": { \"anchor\": \"created_at\", \"seconds\": 3600 } }", filename, sz); - - httplib::Headers headers; - headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); - - httplib::Result res = cli.Post("/v1/uploads", headers, body, "application/json"); - if (!res || res->status != 200) { - logger::error("ERROR Failed to create upload."); - logger::error(res->body.c_str()); - fclose(orig_file); - return 0; - } - - char upload_id[128]; - strops::get_json_value(res->body.c_str(), "id", upload_id, sizeof(upload_id)); - size_t part_size = 64000000; // 64mb - logger::info("Created upload %s with part size %zu.", upload_id, part_size); - - char *buffer = (char*)memops::alloc(part_size); - - char completion_body[1048]; - strops::format(completion_body, sizeof(completion_body), "{\"part_ids\": ["); - - int part_number = 0; - while (1) { - size_t read_bytes = fread(buffer, 1, part_size, orig_file); - if (read_bytes == 0) break; - - httplib::Headers part_headers; - part_headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); - - std::string chunk(buffer, read_bytes); - - httplib::UploadFormDataItems items = { - {"data", chunk, filename, "application/octet-stream"} - }; - - char path[256]; - strops::format(path, sizeof(path), "/v1/uploads/%s/parts?part_number=%d", upload_id, part_number); - - httplib::Result part_res = cli.Post(path, part_headers, items); - - if (!part_res || part_res->status != 200) { - logger::error("Failed to upload part %d.", part_number); - logger::error(part_res->body.c_str()); - memops::unalloc(buffer); - fclose(orig_file); - return 0; - } - else { - char part_id[128]; - strops::get_json_value(part_res->body.c_str(), "id", part_id, sizeof(part_id)); - if (part_number == 0) strops::format(completion_body+strops::length(completion_body), sizeof(completion_body)-strops::length(completion_body), "\"%s\"", part_id); - if (part_number != 0) strops::format(completion_body+strops::length(completion_body), sizeof(completion_body)-strops::length(completion_body), ", \"%s\"", part_id); - } - - logger::info("Uploaded part %d\n", part_number); - part_number++; - } - - strops::format(completion_body+strops::length(completion_body), sizeof(completion_body)-strops::length(completion_body), "]}"); - - memops::unalloc(buffer); - fclose(orig_file); - - // ---------- Step 3: Complete upload ---------- - - httplib::Result complete_res = cli.Post((std::string("/v1/uploads/") + upload_id + "/complete").c_str(), - headers, completion_body, "application/json"); - - - if (!complete_res || complete_res->status != 200) { - logger::error("ERROR Failed to complete upload."); - logger::error(complete_res->body.c_str()); - return 0; - } - - char* completion_body_response = (char*)complete_res->body.c_str(); - strops::get_json_value(completion_body_response, "id", file_id, file_id_len, 1); - - return 1; -} - -static bool _openAI_get_available_models(importer::model_list_request* buffer) -{ - const char *api_key = administration::get_ai_service().api_key_public; - - httplib::SSLClient cli("api.openai.com", 443); - - httplib::Headers headers; - headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); - - httplib::Result res = cli.Get("/v1/models", headers); - if (!res || res->status != 200) { - logger::error("ERROR Failed to get models list."); - logger::error(res->body.c_str()); - return 0; - } - - char* completion_body_response = (char*)res->body.c_str(); - - u32 count = 0; - char model_name[MAX_LEN_SHORT_DESC]; - - while(1) { - if (!strops::get_json_value(completion_body_response, "id", model_name, MAX_LEN_SHORT_DESC, count++)) break; - if (count == MAX_MODEL_LIST_RESULT_COUNT) break; - - strops::copy(buffer->result[buffer->result_count++], model_name, MAX_LEN_SHORT_DESC); - } - - return 1; -} - -importer::ai_provider_impl _chatgpt_api_provider = { - "OpenAI", - "gpt-5-nano", - _openAI_upload_file, - _openAI_query_with_file, - _openAI_batch_query_with_file, - _openAI_get_available_models, -}; \ No newline at end of file diff --git a/src/importer.cpp b/src/importer.cpp index 3c56062..45fb16c 100644 --- a/src/importer.cpp +++ b/src/importer.cpp @@ -29,6 +29,8 @@ extern importer::ai_provider_impl _chatgpt_api_provider; extern importer::ai_provider_impl _deepseek_api_provider; +extern importer::email_provider_impl _mailersend_api_provider; + importer::ai_provider_impl importer::get_ai_provider_implementation(ai_provider provider) { switch(provider) @@ -41,6 +43,17 @@ importer::ai_provider_impl importer::get_ai_provider_implementation(ai_provider return importer::ai_provider_impl {0}; } +importer::email_provider_impl importer::get_email_provider_implementation(email_provider provider) +{ + switch(provider) + { + case EMAIL_PROVIDER_MAILERSEND: return _mailersend_api_provider; + default: assert(0); break; + } + + return importer::email_provider_impl {0}; +} + static void _batch_query_response_handler(invoice* buffer, char* json) { int alloc_size = 1000; diff --git a/src/providers/DeepSeek.cpp b/src/providers/DeepSeek.cpp new file mode 100644 index 0000000..c34e299 --- /dev/null +++ b/src/providers/DeepSeek.cpp @@ -0,0 +1,120 @@ +/* +* Copyright (c) 2025 Aldrik Ramaekers +* +* Permission to use, copy, modify, and/or distribute this software for any +* purpose with or without fee is hereby granted, provided that the above +* copyright notice and this permission notice appear in all copies. +* +* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ + +#define CPPHTTPLIB_OPENSSL_SUPPORT +#include "httplib.h" +#include "strops.hpp" +#include "memops.hpp" +#include "logger.hpp" +#include "importer.hpp" + +#define QUERY_BUFFER_SIZE 1000000 + +char* query_buffer = 0; +static bool _DeepSeek_query_with_file(const char* query, size_t query_length, char* file_id, char** response) +{ + (void)file_id; + (void)query_length; + assert(query_buffer); + + const char *api_key = administration::get_ai_service().api_key_public; + + httplib::SSLClient cli("api.deepseek.com"); + //cli.enable_server_certificate_verification(false); + + //char* query_escaped = strops::prep_str_for_json(query, query_length); + //memops::unalloc(query); // TODO why?? + + size_t file_size = strops::length(query_buffer); + sprintf(query_buffer + file_size, "%s", query); + + char* query_escaped = strops::prep_str_for_json(query_buffer, strops::length(query_buffer)); + + size_t body_size = file_size + QUERY_BUFFER_SIZE; + char* body = (char*)memops::alloc(body_size); + strops::format(body, body_size, + "{\"model\":\"%s\", \"messages\": [ { \"role\": \"user\", \"content\": \"%s\" } ] }", administration::get_ai_service().model_name, query_escaped); + + httplib::Headers headers; + headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + headers.insert(std::make_pair("Content-Type", "application/json")); + headers.insert(std::make_pair("Accept", "application/json")); + + httplib::Result res = cli.Post("/chat/completions", headers, body, "application/json"); + memops::unalloc(body); + + if (!res || res->status != 200) { + logger::error("ERROR Failed to query API."); + logger::error(res->body.c_str()); + return 0; + } + + char* response_body = (char*)res->body.c_str(); + *response = (char*)memops::alloc(100000); + memops::zero(*response, 100000); + strops::copy(*response, response_body, 100000); + + strops::get_json_value(*response, "content", *response, 100000); + *response = strops::unprep_str_from_json(*response); + + return 1; +} + +static bool _DeepSeek_upload_file(char* file_path, char* file_id, size_t file_id_len) +{ + (void)file_id; + (void)file_id_len; + const char *filename = strops::get_filename(file_path); + + FILE* orig_file = fopen(file_path, "r"); + if (orig_file == NULL) { + logger::error("ERROR: file to upload could not be opened."); + return 0; + } + + fseek(orig_file, 0L, SEEK_END); + long sz = ftell(orig_file); + fseek(orig_file, 0, SEEK_SET); + + size_t buffer_size = sz + QUERY_BUFFER_SIZE; + char* file_content_buffer = (char*)memops::alloc(buffer_size); + memops::zero(file_content_buffer, buffer_size); + + query_buffer = file_content_buffer; + + file_content_buffer += sprintf(file_content_buffer, "[file name]: %s\n", filename); + file_content_buffer += sprintf(file_content_buffer, "[file content begin]\n"); + + fread(file_content_buffer, sz, 1, orig_file); + file_content_buffer += sz; + + for (int i = 0; i < file_content_buffer-query_buffer; i++) if (query_buffer[i] <= 0x1f) query_buffer[i] = ' '; + + file_content_buffer += sprintf(file_content_buffer, "\n[file content end]\n"); + file_content_buffer[0] = 0; + + fclose(orig_file); + + return 1; +} + +importer::ai_provider_impl _deepseek_api_provider = { + "DeekSeek", + "deepseek-reasoner", + _DeepSeek_upload_file, + _DeepSeek_query_with_file, + 0, +}; \ No newline at end of file diff --git a/src/providers/MailerSend.cpp b/src/providers/MailerSend.cpp new file mode 100644 index 0000000..961e457 --- /dev/null +++ b/src/providers/MailerSend.cpp @@ -0,0 +1,35 @@ +/* +* Copyright (c) 2025 Aldrik Ramaekers +* +* Permission to use, copy, modify, and/or distribute this software for any +* purpose with or without fee is hereby granted, provided that the above +* copyright notice and this permission notice appear in all copies. +* +* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ + +#include + +#define CPPHTTPLIB_OPENSSL_SUPPORT +#include "httplib.h" + +#include "memops.hpp" +#include "strops.hpp" +#include "logger.hpp" +#include "importer.hpp" + +bool _MailerSend_send_email(char* sender, char* recipients, u32 recipients_count, const char* subject, const char* text) +{ + return false; +} + +importer::email_provider_impl _mailersend_api_provider = { + "MailerSend", + _MailerSend_send_email, +}; \ No newline at end of file diff --git a/src/providers/openAI.cpp b/src/providers/openAI.cpp new file mode 100644 index 0000000..d1495dc --- /dev/null +++ b/src/providers/openAI.cpp @@ -0,0 +1,276 @@ +/* +* Copyright (c) 2025 Aldrik Ramaekers +* +* Permission to use, copy, modify, and/or distribute this software for any +* purpose with or without fee is hereby granted, provided that the above +* copyright notice and this permission notice appear in all copies. +* +* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ + +#include + +#define CPPHTTPLIB_OPENSSL_SUPPORT +#include "httplib.h" + +#include "memops.hpp" +#include "strops.hpp" +#include "logger.hpp" +#include "importer.hpp" + +static bool _openAI_batch_query_with_file(const char** queries, size_t query_count, char* file_id, invoice* buffer, importer::batch_query_response_handler response_handler) +{ + const char *api_key = administration::get_ai_service().api_key_public; + httplib::SSLClient cli("api.openai.com", 443); + + thrd_t threads[20]; + assert(query_count <= 20); + + for (u32 i = 0; i < query_count; i++) + { + auto* func = new auto([&api_key, &cli, i, &file_id, &response_handler, &buffer, &queries]() { + char* query_escaped = strops::prep_str_for_json(queries[i], 1000); + + size_t body_size = 1000; // Ballpark + char* body = (char*)memops::alloc(body_size); + strops::format(body, body_size, + "{ \"model\":\"%s\", " + " \"input\": [ " + " { \"role\": \"user\", " + " \"content\": [ " + " { \"type\": \"input_file\", \"file_id\": \"%s\" }, " + " { \"type\": \"input_text\", \"text\": \"%s\" } " + " ] " + " }" + "], " + " \"text\": { \"format\": { \"type\": \"json_object\" } } " + "}", administration::get_ai_service().model_name, file_id, query_escaped); + + httplib::Headers headers; + headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + + httplib::Result res = cli.Post("/v1/responses", headers, body, "application/json"); + memops::unalloc(body); + + if (!res || res->status != 200) { + logger::error("ERROR Failed to query API."); + logger::error(res->body.c_str()); + return 0; + } + + char* response_body = (char*)res->body.c_str(); + char* response = (char*)memops::alloc(5000); + memops::zero(response, 5000); + strops::copy(response, response_body, 5000); + + strops::get_json_value(response, "text", response, 5000); + strops::unprep_str_from_json(response); + + response_handler(buffer, response); + + memops::unalloc(response); + memops::unalloc(query_escaped); + return 1; + }); + + auto trampoline = [](void* arg) -> int { + auto* f = static_cast(arg); + (*f)(); + delete f; + return 0; + }; + + thrd_create(&threads[i], trampoline, func); + } + + for (u32 i = 0; i < query_count; i++) thrd_join(threads[i], nullptr); + + return 1; +} + +static bool _openAI_query_with_file(const char* query, size_t query_length, char* file_id, char** response) +{ + const char *api_key = administration::get_ai_service().api_key_public; + + httplib::SSLClient cli("api.openai.com", 443); + //cli.enable_server_certificate_verification(false); + + char* query_escaped = strops::prep_str_for_json(query, query_length); + //memops::unalloc(query); + + size_t body_size = query_length + 200; + char* body = (char*)memops::alloc(body_size); + strops::format(body, body_size, + "{\"model\":\"%s\", \"input\": [ { \"role\": \"user\", \"content\": [ { \"type\": \"input_file\", \"file_id\": \"%s\" }, " + "{ \"type\": \"input_text\", \"text\": \"%s\" } ] } ] }", administration::get_ai_service().model_name, file_id, query_escaped); + + httplib::Headers headers; + headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + + httplib::Result res = cli.Post("/v1/responses", headers, body, "application/json"); + memops::unalloc(body); + + if (!res || res->status != 200) { + logger::error("ERROR Failed to query API."); + logger::error(res->body.c_str()); + return 0; + } + + char* response_body = (char*)res->body.c_str(); + *response = (char*)memops::alloc(100000); + memops::zero(*response, 100000); + strops::copy(*response, response_body, 100000); + + strops::get_json_value(*response, "text", *response, 100000); + *response = strops::unprep_str_from_json(*response); + + return 1; +} + +static bool _openAI_upload_file(char* file_path, char* file_id, size_t file_id_len) +{ + const char *api_key = administration::get_ai_service().api_key_public; + const char *filename = strops::get_filename(file_path); + + FILE* orig_file = fopen(file_path, "rb"); + if (orig_file == NULL) { + logger::error("ERROR: file to upload could not be opened."); + return 0; + } + + fseek(orig_file, 0L, SEEK_END); + long sz = ftell(orig_file); + fseek(orig_file, 0, SEEK_SET); + + httplib::SSLClient cli("api.openai.com", 443); + //cli.enable_server_certificate_verification(false); + + char body[512]; + strops::format(body, sizeof(body), "{\"filename\":\"%s\",\"purpose\":\"user_data\", \"bytes\": %d, \"mime_type\": \"application/pdf\", \"expires_after\": { \"anchor\": \"created_at\", \"seconds\": 3600 } }", filename, sz); + + httplib::Headers headers; + headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + + httplib::Result res = cli.Post("/v1/uploads", headers, body, "application/json"); + if (!res || res->status != 200) { + logger::error("ERROR Failed to create upload."); + logger::error(res->body.c_str()); + fclose(orig_file); + return 0; + } + + char upload_id[128]; + strops::get_json_value(res->body.c_str(), "id", upload_id, sizeof(upload_id)); + size_t part_size = 64000000; // 64mb + logger::info("Created upload %s with part size %zu.", upload_id, part_size); + + char *buffer = (char*)memops::alloc(part_size); + + char completion_body[1048]; + strops::format(completion_body, sizeof(completion_body), "{\"part_ids\": ["); + + int part_number = 0; + while (1) { + size_t read_bytes = fread(buffer, 1, part_size, orig_file); + if (read_bytes == 0) break; + + httplib::Headers part_headers; + part_headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + + std::string chunk(buffer, read_bytes); + + httplib::UploadFormDataItems items = { + {"data", chunk, filename, "application/octet-stream"} + }; + + char path[256]; + strops::format(path, sizeof(path), "/v1/uploads/%s/parts?part_number=%d", upload_id, part_number); + + httplib::Result part_res = cli.Post(path, part_headers, items); + + if (!part_res || part_res->status != 200) { + logger::error("Failed to upload part %d.", part_number); + logger::error(part_res->body.c_str()); + memops::unalloc(buffer); + fclose(orig_file); + return 0; + } + else { + char part_id[128]; + strops::get_json_value(part_res->body.c_str(), "id", part_id, sizeof(part_id)); + if (part_number == 0) strops::format(completion_body+strops::length(completion_body), sizeof(completion_body)-strops::length(completion_body), "\"%s\"", part_id); + if (part_number != 0) strops::format(completion_body+strops::length(completion_body), sizeof(completion_body)-strops::length(completion_body), ", \"%s\"", part_id); + } + + logger::info("Uploaded part %d\n", part_number); + part_number++; + } + + strops::format(completion_body+strops::length(completion_body), sizeof(completion_body)-strops::length(completion_body), "]}"); + + memops::unalloc(buffer); + fclose(orig_file); + + // ---------- Step 3: Complete upload ---------- + + httplib::Result complete_res = cli.Post((std::string("/v1/uploads/") + upload_id + "/complete").c_str(), + headers, completion_body, "application/json"); + + + if (!complete_res || complete_res->status != 200) { + logger::error("ERROR Failed to complete upload."); + logger::error(complete_res->body.c_str()); + return 0; + } + + char* completion_body_response = (char*)complete_res->body.c_str(); + strops::get_json_value(completion_body_response, "id", file_id, file_id_len, 1); + + return 1; +} + +static bool _openAI_get_available_models(importer::model_list_request* buffer) +{ + const char *api_key = administration::get_ai_service().api_key_public; + + httplib::SSLClient cli("api.openai.com", 443); + + httplib::Headers headers; + headers.insert(std::make_pair("Authorization", std::string("Bearer ") + api_key)); + + httplib::Result res = cli.Get("/v1/models", headers); + if (!res || res->status != 200) { + logger::error("ERROR Failed to get models list."); + logger::error(res->body.c_str()); + return 0; + } + + char* completion_body_response = (char*)res->body.c_str(); + + u32 count = 0; + char model_name[MAX_LEN_SHORT_DESC]; + + while(1) { + if (!strops::get_json_value(completion_body_response, "id", model_name, MAX_LEN_SHORT_DESC, count++)) break; + if (count == MAX_MODEL_LIST_RESULT_COUNT) break; + + strops::copy(buffer->result[buffer->result_count++], model_name, MAX_LEN_SHORT_DESC); + } + + return 1; +} + +importer::ai_provider_impl _chatgpt_api_provider = { + "OpenAI", + "gpt-5-nano", + _openAI_upload_file, + _openAI_query_with_file, + _openAI_batch_query_with_file, + _openAI_get_available_models, +}; \ No newline at end of file diff --git a/src/ui/imgui_extensions.cpp b/src/ui/imgui_extensions.cpp index a90549f..f8adb70 100644 --- a/src/ui/imgui_extensions.cpp +++ b/src/ui/imgui_extensions.cpp @@ -525,7 +525,7 @@ namespace ImGui } int selected_tax_rate_index = -1; - char rate_str_buf[MAX_LEN_LONG_DESC]; + char rate_str_buf[MAX_LEN_LONG_DESC] = {0}; if (selected_tax_rate) { char category_code_desc[MAX_LEN_LONG_DESC]; diff --git a/src/ui/ui_invoices.cpp b/src/ui/ui_invoices.cpp index c4d393c..dc51fd6 100644 --- a/src/ui/ui_invoices.cpp +++ b/src/ui/ui_invoices.cpp @@ -503,9 +503,9 @@ static void draw_invoice_view() if (ImGui::Selectable(locale::get("ui.sendAs.email"), false)) { } - if (ImGui::Selectable(locale::get("ui.sendAs.einvoice"), false)) { + // if (ImGui::Selectable(locale::get("ui.sendAs.einvoice"), false)) { - } + // } ImGui::EndCombo(); } ImGui::PushItemWidth(0.0f); diff --git a/src/ui/ui_settings.cpp b/src/ui/ui_settings.cpp index c458260..240e376 100644 --- a/src/ui/ui_settings.cpp +++ b/src/ui/ui_settings.cpp @@ -33,7 +33,8 @@ u32 cost_center_count; cost_center* cost_centers = 0; static int select_company_tab = 0; -static ai_service new_service; +static ai_service new_ai_service; +static email_service new_email_service; void ui::destroy_settings() { @@ -54,7 +55,8 @@ void ui::setup_settings() cost_centers = (cost_center*)memops::alloc(cost_center_count * sizeof(cost_center)); administration::cost_center_get_all(cost_centers); - new_service = administration::get_ai_service(); + new_ai_service = administration::get_ai_service(); + new_email_service = administration::get_email_service(); } } @@ -242,12 +244,13 @@ static void draw_cost_centers() } } -static void draw_services() -{ +// Dropdown to select ai service. +// If a new service is selected -> model is set to first result in available model list. +static void draw_ai_service_ui() +{ static importer::model_list_request* model_request = 0; static bool set_model_on_load = false; - // AI service if (ImGui::CollapsingHeader(locale::get("settings.services.ai_service"))) { char* ai_service_names[AI_PROVIDER_END]; @@ -255,13 +258,13 @@ static void draw_services() ai_service_names[i] = importer::get_ai_provider_implementation((ai_provider)i).provider_name; } - if (ImGui::BeginCombo(locale::get("settings.services.ai_service.provider"), ai_service_names[new_service.provider])) + if (ImGui::BeginCombo(locale::get("settings.services.ai_service.provider"), ai_service_names[new_ai_service.provider])) { for (u32 n = 0; n < AI_PROVIDER_END; n++) { - bool is_selected = n == (uint32_t)new_service.provider; + bool is_selected = n == (uint32_t)new_ai_service.provider; if (ImGui::Selectable(ai_service_names[n], is_selected)) { - new_service.provider = (ai_provider)n; + new_ai_service.provider = (ai_provider)n; model_request = 0; set_model_on_load = true; } @@ -270,10 +273,10 @@ static void draw_services() } ImGui::InputTextWithHint(locale::get("settings.services.ai_service.pubkey"), locale::get("settings.services.ai_service.pubkey"), - new_service.api_key_public, sizeof(new_service.api_key_public)); + new_ai_service.api_key_public, sizeof(new_ai_service.api_key_public)); if (!model_request) { - model_request = importer::ai_get_available_models(new_service.provider); + model_request = importer::ai_get_available_models(new_ai_service.provider); } // Default to first result in model list, or hardcoded default model. @@ -282,22 +285,22 @@ static void draw_services() set_model_on_load = false; if (model_request->result_count > 0) { - strops::copy(new_service.model_name, model_request->result[0], sizeof(new_service.model_name)); + strops::copy(new_ai_service.model_name, model_request->result[0], sizeof(new_ai_service.model_name)); } else { - strops::copy(new_service.model_name, importer::get_ai_provider_implementation(new_service.provider).default_model, sizeof(new_service.model_name)); + strops::copy(new_ai_service.model_name, importer::get_ai_provider_implementation(new_ai_service.provider).default_model, sizeof(new_ai_service.model_name)); } } } if (model_request->status == importer::status::IMPORT_DONE && model_request->error == I_ERR_SUCCESS) { - if (ImGui::BeginCombo(locale::get("settings.services.ai_service.model"), new_service.model_name)) + if (ImGui::BeginCombo(locale::get("settings.services.ai_service.model"), new_ai_service.model_name)) { for (u32 n = 0; n < model_request->result_count; n++) { - bool is_selected = strops::equals(new_service.model_name, model_request->result[n]); + bool is_selected = strops::equals(new_ai_service.model_name, model_request->result[n]); if (ImGui::Selectable(model_request->result[n], is_selected)) { - strops::copy(new_service.model_name, model_request->result[n], sizeof(new_service.model_name)); + strops::copy(new_ai_service.model_name, model_request->result[n], sizeof(new_ai_service.model_name)); } } ImGui::EndCombo(); @@ -317,7 +320,7 @@ static void draw_services() ImGui::SameLine(); } - ImGui::TextUnformatted(new_service.model_name); + ImGui::TextUnformatted(new_ai_service.model_name); ImGui::EndComboPreview(); } @@ -329,11 +332,48 @@ static void draw_services() if (ImGui::Button(locale::get("form.save"), true)) { administration_writer::set_write_completed_event_callback(0); - administration::set_ai_service(new_service); + administration::set_ai_service(new_ai_service); } } } +static void draw_email_service_ui() +{ + if (ImGui::CollapsingHeader(locale::get("settings.services.email_service"))) + { + char* email_service_names[EMAIL_PROVIDER_END]; + for (u32 i = 0; i < EMAIL_PROVIDER_END; i++) { + email_service_names[i] = importer::get_email_provider_implementation((email_provider)i).provider_name; + } + + if (ImGui::BeginCombo(locale::get("settings.services.email_service.provider"), email_service_names[new_ai_service.provider])) + { + for (u32 n = 0; n < EMAIL_PROVIDER_END; n++) + { + bool is_selected = n == (uint32_t)new_email_service.provider; + if (ImGui::Selectable(email_service_names[n], is_selected)) { + new_email_service.provider = (email_provider)n; + } + } + ImGui::EndCombo(); + } + + ImGui::InputTextWithHint(locale::get("settings.services.email_service.pubkey"), + locale::get("settings.services.email_service.pubkey"), new_email_service.api_key, sizeof(new_email_service.api_key)); + + if (ImGui::Button(locale::get("form.save"), true)) { + administration_writer::set_write_completed_event_callback(0); + administration::set_email_service(new_email_service); + } + } +} + +static void draw_services() +{ + draw_email_service_ui(); + draw_ai_service_ui(); +} + void ui::draw_settings() { if (ImGui::BeginTabBar("SettingsTabBar")) -- cgit v1.2.3-70-g09d2