]> Eric's Git Repo - listv4.git/commitdiff
Added chat support and other enhancements main
authorEric Wertz <ericwertz@Erics-MacBook-Pro.local>
Sat, 9 Aug 2025 23:36:38 +0000 (19:36 -0400)
committerEric Wertz <ericwertz@Erics-MacBook-Pro.local>
Sat, 9 Aug 2025 23:36:38 +0000 (19:36 -0400)
45 files changed:
.gitignore
Makefile
backend/AuthController.cpp
backend/AuthController.h
backend/ChatController.cpp [new file with mode: 0644]
backend/ChatController.h [new file with mode: 0644]
backend/Controller.cpp
backend/Controller.h
backend/DBQuery.h
backend/Database.h
backend/HTTPRequest.cpp
backend/HTTPRequest.h
backend/HTTPRequestRouter.cpp
backend/HTTPRequestRouter.h
backend/HTTPResponse.cpp
backend/ListController.cpp
backend/ListController.h
backend/SettingsHelper.cpp [new file with mode: 0644]
backend/SettingsHelper.h [new file with mode: 0644]
backend/StreamingResponse.cpp [new file with mode: 0644]
backend/StreamingResponse.h [new file with mode: 0644]
backend/main.cpp
frontend/app/AppContext.tsx
frontend/app_routes.tsx
frontend/components/Accordion.tsx [new file with mode: 0644]
frontend/components/BottomBarContainer.tsx [new file with mode: 0644]
frontend/components/ChatConversation.tsx [new file with mode: 0644]
frontend/components/ChatInputForm.tsx [new file with mode: 0644]
frontend/components/ChatMessage.tsx [new file with mode: 0644]
frontend/components/Icons.tsx
frontend/components/ListContext.tsx
frontend/components/LoadingSpinner.tsx
frontend/components/Toolbar.tsx
frontend/components/ToolbarButton.tsx
frontend/components/TopBarContainer.tsx [new file with mode: 0644]
frontend/components/View.tsx [new file with mode: 0644]
frontend/esbuild.config.js
frontend/package.json
frontend/static/index.html
frontend/styles.css
frontend/tsconfig.json
frontend/views/Chat.tsx [new file with mode: 0644]
frontend/views/Home.tsx [deleted file]
frontend/views/List.tsx
frontend/views/Login.tsx

index b00ae23eb80e2f4d759137f2ed34a2c777b660c5..be6a0cbcce2f877ebfbfa90e9660e5ca805b459c 100644 (file)
@@ -10,3 +10,5 @@ data/sqlite3mc
 
 frontend/node_modules
 frontend/package-lock.json
+
+app_settings.json
index 8c095dd3a268b2d91712912b661c4d6c59ca6c4d..4c774682bfa30d9bb9bd7eb590220a2938c2d995 100644 (file)
--- 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
index f0b4b3fdc269a3ab365785b5f62478b372853f39..dd5e0ccbf553749473136c711795968814d20b11 100644 (file)
@@ -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
index 210d607d5ac63453683b40d0bbeda00e9214635d..9f68f06893719c6467dec59758e5903b8e973a0a 100644 (file)
@@ -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 (file)
index 0000000..90d007d
--- /dev/null
@@ -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 <sstream>
+#include <curl/curl.h>
+
+#include <thread>
+#include <chrono>
+
+// 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<CChatController*>(c)->status(r); } },
+        {"update", true, [](CController* c, CHTTPRequest& r) { return static_cast<CChatController*>(c)->update(r); } },
+        {"list", true, [](CController* c, CHTTPRequest& r) { return static_cast<CChatController*>(c)->list(r); } },
+        {"fetch", true, [](CController* c, CHTTPRequest& r) { return static_cast<CChatController*>(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<std::chrono::seconds>(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<NDJSONStreamContext*>(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: <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<std::pair<std::string, std::string>> 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("</think>");
+    if (pos != std::string::npos) {
+        std::string title = assistant_response_str.substr(pos + 8); // 8 is length of </think>
+        // 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 (file)
index 0000000..aa9d040
--- /dev/null
@@ -0,0 +1,23 @@
+#pragma once
+
+#include "Controller.h"
+#include "StreamingResponse.h"
+#include <sstream>
+
+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
index 2104c1caebb28ea7c7beb06a55b92e77940ae241..59ac4e016881c27dd867b68e19d6b9c6f48fb78d 100644 (file)
@@ -4,7 +4,7 @@
 
 #include <stdexcept>
 
-CHTTPResponse* CController::handle(CHTTPRequest& request, const std::vector<std::string>& args)
+void CController::handle(CHTTPRequest& request, const std::vector<std::string>& args)
 {
     auto actions = getActions();
     auto a = actions[0];
@@ -22,26 +22,30 @@ CHTTPResponse* CController::handle(CHTTPRequest& request, const std::vector<std:
                         throw HTTPUnauthorizedException("Must be logged in to access this resource");
                     }
                 }
-                return a.action(this, request);
+                a.action(this, request);
+                return;
             }
             catch (const HTTPException& e)
             {
-                return errorResponse( request, e.what(), e.getStatusCode());
+                errorResponse( request, e.what(), e.getStatusCode());
+                return;
             }
             catch (const std::runtime_error& e)
             {
-                return errorResponse( request, e.what(), 500);
+                errorResponse( request, e.what(), 500);
+                return;
             }
         }
         a = actions[++i];
     }
 
-    return errorResponse( request, "Action " + args[1] + " not found", 404);
+    errorResponse( request, "Action " + args[1] + " not found", 404);
 }
 
-CHTTPResponse* CController::errorResponse(CHTTPRequest& request, const std::string& message, int statusCode)
+void CController::errorResponse(CHTTPRequest& request, const std::string& message, int statusCode)
 {
-    auto response = new CJSONResponse(request, "error", message, "{}");
-    response->statusCode = statusCode;
-    return response;
+    std::cout << "errorResponse: " << message << std::endl;
+    CJSONResponse response(request, "error", message, "{}");
+    response.statusCode = statusCode;
+    response.send();
 }
index 60b77cc1bf9b88054350c16e5561c58fe95ac4a3..fbc0ecad7ed64938de6a93fae01d6b4c295a94b6 100644 (file)
@@ -12,15 +12,15 @@ struct ACTIONMAP
 {
     std::string name;
     bool auth_required;
-    std::function<CHTTPResponse*(CController*, CHTTPRequest&)> action;
+    std::function<void(CController*, CHTTPRequest&)> action;
 };
 
 class CController
 {
 public:
     virtual ~CController() = default;
-    CHTTPResponse* handle(CHTTPRequest& request, const std::vector<std::string>& args);
-    CHTTPResponse* errorResponse(CHTTPRequest& request, const std::string& message, int statusCode);
+    void handle(CHTTPRequest& request, const std::vector<std::string>& args);
+    void errorResponse(CHTTPRequest& request, const std::string& message, int statusCode);
 protected:
     virtual const ACTIONMAP* getActions() = 0;
 };
\ No newline at end of file
index a83ec946c8c0525d17fe6b49af6b40444d514bb8..4e4228183c5e2f1b98a6c81df4dc7497c7257df0 100644 (file)
@@ -132,6 +132,20 @@ class CDBQuery
             bind_params(index + 1, std::forward<Args>(args)...);
         }
 
+        template<typename... Args>
+        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>(args)...);
+        }
+
         void bind_params( [[maybe_unused]] int index ) {};
 
         template<typename... Args>
index c93b5838dd75d0ebd359e0f98807cf70a8cbc29b..3dae779c708349bfccd2105cdcf6903c428876dd 100644 (file)
@@ -3,6 +3,7 @@
 #include <string>
 #include <vector>
 #include <sstream>
+#include <iostream>
 
 #include "sqlite3.h"
 #include "DBQuery.h"
