From: Eric Wertz Date: Sat, 9 Aug 2025 23:36:38 +0000 (-0400) Subject: Added chat support and other enhancements X-Git-Url: https://ericdwertz.com/git/?a=commitdiff_plain;h=HEAD;p=listv4.git Added chat support and other enhancements --- diff --git a/.gitignore b/.gitignore index b00ae23..be6a0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ data/sqlite3mc frontend/node_modules frontend/package-lock.json + +app_settings.json diff --git a/Makefile b/Makefile index 8c095dd..4c77468 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ CFLAGS = -Wall -Wextra -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong INCLUDES = -I$(SRC_DIR) -I$(OPENSSL_PATH)/include # Link flags -LDFLAGS_BASE = -L$(OPENSSL_PATH)/lib -lssl -lcrypto +LDFLAGS_BASE = -L$(OPENSSL_PATH)/lib -lssl -lcrypto -lcurl ifeq ($(OS),Darwin) LDFLAGS_OS_SPECIFIC = -Wl,-dead_strip -framework Security diff --git a/backend/AuthController.cpp b/backend/AuthController.cpp index f0b4b3f..dd5e0cc 100644 --- a/backend/AuthController.cpp +++ b/backend/AuthController.cpp @@ -19,23 +19,24 @@ const ACTIONMAP* CAuthController::getActions() return actions; } -CHTTPResponse* CAuthController::check(CHTTPRequest& request) +void CAuthController::check(CHTTPRequest& request) { if(request.checkAuth()) { - return new CJSONResponse(request, "success", "User is authenticated", + CJSONResponse response(request, "success", "User is authenticated", "{\"authenticated\": true, \"username\": \"" + request.getUser().getUsername() + "\", \"uuid\": \"" + request.getUser().getUUID() + "\"}"); + response.send(); } else { - auto response = new CJSONResponse(request, "error", "User is not authenticated", "{\"authenticated\": false}"); - response->statusCode = 401; - response->setHeader("Set-Cookie", "SESSID=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax"); - return response; + CJSONResponse response(request, "error", "User is not authenticated", "{\"authenticated\": false}"); + response.statusCode = 401; + response.setHeader("Set-Cookie", "SESSID=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax"); + response.send(); } } -CHTTPResponse* CAuthController::login(CHTTPRequest& request) +void CAuthController::login(CHTTPRequest& request) { if( request.getUser().isLoggedIn() ) { @@ -44,26 +45,24 @@ CHTTPResponse* CAuthController::login(CHTTPRequest& request) //Login will throw an exception if the user is not found or the password is incorrect auto sessionId = request.getUser().login(request.getBody()); - auto response = new CJSONResponse(request, "success", "User is authenticated", "{\"authenticated\": true, \"username\": \"" + request.getUser().getUsername() + "\"}"); - response->setHeader("Set-Cookie", "SESSID=" + sessionId + "; Path=/; HttpOnly; SameSite=Lax"); - return response; + CJSONResponse response(request, "success", "User is authenticated", "{\"authenticated\": true, \"username\": \"" + request.getUser().getUsername() + "\"}"); + response.setHeader("Set-Cookie", "SESSID=" + sessionId + "; Path=/; HttpOnly; SameSite=Lax"); + response.send(); } -CHTTPResponse* CAuthController::signup(CHTTPRequest& request) +void CAuthController::signup(CHTTPRequest& request) { request.getUser().create(request.getBody()); - auto response = new CJSONResponse(request, "success", "User is authenticated", "{\"authenticated\": true}"); - + CJSONResponse response(request, "success", "User is authenticated", "{\"authenticated\": true}"); auto sessionId = request.getUser().login(request.getBody()); - response->setHeader("Set-Cookie", "SESSID=" + sessionId + "; Path=/; HttpOnly; SameSite=Lax"); - - return response; + response.setHeader("Set-Cookie", "SESSID=" + sessionId + "; Path=/; HttpOnly; SameSite=Lax"); + response.send(); } -CHTTPResponse* CAuthController::logout(CHTTPRequest& request) +void CAuthController::logout(CHTTPRequest& request) { request.getUser().logout(); - auto response = new CJSONResponse(request, "success", "User is logged out", "{\"authenticated\": false}"); - response->setHeader("Set-Cookie", "SESSID=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax"); - return response; + CJSONResponse response(request, "success", "User is logged out", "{\"authenticated\": false}"); + response.setHeader("Set-Cookie", "SESSID=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax"); + response.send(); } \ No newline at end of file diff --git a/backend/AuthController.h b/backend/AuthController.h index 210d607..9f68f06 100644 --- a/backend/AuthController.h +++ b/backend/AuthController.h @@ -10,8 +10,8 @@ protected: const ACTIONMAP* getActions() override; private: - CHTTPResponse* check(CHTTPRequest& request); - CHTTPResponse* signup(CHTTPRequest& request); - CHTTPResponse* login(CHTTPRequest& request); - CHTTPResponse* logout(CHTTPRequest& request); + void check(CHTTPRequest& request); + void signup(CHTTPRequest& request); + void login(CHTTPRequest& request); + void logout(CHTTPRequest& request); }; \ No newline at end of file diff --git a/backend/ChatController.cpp b/backend/ChatController.cpp new file mode 100644 index 0000000..90d007d --- /dev/null +++ b/backend/ChatController.cpp @@ -0,0 +1,427 @@ +#include "ChatController.h" +#include "HTTPExceptions.h" +#include "JSONResponse.h" +#include "StreamingResponse.h" +#include "SettingsHelper.h" +#include "Database.h" +#include "util.h" + +#include +#include + +#include +#include + +// Lazy accessors to avoid static-init before g_rootDir is set +static const std::string& CHAT_URL() { + static std::string v = CSettingsHelper::get("chat_url", "http://localhost:11434/api/chat"); + return v; +} +static const std::string& WAKE_HOST() { + static std::string v = CSettingsHelper::get("wake_host", "localhost"); + return v; +} +static const std::string& WAKE_ADDRESS() { + static std::string v = CSettingsHelper::get("wake_address", ""); + return v; +} + +// New item type constants for chat +#define ITEM_TYPE_CHAT_CONVERSATION "chatconversation" +#define ITEM_TYPE_CHAT_MESSAGE "chatmessage" +#define LINK_TYPE_CHAT_MESSAGE "chatmessage" + +const ACTIONMAP* CChatController::getActions() +{ + static ACTIONMAP actions[] = { + {"status", true, [](CController* c, CHTTPRequest& r) { return static_cast(c)->status(r); } }, + {"update", true, [](CController* c, CHTTPRequest& r) { return static_cast(c)->update(r); } }, + {"list", true, [](CController* c, CHTTPRequest& r) { return static_cast(c)->list(r); } }, + {"fetch", true, [](CController* c, CHTTPRequest& r) { return static_cast(c)->fetch(r); } }, + { "", false, nullptr } + }; + return actions; +} + +void CChatController::status(CHTTPRequest& request) +{ + CStreamingResponse response(request); + response.begin(); + + // Do a quick ping test to see if host is reachable + int pingResult = system(std::string("ping -c 1 -t 1 " + WAKE_HOST()).c_str()); + if (pingResult == 0) + { + response.stream("{\"status\": \"online\"}"); + response.end(); + return; + } + + response.stream("{\"status\": \"offline\", \"message\": \"Waking up host\"}"); + + // Wake up the host + if (!WAKE_ADDRESS().empty()) { + system(std::string("wakeonlan " + WAKE_ADDRESS()).c_str()); + } + std::this_thread::sleep_for(std::chrono::seconds(1)); + + // Try pinging for up to 60 seconds + auto start = std::chrono::steady_clock::now(); + while (true) { + // Check if we've exceeded 60 seconds + auto now = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(now - start).count() >= 120) { + response.stream("{\"status\": \"offline\", \"message\": \"Host did not respond after 120 seconds\"}"); + break; + } + + // Try pinging + response.stream("{\"status\": \"waking\", \"message\": \"Sending ping\"}"); + if (system(std::string("ping -c 1 -t 1 " + WAKE_HOST()).c_str()) == 0) { + response.stream("{\"status\": \"online\"}"); + break; + } + } + + + response.end(); +} + +// Context object passed to the CURL write callback when streaming NDJSON +struct NDJSONStreamContext { + CStreamingResponse* resp; + std::ostringstream* assistant_ss; // Accumulate assistant response + CDatabase* db; + std::string buffer; // Holds partial data between callbacks +}; + +// Forward streamed bytes from libcurl, parse NDJSON lines, stream assistant messages, and accumulate content +static size_t NDJSONWriteCallback(char* ptr, size_t size, size_t nmemb, void* userdata) +{ + size_t totalSize = size * nmemb; + if (!userdata || totalSize == 0) + { + return totalSize; + } + + NDJSONStreamContext* ctx = static_cast(userdata); + + // If client is no longer active, stop attempting to stream to it + if (ctx->resp && !ctx->resp->isActive()) { + ctx->resp = nullptr; + } + + // Append the new data to the buffer + ctx->buffer.append(ptr, totalSize); + + // Process complete lines (delimited by '\n') + size_t newlinePos; + while ((newlinePos = ctx->buffer.find('\n')) != std::string::npos) + { + std::string line = ctx->buffer.substr(0, newlinePos); + ctx->buffer.erase(0, newlinePos + 1); + + if (line.empty()) + continue; + + // Try to extract the message content from the NDJSON object + std::string content; + try { + ctx->db->extractJsonValues(line, "$.message.content", content); + + } catch (const std::exception&) { + // Ignore extraction errors (line might not have the expected JSON structure) + std::cout << "error extracting json values" << std::endl; + } + + if (!content.empty()) + { + // Accumulate the assistant response string + *(ctx->assistant_ss) << content; + + // Build a JSON object { role: 'assistant', content: } + std::string message_json = ctx->db->encodeJsonObject("role", "assistant", "content", content); + + // Stream the JSON object to the client + if (ctx->resp) + { + try { + ctx->resp->stream(message_json); + if (!ctx->resp->isActive()) { + ctx->resp = nullptr; + } + } catch (const std::exception& e) { + std::cerr << "Error streaming chat chunk: " << e.what() << std::endl; + ctx->resp = nullptr; + } + } + } + } + + return totalSize; // Tell libcurl we consumed the data +} + +void CChatController::generateResponse(std::string chat_payload, CDatabase* db, std::ostringstream& assistant_response, CStreamingResponse* response) +{ + if (!db) + { + if( response ) + streamChatChunk( response, db, "error", "database pointer is null" ); + else + throw std::runtime_error("database pointer is null"); + return; + } + + // Initialise libcurl + curl_global_init(CURL_GLOBAL_DEFAULT); + CURL* curl = curl_easy_init(); + + if (curl) + { + // Upstream chat endpoint + curl_easy_setopt(curl, CURLOPT_URL, CHAT_URL().c_str()); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + + // Send the prepared JSON payload + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, chat_payload.c_str()); + + // Set required headers for JSON + struct curl_slist* headers = nullptr; + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + // Configure streaming callback + NDJSONStreamContext stream_ctx{ response, &assistant_response, db, "" }; + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, NDJSONWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &stream_ctx); + + // Perform the request (synchronous) + if( response ) + streamMetaChunk( response, db, "status", "Awaiting response..." ); + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) + { + if( response ) + streamChatChunk( response, db, "error", curl_easy_strerror(res) ); + else + throw std::runtime_error(curl_easy_strerror(res)); + } + + // Cleanup + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + } + else + { + if( response ) + streamChatChunk( response, db, "error", "failed to initialise libcurl" ); + else + throw std::runtime_error("failed to initialise libcurl"); + } + + curl_global_cleanup(); +} + +void CChatController::list(CHTTPRequest& request) +{ + CDatabase* db = &request.getUser().getDatabase(); + std::string conversations_json; + CDBQuery query = db->query("select " + "json_group_array( json_insert( json( json ), '$._uuid', uuid, '$._created_at', created_at, '$._updated_at', updated_at ) ORDER BY updated_at DESC ) " + "from items where type='chatconversation'"); + query.fetch_row(conversations_json); + CJSONResponse response(request, "success", "success", conversations_json); + response.send(); +} + +void CChatController::fetch(CHTTPRequest& request) +{ + CDatabase* db = &request.getUser().getDatabase(); + std::string conversation_id = request.requireArgument(0); + std::string conversation_json; + CDBQuery query = db->query("select " + "IF( ci.uuid IS NOT NULL, json_object( " + "'_uuid', ci.uuid, " + "'_created_at', ci.created_at, " + "'_updated_at', ci.updated_at, " + "'_title', ci.json->'title', " + "'messages', json_group_array( json(m.json) ORDER BY m.created_at ASC )" + "), \"\" ) " + "from items ci " + "join itemslink il on il.parent_id=ci.rowid and il.type='chatmessage' " + "join items m on m.rowid=il.child_id and m.type='chatmessage' " + "where ci.uuid=? and ci.type='chatconversation' and m.json->>'role'<>'system' " + "order by m.created_at asc", conversation_id); + query.fetch_row(conversation_json); + if( conversation_json.empty() ) + throw HTTPNotFoundException("Conversation not found"); + CJSONResponse response(request, "success", "success", conversation_json); + response.send(); +} + +void CChatController::update(CHTTPRequest& request) +{ + CDatabase* db = &request.getUser().getDatabase(); + std::string requestBody = request.getBody(); + if (requestBody.empty()) { + throw HTTPBadRequestException("Request body is empty"); + } + + std::string conversation_id, user_message; + try { + db->extractJsonValues( requestBody, + "$.conversation_id", conversation_id, + "$.user_message", user_message + ); + } catch (const std::exception& e) { + throw HTTPBadRequestException("Invalid request body"); + } + + std::string conversation_uuid; + int conversation_rowid = 0; + std::vector> messages; // (role, content) + + if (conversation_id == "new") { + // 1. Create new conversation item + conversation_uuid = generate_id(); + std::string conversation_json = db->encodeJsonObject("title", "New Conversation"); + db->query( + "INSERT INTO items (uuid, json, type) VALUES (?, ?, ?)", + conversation_uuid, conversation_json, ITEM_TYPE_CHAT_CONVERSATION + ).execute(); + conversation_rowid = db->lastInsertRowid(); + + // Add system message + appendMessage(db, conversation_rowid, "system", "You are a helpful personal assistant"); + } else { + // Existing conversation: fetch conversation rowid + conversation_uuid = conversation_id; + CDBQuery get_rowid_query = db->query("SELECT rowid FROM items WHERE uuid = ? AND type = ?", conversation_uuid, ITEM_TYPE_CHAT_CONVERSATION); + if (get_rowid_query.fetch_row(conversation_rowid) != SQLITE_ROW) { + throw HTTPBadRequestException("Conversation not found"); + } + } + + // Add the new user message to the conversation + appendMessage(db, conversation_rowid, "user", user_message); + + // Fetch all messages for this conversation, ordered by creation time, as a JSON array + std::string messages_array; + { + const std::string fetch_msgs_sql = + "SELECT json_group_array(json(m.json)) FROM itemslink l " + "JOIN items m ON l.child_id = m.rowid " + "WHERE l.parent_id = ? AND l.type = ? AND m.type = ? " + "ORDER BY m.rowid ASC"; + CDBQuery msgs_query = db->query(fetch_msgs_sql, conversation_rowid, LINK_TYPE_CHAT_MESSAGE, ITEM_TYPE_CHAT_MESSAGE); + if (msgs_query.fetch_row(messages_array) != SQLITE_ROW || messages_array.empty()) { + messages_array = "[]"; + } + } + + // Prepare chat model payload + std::string chat_payload = std::string("{\"model\": \"qwen3:8b\", \"messages\": ") + messages_array + "}"; + + // Begin streaming response + CStreamingResponse response(request); + response.begin(); + + if( conversation_id == "new" ) + { + // Send conversation info as status + streamMetaChunk( &response, db, "conversation_id", conversation_uuid ); + } + + // Stream chat response and capture assistant content + std::ostringstream assistant_response_ss; + generateResponse(chat_payload, db, assistant_response_ss, &response); + + std::string assistant_response_str = assistant_response_ss.str(); + + // After streaming, add assistant message to DB + if (!assistant_response_str.empty()) { + appendMessage(db, conversation_rowid, "assistant", assistant_response_str); + } + + streamMetaChunk( &response, db, "response_finished", "true" ); + + if( conversation_id == "new" ) + { + // Update conversation title + std::string title = generateTitle(db, conversation_rowid); + db->query("update items set json=json_set(json, '$.title', ?) where rowid=?", title, conversation_rowid).execute(); + streamMetaChunk( &response, db, "title", title ); + } + + try { + response.end(); + } catch (const std::exception& e) { + std::cerr << "Error ending response: " << e.what() << std::endl; + } +} + +void CChatController::appendMessage( CDatabase* db, int conversation_rowid, std::string role, std::string content ) +{ + std::string message_uuid = generate_id(); + db->query( + "INSERT INTO items (uuid, json, type) VALUES (?, json_object('role', ?, 'content', ?), ?)", + message_uuid, role, content, ITEM_TYPE_CHAT_MESSAGE + ).execute(); + // Get message rowid + int message_rowid = db->lastInsertRowid(); + // Link message to conversation + db->query( + "INSERT INTO itemslink (parent_id, child_id, type) VALUES (?, ?, ?)", + conversation_rowid, message_rowid, LINK_TYPE_CHAT_MESSAGE + ).execute(); +} + +std::string CChatController::generateTitle( CDatabase* db, int conversation_rowid ) +{ + std::string conversation_dump; + auto query = db->query("SELECT GROUP_CONCAT( str, '\n\n' ) FROM (select " + "concat( i.json->>'role', ': ', i.json->>'content') as str " + "from itemslink l " + "join items i on i.rowid=l.child_id " + "where l.type='chatmessage' and l.parent_id=? " + "order by i.created_at asc )", conversation_rowid); + query.fetch_row(conversation_dump); + + std::string system_prompt = "/nothink\n" + "Please generate a brief, descriptive title (under 80 characters) summarizing the main topic or key points of the following conversation.\n" + "Only output the title with no other formatting.\n\n```\n" + conversation_dump + "\n```"; + std::string chat_payload = std::string("{\"model\": \"qwen3:8b\", \"messages\": [ " +db->encodeJsonObject("role", "system", "content", system_prompt) + " ] }"); + + std::ostringstream assistant_response_ss; + generateResponse(chat_payload, db, assistant_response_ss); + + std::string assistant_response_str = assistant_response_ss.str(); + + size_t pos = assistant_response_str.find(""); + if (pos != std::string::npos) { + std::string title = assistant_response_str.substr(pos + 8); // 8 is length of + // Trim whitespace from start and end + title.erase(0, title.find_first_not_of(" \n\r\t")); + title.erase(title.find_last_not_of(" \n\r\t") + 1); + return title; + } + return assistant_response_str; +} + +void CChatController::streamChatChunk( CStreamingResponse* response, CDatabase* db, const std::string role, const std::string content ) +{ + try { + response->stream( db->encodeJsonObject( "role", role, "content", content ) ); + } catch (const std::exception& e) { + std::cerr << "Error streaming chat chunk: " << e.what() << std::endl; + } +} + +void CChatController::streamMetaChunk( CStreamingResponse* response, CDatabase* db, const std::string prop, const std::string value ) +{ + try { + response->stream( db->encodeJsonObject( "role", "status", "content", "", prop, value ) ); + } catch (const std::exception& e) { + std::cerr << "Error streaming meta chunk: " << e.what() << std::endl; + } +} \ No newline at end of file diff --git a/backend/ChatController.h b/backend/ChatController.h new file mode 100644 index 0000000..aa9d040 --- /dev/null +++ b/backend/ChatController.h @@ -0,0 +1,23 @@ +#pragma once + +#include "Controller.h" +#include "StreamingResponse.h" +#include + +class CChatController : public CController +{ +protected: + const ACTIONMAP* getActions() override; +private: + void status(CHTTPRequest& request); + void update(CHTTPRequest& request); + void list(CHTTPRequest& request); + void fetch(CHTTPRequest& request); + + void appendMessage( CDatabase* db, int conversation_rowid, std::string role, std::string content ); + void generateResponse(std::string chat_payload, CDatabase* db, std::ostringstream& assistant_response, CStreamingResponse* response = nullptr); + std::string generateTitle( CDatabase* db, int conversation_rowid ); + + void streamChatChunk( CStreamingResponse* response, CDatabase* db, const std::string role, const std::string content ); + void streamMetaChunk( CStreamingResponse* response, CDatabase* db, const std::string prop, const std::string value ); +}; \ No newline at end of file diff --git a/backend/Controller.cpp b/backend/Controller.cpp index 2104c1c..59ac4e0 100644 --- a/backend/Controller.cpp +++ b/backend/Controller.cpp @@ -4,7 +4,7 @@ #include -CHTTPResponse* CController::handle(CHTTPRequest& request, const std::vector& args) +void CController::handle(CHTTPRequest& request, const std::vector& args) { auto actions = getActions(); auto a = actions[0]; @@ -22,26 +22,30 @@ CHTTPResponse* CController::handle(CHTTPRequest& request, const std::vectorstatusCode = statusCode; - return response; + std::cout << "errorResponse: " << message << std::endl; + CJSONResponse response(request, "error", message, "{}"); + response.statusCode = statusCode; + response.send(); } diff --git a/backend/Controller.h b/backend/Controller.h index 60b77cc..fbc0eca 100644 --- a/backend/Controller.h +++ b/backend/Controller.h @@ -12,15 +12,15 @@ struct ACTIONMAP { std::string name; bool auth_required; - std::function action; + std::function action; }; class CController { public: virtual ~CController() = default; - CHTTPResponse* handle(CHTTPRequest& request, const std::vector& args); - CHTTPResponse* errorResponse(CHTTPRequest& request, const std::string& message, int statusCode); + void handle(CHTTPRequest& request, const std::vector& args); + void errorResponse(CHTTPRequest& request, const std::string& message, int statusCode); protected: virtual const ACTIONMAP* getActions() = 0; }; \ No newline at end of file diff --git a/backend/DBQuery.h b/backend/DBQuery.h index a83ec94..4e42281 100644 --- a/backend/DBQuery.h +++ b/backend/DBQuery.h @@ -132,6 +132,20 @@ class CDBQuery bind_params(index + 1, std::forward(args)...); } + template + void bind_params( int index, const char* value, Args&&... args) + { + if(value == nullptr) + { + sqlite3_bind_null(_stmt, index); + } + else + { + sqlite3_bind_text(_stmt, index, value, -1, SQLITE_TRANSIENT); + } + bind_params(index + 1, std::forward(args)...); + } + void bind_params( [[maybe_unused]] int index ) {}; template diff --git a/backend/Database.h b/backend/Database.h index c93b583..3dae779 100644 --- a/backend/Database.h +++ b/backend/Database.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "sqlite3.h" #include "DBQuery.h" @@ -43,19 +44,18 @@ class CDatabase int lastInsertRowid(); template - std::string encodeJsonObject(Args&... args) + std::string encodeJsonObject(Args&&... args) { static_assert(sizeof...(Args) % 2 == 0, "encodeJsonObject requires an even number of arguments"); std::ostringstream select_str; - std::ostringstream params_str; - build_encode_sql_object(select_str, params_str, 0, args...); + build_encode_sql_object(select_str, 0, std::forward(args)...); std::ostringstream sql; - sql << "SELECT json_object(" << select_str.str() << ") FROM (SELECT " << params_str.str() << " as json_request)"; + sql << "SELECT json_object(" << select_str.str() << ")"; CDBQuery encode_query = query(sql.str()); - bind_encode_params(encode_query, 0, args...); + bind_encode_params(encode_query, 1, std::forward(args)...); std::string result; if( encode_query.fetch_row(result) == SQLITE_ROW ) @@ -82,24 +82,23 @@ class CDatabase void build_extract_sql_select([[maybe_unused]] std::ostringstream& sql, [[maybe_unused]] int i) {}; template - void build_encode_sql_object(std::ostringstream& select_str, std::ostringstream& params_str, int i, std::string json_key, [[maybe_unused]] T val, Args&... args) + void build_encode_sql_object(std::ostringstream& select_str, int i, std::string json_key, [[maybe_unused]] T val, Args&&... args) { - select_str << json_key << ", " << val; - params_str << "? as " << json_key; + select_str << "'" << json_key << "', ?"; if( sizeof...(args) > 0 ) { select_str << ", "; } - build_encode_sql_object(select_str, params_str, i + 1, args...); + build_encode_sql_object(select_str, i + 1, std::forward(args)...); } - void build_encode_sql_object([[maybe_unused]] std::ostringstream& select_str, [[maybe_unused]] std::ostringstream& params_str, [[maybe_unused]] int i) {}; + void build_encode_sql_object([[maybe_unused]] std::ostringstream& select_str, [[maybe_unused]] int i) {}; template - void bind_encode_params(CDBQuery& query, int i, [[maybe_unused]] std::string json_key, T val, Args&... args) + void bind_encode_params(CDBQuery& query, int i, [[maybe_unused]] std::string json_key, T val, Args&&... args) { query.bind(i, val); - bind_encode_params(query, i + 1, args...); + bind_encode_params(query, i + 1, std::forward(args)...); } void bind_encode_params([[maybe_unused]] CDBQuery& query, [[maybe_unused]] int i) {}; diff --git a/backend/HTTPRequest.cpp b/backend/HTTPRequest.cpp index 3619be3..d528841 100644 --- a/backend/HTTPRequest.cpp +++ b/backend/HTTPRequest.cpp @@ -1,4 +1,5 @@ #include "HTTPRequest.h" +#include "HTTPExceptions.h" #include #include @@ -300,4 +301,11 @@ std::string CHTTPRequest::getBody() _bodyRead = true; return _body; -} \ No newline at end of file +} + +std::string CHTTPRequest::requireArgument( unsigned int index ) +{ + if( index + 2 >= _pathComponents.size() ) + throw HTTPBadRequestException("Missing argument: " + std::to_string(index)); + return _pathComponents[index + 2]; +} \ No newline at end of file diff --git a/backend/HTTPRequest.h b/backend/HTTPRequest.h index 2cb9ad8..ae80421 100644 --- a/backend/HTTPRequest.h +++ b/backend/HTTPRequest.h @@ -20,6 +20,7 @@ public: std::string getQueryParameter(const std::string& name) const; std::string getHeader(const std::string& name) const; std::string getCookie(const std::string& name) const; + std::string requireArgument( unsigned int index ); CUser& getUser() { return _user; } int getSocket() const { return _socket; } const std::vector& getPathComponents() const { return _pathComponents; } diff --git a/backend/HTTPRequestRouter.cpp b/backend/HTTPRequestRouter.cpp index 810ce2d..0339114 100644 --- a/backend/HTTPRequestRouter.cpp +++ b/backend/HTTPRequestRouter.cpp @@ -3,6 +3,7 @@ #include "AuthController.h" #include "JSONResponse.h" #include "ListController.h" +#include "ChatController.h" #include #include @@ -12,6 +13,7 @@ const std::string frontendRoutes[] = { "/login", "/signup", "/list", + "/chat", }; bool isFrontendRoute(const std::string& path) @@ -29,6 +31,7 @@ CHTTPRequestRouter::CHTTPRequestRouter() // Register default controllers registerController("auth", std::make_unique()); registerController("list", std::make_unique()); + registerController("chat", std::make_unique()); } CHTTPRequestRouter::~CHTTPRequestRouter() = default; @@ -38,31 +41,30 @@ void CHTTPRequestRouter::registerController(const std::string& name, std::unique _controllers[name] = std::move(controller); } -CHTTPResponse* CHTTPRequestRouter::handle(CHTTPRequest& request) +void CHTTPRequestRouter::handle(CHTTPRequest& request) { try { - return createResponse(request); + createResponse(request); } catch (const HTTPException& e) { - return errorResponse( request, e.what(), e.getStatusCode()); + errorResponse( request, e.what(), e.getStatusCode()); } catch (const std::runtime_error& e) { - return errorResponse( request, e.what(), 500); + errorResponse( request, e.what(), 500); } } -CHTTPResponse* CHTTPRequestRouter::errorResponse(CHTTPRequest& request, const std::string& message, int statusCode) +void CHTTPRequestRouter::errorResponse(CHTTPRequest& request, const std::string& message, int statusCode) { - auto response = new CHTTPResponse(request); - response->responseBody << message; - response->statusCode = statusCode; - return response; + CJSONResponse response(request, "error", message, "{}"); + response.statusCode = statusCode; + response.send(); } -CHTTPResponse* CHTTPRequestRouter::createResponse(CHTTPRequest& request) +void CHTTPRequestRouter::createResponse(CHTTPRequest& request) { auto pathComponents = request.getPathComponents(); if (pathComponents.size() > 1) @@ -76,19 +78,21 @@ CHTTPResponse* CHTTPRequestRouter::createResponse(CHTTPRequest& request) if (isFrontendRoute(request.getPath())) { - return new CStaticFileResponse(request, "index.html"); + CStaticFileResponse response(request, "index.html"); + response.send(); + return; } // No path components or no matching controller - try static file try { - return new CStaticFileResponse(request, request.getPath()); + CStaticFileResponse response(request, request.getPath()); + response.send(); } catch (const HTTPNotFoundException& e) { - auto response = new CStaticFileResponse(request, "index.html"); - response->statusCode = 404; - return response; - + CStaticFileResponse response(request, "index.html"); + response.statusCode = 404; + response.send(); } } \ No newline at end of file diff --git a/backend/HTTPRequestRouter.h b/backend/HTTPRequestRouter.h index a08f6a1..8b2a7a6 100644 --- a/backend/HTTPRequestRouter.h +++ b/backend/HTTPRequestRouter.h @@ -13,13 +13,13 @@ public: CHTTPRequestRouter(); ~CHTTPRequestRouter(); - CHTTPResponse* handle(CHTTPRequest& request); + void handle(CHTTPRequest& request); // Allow dynamic controller registration void registerController(const std::string& name, std::unique_ptr controller); private: std::unordered_map> _controllers; - CHTTPResponse* errorResponse(CHTTPRequest& request, const std::string& message, int statusCode); - CHTTPResponse* createResponse(CHTTPRequest& request); + void errorResponse(CHTTPRequest& request, const std::string& message, int statusCode); + void createResponse(CHTTPRequest& request); }; \ No newline at end of file diff --git a/backend/HTTPResponse.cpp b/backend/HTTPResponse.cpp index e2c2344..4f40a0c 100644 --- a/backend/HTTPResponse.cpp +++ b/backend/HTTPResponse.cpp @@ -99,6 +99,8 @@ void CHTTPResponse::send() if( write(_request.getSocket(), out.str().c_str(), out.tellp()) == -1 ) { - die("Failed to write response to socket"); + std::cerr << "Failed to write response to socket" << std::endl; } + + log(); } \ No newline at end of file diff --git a/backend/ListController.cpp b/backend/ListController.cpp index c2bef17..1c620ea 100644 --- a/backend/ListController.cpp +++ b/backend/ListController.cpp @@ -112,7 +112,7 @@ std::string CListController::buildItemWithChildren(CDatabase* db, int rowid) { return json_builder.str(); } -CHTTPResponse* CListController::fetch(CHTTPRequest& request) { +void CListController::fetch(CHTTPRequest& request) { CDatabase* db = &request.getUser().getDatabase(); // Get root items (items with no parent) @@ -149,10 +149,11 @@ CHTTPResponse* CListController::fetch(CHTTPRequest& request) { } result_builder << "]"; - return new CJSONResponse(request, "success", "List fetched successfully", result_builder.str()); + CJSONResponse response(request, "success", "List fetched successfully", result_builder.str()); + response.send(); } -CHTTPResponse* CListController::save(CHTTPRequest& request) { +void CListController::save(CHTTPRequest& request) { CDatabase* db = &request.getUser().getDatabase(); const std::string sql_upsert_item = "INSERT INTO items (uuid, json, type) " @@ -310,9 +311,9 @@ CHTTPResponse* CListController::save(CHTTPRequest& request) { db->query("COMMIT").execute(); - CJSONResponse* success_response = new CJSONResponse(request, "success", "List saved successfully"); - success_response->statusCode = 200; // OK - return success_response; + CJSONResponse response(request, "success", "List saved successfully"); + response.statusCode = 200; // OK + response.send(); } catch (...) { try { db->query("ROLLBACK").execute(); } catch (...) {} throw; diff --git a/backend/ListController.h b/backend/ListController.h index f553b8d..0db883a 100644 --- a/backend/ListController.h +++ b/backend/ListController.h @@ -15,8 +15,8 @@ public: protected: virtual const ACTIONMAP* getActions() override; private: - CHTTPResponse* save(CHTTPRequest& request); - CHTTPResponse* fetch(CHTTPRequest& request); + void save(CHTTPRequest& request); + void fetch(CHTTPRequest& request); std::string buildItemWithChildren(CDatabase* db, int rowid); diff --git a/backend/SettingsHelper.cpp b/backend/SettingsHelper.cpp new file mode 100644 index 0000000..85adb40 --- /dev/null +++ b/backend/SettingsHelper.cpp @@ -0,0 +1,49 @@ +#include "SettingsHelper.h" + +#include +#include +#include + +#include "util.h" // for g_rootDir +#include "Database.h" + +std::map CSettingsHelper::s_settings; +std::once_flag CSettingsHelper::s_loadOnceFlag; + +void CSettingsHelper::ensureLoaded() { + std::call_once(s_loadOnceFlag, []() { + // Build path relative to g_rootDir + std::string path = g_rootDir + "/app_settings.json"; + loadFromFile(path); + }); +} + +std::string CSettingsHelper::get(const std::string& key, const std::string& defaultValue) { + ensureLoaded(); + auto it = s_settings.find(key); + if (it != s_settings.end()) return it->second; + return defaultValue; +} + +void CSettingsHelper::loadFromFile(const std::string& path) { + std::ifstream in(path); + if (!in.is_open()) { + // No settings file; leave map empty + return; + } + + std::ostringstream ss; + ss << in.rdbuf(); + const std::string json = ss.str(); + + // Use SQLite JSON functions to iterate keys via json_each + CDatabase memDb(":memory:"); + std::string key; + std::string value; + CDBQuery q = memDb.query("SELECT key, value FROM json_each(?)", json); + while (q.fetch_row(key, value) == SQLITE_ROW) { + s_settings[key] = value; + } +} + + diff --git a/backend/SettingsHelper.h b/backend/SettingsHelper.h new file mode 100644 index 0000000..eebc020 --- /dev/null +++ b/backend/SettingsHelper.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +// Simple static settings loader for app_settings.json +class CSettingsHelper { +public: + // Ensure settings are loaded (idempotent) + static void ensureLoaded(); + + // Get a value by key; returns defaultValue if key missing + static std::string get(const std::string& key, const std::string& defaultValue = ""); + +private: + static void loadFromFile(const std::string& path); + + static std::map s_settings; + static std::once_flag s_loadOnceFlag; +}; + + diff --git a/backend/StreamingResponse.cpp b/backend/StreamingResponse.cpp new file mode 100644 index 0000000..1cbccde --- /dev/null +++ b/backend/StreamingResponse.cpp @@ -0,0 +1,71 @@ +#include "StreamingResponse.h" + +#include +#include +#include + +CStreamingResponse::CStreamingResponse(CHTTPRequest& request) : CHTTPResponse(request) +{ +} + +CStreamingResponse::~CStreamingResponse() +{ +} + +void CStreamingResponse::begin() +{ + std::ostringstream headers; + + setHeader("Content-Type", "text/event-stream; charset=utf-8"); + setHeader("Cache-Control", "no-cache"); + setHeader("Transfer-Encoding", "chunked"); + + writeStatusLine(headers); + writeHeaders(headers); + headers << crlf(); + + // Write headers first + if( write(_request.getSocket(), headers.str().c_str(), headers.tellp()) == -1 ) + { + _active = false; + return; + } + + // Prepare initial chunk (body) + std::string initialBody = "retry: 1000\n\n"; + std::ostringstream chunk; + chunk << std::hex << initialBody.size() << crlf() << initialBody << crlf(); + std::string chunkStr = chunk.str(); + if( write(_request.getSocket(), chunkStr.c_str(), chunkStr.size()) == -1 ) + { + _active = false; + return; + } +} + +void CStreamingResponse::stream(const std::string& data) +{ + if (!_active) return; + std::string chunkData = "data: " + data + "\n\n"; + std::ostringstream chunk; + chunk << std::hex << chunkData.size() << crlf() << chunkData << crlf(); + std::string chunkStr = chunk.str(); + if( write(_request.getSocket(), chunkStr.c_str(), chunkStr.size()) == -1 ) + { + _active = false; + return; + } +} + +void CStreamingResponse::end() +{ + if (!_active) return; + // Send zero-length chunk to signal end + std::string endChunk = std::string("0") + crlf() + crlf(); + if( write(_request.getSocket(), endChunk.c_str(), endChunk.size()) == -1 ) + { + _active = false; + return; + } + log(); +} \ No newline at end of file diff --git a/backend/StreamingResponse.h b/backend/StreamingResponse.h new file mode 100644 index 0000000..2e96c95 --- /dev/null +++ b/backend/StreamingResponse.h @@ -0,0 +1,20 @@ +#pragma once + +#include "HTTPResponse.h" + +class CStreamingResponse : public CHTTPResponse +{ +public: + CStreamingResponse(CHTTPRequest& request); + ~CStreamingResponse(); + + void begin(); + void stream(const std::string& data); + void end(); + + // Returns false once the client disconnects or any write fails + bool isActive() const { return _active; } + +private: + bool _active = true; +}; \ No newline at end of file diff --git a/backend/main.cpp b/backend/main.cpp index 0d24b3e..e3735fa 100644 --- a/backend/main.cpp +++ b/backend/main.cpp @@ -10,10 +10,12 @@ #include #include #include +#include #include "HTTPRequest.h" #include "HTTPRequestRouter.h" #include "util.h" +#include "JSONResponse.h" std::map threadMap; std::vector completeThreads; @@ -32,15 +34,18 @@ void handle_client(int thread_id, int client_socket) { return; } - // Create appropriate response handler using factory - CHTTPResponse* response = router.handle(request); + try + { + router.handle(request); + } + catch(const std::exception& e) + { + std::cerr << "Unhandled exception: " << e.what() << '\n'; + CJSONResponse response(request, "error", "Unhandled exception: " + std::string(e.what()), "{}"); + response.statusCode = 500; + response.send(); + } - // Send the response - response->send(); - response->log(); - - // Clean up - delete response; // Close the connection close(client_socket); @@ -100,6 +105,9 @@ int main(int argc, char* argv[]) { // Set the global root directory g_rootDir = root_dir; + // Ignore SIGPIPE so writes to closed sockets don't kill the process + signal(SIGPIPE, SIG_IGN); + int server_socket; struct sockaddr_in server_address; @@ -110,6 +118,14 @@ int main(int argc, char* argv[]) { return 1; } + // Avoid SIGPIPE on macOS for this socket as well +#ifdef SO_NOSIGPIPE + { + int on = 1; + setsockopt(server_socket, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); + } +#endif + // Set up the server address server_address.sin_family = AF_INET; server_address.sin_addr.s_addr = INADDR_ANY; @@ -138,6 +154,14 @@ int main(int argc, char* argv[]) { continue; } + // Avoid SIGPIPE on client sockets (primarily macOS) +#ifdef SO_NOSIGPIPE + { + int on = 1; + setsockopt(client_socket, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); + } +#endif + // Launch a new thread to handle the client auto lk = std::unique_lock( threadMapLock ); threadMap[threadId] = std::thread( handle_client, threadId, client_socket ); diff --git a/frontend/app/AppContext.tsx b/frontend/app/AppContext.tsx index 80e984a..1bce3cc 100644 --- a/frontend/app/AppContext.tsx +++ b/frontend/app/AppContext.tsx @@ -1,4 +1,4 @@ -import { h, Component, createContext } from 'preact'; +import { h, Component, createContext, ComponentChildren } from 'preact'; interface AppNotification { visible: boolean; @@ -12,6 +12,11 @@ interface User { uuid: string; } +interface AppToolbar { + position: 'top' | 'bottom'; + component: ComponentChildren; +} + // Define the shape of your context interface AppContextType { isLoading: boolean; @@ -26,7 +31,10 @@ interface AppContextType { setUser: (user: User) => void; checkAuth: () => void; fetchData: (url: string, body?: any) => Promise; + streamSSE: (url: string, body: any, onMessage: (obj: any) => void, onError?: (err: any) => void) => void; logout: () => void; + setToolbars: (toolbars: AppToolbar[]) => void; + toolbars: AppToolbar[]; } @@ -127,12 +135,66 @@ export class AppContextProvider extends Component<{ children: any }> { } }); - return null; + return { status: 'error', message: error.message || 'Failed to load data. Please check connection.' }; }) .finally(() => { this.setState({ isLoading: false }); }); }, + streamSSE: async (url: string, body: any, onMessage: (obj: any) => void, onError?: (err: any) => void) => { + try { + const response = await fetch(url, { + method: body ? 'POST' : 'GET', + headers: { + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + credentials: 'include', + }); + if (!response.body) throw new Error('No response body'); + const reader = response.body.getReader(); + let buffer = ''; + const decoder = new TextDecoder(); + let done = false; + while (!done) { + const { value, done: streamDone } = await reader.read(); + done = streamDone; + if (value) { + buffer += decoder.decode(value, { stream: !done }); + let lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + // Check if line starts with "data: " and extract JSON after it + if (line.startsWith('data: ')) { + const jsonStr = line.substring(6); // Skip "data: " prefix + const obj = JSON.parse(jsonStr); + onMessage(obj); + if (obj.done) { + reader.cancel(); + return; + } + } + } catch (e) { + // Optionally log parse errors + } + } + } + } + // Handle any remaining buffer + if (buffer.trim()) { + try { + const obj = JSON.parse(buffer); + onMessage(obj); + } catch (e) { + // Optionally log parse errors + } + } + } catch (err) { + if (onError) onError(err); + } + }, logout: () => { // Add a cache-busting query parameter const cacheBuster = `?cb=${new Date().getTime()}`; @@ -152,7 +214,11 @@ export class AppContextProvider extends Component<{ children: any }> { // Optionally, still redirect or show an error window.location.href = '/'; // Or handle error more gracefully }); - } + }, + setToolbars: (toolbars: AppToolbar[]) => { + this.setState({ toolbars: toolbars }); + }, + toolbars: [], }; render() { @@ -162,4 +228,4 @@ export class AppContextProvider extends Component<{ children: any }> { ); } -} \ No newline at end of file +} diff --git a/frontend/app_routes.tsx b/frontend/app_routes.tsx index d6da90b..464a406 100644 --- a/frontend/app_routes.tsx +++ b/frontend/app_routes.tsx @@ -3,6 +3,7 @@ import Home from 'views/Home'; import List from 'views/List'; import SignUp from 'views/SignUp'; import Login from 'views/Login'; +import Chat from 'views/Chat'; interface AppRoute { path: string; @@ -35,6 +36,12 @@ export const AppRoutes = [ title: 'Login', auth_required: false, }, + { + path: '/chat', + view: Chat, + title: 'Chat', + auth_required: true, + }, ]; export default AppRoutes; \ No newline at end of file diff --git a/frontend/components/Accordion.tsx b/frontend/components/Accordion.tsx new file mode 100644 index 0000000..8a300fb --- /dev/null +++ b/frontend/components/Accordion.tsx @@ -0,0 +1,30 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import { ChevronDownIcon, ChevronRightIcon } from './Icons'; + +interface AccordionProps { + title: string; + children: h.JSX.Element; +} + +export const Accordion = ({ title, children }: AccordionProps) => { + const [open, setOpen] = useState(false); + return ( +
+
setOpen(o => !o)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setOpen(o => !o); }} + > + + + + {title} +
+
{children}
+
+ ); +}; \ No newline at end of file diff --git a/frontend/components/BottomBarContainer.tsx b/frontend/components/BottomBarContainer.tsx new file mode 100644 index 0000000..526bd29 --- /dev/null +++ b/frontend/components/BottomBarContainer.tsx @@ -0,0 +1,77 @@ +import { h, ComponentChildren, Fragment } from 'preact'; +import { useEffect, useRef, useState } from 'preact/hooks'; + +interface BottomBarContainerProps { + children?: ComponentChildren; +} + +export const BottomBarContainer = ({ children }: BottomBarContainerProps) => { + const barRef = useRef(null); + const [ height, setHeight ] = useState(0); + const spacerRef = useRef(null); + + useEffect(() => { + function resizeHandler() { + if (!barRef.current) { + return; + } + + // Check if visual viewport API is available + if (!window.visualViewport) { + return; + } + + const { height: viewportHeight, offsetTop } = window.visualViewport; + + // Get the window height for comparison + const windowHeight = window.innerHeight; + + // If we're on desktop (viewport height is close to window height), reset transform + if (viewportHeight > windowHeight * 0.9) { + barRef.current.style.transform = 'translate(0px, 0px)'; + return; + } + + // Calculate how much the keyboard has reduced the viewport + const keyboardHeight = windowHeight - viewportHeight; + + // Move the bottom bar up by the keyboard height and account for viewport offset + // We use negative value to move up, and subtract offsetTop to handle viewport shifts + barRef.current.style.transform = `translate(0px, -${keyboardHeight - offsetTop}px)`; + } + + // run first time to initialize + resizeHandler(); + + // subscribe to events which affect scroll, or viewport position + window.visualViewport?.addEventListener('resize', resizeHandler); + window.visualViewport?.addEventListener('scroll', resizeHandler); + window?.addEventListener('touchmove', resizeHandler); + + // unsubscribe + return () => { + window.visualViewport?.removeEventListener('resize', resizeHandler); + window.visualViewport?.removeEventListener('scroll', resizeHandler); + window?.removeEventListener('touchmove', resizeHandler); + }; + }, []); + + useEffect(() => { + if( spacerRef.current && barRef.current ) { + setHeight(barRef.current.offsetHeight); + } + }, [spacerRef.current, barRef.current]); + + if( !children ) { + return null; + } + + return ( + +
+ {children} +
+
+ + ); +}; \ No newline at end of file diff --git a/frontend/components/ChatConversation.tsx b/frontend/components/ChatConversation.tsx new file mode 100644 index 0000000..31eca75 --- /dev/null +++ b/frontend/components/ChatConversation.tsx @@ -0,0 +1,20 @@ +import { h } from 'preact'; +import { ChatMessage, ChatMessageProps } from './ChatMessage'; + +interface ChatConversationProps { + messages: ChatMessageProps[]; + statusMessage: string | null; +} + +export const ChatConversation = ({ messages, statusMessage }: ChatConversationProps) => { + return ( +
+
+ {messages.map((message) => ( + + ))} +
+ { statusMessage &&
{statusMessage}
} +
+ ); +}; \ No newline at end of file diff --git a/frontend/components/ChatInputForm.tsx b/frontend/components/ChatInputForm.tsx new file mode 100644 index 0000000..d32410c --- /dev/null +++ b/frontend/components/ChatInputForm.tsx @@ -0,0 +1,51 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import { SendIcon } from './Icons'; + +interface ChatInputFormProps { + onSubmit: (message: string) => void; + inputDisabled: boolean; +} + +export const ChatInputForm = ({ onSubmit, inputDisabled }: ChatInputFormProps) => { + const [input, setInput] = useState(''); + + const submitMessage = (e: Event) => { + e.preventDefault(); + if (inputDisabled) return; + const trimmed = input.trim(); + if (!trimmed) return; + setInput(''); + onSubmit(trimmed); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (inputDisabled) return; + const trimmed = input.trim(); + if (!trimmed) return; + setInput(''); + onSubmit(trimmed); + } + }; + + return ( +
+
+