@@ -43,19 +44,18 @@ class CDatabase
         int lastInsertRowid();
 
         template<typename... Args>
-        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>(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>(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<typename... Args, typename T>
-        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>(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<typename... Args, typename T>
-        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>(args)...);
         }
 
         void bind_encode_params([[maybe_unused]] CDBQuery& query, [[maybe_unused]] int i) {};
index 3619be387009e2b38dc8024735cfad2852ae703a..d5288415c4599b80dea0e049d123c41224a74812 100644 (file)
@@ -1,4 +1,5 @@
 #include "HTTPRequest.h"
+#include "HTTPExceptions.h"
 
 #include <unistd.h>
 #include <iostream>
@@ -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
index 2cb9ad8b3cbd3eb2a4245041ab74b3559a49c102..ae8042154a343780ad5ba8009925e49639a64bbb 100644 (file)
@@ -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<std::string>& getPathComponents() const { return _pathComponents; }
index 810ce2dbe005497ab9cb9b59d0298364d57b5536..033911461d9d9e9ce57da44461d75a092b6287d7 100644 (file)
@@ -3,6 +3,7 @@
 #include "AuthController.h"
 #include "JSONResponse.h"
 #include "ListController.h"
+#include "ChatController.h"
 
 #include <iostream>
 #include <stdexcept>
@@ -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<CAuthController>());
     registerController("list", std::make_unique<CListController>());
+    registerController("chat", std::make_unique<CChatController>());
 }
 
 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
index a08f6a109324e98088a90a9b809ca783cf7a74fc..8b2a7a6c5eae3d82e3e8c1a1a986f8e77cde4aeb 100644 (file)
@@ -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<CController> controller);
     
 private:
     std::unordered_map<std::string, std::unique_ptr<CController>> _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
index e2c2344941a19b2c7eced1a26e2d3c81a182a9b3..4f40a0c64c1f8e741997f49527f41520792556a2 100644 (file)
@@ -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
index c2bef17d5aef6a8af04ac00b77f4d18719cb59ca..1c620eab4754b847ac70887522ecde2131206e27 100644 (file)
@@ -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;
index f553b8d822c50293b6932fc1c8ecd2382d369f08..0db883a57d218e181aaad7227038b9f2a12fc61f 100644 (file)
@@ -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 (file)
index 0000000..85adb40
--- /dev/null
@@ -0,0 +1,49 @@
+#include "SettingsHelper.h"
+
+#include <fstream>
+#include <sstream>
+#include <stdexcept>
+
+#include "util.h" // for g_rootDir
+#include "Database.h"
+
+std::map<std::string, std::string> 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 (file)
index 0000000..eebc020
--- /dev/null
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <string>
+#include <map>
+#include <mutex>
+
+// 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<std::string, std::string> s_settings;
+    static std::once_flag s_loadOnceFlag;
+};
+
+
diff --git a/backend/StreamingResponse.cpp b/backend/StreamingResponse.cpp
new file mode 100644 (file)
index 0000000..1cbccde
--- /dev/null
@@ -0,0 +1,71 @@
+#include "StreamingResponse.h"
+
+#include <unistd.h>
+#include <fstream>
+#include <cerrno>
+
+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 (file)
index 0000000..2e96c95
--- /dev/null
@@ -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
index 0d24b3e1748ac37af05501d330a4d1f0f8b3e6cb..e3735fa3153b46e2437fbe079fd19fd76bdcd40d 100644 (file)
 #include <cstdlib>
 #include <netinet/in.h>
 #include <unistd.h>
+#include <signal.h>
 
 #include "HTTPRequest.h"
 #include "HTTPRequestRouter.h"
 #include "util.h"
+#include "JSONResponse.h"
 
 std::map<int,std::thread> threadMap;
 std::vector<int> 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<std::mutex>( threadMapLock );
         threadMap[threadId] = std::thread( handle_client, threadId, client_socket );
index 80e984af0bca4b14f5bc5124fd86a196cdebfc0e..1bce3ccb07b8456e5bab53375128e0fb8abebd28 100644 (file)
@@ -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<any>;
+    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 }> {
             </AppContext.Provider>
         );
     }
-} 
\ No newline at end of file
+}
index d6da90bf5e84075268b4a042dd5ae1711148198c..464a406cd081f2a4e5d8763938b7702644249b5a 100644 (file)
@@ -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 (file)
index 0000000..8a300fb
--- /dev/null
@@ -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 (
+        <div className={`accordion${open ? ' open' : ''}`}>
+            <div
+                className="accordion-title"
+                tabIndex={0}
+                role="button"
+                aria-expanded={open}
+                onClick={() => setOpen(o => !o)}
+                onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setOpen(o => !o); }}
+            >
+                <span className="accordion-icon">
+                    <ChevronRightIcon size={20} />
+                </span>
+                <span className="accordion-title-text">{title}</span>
+            </div>
+            <div className={`accordion-content${open ? ' expanded' : ' collapsed'}`}>{children}</div>
+        </div>
+    );
+};
\ No newline at end of file
diff --git a/frontend/components/BottomBarContainer.tsx b/frontend/components/BottomBarContainer.tsx
new file mode 100644 (file)
index 0000000..526bd29
--- /dev/null
@@ -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<HTMLDivElement>(null);
+    const [ height, setHeight ] = useState(0);
+    const spacerRef = useRef<HTMLDivElement>(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 (
+        <Fragment>
+            <div className="bottom-bar-container" ref={barRef}>
+                {children}
+            </div>
+            <div ref={spacerRef} style={{ height: `${height}px` }} />
+        </Fragment>
+    );
+}; 
\ No newline at end of file
diff --git a/frontend/components/ChatConversation.tsx b/frontend/components/ChatConversation.tsx
new file mode 100644 (file)
index 0000000..31eca75
--- /dev/null
@@ -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 (
+        <div className="chat-conversation-scrollable">
+            <div className="chat-conversation">
+                {messages.map((message) => (
+                    <ChatMessage {...message} />
+                ))}
+            </div>
+            { statusMessage && <div>{statusMessage}</div> }
+        </div>
+    );
+};
\ No newline at end of file
diff --git a/frontend/components/ChatInputForm.tsx b/frontend/components/ChatInputForm.tsx
new file mode 100644 (file)
index 0000000..d32410c
--- /dev/null
@@ -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 (
+        <form onSubmit={submitMessage} className="chat-form chat-form-bottom">
+            <div className="chat-input-wrapper">
+                <textarea
+                    value={input}
+                    onInput={e => setInput((e.target as HTMLTextAreaElement).value)}
+                    onKeyDown={handleKeyDown}
+                    placeholder={inputDisabled ? "Waiting for response..." : "Type your message..."}
+                    className="chat-input"
+                    disabled={inputDisabled}
+                    rows={3}
+                />
+                <button type="submit" disabled={inputDisabled || !input.trim()} className="chat-send-btn-embedded">
+                    <SendIcon size={22} />
+                </button>
+            </div>
+        </form>
+    );
+}; 
\ No newline at end of file
diff --git a/frontend/components/ChatMessage.tsx b/frontend/components/ChatMessage.tsx
new file mode 100644 (file)
index 0000000..b936cb0
--- /dev/null
@@ -0,0 +1,32 @@
+import { h } from 'preact';
+import Markdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { Accordion } from './Accordion';
+
+export interface ChatMessageProps {
+    role: 'user' | 'assistant' | 'error' | 'status';
+    content: string;
+}
+
+export const ChatMessage = ({ role, content }: ChatMessageProps) => {
+    const className =
+        role === 'user'
+            ? 'chat-message chat-message-user'
+            : role === 'assistant'
+            ? 'chat-message chat-message-assistant'
+            : 'chat-message';
+
+    let reasoningContent = null;
+    if( role === 'assistant' ) {
+        const match = content.match(/<think>\n(.*?)(?:<\/think>|$)/s);
+        if( match ) {
+            reasoningContent = match[1];
+            content = content.replace(match[0], '').trim();
+        }
+    }
+
+    return <div className={className}>
+        {reasoningContent && <Accordion title="Show Thinking" children={<div className="chat-message-reasoning">{reasoningContent}</div>} />}
+        <Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
+    </div>;
+};
\ No newline at end of file
index 67d0d35d0685304cfd4c149388cf717595c58082..536d395d73f805e7ccf58496425383487454de73 100644 (file)
@@ -219,4 +219,88 @@ export const CircleIcon = ({ size = 24, color = 'currentColor', className = '' }
     >
         <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
     </svg>
-);
\ No newline at end of file
+);
+
+export const ConversationHistoryIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+    <svg
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        fill={color}
+        class={className}
+        style={{ verticalAlign: 'middle' }}
+    >
+        <path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/>
+    </svg>
+);
+
+export const NewConversationIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+    <svg
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        fill="none"
+        stroke={color}
+        strokeWidth="2"
+        class={className}
+        style={{ verticalAlign: 'middle' }}
+    >
+        {/* Chat bubble */}
+        <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
+        {/* Plus sign */}
+        <line x1="12" y1="7" x2="12" y2="13" />
+        <line x1="9" y1="10" x2="15" y2="10" />
+    </svg>
+);
+
+export const ChevronDownIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+    <svg
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        fill="none"
+        stroke={color}
+        stroke-width="2"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        class={className}
+        style={{ verticalAlign: 'middle' }}
+    >
+        <polyline points="6 9 12 15 18 9" />
+    </svg>
+);
+
+export const ChevronRightIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+    <svg
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        fill="none"
+        stroke={color}
+        stroke-width="2"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        class={className}
+        style={{ verticalAlign: 'middle' }}
+    >
+        <polyline points="9 6 15 12 9 18" />
+    </svg>
+);
+
+export const SendIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+    <svg
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        fill="none"
+        stroke={color}
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        class={className}
+        style={{ verticalAlign: 'middle' }}
+    >
+        <line x1="22" y1="2" x2="11" y2="13" />
+        <polygon points="22 2 15 22 11 13 2 9 22 2" />
+    </svg>
+);
index 92da97ca1abbd6e5ad35bc5a72b7bbdb47b8f502..aac8e5fddaf0ba83c0c177c18adcf49153edbd43 100644 (file)
@@ -269,7 +269,7 @@ export const useListState = () => {
                 setSaveStatus('saving');
                 const response = await fetchData('/list/save', flatItems);
                 
-                if (!response.ok) throw new Error(`Save failed: ${response.status}`);
+                if (response.status === 'error') throw new Error(response.message);
                 
                 const savedUuids = new Set(flatItems.map(item => item.uuid));
                 setItems(prevItems => clearDirtyFlags(prevItems, savedUuids));
index 140c468ba0df57d1d14cfe3bf67bd7dd4da8b77c..16a71677819373ff6bf406e2a094b5092c9490ff 100644 (file)
@@ -3,8 +3,14 @@ import { useContext } from 'preact/hooks';
 import { AppContext } from '../app/AppContext';
 import 'styles.css';
 
-const LoadingSpinner = () => {
-    return <div className="loader"></div>;
+interface LoadingSpinnerProps {
+    size?: 'small' | 'large';
+    color?: string;
+}
+
+const LoadingSpinner = ({ size = 'large', color = '#000' }: LoadingSpinnerProps) => {
+    const sizeClass = size === 'small' ? 'loader-small' : 'loader';
+    return <div className={sizeClass} style={{ width: size, height: size, borderColor: color }}></div>;
 };
 
 export default LoadingSpinner;
\ No newline at end of file
index 7daaac74c32cde86ab4f94438d10b3513a2e58e9..8de10042b347a5d2c793b63b5292e06a09668266 100644 (file)
@@ -1,5 +1,5 @@
 import { h, JSX } from 'preact';
-import { useContext, useEffect, useRef } from 'preact/hooks';
+import { useContext } from 'preact/hooks';
 import { AppContext } from 'app/AppContext';
 import LoadingSpinner from './LoadingSpinner';
 import { MenuIcon, CheckIcon, CircleIcon } from './Icons';
@@ -12,7 +12,6 @@ interface ToolbarProps {
 
 export const Toolbar = ({ buttons, onMenuClick }: ToolbarProps) => {
     const appContext = useContext(AppContext);
-    const toolbarRef = useRef<HTMLDivElement>(null);
 
     const handleMenuClick = () => {
         if (onMenuClick) {
@@ -23,98 +22,55 @@ export const Toolbar = ({ buttons, onMenuClick }: ToolbarProps) => {
         }
     };
 
-    useEffect(() => {
-        function resizeHandler() {
-            if( !toolbarRef.current) {
-                return;
-            }
-
-            if( window.visualViewport?.width && window.visualViewport.width > 480) {
-                toolbarRef.current.style.transform = `translate( 0px, 0px)`;
-                return;
-            }
-
-            // viewport height
-            const viewportHeight = window.visualViewport?.height ?? 0;
-            const offsetTop = window.visualViewport?.offsetTop ?? 0;
-            const viewportScale = window.visualViewport?.scale ?? 1;
-            // math - removed scale transform to prevent event handling issues
-            toolbarRef.current.style.transform = `translate( 0px, ${viewportHeight - toolbarRef.current.offsetHeight + 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);
-          };
-    }, []);
-
     return (
-        <div class="toolbar-spacer">
-            <div class="toolbar" ref={toolbarRef}>
-                <div class="toolbar-left">
-                    <ToolbarButton
-                        icon={<MenuIcon size={20} />}
-                        onClick={handleMenuClick}
-                        title="Menu"
-                        menuOptions={[
-                            {
-                                label: 'List',
-                                onClick: () => {
-                                    window.location.href = '/list';
-                                }
-                            },
-                            {
-                                label: 'Chat',
-                                onClick: () => {
-                                    window.location.href = '/chat';
-                                }
-                            },
-                            {
-                                label: 'Logout',
-                                onClick: () => {
-                                    appContext.logout();
-                                }
+        <div class="toolbar">
+            <div class="toolbar-left">
+                <ToolbarButton
+                    icon={<MenuIcon size={20} />}
+                    onClick={handleMenuClick}
+                    title="Menu"
+                    menuOptions={[
+                        {
+                            label: 'List',
+                            onClick: () => {
+                                window.location.href = '/list';
                             }
-                        ]}
-                    />
-                    <div class="toolbar-separator" />
-                    {buttons.map((button, index) => (
-                        <div key={index} class="toolbar-button-container">
-                            <ToolbarButton
-                                icon={button.icon}
-                                onClick={button.onClick}
-                                title={button.title}
-                                disabled={button.disabled}
-                                active={button.active}
-                            />
-                            {button.separatorAfter && (
-                                <div class="toolbar-separator" />
-                            )}
-                        </div>
-                    ))}
-                </div>
-                
-                <div class="toolbar-right">
-                    {appContext.saveStatus === 'saving' ? (
-                        <LoadingSpinner />
-                    ) : appContext.saveStatus === 'saved' ? (
-                        <CheckIcon size={20} />
-                    ) : appContext.saveStatus === 'queued' ? (
-                        <CircleIcon size={20} />
-                    ) : (
-                        <CheckIcon size={20} />
-                    )}
-                </div>
+                        },
+                        {
+                            label: 'Chat',
+                            onClick: () => {
+                                window.location.href = '/chat';
+                            }
+                        },
+                        {
+                            label: 'Logout',
+                            onClick: () => {
+                                appContext.logout();
+                            }
+                        }
+                    ]}
+                />
+                <div class="toolbar-separator" />
+                {buttons.map((button, index) => (
+                    <div key={index} class="toolbar-button-container">
+                        <ToolbarButton {...button}/>
+                        {button.separatorAfter && (
+                            <div class="toolbar-separator" />
+                        )}
+                    </div>
+                ))}
+            </div>
+            
+            <div class="toolbar-right">
+                {appContext.saveStatus === 'saving' ? (
+                    <LoadingSpinner size="small" />
+                ) : appContext.saveStatus === 'saved' ? (
+                    <CheckIcon size={20} />
+                ) : appContext.saveStatus === 'queued' ? (
+                    <CircleIcon size={20} />
+                ) : (
+                    <CheckIcon size={20} />
+                )}
             </div>
         </div>
     );
index 6df8e0fdab5e013296d1bb3ef2c436aa8f82cf83..c43f03912a91021f28db4bfbf208f471c519e4f0 100644 (file)
@@ -28,6 +28,8 @@ export const ToolbarButton = ({
     menuOptions 
 }: ToolbarButtonProps) => {
     const [showMenu, setShowMenu] = useState(false);
+    const [menuPlacement, setMenuPlacement] = useState<{ dropUp: boolean; alignRight: boolean }>({ dropUp: false, alignRight: false });
+    const [menuMaxHeightPx, setMenuMaxHeightPx] = useState<number | undefined>(undefined);
     const buttonRef = useRef<HTMLButtonElement>(null);
     const menuRef = useRef<HTMLDivElement>(null);
 
@@ -57,7 +59,14 @@ export const ToolbarButton = ({
         if (disabled) return;
         
         if (hasMenu) {
-            setShowMenu(!showMenu);
+            const willShow = !showMenu;
+            setShowMenu(willShow);
+            if (willShow) {
+                // Compute placement on next frame to ensure DOM is updated
+                requestAnimationFrame(() => {
+                    computeAndSetMenuPlacement();
+                });
+            }
         } else if (onClick) {
             onClick();
         }
@@ -68,6 +77,56 @@ export const ToolbarButton = ({
         setShowMenu(false);
     };
 
+    const computeAndSetMenuPlacement = () => {
+        const buttonEl = buttonRef.current;
+        const menuEl = menuRef.current;
+        if (!buttonEl || !menuEl) return;
+
+        const buttonRect = buttonEl.getBoundingClientRect();
+        const menuRect = menuEl.getBoundingClientRect();
+
+        const viewportWidth = window.innerWidth;
+        const viewportHeight = window.innerHeight;
+
+        // Determine vertical placement: drop up if not enough space below
+        const spaceBelow = viewportHeight - buttonRect.bottom;
+        const spaceAbove = buttonRect.top;
+        const dropUp = spaceBelow < menuRect.height && spaceAbove > spaceBelow;
+
+        // Determine horizontal alignment: align right if overflowing to the right and there's more space to the left
+        const wouldOverflowRight = buttonRect.left + menuRect.width > viewportWidth;
+        const alignRight = wouldOverflowRight && (buttonRect.right > menuRect.width || buttonRect.right > viewportWidth / 2);
+
+        setMenuPlacement({ dropUp, alignRight });
+
+        // Compute max available height for the menu to keep it on-screen
+        const verticalPaddingPx = 8; // small breathing room
+        const availableHeight = dropUp
+            ? Math.max(0, spaceAbove - verticalPaddingPx)
+            : Math.max(0, spaceBelow - verticalPaddingPx);
+        const clampedMax = Math.max(96, Math.min(Math.floor(viewportHeight * 0.9), Math.floor(availableHeight)));
+        setMenuMaxHeightPx(clampedMax);
+    };
+
+    // Recompute placement on resize/scroll when menu is open
+    useEffect(() => {
+        if (!showMenu) return;
+        const handler = () => computeAndSetMenuPlacement();
+        window.addEventListener('resize', handler);
+        window.addEventListener('scroll', handler, true);
+        const vv = (window as any).visualViewport as VisualViewport | undefined;
+        vv?.addEventListener('resize', handler);
+        vv?.addEventListener('scroll', handler);
+        // initial compute in case content changed
+        computeAndSetMenuPlacement();
+        return () => {
+            window.removeEventListener('resize', handler);
+            window.removeEventListener('scroll', handler, true);
+            vv?.removeEventListener('resize', handler);
+            vv?.removeEventListener('scroll', handler);
+        };
+    }, [showMenu]);
+
     return (
         <div class="toolbar-button-wrapper">
             <button 
@@ -84,7 +143,16 @@ export const ToolbarButton = ({
             </button>
             
             {hasMenu && showMenu && (
-                <div ref={menuRef} class="toolbar-menu">
+                <div 
+                    ref={menuRef} 
+                    class={`toolbar-menu ${menuPlacement.dropUp ? 'drop-up' : ''} ${menuPlacement.alignRight ? 'align-right' : ''}`}
+                    style={{
+                        maxHeight: menuMaxHeightPx ? `${menuMaxHeightPx}px` : undefined,
+                        overflowY: 'auto',
+                        WebkitOverflowScrolling: 'touch',
+                        overscrollBehavior: 'contain'
+                    }}
+                >
                     {menuOptions.map((option, index) => (
                         <button
                             key={index}
diff --git a/frontend/components/TopBarContainer.tsx b/frontend/components/TopBarContainer.tsx
new file mode 100644 (file)
index 0000000..7640a99
--- /dev/null
@@ -0,0 +1,92 @@
+import { h, ComponentChildren, Fragment, toChildArray } from 'preact';
+import { useEffect, useRef, useState } from 'preact/hooks';
+
+interface TopBarContainerProps {
+    children?: ComponentChildren;
+}
+
+export const TopBarContainer = ({ children }: TopBarContainerProps) => {
+    const barRef = useRef<HTMLDivElement>(null);
+    const [ height, setHeight ] = useState(0);
+    const spacerRef = useRef<HTMLDivElement>(null);
+    const childCount = toChildArray(children).length;
+
+    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;
+            
+            // For top bar, we want to move it down by the keyboard height to keep it visible
+            // When keyboard appears, the viewport shrinks, so we need to move the top bar down
+            barRef.current.style.transform = `translate(0px, ${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, childCount]);
+
+    // Keep spacer height in sync with bar size changes
+    useEffect(() => {
+        if (!barRef.current || typeof ResizeObserver === 'undefined') {
+            return;
+        }
+        const observer = new ResizeObserver(() => {
+            if (barRef.current) {
+                setHeight(barRef.current.offsetHeight);
+            }
+        });
+        observer.observe(barRef.current);
+        return () => observer.disconnect();
+    }, [barRef.current, childCount]);
+
+    if (childCount === 0) {
+        return null;
+    }
+
+    return (
+        <Fragment>
+            <div className="top-bar-container" ref={barRef}>
+                {children}
+            </div>
+            <div ref={spacerRef} style={{ height: `${height}px` }} />
+        </Fragment>
+    );
+}; 
\ No newline at end of file
diff --git a/frontend/components/View.tsx b/frontend/components/View.tsx
new file mode 100644 (file)
index 0000000..0acd1ed
--- /dev/null
@@ -0,0 +1,75 @@
+import { h, ComponentChildren } from 'preact';
+import { useContext, useEffect, useRef, useState } from 'preact/hooks';
+import { AppContext } from '../app/AppContext';
+import { Toolbar } from 'components/Toolbar';
+import { TopBarContainer } from 'components/TopBarContainer';
+import { BottomBarContainer } from 'components/BottomBarContainer';
+
+interface ViewProps {
+    children: ComponentChildren;
+    onMobileViewEnable?: (isMobile: boolean) => void;
+}
+
+export const View = ({ 
+    children, 
+    onMobileViewEnable,
+}: ViewProps) => {
+    const { toolbars } = useContext(AppContext);
+    const [ topBarContent, setTopBarContent ] = useState<ComponentChildren>(null);
+    const [ bottomBarContent, setBottomBarContent ] = useState<ComponentChildren>(null);
+    const [ isMobile, setIsMobile ] = useState(false);
+    const prevIsMobileRef = useRef<boolean>(false);
+
+    useEffect(() => {
+        const topBars = toolbars.filter(toolbar => toolbar.position === 'top');
+        const bottomBars = toolbars.filter(toolbar => toolbar.position === 'bottom');
+        setTopBarContent(topBars.map(bar => bar.component));
+        setBottomBarContent(bottomBars.map(bar => bar.component));
+    }, [toolbars]);
+
+    useEffect(() => {
+        const checkMobileState = () => {
+            if (window.visualViewport) {
+                const newIsMobile = window.visualViewport.width < 480;
+                setIsMobile(newIsMobile);
+            } else {
+                setIsMobile(false);
+            }
+        };
+
+        // Initial check
+        checkMobileState();
+
+        // Add resize event listener
+        window.addEventListener('resize', checkMobileState);
+
+        // Cleanup
+        return () => {
+            window.removeEventListener('resize', checkMobileState);
+        };
+    }, []);
+
+    useEffect(() => {
+        // Only call callback when mobile state changes
+        if (onMobileViewEnable && prevIsMobileRef.current !== isMobile) {
+            onMobileViewEnable(isMobile);
+            prevIsMobileRef.current = isMobile;
+        }
+    }, [isMobile, onMobileViewEnable]);
+
+    return (
+        <div className="view">
+            <TopBarContainer>
+                {topBarContent}
+            </TopBarContainer>
+            
+            <main className="view-main">
+                {children}
+            </main>
+
+            <BottomBarContainer>
+                {bottomBarContent}
+            </BottomBarContainer>
+        </div>
+    );
+};
\ No newline at end of file
index 6d2aa9be9141459026292eb9f91708c098250707..0de8669d5eef393fc2be32f50cab8a077bed873d 100644 (file)
@@ -13,6 +13,10 @@ esbuild.build({
   minify: !isDev,
   jsxFactory: 'h',
   jsxFragment: 'Fragment',
+  alias: {
+    'react': 'preact/compat',
+    'react-dom': 'preact/compat',
+  },
   loader: {
     '.js': 'jsx',
     '.ts': 'tsx',
@@ -37,7 +41,14 @@ esbuild.build({
       skipLibCheck: true,
       jsx: 'transform',
       jsxFactory: 'h',
-      jsxFragmentFactory: 'Fragment'
+      jsxFragmentFactory: 'Fragment',
+      baseUrl: ".",
+      paths: {
+        react: ["./node_modules/preact/compat/"],
+        "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
+        "react-dom": ["./node_modules/preact/compat/"],
+        "react-dom/*": ["./node_modules/preact/compat/*"]
+      }
     }
   }
 }).catch((error) => {
index db77790c99e24386fcec1e21aa5e2f86480995da..c0cf06d7153904d0a3c799ac931e8358f6866157 100644 (file)
@@ -2,6 +2,8 @@
   "dependencies": {
     "esbuild": "^0.25.4",
     "preact": "^10.26.6",
-    "preact-router": "^4.1.2"
+    "preact-router": "^4.1.2",
+    "react-markdown": "^10.1.0",
+    "remark-gfm": "^4.0.0"
   }
 }
index 99609e80b2550870c11815d25c557226e1c154d7..17ed31b77e94791277901105cbccfae93e0abb0b 100644 (file)
@@ -2,7 +2,7 @@
 <html lang="en">
 <head>
     <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="viewport" content="height=device-height, width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi">
     <meta http-equiv="X-UA-Compatible" content="ie=edge">
     <title>Document</title>
     <link rel="stylesheet" href="%CSS_PATH%">
index 9135a9d9869236142cc333ca1c0d6be29f098435..cf09b81267aaa36ba78e1c6ad8d697c714845940 100644 (file)
@@ -1,11 +1,12 @@
 @import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&display=swap');
 
 /* Base Styles */
-body {
+html, body {
     font-family: 'Lato', sans-serif;
-    padding-bottom: 3.25rem; /* Account for fixed bottom toolbar */
     margin: 0;
-    min-height: 100vh;
+    padding: 0;
+    /*height: 100%;*/
+    /*overflow-x: hidden;*/
     -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
 }
 
@@ -39,6 +40,10 @@ h1 {
     animation: prixClipFix 2s linear infinite;
 }
 
+.loader-small::before {
+    border-width: 0.15rem;
+}
+
 @keyframes rotate {
     100% { transform: rotate(360deg) }
 }
@@ -51,14 +56,26 @@ h1 {
     100% { clip-path: polygon(50% 50%,0 0,100% 0,100% 100%,0 100%,0 0) }
 }
 
-/* Form Styles */
-form {
+.login-form {
     max-width: 25rem;
-    margin: 2rem auto;
-    padding: 2rem;
     background: white;
     border-radius: 0.5rem;
     box-shadow: 0 0.125rem 0.625rem rgba(0, 0, 0, 0.1);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+}
+
+/* Form Styles */
+/* Ensure inputs and buttons calculate width consistently */
+form input, form button, form textarea, form select {
+    box-sizing: border-box;
+}
+
+form {
+    margin: 2rem auto;
+    padding: 2rem;
 }
 
 form h1 {
@@ -182,7 +199,7 @@ form button:active {
 }
 
 /* List Item Styles */
-ul {
+.list {
     list-style: disc;
 }
 
@@ -256,6 +273,47 @@ li {
     font-style: italic;
 }
 
+/* Container Styles */
+.top-bar-container {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    z-index: 2000;
+    background: none;
+    display: flex;
+    flex-direction: column;
+}
+
+.bottom-bar-container {
+    position: fixed;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    z-index: 1000;
+    background: none;
+    display: flex;
+    flex-direction: column;
+}
+
+.hide-on-mobile {
+    display: block;
+}
+
+.hide-on-desktop {
+    display: none;
+}
+
+@media (max-width: 480px) {
+    .hide-on-mobile {
+        display: none;
+    }
+    
+    .hide-on-desktop {
+        display: block;
+    }
+}
+
 /* Toolbar Styles */
 .toolbar {
     display: flex;
@@ -266,14 +324,10 @@ li {
     backdrop-filter: blur(0.5rem);
     -webkit-backdrop-filter: blur(0.5rem);
     border-bottom: 1px solid #dee2e6;
-    position: fixed;
-    top: 0;
-    left: 0;
-    right: 0;
-    z-index: 1000;
     height: 3.25rem;
     box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
     -webkit-overflow-scrolling: touch;
+    z-index: 10000;
 }
 
 .toolbar-spacer {
@@ -386,15 +440,31 @@ li {
     top: 100%;
     left: 0;
     min-width: 11.25rem;
+    max-width: min(90vw, 22rem);
     background: white;
     border: 1px solid #dee2e6;
     border-radius: 0.375rem;
     box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.15);
-    z-index: 1001;
+    z-index: 10000;
     margin-top: 0.25rem;
     padding: 0.25rem 0;
     backdrop-filter: blur(0.625rem);
     animation: menuFadeIn 0.15s ease-out;
+    overflow-x: hidden;
+    /* Make long menus scroll instead of overflowing off-screen */
+    max-height: 90vh;
+}
+
+.toolbar-menu.align-right {
+    left: auto;
+    right: 0;
+}
+
+.toolbar-menu.drop-up {
+    top: auto;
+    bottom: 100%;
+    margin-top: 0;
+    margin-bottom: 0.25rem;
 }
 
 @keyframes menuFadeIn {
@@ -500,6 +570,9 @@ li {
     .toolbar-menu {
         min-width: 10rem;
         margin-top: 0.1875rem;
+        max-height: 85vh;
+        overscroll-behavior: contain;
+        -webkit-overflow-scrolling: touch;
     }
 
     .toolbar-menu-item {
@@ -518,8 +591,8 @@ li {
     .toolbar {
         height: 2.5rem;
         padding: 0.25rem 0.5rem;
-        /*top: auto;
-        bottom: 0;*/
+        border-bottom: none;
+        border-top: 1px solid #dee2e6;
     }
 
     .toolbar-spacer {
@@ -556,6 +629,10 @@ li {
         min-width: 8.75rem;
         margin-top: 0.125rem;
         padding: 0.125rem 0;
+        /* Mobile: tighter max-height so the OS chrome doesn't overlap */
+        max-height: 80vh;
+        overscroll-behavior: contain;
+        -webkit-overflow-scrolling: touch;
     }
 
     .toolbar-menu-item {
@@ -569,3 +646,232 @@ li {
         height: 0.75rem;
     }
 }
+
+/* Chat form styles */
+.chat-form {
+    display: flex;
+    flex-direction: column;
+    gap: 0.5rem;
+    margin: 0;
+}
+
+/* Embedded chat input and send button */
+.chat-input-wrapper {
+    position: relative;
+    width: 100%;
+    display: flex;
+    align-items: flex-end;
+}
+
+.chat-input {
+    width: 100%;
+    font-size: 1rem;
+    padding: 0.75rem 3rem 0.75rem 0.75rem; /* right padding for button */
+    border: 1px solid #ddd;
+    border-radius: 0.25rem;
+    resize: vertical;
+    min-height: 3rem;
+    box-sizing: border-box;
+    transition: border-color 0.2s;
+}
+
+.chat-send-btn-embedded {
+    position: absolute;
+    right: 0.5rem;
+    bottom: 0.5rem;
+    background: #0074ff;
+    border: none;
+    border-radius: 50%;
+    width: 2.5rem;
+    height: 2.5rem;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: white;
+    cursor: pointer;
+    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+    transition: background 0.2s, box-shadow 0.2s;
+    z-index: 2;
+    padding: 0;
+}
+
+.chat-send-btn-embedded:disabled {
+    background: #b3d1ff;
+    cursor: not-allowed;
+}
+
+.chat-send-btn-embedded:not(:disabled):hover {
+    background: #0056b3;
+}
+
+/* Chat message styles */
+
+.chat-message {
+    margin-bottom: 0.5rem;
+    word-wrap: break-word;
+}
+
+.chat-message-user {
+    margin-left: auto; /* Push to right */
+    width: 60%;
+    background: #e6f0ff;
+    border-radius: 0.5rem;
+    padding: 0.5rem 0.75rem;
+}
+
+.chat-message-assistant {
+    width: 100%;
+    max-width: 90vw;
+    padding: 1.0rem;
+    word-wrap: break-word;
+}
+
+.chat-message-reasoning {
+    white-space: pre-wrap;
+    margin-bottom: 0.5rem;
+    word-wrap: break-word;
+    font-size: 0.9rem;
+    color: #666;
+    background: #f7f7f7;
+    border-radius: 0.5rem;
+    padding: 0.5rem 0.75rem;
+    border: 1px solid #e0e0e0;
+    font-family: monospace;
+}
+
+/* Accordion Styles */
+.accordion {
+    border: 1px solid #e0e0e0;
+    border-radius: 0.5rem;
+    margin-bottom: 1rem;
+    background: #fff;
+    box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.03);
+    overflow: hidden;
+}
+.accordion-title {
+    display: flex;
+    align-items: center;
+    cursor: pointer;
+    padding: 0.75rem 1rem;
+    font-weight: 600;
+    font-size: 1.1rem;
+    user-select: none;
+    background: #f7f7f7;
+    border-bottom: 1px solid #e0e0e0;
+    transition: background 0.2s;
+    outline: none;
+}
+.accordion-title:focus, .accordion-title:hover {
+    background: #eaf3ff;
+}
+.accordion-icon {
+    margin-right: 0.75rem;
+    display: flex;
+    align-items: center;
+    transition: transform 0.5s cubic-bezier(0.4,0,0.2,1);
+}
+.accordion.open .accordion-icon {
+    transform: rotate(90deg);
+}
+.accordion-title-text {
+    flex: 1;
+}
+.accordion-content {
+    overflow: hidden;
+    max-height: 1000px;
+    transition: max-height 0.5s cubic-bezier(0.4,0,0.2,1), padding 0.5s, opacity 0.5s;
+    padding: 1rem;
+    opacity: 1;
+}
+.accordion-content.collapsed {
+    max-height: 0;
+    padding-top: 0;
+    padding-bottom: 0;
+    opacity: 0;
+    pointer-events: none;
+}
+.accordion-content.expanded {
+    opacity: 1;
+}
+
+.view {
+    display: flex;
+    flex-direction: column;
+    position: absolute;
+    width: 100vw;
+}
+
+.view-main {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 0;
+    width: 100%;
+    height: 100%;
+}
+
+/* List-specific layout overrides */
+.list-container {
+    width: 100%;
+    margin: 0;
+    padding-left: 2rem;
+    box-sizing: border-box;
+    min-height: 0; /* Allow shrinking */
+}
+
+.chat-conversation-scrollable {
+    flex: 1;
+    padding: 1rem 0;
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    overflow-y: auto;
+    /*overflow-x: hidden;*/
+    background: #f8f9fa;
+    min-width: 100vw;
+    min-height: 100vh;
+}
+
+.chat-conversation {
+    margin-left: 2.5rem;
+    margin-right: 2.5rem;
+}
+
+.chat-form.chat-form-bottom {
+    flex-shrink: 0;
+    background: linear-gradient(135deg, rgba(248, 249, 250, 0.95) 0%, rgba(233, 236, 239, 0.95) 100%);
+    backdrop-filter: blur(0.5rem);
+    -webkit-backdrop-filter: blur(0.5rem);
+    border-top: 1px solid #dee2e6;
+    box-shadow: 0 -0.125rem 0.5rem rgba(0, 0, 0, 0.05);
+    padding: 0.75rem 1rem;
+    margin: 0;
+}
+
+@media (max-width: 768px) {
+    .chat-conversation {
+        margin-left: 1rem;
+        margin-right: 1rem;
+    }
+}
+
+@media (max-width: 480px) {
+    .chat-conversation {
+        margin-left: 0.5rem;
+        margin-right: 0.5rem;
+    }
+    .chat-input-wrapper {
+        min-height: 3.5rem;
+    }
+    .chat-send-btn-embedded {
+        width: 2.2rem;
+        height: 2.2rem;
+        right: 0.25rem;
+        bottom: 0.25rem;
+    }
+    .chat-input {
+        padding-right: 2.5rem;
+    }
+}
\ No newline at end of file
index 2466fb093564489a249923194f994a27d3b1ac3f..5dcea51ab2f38ac9c602ae3b0a8c1d516f37b507 100644 (file)
     "resolveJsonModule": true,
     "baseUrl": ".",
     "paths": {
+      "react": ["./node_modules/preact/compat/"],
+      "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
+      "react-dom": ["./node_modules/preact/compat/"],
+      "react-dom/*": ["./node_modules/preact/compat/*"],
       "components/*": ["components/*"]
     }
   },
diff --git a/frontend/views/Chat.tsx b/frontend/views/Chat.tsx
new file mode 100644 (file)
index 0000000..6cee065
--- /dev/null
@@ -0,0 +1,202 @@
+import { Fragment, h } from 'preact';
+import { useContext, useEffect, useState } from 'preact/hooks';
+import { AppContext } from '../app/AppContext';
+import LoadingSpinner from '../components/LoadingSpinner';
+import { ChatConversation } from 'components/ChatConversation';
+import { ChatInputForm } from 'components/ChatInputForm';
+import { ChatMessageProps } from 'components/ChatMessage';
+import { View } from 'components/View';
+import { ConversationHistoryIcon, NewConversationIcon } from 'components/Icons';
+import { Toolbar } from 'components/Toolbar';
+
+interface ChatMessageChunk {
+    role: 'user' | 'assistant' | 'error' | 'status';
+    content: string;
+    conversation_id?: string;
+    title?: string;
+    response_finished?: boolean;
+}
+
+interface ChatConversationItem {
+    _uuid: string;
+    _created_at: string;
+    _updated_at: string;
+    title: string;
+}
+
+const Chat = () => {
+    const { streamSSE, setToolbars, setSaveStatus, fetchData } = useContext(AppContext);
+    const [ status, setStatus ] = useState<string | null>('Checking status...');
+    const [ serverStatus, setServerStatus ] = useState<'checking' | 'online' | 'offline' | 'waking'>('checking');
+    const [ messages, setMessages ] = useState<ChatMessageProps[]>([]);
+    const [ conversationId, setConversationId ] = useState<string>('new');
+    const [ awaitingResponse, setAwaitingResponse ] = useState(false);
+    const [ conversationItems, setConversationItems ] = useState<ChatConversationItem[]>([]);
+    const [ loadingConversations, setLoadingConversations ] = useState<boolean>(false);
+    const [ loadingMessages, setLoadingMessages ] = useState<boolean>(false);
+    const [ isMobile, setIsMobile ] = useState<boolean>(false);
+
+    // Fetch conversation list and set toolbar buttons
+    useEffect(() => {
+        let cancelled = false;
+        setLoadingConversations(true);
+        fetchData('/chat/list')
+            .then((data) => {
+                if (cancelled) return;
+                if (data && data.status === 'success' && Array.isArray(data.data)) {
+                    setConversationItems(data.data);
+                } else {
+                    setConversationItems([]);
+                }
+            })
+            .catch(() => {
+                if (!cancelled) setConversationItems([]);
+            })
+            .finally(() => {
+                if (!cancelled) setLoadingConversations(false);
+            });
+        return () => { cancelled = true; };
+    }, [fetchData]);
+
+    // Update toolbar buttons when conversationItems or loadingConversations changes
+    useEffect(() => {
+        const buttons = [
+            {
+                icon: <NewConversationIcon size={20} />,
+                title: 'New Conversation',
+                disabled: loadingConversations,
+                onClick: () => {
+                    setConversationId('new');
+                    setMessages([]);
+                }
+            },
+            {
+                icon: <ConversationHistoryIcon size={20} />,
+                title: 'Conversation History',
+                disabled: loadingConversations,
+                menuOptions: conversationItems.map((item) => ({
+                    label: item.title,
+                    onClick: async () => {
+                        setLoadingMessages(true);
+                        try {
+                            const data = await fetchData(`/chat/fetch/${item._uuid}`);
+                            if (data && data.status === 'success' && data.data) {
+                                setConversationId(item._uuid);
+                                if (Array.isArray(data.data.messages)) {
+                                    setMessages(data.data.messages.map((msg: any) => ({ role: msg.role, content: msg.content })));
+                                } else {
+                                    setMessages([]);
+                                }
+                            } else {
+                                setMessages([]);
+                            }
+                        } catch (e) {
+                            setMessages([]);
+                        } finally {
+                            setLoadingMessages(false);
+                        }
+                    },
+                })),
+            }
+        ];
+        setToolbars([
+            {
+                position: isMobile ? 'bottom' : 'top',
+                component: <Toolbar buttons={buttons} />,
+            },
+            {
+                position: 'bottom',
+                component: <ChatInputForm 
+                    onSubmit={sendMessage} 
+                    inputDisabled={awaitingResponse || loadingMessages} 
+                />,
+            },
+        ]);
+    }, [isMobile, conversationItems, loadingConversations, setToolbars, fetchData, awaitingResponse, loadingMessages]);
+
+    // Check chat status on mount
+    useEffect(() => {
+        let cancelled = false;
+
+        streamSSE(
+            '/chat/status',
+            null,
+            (obj) => {
+                if (cancelled) return;
+                
+                setServerStatus(obj.status);
+                setStatus(obj.message);
+            },
+            (err) => {
+                if (!cancelled) {
+                    setServerStatus('offline');
+                    setStatus(err.message);
+                }
+            }
+        );
+
+        return () => { cancelled = true; };
+    }, [streamSSE]);
+
+    const sendMessage = async (message: string) => {
+        // POST to /chat/update with user message
+        const body = { conversation_id: conversationId, user_message: message };
+        let assistantResponse = '';
+        const prevMessages: ChatMessageProps[] = [ ...messages, { role: 'user', content: message } ];
+        setMessages(prevMessages);
+        setSaveStatus('saving');
+        setAwaitingResponse(true);
+        try {
+            await streamSSE(
+                '/chat/update',
+                body,
+                (obj: ChatMessageChunk) => {
+                    if (obj.role === 'assistant') {
+                        assistantResponse += obj.content;
+                    }
+                    if (obj.role === 'status') {
+                        if( obj.content )
+                            setStatus(obj.content);
+                        if( obj.conversation_id )
+                            setConversationId(obj.conversation_id);
+                        if( obj.title )
+                            console.log("title", obj.title);
+                        if( obj.response_finished )
+                            setAwaitingResponse(false);
+                    }
+                    else
+                    {
+                        setStatus(null);
+                    }
+                    setMessages(() => [...prevMessages, { role: obj.role, content: assistantResponse }]);
+                },
+                (err) => {
+                    setMessages(() => [...prevMessages, { role: 'error', content: err.message }]);
+                }
+            );
+        } catch (e) {
+            setMessages((prev) => [...prevMessages, { role: 'error', content: 'Error sending message.' }]);
+        }
+
+        setMessages((prev) => [...prevMessages, { role: 'assistant', content: assistantResponse }]);
+        setAwaitingResponse(false);
+        setSaveStatus('saved');
+    };
+
+    return (
+        <View onMobileViewEnable={setIsMobile}>
+            { serverStatus === 'online' ? 
+                <ChatConversation 
+                    messages={messages} 
+                    statusMessage={status} 
+                /> :
+                <div style={{ padding: '2rem', alignItems: 'center', display: 'flex', flexDirection: 'column' }}>
+                    <LoadingSpinner />
+                    <div style={{ marginTop: '1rem' }}>{status}</div>
+                </div> 
+            }
+        </View>
+    )
+};
+
+export default Chat;
\ No newline at end of file
diff --git a/frontend/views/Home.tsx b/frontend/views/Home.tsx
deleted file mode 100644 (file)
index 1bcd95d..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-import { h } from 'preact';
-import { useContext } from 'preact/hooks';
-import { AppContext } from '../app/AppContext';
-import { Toolbar } from '../components/Toolbar';
-
-const Home = () => {
-    const { user } = useContext(AppContext);
-    
-    return (
-        <div>
-            <Toolbar/>
-            <h1>Welcome to List</h1>
-            <p>Authenticated as {user?.username}</p>
-        </div>
-    );
-};
-
-export default Home; 
\ No newline at end of file
index f06b3a8ab7812aa16e9fc77af773b8408ee06a54..2f7a1b89a3307a82ea8b5b0c991f804591a0f1d5 100644 (file)
@@ -1,82 +1,91 @@
 import { h } from 'preact';
-import { useContext } from 'preact/hooks';
+import { useContext, useEffect, useState } from 'preact/hooks';
 import { ListContextProvider, useListState } from 'components/ListContext';
 import { ListItem } from 'components/ListItem';
-import { Toolbar } from 'components/Toolbar';
-import ToolbarButton, { ToolbarButtonProps } from 'components/ToolbarButton';
+import { AppContext } from '../app/AppContext';
 import { IndentIcon, OutdentIcon, ImportantIcon, CheckIcon, MoveUpIcon, MoveDownIcon } from 'components/Icons';
+import { View } from 'components/View';
+import Toolbar from 'components/Toolbar';
 
-const ListWithToolbar = () => {
+const ListComponent = () => {
     const listContext = useContext(ListContextProvider);
+    const { setToolbars } = useContext(AppContext);
+    const [ isMobile, setIsMobile ] = useState(false);
 
-    const buttons: ToolbarButtonProps[] = [
-        {
-            icon: <MoveUpIcon size={20} />,
-            onClick: listContext.moveUpItem,
-            title: 'Move Up (Ctrl+Up)',
-            disabled: !listContext.canMoveUp,
-        },
-        {
-            icon: <MoveDownIcon size={20} />,
-            onClick: listContext.moveDownItem,
-            title: 'Move Down (Ctrl+Down)',
-            disabled: !listContext.canMoveDown,
-            separatorAfter: true,
-        },
-        {
-            icon: <OutdentIcon size={20} />,
-            onClick: listContext.promoteItem,
-            title: 'Outdent (Shift+Tab)',
-            disabled: !listContext.canPromote,
-        },
-        {
-            icon: <IndentIcon size={20} />,
-            onClick: listContext.demoteItem,
-            title: 'Indent (Tab)',
-            disabled: !listContext.canDemote,
-            separatorAfter: true,
-        },
-        {
-            icon: <ImportantIcon size={20} />,
-            onClick: listContext.toggleImportantItem,
-            title: 'Toggle Important (Ctrl+B)',
-            disabled: !listContext.focusedItem,
-            active: listContext.focusedItem?.important || false,
-        },
-        {
-            icon: <CheckIcon size={20} />,
-            onClick: listContext.toggleCompletedItem,
-            title: 'Toggle Completed (Ctrl+S)',
-            disabled: !listContext.focusedItem,
-            active: !!listContext.focusedItem?.completed_at,
-        },
-    ];
+    useEffect(() => {
+        const buttons = [
+            {
+                icon: <MoveUpIcon size={20} />,
+                onClick: listContext.moveUpItem,
+                title: 'Move Up (Ctrl+Up)',
+                disabled: !listContext.canMoveUp,
+            },
+            {
+                icon: <MoveDownIcon size={20} />,
+                onClick: listContext.moveDownItem,
+                title: 'Move Down (Ctrl+Down)',
+                disabled: !listContext.canMoveDown,
+                separatorAfter: true,
+            },
+            {
+                icon: <OutdentIcon size={20} />,
+                onClick: listContext.promoteItem,
+                title: 'Outdent (Shift+Tab)',
+                disabled: !listContext.canPromote,
+            },
+            {
+                icon: <IndentIcon size={20} />,
+                onClick: listContext.demoteItem,
+                title: 'Indent (Tab)',
+                disabled: !listContext.canDemote,
+                separatorAfter: true,
+            },
+            {
+                icon: <ImportantIcon size={20} />,
+                onClick: listContext.toggleImportantItem,
+                title: 'Toggle Important (Ctrl+B)',
+                disabled: !listContext.focusedItem,
+                active: listContext.focusedItem?.important || false,
+            },
+            {
+                icon: <CheckIcon size={20} />,
+                onClick: listContext.toggleCompletedItem,
+                title: 'Toggle Completed (Ctrl+S)',
+                disabled: !listContext.focusedItem,
+                active: !!listContext.focusedItem?.completed_at,
+            },
+        ];
+
+        setToolbars([
+            {
+                position: isMobile ? 'bottom' : 'top',
+                component: <Toolbar buttons={buttons} />,
+            },
+        ]);
+    }, [ isMobile, listContext.canMoveUp, listContext.canMoveDown, listContext.canPromote, listContext.canDemote, listContext.focusedItem?.important, listContext.focusedItem?.completed_at ]);
     
     return (
-        <div>
-            <Toolbar buttons={buttons} />
-            <div>
-                <ul>
-                    {listContext.items.filter(item => !item.__is_deleted).map(item => (
-                        <ListItem 
-                            key={item._uuid} 
-                            item={item} 
-                            onChange={listContext.handleItemChange} 
-                            moveFocus={listContext.handleMoveFocus}
-                            promote={listContext.handlePromote}
-                            demote={listContext.handleDemote}
-                            toggleImportant={listContext.handleToggleImportant}
-                            toggleCompleted={listContext.handleToggleCompleted}
-                            createItem={listContext.handleCreateItem}
-                            deleteItem={listContext.handleDeleteItem}
-                            onFocus={listContext.handleItemFocus}
-                            moveUp={listContext.handleMoveUp}
-                            moveDown={listContext.handleMoveDown}
-                        />
-                    ))}
-                </ul>
-            </div>
-        </div>
+        <View onMobileViewEnable={setIsMobile}>
+            <ul className="list-container">
+                {listContext.items.filter(item => !item.__is_deleted).map(item => (
+                    <ListItem 
+                        key={item._uuid} 
+                        item={item} 
+                        onChange={listContext.handleItemChange} 
+                        moveFocus={listContext.handleMoveFocus}
+                        promote={listContext.handlePromote}
+                        demote={listContext.handleDemote}
+                        toggleImportant={listContext.handleToggleImportant}
+                        toggleCompleted={listContext.handleToggleCompleted}
+                        createItem={listContext.handleCreateItem}
+                        deleteItem={listContext.handleDeleteItem}
+                        onFocus={listContext.handleItemFocus}
+                        moveUp={listContext.handleMoveUp}
+                        moveDown={listContext.handleMoveDown}
+                    />
+                ))}
+            </ul>
+        </View>
     );
 };
 
@@ -109,7 +118,7 @@ export const List = () => {
             handleMoveUp: listState.handleMoveUp,
             handleMoveDown: listState.handleMoveDown,
         }}>
-            <ListWithToolbar />
+            <ListComponent />
         </ListContextProvider.Provider>
     );
 };
index 1b08b95e0fc8f688a2faab4cc2eea29ab50e09c9..726422c8877fb7f64bcc88654275b49ce0370d6a 100644 (file)
@@ -1,9 +1,10 @@
 import { h } from "preact";
 import { useContext } from "preact/hooks";
 import { AppContext } from "../app/AppContext";
+import { View } from "components/View";
 
 const Login = () => {
-    const { fetchData } = useContext(AppContext);
+    const { fetchData, setNotification } = useContext(AppContext);
     
     const handleSubmit = async (e: Event) => {
         e.preventDefault();
@@ -20,26 +21,37 @@ const Login = () => {
             
             if (!response) {
                 console.error('Login failed');
+                setNotification({
+                    visible: true,
+                    message: 'Invalid username or password',
+                    showSpinner: false,
+                    type: 'error'
+                });
                 return;
             }
             
-            // Handle successful login here
-            window.location.href = '/'; // Redirect to home page after successful login
+            window.location.href = '/';
         } catch (error) {
             console.error('Login error:', error);
-            // Handle login error here
+            setNotification({
+                visible: true,
+                message: 'Invalid username or password',
+                showSpinner: false,
+                type: 'error'
+            });
         }
     };
 
-    return <div>
-        Login
-        <form onSubmit={handleSubmit}>
-            <input type="text" name="username" />
-            <input type="password" name="password" />
-            <button type="submit">Login</button>
-        </form>
-        <a href="/signup">Sign up</a>
-    </div>;
+    return (<View>
+        <div className="login-form">
+            <form onSubmit={handleSubmit}>
+                <input type="text" name="username" />
+                <input type="password" name="password" />
+                <button type="submit">Login</button>
+            </form>
+            <a href="/signup">Sign up</a>
+        </div>
+    </View>);
 };
 
 export default Login;
\ No newline at end of file