Răsfoiți Sursa

feat: add LLM fine-tuning, templating runner, full-page chat & markdown

Major features implemented:

1. Provider Fine-tuning Options
   - Add temperature, top_p, top_k, frequency_penalty, presence_penalty,
     repetition_penalty, seed, and stop_sequences to ChatRequest
   - OpenAI provider uses temperature, top_p, frequency_penalty,
     presence_penalty, seed, stop
   - Anthropic provider uses temperature, top_p, top_k, stop_sequences
   - Workspace settings for LLM parameters

2. Runner Service (smartbotic-crm-runner)
   - New gRPC service for JavaScript template execution
   - QuickJS-based sandboxed JS engine with security limits
   - Template syntax: {{var}}, {{#if}}, {{#each}}, {{! expr }}
   - Database access via db.get(), db.query(), db.count()
   - Context variables: user, workspace, page, session
   - Memory, time, and query limits for security

3. Full-Page Chat
   - New /chat route with dedicated chat page
   - Session sidebar with chat history
   - Chat navigation in main sidebar

4. Markdown Rendering
   - ReactMarkdown with remark-gfm for chat messages
   - Syntax highlighting for code blocks
   - Tailwind typography plugin integration

5. LLM Settings UI
   - Model selector component
   - Fine-tuning parameters form
   - System prompt editor with template support
fszontagh 7 luni în urmă
părinte
comite
991ff4aff1
63 a modificat fișierele cu 4605 adăugiri și 187 ștergeri
  1. 1 0
      CMakeLists.txt
  2. 1 0
      llm/CMakeLists.txt
  3. 3 0
      llm/include/smartbotic/llm/config.hpp
  4. 18 1
      llm/include/smartbotic/llm/grpc/config_service.hpp
  5. 25 0
      llm/include/smartbotic/llm/grpc/session_service.hpp
  6. 10 1
      llm/include/smartbotic/llm/provider/provider_interface.hpp
  7. 45 0
      llm/include/smartbotic/llm/runner_client.hpp
  8. 21 0
      llm/src/config.cpp
  9. 44 3
      llm/src/grpc/config_service.cpp
  10. 24 1
      llm/src/grpc/llm_service.cpp
  11. 1 1
      llm/src/grpc/server.cpp
  12. 298 1
      llm/src/grpc/session_service.cpp
  13. 11 0
      llm/src/provider/anthropic_provider.cpp
  14. 17 0
      llm/src/provider/openai_provider.cpp
  15. 104 0
      llm/src/runner_client.cpp
  16. 1 0
      proto/CMakeLists.txt
  17. 48 0
      proto/proto/llm.proto
  18. 87 0
      proto/proto/runner.proto
  19. 90 0
      runner/CMakeLists.txt
  20. 24 0
      runner/include/smartbotic/runner/config.hpp
  21. 36 0
      runner/include/smartbotic/runner/grpc/health_service.hpp
  22. 28 0
      runner/include/smartbotic/runner/grpc/script_service.hpp
  23. 34 0
      runner/include/smartbotic/runner/grpc/server.hpp
  24. 33 0
      runner/include/smartbotic/runner/grpc/template_service.hpp
  25. 26 0
      runner/include/smartbotic/runner/js_api.hpp
  26. 109 0
      runner/include/smartbotic/runner/js_engine.hpp
  27. 51 0
      runner/include/smartbotic/runner/template_parser.hpp
  28. 85 0
      runner/src/config.cpp
  29. 47 0
      runner/src/grpc/health_service.cpp
  30. 204 0
      runner/src/grpc/script_service.cpp
  31. 88 0
      runner/src/grpc/server.cpp
  32. 249 0
      runner/src/grpc/template_service.cpp
  33. 144 0
      runner/src/js_api.cpp
  34. 486 0
      runner/src/js_engine.cpp
  35. 109 0
      runner/src/main.cpp
  36. 286 0
      runner/src/template_parser.cpp
  37. 1 1
      systemd/smartbotic-crm-database.service
  38. 3 1
      systemd/smartbotic-crm-llm.service
  39. 29 0
      systemd/smartbotic-crm-runner.service
  40. 2 0
      webserver/include/smartbotic/webserver/http_server.hpp
  41. 8 0
      webserver/include/smartbotic/webserver/llm_client.hpp
  42. 198 36
      webserver/src/http_server.cpp
  43. 22 0
      webserver/src/llm_client.cpp
  44. 29 0
      webui/package-lock.json
  45. 1 0
      webui/package.json
  46. 34 1
      webui/src/App.tsx
  47. 8 1
      webui/src/api/chat.ts
  48. 1 1
      webui/src/components/Chat/ChatIcon.tsx
  49. 2 2
      webui/src/components/Chat/ChatInput.tsx
  50. 51 3
      webui/src/components/Chat/ChatMessage.tsx
  51. 3 18
      webui/src/components/Chat/ChatWindow.tsx
  52. 2 2
      webui/src/components/Chat/SessionSidebar.tsx
  53. 16 0
      webui/src/components/Chat/index.tsx
  54. 349 0
      webui/src/components/LlmSettings.tsx
  55. 241 0
      webui/src/components/ModelSelector.tsx
  56. 14 0
      webui/src/components/layout/Sidebar.tsx
  57. 25 18
      webui/src/contexts/AuthContext.tsx
  58. 121 46
      webui/src/contexts/ChatContext.tsx
  59. 231 0
      webui/src/pages/ChatPage.tsx
  60. 3 3
      webui/src/pages/Login.tsx
  61. 295 45
      webui/src/pages/Workspaces.tsx
  62. 8 0
      webui/src/types/chat.ts
  63. 20 1
      webui/tailwind.config.js

+ 1 - 0
CMakeLists.txt

@@ -112,6 +112,7 @@ add_subdirectory(common)
 add_subdirectory(proto)
 add_subdirectory(proto)
 add_subdirectory(database)
 add_subdirectory(database)
 add_subdirectory(llm)
 add_subdirectory(llm)
+add_subdirectory(runner)
 add_subdirectory(webserver)
 add_subdirectory(webserver)
 add_subdirectory(webui)
 add_subdirectory(webui)
 
 

+ 1 - 0
llm/CMakeLists.txt

@@ -40,6 +40,7 @@ add_library(smartbotic_llm_grpc STATIC
     src/grpc/model_service.cpp
     src/grpc/model_service.cpp
     src/grpc/config_service.cpp
     src/grpc/config_service.cpp
     src/grpc/server.cpp
     src/grpc/server.cpp
+    src/runner_client.cpp
 )
 )
 
 
 target_include_directories(smartbotic_llm_grpc
 target_include_directories(smartbotic_llm_grpc

+ 3 - 0
llm/include/smartbotic/llm/config.hpp

@@ -37,6 +37,9 @@ struct LlmConfig {
     // Database node connection
     // Database node connection
     std::string database_address = "127.0.0.1:50051";
     std::string database_address = "127.0.0.1:50051";
 
 
+    // Runner node connection (for template rendering)
+    std::string runner_address = "127.0.0.1:50053";
+
     // LLM defaults
     // LLM defaults
     std::string default_model = "gpt-4o";
     std::string default_model = "gpt-4o";
     int64_t session_timeout_seconds = 86400;  // 24 hours
     int64_t session_timeout_seconds = 86400;  // 24 hours

+ 18 - 1
llm/include/smartbotic/llm/grpc/config_service.hpp

@@ -1,8 +1,11 @@
 #pragma once
 #pragma once
 
 
 #include <grpcpp/grpcpp.h>
 #include <grpcpp/grpcpp.h>
+#include <atomic>
+#include <chrono>
 #include <memory>
 #include <memory>
 #include <mutex>
 #include <mutex>
+#include <thread>
 #include <unordered_map>
 #include <unordered_map>
 
 
 #include "database.grpc.pb.h"
 #include "database.grpc.pb.h"
@@ -15,7 +18,10 @@ namespace smartbotic::llm::grpc {
 class ConfigServiceImpl final : public ::smartbotic::llm::ConfigService::Service {
 class ConfigServiceImpl final : public ::smartbotic::llm::ConfigService::Service {
 public:
 public:
     ConfigServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory,
     ConfigServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory,
-                      const std::string& database_address);
+                      const std::string& database_address,
+                      std::chrono::seconds reload_interval = std::chrono::seconds(30));
+
+    ~ConfigServiceImpl();
 
 
     /// Initialize the service (create collections, load providers)
     /// Initialize the service (create collections, load providers)
     [[nodiscard]] auto Initialize() -> bool;
     [[nodiscard]] auto Initialize() -> bool;
@@ -52,6 +58,12 @@ public:
     [[nodiscard]] auto GetApiKey(const std::string& provider_id, const std::string& workspace_id)
     [[nodiscard]] auto GetApiKey(const std::string& provider_id, const std::string& workspace_id)
         -> std::string;
         -> std::string;
 
 
+    /// Start the background reload thread
+    void StartReloadThread();
+
+    /// Stop the background reload thread
+    void StopReloadThread();
+
 private:
 private:
     /// Connect to database
     /// Connect to database
     [[nodiscard]] auto ConnectToDatabase() -> bool;
     [[nodiscard]] auto ConnectToDatabase() -> bool;
@@ -85,6 +97,11 @@ private:
     // Workspace BYOK keys (workspace_id:provider_id -> key)
     // Workspace BYOK keys (workspace_id:provider_id -> key)
     std::unordered_map<std::string, std::string> workspace_keys_;
     std::unordered_map<std::string, std::string> workspace_keys_;
 
 
+    // Background reload thread
+    std::chrono::seconds reload_interval_;
+    std::atomic<bool> reload_running_{false};
+    std::thread reload_thread_;
+
     static constexpr const char* kProvidersCollection = "__llm_providers";
     static constexpr const char* kProvidersCollection = "__llm_providers";
     static constexpr const char* kWorkspaceKeysCollection = "__llm_workspace_keys";
     static constexpr const char* kWorkspaceKeysCollection = "__llm_workspace_keys";
 };
 };

+ 25 - 0
llm/include/smartbotic/llm/grpc/session_service.hpp

@@ -2,17 +2,29 @@
 
 
 #include <grpcpp/grpcpp.h>
 #include <grpcpp/grpcpp.h>
 #include <memory>
 #include <memory>
+#include <functional>
+#include <optional>
+
+#include <nlohmann/json.hpp>
 
 
 #include "llm.grpc.pb.h"
 #include "llm.grpc.pb.h"
 #include "smartbotic/llm/session/session_store.hpp"
 #include "smartbotic/llm/session/session_store.hpp"
+#include "smartbotic/llm/provider/provider_factory.hpp"
 
 
 namespace smartbotic::llm::grpc {
 namespace smartbotic::llm::grpc {
 
 
+// Callback to get workspace settings (helper model info)
+using WorkspaceSettingsCallback = std::function<std::optional<nlohmann::json>(const std::string& workspace_id)>;
+
 class SessionServiceImpl final : public ::smartbotic::llm::SessionService::Service {
 class SessionServiceImpl final : public ::smartbotic::llm::SessionService::Service {
 public:
 public:
     explicit SessionServiceImpl(std::shared_ptr<session::ISessionStore> session_store,
     explicit SessionServiceImpl(std::shared_ptr<session::ISessionStore> session_store,
+                                std::shared_ptr<provider::ProviderFactory> provider_factory,
                                 const std::string& default_model);
                                 const std::string& default_model);
 
 
+    // Set callback to fetch workspace settings
+    void SetWorkspaceSettingsCallback(WorkspaceSettingsCallback callback);
+
     ::grpc::Status CreateSession(::grpc::ServerContext* context,
     ::grpc::Status CreateSession(::grpc::ServerContext* context,
                                  const ::smartbotic::llm::CreateSessionRequest* request,
                                  const ::smartbotic::llm::CreateSessionRequest* request,
                                  ::smartbotic::llm::Session* response) override;
                                  ::smartbotic::llm::Session* response) override;
@@ -41,9 +53,22 @@ public:
                               const ::smartbotic::llm::AddMessageRequest* request,
                               const ::smartbotic::llm::AddMessageRequest* request,
                               ::smartbotic::llm::Session* response) override;
                               ::smartbotic::llm::Session* response) override;
 
 
+    ::grpc::Status CompressSession(::grpc::ServerContext* context,
+                                   const ::smartbotic::llm::CompressSessionRequest* request,
+                                   ::smartbotic::llm::Session* response) override;
+
+    ::grpc::Status GenerateTitle(::grpc::ServerContext* context,
+                                 const ::smartbotic::llm::GenerateTitleRequest* request,
+                                 ::smartbotic::llm::GenerateTitleResponse* response) override;
+
 private:
 private:
     std::shared_ptr<session::ISessionStore> session_store_;
     std::shared_ptr<session::ISessionStore> session_store_;
+    std::shared_ptr<provider::ProviderFactory> provider_factory_;
     std::string default_model_;
     std::string default_model_;
+    WorkspaceSettingsCallback workspace_settings_callback_;
+
+    // Helper methods
+    auto GetHelperModel(const std::string& workspace_id) -> std::pair<std::string, std::string>;
 };
 };
 
 
 }  // namespace smartbotic::llm::grpc
 }  // namespace smartbotic::llm::grpc

+ 10 - 1
llm/include/smartbotic/llm/provider/provider_interface.hpp

@@ -88,9 +88,18 @@ struct ChatRequest {
     std::optional<std::string> system_prompt;
     std::optional<std::string> system_prompt;
     std::vector<ToolDefinition> tools;
     std::vector<ToolDefinition> tools;
 
 
-    // Parameters
+    // Base parameters
     float temperature = 1.0f;
     float temperature = 1.0f;
     int max_tokens = 4096;
     int max_tokens = 4096;
+
+    // Fine-tuning parameters (optional - provider ignores if unsupported)
+    std::optional<double> top_p;              // 0.0 - 1.0 (nucleus sampling)
+    std::optional<int> top_k;                 // 1 - 100 (Anthropic/local models)
+    std::optional<double> frequency_penalty;  // -2.0 - 2.0 (OpenAI)
+    std::optional<double> presence_penalty;   // -2.0 - 2.0 (OpenAI)
+    std::optional<double> repetition_penalty; // 0.0 - 2.0 (local models)
+    std::optional<int64_t> seed;              // For reproducibility
+    std::vector<std::string> stop_sequences;  // Custom stop sequences
 };
 };
 
 
 /// Usage information
 /// Usage information

+ 45 - 0
llm/include/smartbotic/llm/runner_client.hpp

@@ -0,0 +1,45 @@
+#pragma once
+
+#include <map>
+#include <memory>
+#include <string>
+
+namespace smartbotic::llm {
+
+/// Result of a template rendering operation
+struct RenderResult {
+    bool success = false;
+    std::string result;
+    std::string error;
+};
+
+/// Client for the runner service (template rendering)
+class RunnerClient {
+public:
+    explicit RunnerClient(const std::string& address);
+    ~RunnerClient();
+
+    // Non-copyable
+    RunnerClient(const RunnerClient&) = delete;
+    RunnerClient& operator=(const RunnerClient&) = delete;
+
+    // Movable
+    RunnerClient(RunnerClient&&) noexcept;
+    RunnerClient& operator=(RunnerClient&&) noexcept;
+
+    /// Render a template with context
+    [[nodiscard]] auto RenderTemplate(
+        const std::string& template_str,
+        const std::string& workspace_id,
+        const std::string& user_id,
+        const std::map<std::string, std::string>& variables = {}) -> RenderResult;
+
+    /// Check if the runner service is healthy
+    [[nodiscard]] auto IsHealthy() -> bool;
+
+private:
+    class Impl;
+    std::unique_ptr<Impl> impl_;
+};
+
+}  // namespace smartbotic::llm

+ 21 - 0
llm/src/config.cpp

@@ -148,6 +148,11 @@ auto ConfigLoader::LoadFromFile(const std::string& path) -> std::optional<LlmCon
             config.database_address = json["database_address"].get<std::string>();
             config.database_address = json["database_address"].get<std::string>();
         }
         }
 
 
+        // Runner connection
+        if (json.contains("runner_address")) {
+            config.runner_address = json["runner_address"].get<std::string>();
+        }
+
         // LLM settings
         // LLM settings
         if (json.contains("default_model")) {
         if (json.contains("default_model")) {
             config.default_model = json["default_model"].get<std::string>();
             config.default_model = json["default_model"].get<std::string>();
@@ -184,6 +189,7 @@ void ConfigLoader::ApplyEnvironment(LlmConfig& config) {
     config.address = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_ADDRESS", config.address);
     config.address = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_ADDRESS", config.address);
     config.port = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_PORT", config.port);
     config.port = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_PORT", config.port);
     config.database_address = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_DATABASE_ADDRESS", config.database_address);
     config.database_address = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_DATABASE_ADDRESS", config.database_address);
+    config.runner_address = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_RUNNER_ADDRESS", config.runner_address);
 
 
     // LLM settings from environment
     // LLM settings from environment
     config.default_model = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_DEFAULT_MODEL", config.default_model);
     config.default_model = GetEnvOrDefault("SMARTBOTIC_CRM_LLM_DEFAULT_MODEL", config.default_model);
@@ -262,6 +268,16 @@ auto ConfigLoader::ParseCommandLine(int argc, char* argv[], LlmConfig& config)
             continue;
             continue;
         }
         }
 
 
+        if (arg == "--runner-address" || arg == "-r") {
+            if (i + 1 < argc) {
+                config.runner_address = argv[++i];
+            } else {
+                spdlog::error("--runner-address requires an address argument");
+                return false;
+            }
+            continue;
+        }
+
         if (arg == "--default-model") {
         if (arg == "--default-model") {
             if (i + 1 < argc) {
             if (i + 1 < argc) {
                 config.default_model = argv[++i];
                 config.default_model = argv[++i];
@@ -330,6 +346,8 @@ auto ConfigLoader::ParseCommandLine(int argc, char* argv[], LlmConfig& config)
                 config.address = argv[++i];
                 config.address = argv[++i];
             } else if ((arg == "--database-address" || arg == "-d") && i + 1 < argc) {
             } else if ((arg == "--database-address" || arg == "-d") && i + 1 < argc) {
                 config.database_address = argv[++i];
                 config.database_address = argv[++i];
+            } else if ((arg == "--runner-address" || arg == "-r") && i + 1 < argc) {
+                config.runner_address = argv[++i];
             } else if (arg == "--default-model" && i + 1 < argc) {
             } else if (arg == "--default-model" && i + 1 < argc) {
                 config.default_model = argv[++i];
                 config.default_model = argv[++i];
             } else if (arg == "--session-timeout" && i + 1 < argc) {
             } else if (arg == "--session-timeout" && i + 1 < argc) {
@@ -383,6 +401,7 @@ void ConfigLoader::PrintHelp(const char* program_name) {
     spdlog::info("  -p, --port <port>            Port to listen on (default: 50052)");
     spdlog::info("  -p, --port <port>            Port to listen on (default: 50052)");
     spdlog::info("  -a, --address <addr>         Address to bind to (default: 0.0.0.0)");
     spdlog::info("  -a, --address <addr>         Address to bind to (default: 0.0.0.0)");
     spdlog::info("  -d, --database-address <addr>  Database node address (default: 127.0.0.1:50051)");
     spdlog::info("  -d, --database-address <addr>  Database node address (default: 127.0.0.1:50051)");
+    spdlog::info("  -r, --runner-address <addr>    Runner node address (default: 127.0.0.1:50053)");
     spdlog::info("  --default-model <model>      Default LLM model (default: gpt-4o)");
     spdlog::info("  --default-model <model>      Default LLM model (default: gpt-4o)");
     spdlog::info("  --session-timeout <sec>      Session timeout in seconds (default: 86400)");
     spdlog::info("  --session-timeout <sec>      Session timeout in seconds (default: 86400)");
     spdlog::info("  -l, --log-level <level>      Log level: trace, debug, info, warn, error, critical, off");
     spdlog::info("  -l, --log-level <level>      Log level: trace, debug, info, warn, error, critical, off");
@@ -392,6 +411,7 @@ void ConfigLoader::PrintHelp(const char* program_name) {
     spdlog::info("  SMARTBOTIC_CRM_LLM_ADDRESS           Address to bind to");
     spdlog::info("  SMARTBOTIC_CRM_LLM_ADDRESS           Address to bind to");
     spdlog::info("  SMARTBOTIC_CRM_LLM_PORT              Port to listen on");
     spdlog::info("  SMARTBOTIC_CRM_LLM_PORT              Port to listen on");
     spdlog::info("  SMARTBOTIC_CRM_LLM_DATABASE_ADDRESS  Database node address");
     spdlog::info("  SMARTBOTIC_CRM_LLM_DATABASE_ADDRESS  Database node address");
+    spdlog::info("  SMARTBOTIC_CRM_LLM_RUNNER_ADDRESS    Runner node address");
     spdlog::info("  SMARTBOTIC_CRM_LLM_DEFAULT_MODEL     Default LLM model");
     spdlog::info("  SMARTBOTIC_CRM_LLM_DEFAULT_MODEL     Default LLM model");
     spdlog::info("  SMARTBOTIC_CRM_LLM_SESSION_TIMEOUT   Session timeout in seconds");
     spdlog::info("  SMARTBOTIC_CRM_LLM_SESSION_TIMEOUT   Session timeout in seconds");
     spdlog::info("  SMARTBOTIC_CRM_LLM_LOG_LEVEL         Log level");
     spdlog::info("  SMARTBOTIC_CRM_LLM_LOG_LEVEL         Log level");
@@ -407,6 +427,7 @@ void ConfigLoader::PrintConfig(const LlmConfig& config) {
     spdlog::info("  Address:          {}", config.address);
     spdlog::info("  Address:          {}", config.address);
     spdlog::info("  Port:             {}", config.port);
     spdlog::info("  Port:             {}", config.port);
     spdlog::info("  Database Address: {}", config.database_address);
     spdlog::info("  Database Address: {}", config.database_address);
+    spdlog::info("  Runner Address:   {}", config.runner_address);
     spdlog::info("  Default Model:    {}", config.default_model);
     spdlog::info("  Default Model:    {}", config.default_model);
     spdlog::info("  Session Timeout:  {} seconds", config.session_timeout_seconds);
     spdlog::info("  Session Timeout:  {} seconds", config.session_timeout_seconds);
     spdlog::info("  Log Level:        {}", LogLevelToString(config.log_level));
     spdlog::info("  Log Level:        {}", LogLevelToString(config.log_level));

+ 44 - 3
llm/src/grpc/config_service.cpp

@@ -10,8 +10,42 @@
 namespace smartbotic::llm::grpc {
 namespace smartbotic::llm::grpc {
 
 
 ConfigServiceImpl::ConfigServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory,
 ConfigServiceImpl::ConfigServiceImpl(std::shared_ptr<provider::ProviderFactory> provider_factory,
-                                     const std::string& database_address)
-    : provider_factory_(std::move(provider_factory)), database_address_(database_address) {}
+                                     const std::string& database_address,
+                                     std::chrono::seconds reload_interval)
+    : provider_factory_(std::move(provider_factory)),
+      database_address_(database_address),
+      reload_interval_(reload_interval) {}
+
+ConfigServiceImpl::~ConfigServiceImpl() {
+    StopReloadThread();
+}
+
+void ConfigServiceImpl::StartReloadThread() {
+    if (reload_running_.exchange(true)) {
+        return;  // Already running
+    }
+
+    reload_thread_ = std::thread([this]() {
+        spdlog::info("Provider reload thread started (interval: {}s)", reload_interval_.count());
+        while (reload_running_) {
+            std::this_thread::sleep_for(reload_interval_);
+            if (!reload_running_) break;
+
+            spdlog::debug("Reloading providers from database...");
+            if (LoadProviders()) {
+                spdlog::debug("Provider reload complete");
+            }
+        }
+        spdlog::info("Provider reload thread stopped");
+    });
+}
+
+void ConfigServiceImpl::StopReloadThread() {
+    reload_running_ = false;
+    if (reload_thread_.joinable()) {
+        reload_thread_.join();
+    }
+}
 
 
 auto ConfigServiceImpl::ConnectToDatabase() -> bool {
 auto ConfigServiceImpl::ConnectToDatabase() -> bool {
     if (db_channel_) {
     if (db_channel_) {
@@ -60,7 +94,14 @@ auto ConfigServiceImpl::Initialize() -> bool {
     }
     }
 
 
     // Load existing providers
     // Load existing providers
-    return LoadProviders();
+    if (!LoadProviders()) {
+        return false;
+    }
+
+    // Start background reload thread
+    StartReloadThread();
+
+    return true;
 }
 }
 
 
 auto ConfigServiceImpl::LoadProviders() -> bool {
 auto ConfigServiceImpl::LoadProviders() -> bool {

+ 24 - 1
llm/src/grpc/llm_service.cpp

@@ -95,7 +95,7 @@ auto LLMServiceImpl::BuildChatRequest(const ::smartbotic::llm::ChatRequest& requ
         chat_request.messages.push_back(std::move(chat_msg));
         chat_request.messages.push_back(std::move(chat_msg));
     }
     }
 
 
-    // Set parameters
+    // Set base parameters
     if (request.temperature() > 0) {
     if (request.temperature() > 0) {
         chat_request.temperature = request.temperature();
         chat_request.temperature = request.temperature();
     }
     }
@@ -103,6 +103,29 @@ auto LLMServiceImpl::BuildChatRequest(const ::smartbotic::llm::ChatRequest& requ
         chat_request.max_tokens = request.max_tokens();
         chat_request.max_tokens = request.max_tokens();
     }
     }
 
 
+    // Set fine-tuning parameters
+    if (request.has_top_p()) {
+        chat_request.top_p = request.top_p();
+    }
+    if (request.has_top_k()) {
+        chat_request.top_k = request.top_k();
+    }
+    if (request.has_frequency_penalty()) {
+        chat_request.frequency_penalty = request.frequency_penalty();
+    }
+    if (request.has_presence_penalty()) {
+        chat_request.presence_penalty = request.presence_penalty();
+    }
+    if (request.has_repetition_penalty()) {
+        chat_request.repetition_penalty = request.repetition_penalty();
+    }
+    if (request.has_seed()) {
+        chat_request.seed = request.seed();
+    }
+    for (const auto& stop : request.stop_sequences()) {
+        chat_request.stop_sequences.push_back(stop);
+    }
+
     // Convert tools
     // Convert tools
     for (const auto& tool : request.tools()) {
     for (const auto& tool : request.tools()) {
         provider::ToolDefinition def;
         provider::ToolDefinition def;

+ 1 - 1
llm/src/grpc/server.cpp

@@ -40,7 +40,7 @@ auto GrpcServer::Start() -> bool {
         provider_factory_, session_store_, tool_registry_, config_.default_model);
         provider_factory_, session_store_, tool_registry_, config_.default_model);
 
 
     session_service_ = std::make_unique<SessionServiceImpl>(
     session_service_ = std::make_unique<SessionServiceImpl>(
-        session_store_, config_.default_model);
+        session_store_, provider_factory_, config_.default_model);
 
 
     model_service_ = std::make_unique<ModelServiceImpl>(provider_factory_);
     model_service_ = std::make_unique<ModelServiceImpl>(provider_factory_);
 
 

+ 298 - 1
llm/src/grpc/session_service.cpp

@@ -1,14 +1,55 @@
 #include "smartbotic/llm/grpc/session_service.hpp"
 #include "smartbotic/llm/grpc/session_service.hpp"
 
 
 #include <spdlog/spdlog.h>
 #include <spdlog/spdlog.h>
+#include <sstream>
 
 
 #include "smartbotic/llm/grpc/proto_convert.hpp"
 #include "smartbotic/llm/grpc/proto_convert.hpp"
 
 
 namespace smartbotic::llm::grpc {
 namespace smartbotic::llm::grpc {
 
 
 SessionServiceImpl::SessionServiceImpl(std::shared_ptr<session::ISessionStore> session_store,
 SessionServiceImpl::SessionServiceImpl(std::shared_ptr<session::ISessionStore> session_store,
+                                       std::shared_ptr<provider::ProviderFactory> provider_factory,
                                        const std::string& default_model)
                                        const std::string& default_model)
-    : session_store_(std::move(session_store)), default_model_(default_model) {}
+    : session_store_(std::move(session_store)),
+      provider_factory_(std::move(provider_factory)),
+      default_model_(default_model) {}
+
+void SessionServiceImpl::SetWorkspaceSettingsCallback(WorkspaceSettingsCallback callback) {
+    workspace_settings_callback_ = std::move(callback);
+}
+
+auto SessionServiceImpl::GetHelperModel(const std::string& workspace_id)
+    -> std::pair<std::string, std::string> {
+    // Try to get helper model from workspace settings
+    if (workspace_settings_callback_) {
+        auto settings = workspace_settings_callback_(workspace_id);
+        if (settings) {
+            std::string provider_id;
+            std::string model_id;
+
+            if (settings->contains("llm_helper_provider_id")) {
+                provider_id = settings->at("llm_helper_provider_id").get<std::string>();
+            }
+            if (settings->contains("llm_helper_model_id")) {
+                model_id = settings->at("llm_helper_model_id").get<std::string>();
+            }
+
+            // If helper model is configured, use it
+            if (!provider_id.empty() && !model_id.empty()) {
+                return {provider_id, model_id};
+            }
+
+            // Fall back to main model
+            if (settings->contains("llm_provider_id") && settings->contains("llm_model_id")) {
+                return {
+                    settings->at("llm_provider_id").get<std::string>(),
+                    settings->at("llm_model_id").get<std::string>()
+                };
+            }
+        }
+    }
+    return {"", default_model_};
+}
 
 
 ::grpc::Status SessionServiceImpl::CreateSession(
 ::grpc::Status SessionServiceImpl::CreateSession(
     ::grpc::ServerContext* context,
     ::grpc::ServerContext* context,
@@ -155,4 +196,260 @@ SessionServiceImpl::SessionServiceImpl(std::shared_ptr<session::ISessionStore> s
     return ::grpc::Status::OK;
     return ::grpc::Status::OK;
 }
 }
 
 
+::grpc::Status SessionServiceImpl::CompressSession(
+    ::grpc::ServerContext* /*context*/,
+    const ::smartbotic::llm::CompressSessionRequest* request,
+    ::smartbotic::llm::Session* response) {
+    spdlog::debug("CompressSession: {}", request->session_id());
+
+    // Get the session first
+    auto session_result = session_store_->GetSession(
+        request->session_id(), request->user_id(), true);
+
+    if (!session_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::NOT_FOUND, session_result.error);
+    }
+
+    auto& session = session_result.value;
+
+    // Need at least 4 messages to compress (system + some history)
+    if (session.messages.size() < 4) {
+        *response = SessionToProto(session);
+        return ::grpc::Status::OK;
+    }
+
+    // Get helper model
+    auto [provider_id, model_id] = GetHelperModel(request->workspace_id());
+
+    // Build compression prompt - extract text from content parts
+    std::ostringstream conversation;
+    for (const auto& msg : session.messages) {
+        if (msg.role == session::MessageRole::kSystem) continue;
+
+        std::string role_name;
+        switch (msg.role) {
+            case session::MessageRole::kUser: role_name = "User"; break;
+            case session::MessageRole::kAssistant: role_name = "Assistant"; break;
+            default: role_name = "Other"; break;
+        }
+
+        // Extract text content from content parts
+        std::string msg_text;
+        for (const auto& part : msg.content) {
+            if (part.type == session::ContentPartType::kText) {
+                msg_text += part.text;
+            }
+        }
+        conversation << role_name << ": " << msg_text << "\n\n";
+    }
+
+    std::string compress_prompt =
+        "Summarize the following conversation concisely, preserving key information, "
+        "decisions made, and any important context. Keep it under 500 words:\n\n" +
+        conversation.str();
+
+    // Call the LLM to compress
+    std::shared_ptr<provider::IProvider> provider = nullptr;
+    if (!provider_id.empty()) {
+        provider = provider_factory_->Get(provider_id);
+    }
+    if (!provider) {
+        // Try to get any available provider
+        auto providers = provider_factory_->GetAll();
+        if (!providers.empty()) {
+            provider = providers[0];
+        }
+    }
+
+    if (!provider) {
+        spdlog::error("No LLM provider available for compression");
+        *response = SessionToProto(session);
+        return ::grpc::Status::OK;
+    }
+
+    // Build a simple chat request for compression
+    provider::ChatRequest chat_request;
+    chat_request.model = model_id;
+    chat_request.system_prompt = "You are a helpful assistant that summarizes conversations concisely.";
+
+    provider::ChatMessage user_msg;
+    user_msg.role = provider::MessageRole::kUser;
+    provider::ContentPart text_part;
+    text_part.type = provider::ContentPart::Type::kText;
+    text_part.text = compress_prompt;
+    user_msg.content.push_back(text_part);
+    chat_request.messages.push_back(user_msg);
+
+    chat_request.max_tokens = 1000;
+
+    auto chat_result = provider->Chat(chat_request);
+    if (!chat_result.success) {
+        spdlog::error("Failed to compress conversation: {}", chat_result.error);
+        *response = SessionToProto(session);
+        return ::grpc::Status::OK;
+    }
+
+    std::string summary = chat_result.value.content;
+
+    // Clear old messages and add summary
+    auto clear_result = session_store_->ClearMessages(request->session_id(), request->user_id());
+    if (!clear_result.success) {
+        spdlog::error("Failed to clear messages: {}", clear_result.error);
+        *response = SessionToProto(session);
+        return ::grpc::Status::OK;
+    }
+
+    // Add the summary as a system message
+    session::Message summary_msg;
+    summary_msg.id = session::GenerateId();
+    summary_msg.role = session::MessageRole::kSystem;
+    session::ContentPart summary_part;
+    summary_part.type = session::ContentPartType::kText;
+    summary_part.text = "Previous conversation summary:\n" + summary;
+    summary_msg.content.push_back(summary_part);
+    summary_msg.created_at = std::chrono::system_clock::now();
+
+    auto add_result = session_store_->AddMessage(request->session_id(), request->user_id(), summary_msg);
+    if (!add_result.success) {
+        spdlog::error("Failed to add summary message: {}", add_result.error);
+    }
+
+    // Get the updated session
+    auto updated_result = session_store_->GetSession(
+        request->session_id(), request->user_id(), true);
+
+    if (updated_result.success) {
+        *response = SessionToProto(updated_result.value);
+    } else {
+        *response = SessionToProto(session);
+    }
+
+    spdlog::info("Compressed session {} from {} messages to summary",
+                 request->session_id(), session.messages.size());
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status SessionServiceImpl::GenerateTitle(
+    ::grpc::ServerContext* /*context*/,
+    const ::smartbotic::llm::GenerateTitleRequest* request,
+    ::smartbotic::llm::GenerateTitleResponse* response) {
+    spdlog::debug("GenerateTitle for session: {}", request->session_id());
+
+    // Get the session first
+    auto session_result = session_store_->GetSession(
+        request->session_id(), request->user_id(), true);
+
+    if (!session_result.success) {
+        return ::grpc::Status(::grpc::StatusCode::NOT_FOUND, session_result.error);
+    }
+
+    auto& session = session_result.value;
+
+    // Need at least 2 messages (user + assistant) to generate a title
+    if (session.messages.size() < 2) {
+        response->set_title("New Conversation");
+        return ::grpc::Status::OK;
+    }
+
+    // Get helper model
+    auto [provider_id, model_id] = GetHelperModel(request->workspace_id());
+
+    // Build conversation excerpt for title generation - extract text from content parts
+    std::ostringstream conversation;
+    int msg_count = 0;
+    for (const auto& msg : session.messages) {
+        if (msg.role == session::MessageRole::kSystem) continue;
+        if (msg_count >= 6) break;  // Only use first few messages
+
+        std::string role_name;
+        switch (msg.role) {
+            case session::MessageRole::kUser: role_name = "User"; break;
+            case session::MessageRole::kAssistant: role_name = "Assistant"; break;
+            default: role_name = "Other"; break;
+        }
+
+        // Extract text content from content parts
+        std::string msg_text;
+        for (const auto& part : msg.content) {
+            if (part.type == session::ContentPartType::kText) {
+                msg_text += part.text;
+            }
+        }
+        // Truncate long messages
+        if (msg_text.size() > 200) {
+            msg_text = msg_text.substr(0, 200) + "...";
+        }
+        conversation << role_name << ": " << msg_text << "\n\n";
+        msg_count++;
+    }
+
+    // Get provider
+    std::shared_ptr<provider::IProvider> provider = nullptr;
+    if (!provider_id.empty()) {
+        provider = provider_factory_->Get(provider_id);
+    }
+    if (!provider) {
+        auto providers = provider_factory_->GetAll();
+        if (!providers.empty()) {
+            provider = providers[0];
+        }
+    }
+
+    if (!provider) {
+        spdlog::error("No LLM provider available for title generation");
+        response->set_title("Untitled Conversation");
+        return ::grpc::Status::OK;
+    }
+
+    // Build chat request for title generation
+    provider::ChatRequest chat_request;
+    chat_request.model = model_id;
+    chat_request.system_prompt =
+        "You are a helpful assistant that generates short, descriptive titles for conversations. "
+        "Generate only the title, nothing else. Keep it under 50 characters.";
+
+    provider::ChatMessage user_msg;
+    user_msg.role = provider::MessageRole::kUser;
+    provider::ContentPart text_part;
+    text_part.type = provider::ContentPart::Type::kText;
+    text_part.text = "Generate a short title for this conversation:\n\n" + conversation.str();
+    user_msg.content.push_back(text_part);
+    chat_request.messages.push_back(user_msg);
+
+    chat_request.max_tokens = 50;
+
+    auto chat_result = provider->Chat(chat_request);
+    if (!chat_result.success) {
+        spdlog::error("Failed to generate title: {}", chat_result.error);
+        response->set_title("Untitled Conversation");
+        return ::grpc::Status::OK;
+    }
+
+    std::string title = chat_result.value.content;
+
+    // Clean up the title (remove quotes if present)
+    if (title.size() >= 2 && title.front() == '"' && title.back() == '"') {
+        title = title.substr(1, title.size() - 2);
+    }
+
+    // Truncate if too long
+    if (title.size() > 100) {
+        title = title.substr(0, 97) + "...";
+    }
+
+    response->set_title(title);
+
+    // Update the session with the new title
+    session::UpdateSessionRequest update_request;
+    update_request.session_id = request->session_id();
+    update_request.user_id = request->user_id();
+    update_request.title = title;
+    session_store_->UpdateSession(update_request);
+
+    spdlog::info("Generated title for session {}: {}", request->session_id(), title);
+
+    return ::grpc::Status::OK;
+}
+
 }  // namespace smartbotic::llm::grpc
 }  // namespace smartbotic::llm::grpc

+ 11 - 0
llm/src/provider/anthropic_provider.cpp

@@ -199,6 +199,17 @@ auto AnthropicProvider::BuildMessagesRequestBody(const ChatRequest& request, boo
         body["temperature"] = request.temperature;
         body["temperature"] = request.temperature;
     }
     }
 
 
+    // Fine-tuning parameters (Anthropic supports: top_p, top_k, stop_sequences)
+    if (request.top_p.has_value()) {
+        body["top_p"] = *request.top_p;
+    }
+    if (request.top_k.has_value()) {
+        body["top_k"] = *request.top_k;
+    }
+    if (!request.stop_sequences.empty()) {
+        body["stop_sequences"] = request.stop_sequences;
+    }
+
     // Add system prompt if present
     // Add system prompt if present
     if (request.system_prompt.has_value() && !request.system_prompt->empty()) {
     if (request.system_prompt.has_value() && !request.system_prompt->empty()) {
         body["system"] = *request.system_prompt;
         body["system"] = *request.system_prompt;

+ 17 - 0
llm/src/provider/openai_provider.cpp

@@ -179,6 +179,23 @@ auto OpenAIProvider::BuildChatRequestBody(const ChatRequest& request, bool strea
         body["temperature"] = request.temperature;
         body["temperature"] = request.temperature;
     }
     }
 
 
+    // Fine-tuning parameters (OpenAI supports: top_p, frequency_penalty, presence_penalty, seed, stop)
+    if (request.top_p.has_value()) {
+        body["top_p"] = *request.top_p;
+    }
+    if (request.frequency_penalty.has_value()) {
+        body["frequency_penalty"] = *request.frequency_penalty;
+    }
+    if (request.presence_penalty.has_value()) {
+        body["presence_penalty"] = *request.presence_penalty;
+    }
+    if (request.seed.has_value()) {
+        body["seed"] = *request.seed;
+    }
+    if (!request.stop_sequences.empty()) {
+        body["stop"] = request.stop_sequences;
+    }
+
     // Build messages array
     // Build messages array
     nlohmann::json messages = nlohmann::json::array();
     nlohmann::json messages = nlohmann::json::array();
 
 

+ 104 - 0
llm/src/runner_client.cpp

@@ -0,0 +1,104 @@
+#include "smartbotic/llm/runner_client.hpp"
+
+#include <grpcpp/grpcpp.h>
+#include <spdlog/spdlog.h>
+
+#include "runner.grpc.pb.h"
+
+namespace smartbotic::llm {
+
+class RunnerClient::Impl {
+public:
+    explicit Impl(const std::string& address) : address_(address) {
+        channel_ = ::grpc::CreateChannel(address, ::grpc::InsecureChannelCredentials());
+        template_stub_ = ::smartbotic::runner::TemplateService::NewStub(channel_);
+        health_stub_ = ::smartbotic::runner::HealthService::NewStub(channel_);
+    }
+
+    auto RenderTemplate(
+        const std::string& template_str,
+        const std::string& workspace_id,
+        const std::string& user_id,
+        const std::map<std::string, std::string>& variables) -> RenderResult {
+
+        RenderResult result;
+
+        ::smartbotic::runner::RenderTemplateRequest request;
+        request.set_template_(template_str);
+        request.set_workspace_id(workspace_id);
+        request.set_user_id(user_id);
+
+        for (const auto& [key, value] : variables) {
+            (*request.mutable_variables())[key] = value;
+        }
+
+        ::smartbotic::runner::RenderTemplateResponse response;
+        ::grpc::ClientContext context;
+
+        // Set timeout
+        context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(10));
+
+        auto status = template_stub_->RenderTemplate(&context, request, &response);
+
+        if (!status.ok()) {
+            result.success = false;
+            result.error = "gRPC error: " + status.error_message();
+            spdlog::warn("RenderTemplate failed: {}", result.error);
+            return result;
+        }
+
+        result.success = response.success();
+        if (response.success()) {
+            result.result = response.result();
+        } else {
+            result.error = response.error();
+        }
+
+        return result;
+    }
+
+    auto IsHealthy() -> bool {
+        ::smartbotic::runner::HealthCheckRequest request;
+        ::smartbotic::runner::HealthCheckResponse response;
+        ::grpc::ClientContext context;
+
+        context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(5));
+
+        auto status = health_stub_->Check(&context, request, &response);
+
+        if (!status.ok()) {
+            spdlog::debug("Runner health check failed: {}", status.error_message());
+            return false;
+        }
+
+        return response.healthy();
+    }
+
+private:
+    std::string address_;
+    std::shared_ptr<::grpc::Channel> channel_;
+    std::unique_ptr<::smartbotic::runner::TemplateService::Stub> template_stub_;
+    std::unique_ptr<::smartbotic::runner::HealthService::Stub> health_stub_;
+};
+
+RunnerClient::RunnerClient(const std::string& address)
+    : impl_(std::make_unique<Impl>(address)) {}
+
+RunnerClient::~RunnerClient() = default;
+
+RunnerClient::RunnerClient(RunnerClient&&) noexcept = default;
+RunnerClient& RunnerClient::operator=(RunnerClient&&) noexcept = default;
+
+auto RunnerClient::RenderTemplate(
+    const std::string& template_str,
+    const std::string& workspace_id,
+    const std::string& user_id,
+    const std::map<std::string, std::string>& variables) -> RenderResult {
+    return impl_->RenderTemplate(template_str, workspace_id, user_id, variables);
+}
+
+auto RunnerClient::IsHealthy() -> bool {
+    return impl_->IsHealthy();
+}
+
+}  // namespace smartbotic::llm

+ 1 - 0
proto/CMakeLists.txt

@@ -13,6 +13,7 @@ set(PROTO_FILES
     ${PROTO_DIR}/crm.proto
     ${PROTO_DIR}/crm.proto
     ${PROTO_DIR}/database.proto
     ${PROTO_DIR}/database.proto
     ${PROTO_DIR}/llm.proto
     ${PROTO_DIR}/llm.proto
+    ${PROTO_DIR}/runner.proto
 )
 )
 
 
 # Generate C++ sources from proto files
 # Generate C++ sources from proto files

+ 48 - 0
proto/proto/llm.proto

@@ -126,6 +126,12 @@ message ToolResult {
 // Session Types
 // Session Types
 // ============================================================================
 // ============================================================================
 
 
+message ContextInfo {
+    int32 used_tokens = 1;
+    int32 max_tokens = 2;
+    float usage_percent = 3;
+}
+
 message Session {
 message Session {
     string id = 1;
     string id = 1;
     string user_id = 2;
     string user_id = 2;
@@ -136,6 +142,8 @@ message Session {
     repeated Message messages = 7;
     repeated Message messages = 7;
     Timestamp created_at = 8;
     Timestamp created_at = 8;
     Timestamp updated_at = 9;
     Timestamp updated_at = 9;
+    ContextInfo context_info = 10;
+    int32 user_message_count = 11;
 }
 }
 
 
 message SessionSummary {
 message SessionSummary {
@@ -166,6 +174,15 @@ message ChatRequest {
     float temperature = 8;
     float temperature = 8;
     int32 max_tokens = 9;
     int32 max_tokens = 9;
     repeated ToolDefinition tools = 10;   // Tools available for this request
     repeated ToolDefinition tools = 10;   // Tools available for this request
+
+    // Fine-tuning parameters
+    optional double top_p = 11;             // 0.0 - 1.0 (nucleus sampling)
+    optional int32 top_k = 12;              // 1 - 100 (Anthropic/local models)
+    optional double frequency_penalty = 13; // -2.0 - 2.0 (OpenAI)
+    optional double presence_penalty = 14;  // -2.0 - 2.0 (OpenAI)
+    optional double repetition_penalty = 15;// 0.0 - 2.0 (local models)
+    optional int64 seed = 16;               // For reproducibility
+    repeated string stop_sequences = 17;    // Custom stop sequences
 }
 }
 
 
 message ChatResponse {
 message ChatResponse {
@@ -220,6 +237,15 @@ message SubmitToolResultsRequest {
     float temperature = 7;
     float temperature = 7;
     int32 max_tokens = 8;
     int32 max_tokens = 8;
     repeated ToolDefinition tools = 9;
     repeated ToolDefinition tools = 9;
+
+    // Fine-tuning parameters (same as ChatRequest)
+    optional double top_p = 10;
+    optional int32 top_k = 11;
+    optional double frequency_penalty = 12;
+    optional double presence_penalty = 13;
+    optional double repetition_penalty = 14;
+    optional int64 seed = 15;
+    repeated string stop_sequences = 16;
 }
 }
 
 
 service LLMService {
 service LLMService {
@@ -289,6 +315,22 @@ message ClearSessionMessagesRequest {
     string user_id = 2;
     string user_id = 2;
 }
 }
 
 
+message CompressSessionRequest {
+    string session_id = 1;
+    string user_id = 2;
+    string workspace_id = 3;              // For looking up helper model settings
+}
+
+message GenerateTitleRequest {
+    string session_id = 1;
+    string user_id = 2;
+    string workspace_id = 3;              // For looking up helper model settings
+}
+
+message GenerateTitleResponse {
+    string title = 1;
+}
+
 message AddMessageRequest {
 message AddMessageRequest {
     string session_id = 1;
     string session_id = 1;
     string user_id = 2;
     string user_id = 2;
@@ -316,6 +358,12 @@ service SessionService {
 
 
     // Add a message to a session (for tool results, etc.)
     // Add a message to a session (for tool results, etc.)
     rpc AddMessage(AddMessageRequest) returns (Session);
     rpc AddMessage(AddMessageRequest) returns (Session);
+
+    // Compress session messages using helper model
+    rpc CompressSession(CompressSessionRequest) returns (Session);
+
+    // Generate a title for the session using helper model
+    rpc GenerateTitle(GenerateTitleRequest) returns (GenerateTitleResponse);
 }
 }
 
 
 // ============================================================================
 // ============================================================================

+ 87 - 0
proto/proto/runner.proto

@@ -0,0 +1,87 @@
+syntax = "proto3";
+
+package smartbotic.runner;
+
+option cc_enable_arenas = true;
+
+// ============================================================================
+// Template Service - JavaScript-based template rendering
+// ============================================================================
+
+service TemplateService {
+    // Render a template with context variables
+    rpc RenderTemplate(RenderTemplateRequest) returns (RenderTemplateResponse);
+
+    // Validate template syntax without rendering
+    rpc ValidateTemplate(ValidateTemplateRequest) returns (ValidateTemplateResponse);
+}
+
+message RenderTemplateRequest {
+    string template = 1;                    // Template string with {{variable}} syntax
+    string workspace_id = 2;                // Workspace context for database access
+    string user_id = 3;                     // User context
+    map<string, string> variables = 4;      // Additional variables for substitution
+}
+
+message RenderTemplateResponse {
+    bool success = 1;
+    string result = 2;                      // Rendered template content
+    string error = 3;                       // Error message if success is false
+}
+
+message ValidateTemplateRequest {
+    string template = 1;
+}
+
+message ValidateTemplateResponse {
+    bool valid = 1;
+    repeated TemplateError errors = 2;
+}
+
+message TemplateError {
+    int32 line = 1;
+    int32 column = 2;
+    string message = 3;
+}
+
+// ============================================================================
+// Script Service - Direct JavaScript execution
+// ============================================================================
+
+service ScriptService {
+    // Execute a JavaScript script in a sandboxed environment
+    rpc ExecuteScript(ExecuteScriptRequest) returns (ExecuteScriptResponse);
+}
+
+message ExecuteScriptRequest {
+    string script = 1;                      // JavaScript code to execute
+    string workspace_id = 2;                // Workspace context for database access
+    string user_id = 3;                     // User context
+    map<string, string> context = 4;        // Context variables available in script
+    int32 timeout_ms = 5;                   // Execution timeout (max 5000ms)
+}
+
+message ExecuteScriptResponse {
+    bool success = 1;
+    string result = 2;                      // Return value as JSON string
+    string error = 3;                       // Error message if success is false
+    int64 execution_time_ms = 4;            // Actual execution time
+}
+
+// ============================================================================
+// Health Service
+// ============================================================================
+
+service HealthService {
+    rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
+}
+
+message HealthCheckRequest {}
+
+message HealthCheckResponse {
+    bool healthy = 1;
+    string version = 2;
+    int32 active_executions = 3;            // Currently running scripts/templates
+    int64 total_executions = 4;             // Total executions since startup
+    int64 memory_usage_bytes = 5;           // Current memory usage
+}

+ 90 - 0
runner/CMakeLists.txt

@@ -0,0 +1,90 @@
+# Runner service - smartbotic-crm-runner binary with JavaScript templating engine
+
+# QuickJS for JavaScript execution (using quickjs-ng maintained fork)
+FetchContent_Declare(
+    quickjs
+    GIT_REPOSITORY https://github.com/quickjs-ng/quickjs.git
+    GIT_TAG v0.11.0
+)
+
+message(STATUS "Fetching QuickJS...")
+FetchContent_MakeAvailable(quickjs)
+
+# Core runner library (config, JS engine, template parser)
+add_library(smartbotic_runner STATIC
+    src/config.cpp
+    src/js_engine.cpp
+    src/js_api.cpp
+    src/template_parser.cpp
+)
+
+target_include_directories(smartbotic_runner
+    PUBLIC
+        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
+        $<INSTALL_INTERFACE:include>
+)
+
+target_compile_options(smartbotic_runner PRIVATE ${SMARTBOTIC_CXX_WARNINGS})
+
+target_link_libraries(smartbotic_runner
+    PUBLIC
+        smartbotic::common
+        smartbotic::proto
+        spdlog::spdlog
+        nlohmann_json::nlohmann_json
+        qjs
+)
+
+add_library(smartbotic::runner ALIAS smartbotic_runner)
+
+# gRPC service library
+add_library(smartbotic_runner_grpc STATIC
+    src/grpc/server.cpp
+    src/grpc/template_service.cpp
+    src/grpc/script_service.cpp
+    src/grpc/health_service.cpp
+)
+
+target_include_directories(smartbotic_runner_grpc
+    PUBLIC
+        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
+        $<INSTALL_INTERFACE:include>
+)
+
+# Use relaxed warnings for gRPC targets
+set(SMARTBOTIC_RUNNER_GRPC_WARNINGS
+    -Wall
+    -Wextra
+    -Werror
+    -Wno-unused-parameter
+    -Wno-dangling-reference
+    -Wno-pedantic
+    -Wno-overflow
+)
+target_compile_options(smartbotic_runner_grpc PRIVATE ${SMARTBOTIC_RUNNER_GRPC_WARNINGS})
+
+target_link_libraries(smartbotic_runner_grpc
+    PUBLIC
+        smartbotic::runner
+        smartbotic::proto
+        spdlog::spdlog
+)
+
+add_library(smartbotic::runner_grpc ALIAS smartbotic_runner_grpc)
+
+# Runner service executable
+add_executable(smartbotic-crm-runner
+    src/main.cpp
+)
+
+target_include_directories(smartbotic-crm-runner
+    PRIVATE
+        ${CMAKE_CURRENT_SOURCE_DIR}/include
+)
+
+target_compile_options(smartbotic-crm-runner PRIVATE ${SMARTBOTIC_RUNNER_GRPC_WARNINGS})
+
+target_link_libraries(smartbotic-crm-runner
+    PRIVATE
+        smartbotic::runner_grpc
+)

+ 24 - 0
runner/include/smartbotic/runner/config.hpp

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <string>
+
+namespace smartbotic::runner {
+
+/// Runner service configuration
+struct Config {
+    std::string address = "0.0.0.0";
+    int port = 50053;
+    std::string database_address = "127.0.0.1:50051";
+
+    // JavaScript engine limits
+    size_t max_memory_mb = 16;              // Maximum memory for JS runtime
+    int max_execution_time_ms = 5000;       // Maximum script execution time
+    int max_db_queries = 100;               // Maximum database queries per execution
+
+    std::string log_level = "info";
+};
+
+/// Load configuration from environment and optional JSON file
+[[nodiscard]] auto LoadConfig(const std::string& config_file = "") -> Config;
+
+}  // namespace smartbotic::runner

+ 36 - 0
runner/include/smartbotic/runner/grpc/health_service.hpp

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <atomic>
+
+#include <grpcpp/grpcpp.h>
+
+#include "runner.grpc.pb.h"
+
+namespace smartbotic::runner::grpc {
+
+/// Health service implementation
+class HealthServiceImpl final : public ::smartbotic::runner::HealthService::Service {
+public:
+    HealthServiceImpl();
+    ~HealthServiceImpl() override;
+
+    ::grpc::Status Check(
+        ::grpc::ServerContext* context,
+        const ::smartbotic::runner::HealthCheckRequest* request,
+        ::smartbotic::runner::HealthCheckResponse* response) override;
+
+    /// Increment the active execution counter
+    void IncrementActiveExecutions();
+
+    /// Decrement the active execution counter
+    void DecrementActiveExecutions();
+
+    /// Increment total execution counter
+    void IncrementTotalExecutions();
+
+private:
+    std::atomic<int32_t> active_executions_{0};
+    std::atomic<int64_t> total_executions_{0};
+};
+
+}  // namespace smartbotic::runner::grpc

+ 28 - 0
runner/include/smartbotic/runner/grpc/script_service.hpp

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <memory>
+
+#include <grpcpp/grpcpp.h>
+
+#include "runner.grpc.pb.h"
+#include "smartbotic/runner/config.hpp"
+
+namespace smartbotic::runner::grpc {
+
+/// Script service implementation
+class ScriptServiceImpl final : public ::smartbotic::runner::ScriptService::Service {
+public:
+    explicit ScriptServiceImpl(const Config& config, const std::string& database_address);
+    ~ScriptServiceImpl() override;
+
+    ::grpc::Status ExecuteScript(
+        ::grpc::ServerContext* context,
+        const ::smartbotic::runner::ExecuteScriptRequest* request,
+        ::smartbotic::runner::ExecuteScriptResponse* response) override;
+
+private:
+    class Impl;
+    std::unique_ptr<Impl> impl_;
+};
+
+}  // namespace smartbotic::runner::grpc

+ 34 - 0
runner/include/smartbotic/runner/grpc/server.hpp

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include "smartbotic/runner/config.hpp"
+
+namespace smartbotic::runner::grpc {
+
+/// gRPC server for the runner service
+class Server {
+public:
+    explicit Server(const Config& config);
+    ~Server();
+
+    // Non-copyable
+    Server(const Server&) = delete;
+    Server& operator=(const Server&) = delete;
+
+    /// Start the server (blocking)
+    void Run();
+
+    /// Stop the server gracefully
+    void Shutdown();
+
+    /// Get the address the server is listening on
+    [[nodiscard]] auto GetAddress() const -> std::string;
+
+private:
+    class Impl;
+    std::unique_ptr<Impl> impl_;
+};
+
+}  // namespace smartbotic::runner::grpc

+ 33 - 0
runner/include/smartbotic/runner/grpc/template_service.hpp

@@ -0,0 +1,33 @@
+#pragma once
+
+#include <memory>
+
+#include <grpcpp/grpcpp.h>
+
+#include "runner.grpc.pb.h"
+#include "smartbotic/runner/config.hpp"
+
+namespace smartbotic::runner::grpc {
+
+/// Template service implementation
+class TemplateServiceImpl final : public ::smartbotic::runner::TemplateService::Service {
+public:
+    explicit TemplateServiceImpl(const Config& config, const std::string& database_address);
+    ~TemplateServiceImpl() override;
+
+    ::grpc::Status RenderTemplate(
+        ::grpc::ServerContext* context,
+        const ::smartbotic::runner::RenderTemplateRequest* request,
+        ::smartbotic::runner::RenderTemplateResponse* response) override;
+
+    ::grpc::Status ValidateTemplate(
+        ::grpc::ServerContext* context,
+        const ::smartbotic::runner::ValidateTemplateRequest* request,
+        ::smartbotic::runner::ValidateTemplateResponse* response) override;
+
+private:
+    class Impl;
+    std::unique_ptr<Impl> impl_;
+};
+
+}  // namespace smartbotic::runner::grpc

+ 26 - 0
runner/include/smartbotic/runner/js_api.hpp

@@ -0,0 +1,26 @@
+#pragma once
+
+#include <string>
+
+namespace smartbotic::runner {
+
+/// Get the JavaScript API code to inject into the runtime
+/// This provides db, ctx, and utils objects to scripts
+[[nodiscard]] auto GetJsApiCode() -> std::string;
+
+/// Format a date according to a pattern
+[[nodiscard]] auto FormatDate(int64_t timestamp, const std::string& format) -> std::string;
+
+/// Format a number with options (decimal places, thousands separator)
+[[nodiscard]] auto FormatNumber(double num, int decimals, bool thousands_sep) -> std::string;
+
+/// Truncate a string to a maximum length with ellipsis
+[[nodiscard]] auto Truncate(const std::string& str, size_t max_length) -> std::string;
+
+/// Capitalize the first letter of a string
+[[nodiscard]] auto Capitalize(const std::string& str) -> std::string;
+
+/// Generate a UUID v4
+[[nodiscard]] auto GenerateUuid() -> std::string;
+
+}  // namespace smartbotic::runner

+ 109 - 0
runner/include/smartbotic/runner/js_engine.hpp

@@ -0,0 +1,109 @@
+#pragma once
+
+#include <functional>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::runner {
+
+/// Result of a JavaScript execution
+struct JsResult {
+    bool success = false;
+    std::string result;          // Return value as string (or JSON)
+    std::string error;           // Error message if success is false
+    int64_t execution_time_ms = 0;
+};
+
+/// Configuration for JavaScript engine
+struct JsEngineConfig {
+    size_t max_memory = 16 * 1024 * 1024;  // 16MB default
+    int max_execution_time_ms = 5000;       // 5 seconds default
+    size_t max_stack_size = 1024 * 1024;    // 1MB stack
+    int max_db_queries = 100;               // Maximum queries per execution
+    bool allow_eval = false;                // Disable dynamic code execution
+};
+
+/// Context information available to scripts
+struct JsContext {
+    std::string user_id;
+    std::string user_name;
+    std::string user_email;
+    std::string user_role;
+
+    std::string workspace_id;
+    std::string workspace_name;
+
+    std::string page_path;
+    std::string page_title;
+    std::string collection;
+    std::string view_id;
+
+    std::string session_id;
+    std::string session_title;
+    std::string model_id;
+
+    std::map<std::string, std::string> custom;
+};
+
+/// Database query callback for JS scripts
+/// Returns JSON string of results or empty string on error
+using DbQueryCallback = std::function<std::string(
+    const std::string& collection,
+    const std::string& filter_json,
+    int limit,
+    int offset)>;
+
+/// Database get callback for JS scripts
+/// Returns JSON string of document or empty string if not found
+using DbGetCallback = std::function<std::string(
+    const std::string& collection,
+    const std::string& id)>;
+
+/// Database count callback for JS scripts
+using DbCountCallback = std::function<int64_t(
+    const std::string& collection,
+    const std::string& filter_json)>;
+
+/// JavaScript engine wrapper for QuickJS
+class JsEngine {
+public:
+    explicit JsEngine(const JsEngineConfig& config = {});
+    ~JsEngine();
+
+    // Non-copyable
+    JsEngine(const JsEngine&) = delete;
+    JsEngine& operator=(const JsEngine&) = delete;
+
+    // Movable
+    JsEngine(JsEngine&&) noexcept;
+    JsEngine& operator=(JsEngine&&) noexcept;
+
+    /// Set the context available to scripts
+    void SetContext(const JsContext& ctx);
+
+    /// Set database callbacks
+    void SetDbCallbacks(DbQueryCallback query_cb,
+                        DbGetCallback get_cb,
+                        DbCountCallback count_cb);
+
+    /// Execute a JavaScript script
+    [[nodiscard]] auto Execute(const std::string& script) -> JsResult;
+
+    /// Evaluate a JavaScript expression and return the result
+    [[nodiscard]] auto Evaluate(const std::string& expression) -> JsResult;
+
+    /// Get the number of database queries made in the current execution
+    [[nodiscard]] auto GetDbQueryCount() const -> int;
+
+    /// Reset query counter and state for a new execution
+    void Reset();
+
+private:
+    class Impl;
+    std::unique_ptr<Impl> impl_;
+};
+
+}  // namespace smartbotic::runner

+ 51 - 0
runner/include/smartbotic/runner/template_parser.hpp

@@ -0,0 +1,51 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+namespace smartbotic::runner {
+
+/// Error information for template validation (internal, not proto)
+struct TemplateParseError {
+    int line = 0;
+    int column = 0;
+    std::string message;
+};
+
+/// Result of template parsing
+struct ParseResult {
+    bool valid = false;
+    std::string processed_code;          // JavaScript code to execute
+    std::vector<TemplateParseError> errors;   // Validation errors
+};
+
+/// Result of template rendering
+struct RenderResult {
+    bool success = false;
+    std::string result;
+    std::string error;
+};
+
+/**
+ * Template Parser
+ *
+ * Supported syntax:
+ * - {{variable}}                    - Simple variable substitution
+ * - {{user.name}}                   - Nested property access
+ * - {{#if condition}}...{{/if}}     - Conditional block
+ * - {{#each items}}...{{/each}}     - Loop over array
+ * - {{! expression }}               - JavaScript expression (result inserted)
+ *
+ * The parser converts templates into JavaScript code that can be executed
+ * by the JsEngine.
+ */
+class TemplateParser {
+public:
+    /// Parse a template and generate executable JavaScript code
+    [[nodiscard]] static auto Parse(const std::string& template_str) -> ParseResult;
+
+    /// Validate a template without generating code
+    [[nodiscard]] static auto Validate(const std::string& template_str) -> std::vector<TemplateParseError>;
+};
+
+}  // namespace smartbotic::runner

+ 85 - 0
runner/src/config.cpp

@@ -0,0 +1,85 @@
+#include "smartbotic/runner/config.hpp"
+
+#include <cstdlib>
+#include <fstream>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::runner {
+
+namespace {
+
+auto GetEnvOr(const char* name, const std::string& default_val) -> std::string {
+    const char* val = std::getenv(name);
+    return val ? std::string(val) : default_val;
+}
+
+auto GetEnvIntOr(const char* name, int default_val) -> int {
+    const char* val = std::getenv(name);
+    if (val) {
+        try {
+            return std::stoi(val);
+        } catch (...) {
+            // Ignore parse errors
+        }
+    }
+    return default_val;
+}
+
+}  // namespace
+
+auto LoadConfig(const std::string& config_file) -> Config {
+    Config config;
+
+    // Load from JSON file if provided
+    if (!config_file.empty()) {
+        try {
+            std::ifstream file(config_file);
+            if (file.is_open()) {
+                auto json = nlohmann::json::parse(file);
+
+                if (json.contains("address")) {
+                    config.address = json["address"].get<std::string>();
+                }
+                if (json.contains("port")) {
+                    config.port = json["port"].get<int>();
+                }
+                if (json.contains("database_address")) {
+                    config.database_address = json["database_address"].get<std::string>();
+                }
+                if (json.contains("max_memory_mb")) {
+                    config.max_memory_mb = json["max_memory_mb"].get<size_t>();
+                }
+                if (json.contains("max_execution_time_ms")) {
+                    config.max_execution_time_ms = json["max_execution_time_ms"].get<int>();
+                }
+                if (json.contains("max_db_queries")) {
+                    config.max_db_queries = json["max_db_queries"].get<int>();
+                }
+                if (json.contains("log_level")) {
+                    config.log_level = json["log_level"].get<std::string>();
+                }
+
+                spdlog::info("Loaded configuration from {}", config_file);
+            }
+        } catch (const std::exception& e) {
+            spdlog::warn("Failed to load config from {}: {}", config_file, e.what());
+        }
+    }
+
+    // Override with environment variables (using SMARTBOTIC_CRM_ prefix)
+    config.address = GetEnvOr("SMARTBOTIC_CRM_RUNNER_ADDRESS", config.address);
+    config.port = GetEnvIntOr("SMARTBOTIC_CRM_RUNNER_PORT", config.port);
+    config.database_address = GetEnvOr("SMARTBOTIC_CRM_DATABASE_ADDRESS", config.database_address);
+    config.max_memory_mb = static_cast<size_t>(
+        GetEnvIntOr("SMARTBOTIC_CRM_RUNNER_MAX_MEMORY_MB", static_cast<int>(config.max_memory_mb)));
+    config.max_execution_time_ms =
+        GetEnvIntOr("SMARTBOTIC_CRM_RUNNER_MAX_EXECUTION_TIME_MS", config.max_execution_time_ms);
+    config.max_db_queries = GetEnvIntOr("SMARTBOTIC_CRM_RUNNER_MAX_DB_QUERIES", config.max_db_queries);
+    config.log_level = GetEnvOr("SMARTBOTIC_CRM_LOG_LEVEL", config.log_level);
+
+    return config;
+}
+
+}  // namespace smartbotic::runner

+ 47 - 0
runner/src/grpc/health_service.cpp

@@ -0,0 +1,47 @@
+#include "smartbotic/runner/grpc/health_service.hpp"
+
+#include <sys/resource.h>
+
+namespace smartbotic::runner::grpc {
+
+namespace {
+constexpr const char* kVersion = "0.1.0";
+}
+
+HealthServiceImpl::HealthServiceImpl() = default;
+
+HealthServiceImpl::~HealthServiceImpl() = default;
+
+::grpc::Status HealthServiceImpl::Check(
+    ::grpc::ServerContext* context,
+    const ::smartbotic::runner::HealthCheckRequest* request,
+    ::smartbotic::runner::HealthCheckResponse* response) {
+
+    response->set_healthy(true);
+    response->set_version(kVersion);
+    response->set_active_executions(active_executions_.load());
+    response->set_total_executions(total_executions_.load());
+
+    // Get current memory usage
+    struct rusage usage;
+    if (getrusage(RUSAGE_SELF, &usage) == 0) {
+        // maxrss is in kilobytes on Linux
+        response->set_memory_usage_bytes(usage.ru_maxrss * 1024);
+    }
+
+    return ::grpc::Status::OK;
+}
+
+void HealthServiceImpl::IncrementActiveExecutions() {
+    active_executions_++;
+}
+
+void HealthServiceImpl::DecrementActiveExecutions() {
+    active_executions_--;
+}
+
+void HealthServiceImpl::IncrementTotalExecutions() {
+    total_executions_++;
+}
+
+}  // namespace smartbotic::runner::grpc

+ 204 - 0
runner/src/grpc/script_service.cpp

@@ -0,0 +1,204 @@
+#include "smartbotic/runner/grpc/script_service.hpp"
+
+#include <grpcpp/grpcpp.h>
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include "database.grpc.pb.h"
+#include "smartbotic/runner/js_engine.hpp"
+
+namespace smartbotic::runner::grpc {
+
+namespace {
+
+// Convert MapValue to JSON string
+auto MapValueToJson(const ::smartbotic::database::MapValue& map_value) -> std::string;
+
+auto ValueToJson(const ::smartbotic::database::Value& value) -> nlohmann::json {
+    switch (value.kind_case()) {
+        case ::smartbotic::database::Value::kNullValue:
+            return nullptr;
+        case ::smartbotic::database::Value::kBoolValue:
+            return value.bool_value();
+        case ::smartbotic::database::Value::kIntValue:
+            return value.int_value();
+        case ::smartbotic::database::Value::kDoubleValue:
+            return value.double_value();
+        case ::smartbotic::database::Value::kStringValue:
+            return value.string_value();
+        case ::smartbotic::database::Value::kArrayValue: {
+            nlohmann::json arr = nlohmann::json::array();
+            for (const auto& item : value.array_value().values()) {
+                arr.push_back(ValueToJson(item));
+            }
+            return arr;
+        }
+        case ::smartbotic::database::Value::kMapValue: {
+            nlohmann::json obj = nlohmann::json::object();
+            for (const auto& [key, val] : value.map_value().fields()) {
+                obj[key] = ValueToJson(val);
+            }
+            return obj;
+        }
+        default:
+            return nullptr;
+    }
+}
+
+auto MapValueToJson(const ::smartbotic::database::MapValue& map_value) -> std::string {
+    nlohmann::json obj = nlohmann::json::object();
+    for (const auto& [key, val] : map_value.fields()) {
+        obj[key] = ValueToJson(val);
+    }
+    return obj.dump();
+}
+
+}  // namespace
+
+class ScriptServiceImpl::Impl {
+public:
+    Impl(const Config& config, const std::string& database_address)
+        : config_(config), database_address_(database_address) {
+        // Create gRPC channel to database service
+        db_channel_ = ::grpc::CreateChannel(database_address, ::grpc::InsecureChannelCredentials());
+        query_stub_ = ::smartbotic::database::QueryService::NewStub(db_channel_);
+        document_stub_ = ::smartbotic::database::DocumentService::NewStub(db_channel_);
+    }
+
+    auto ExecuteScript(const ::smartbotic::runner::ExecuteScriptRequest& request)
+        -> ::smartbotic::runner::ExecuteScriptResponse {
+        ::smartbotic::runner::ExecuteScriptResponse response;
+
+        // Determine timeout (capped by config)
+        int timeout_ms = request.timeout_ms() > 0
+            ? std::min(request.timeout_ms(), config_.max_execution_time_ms)
+            : config_.max_execution_time_ms;
+
+        // Set up JavaScript engine
+        JsEngineConfig js_config;
+        js_config.max_memory = config_.max_memory_mb * 1024 * 1024;
+        js_config.max_execution_time_ms = timeout_ms;
+        js_config.max_db_queries = config_.max_db_queries;
+
+        JsEngine engine(js_config);
+
+        // Set up context
+        JsContext ctx;
+        ctx.user_id = request.user_id();
+        ctx.workspace_id = request.workspace_id();
+
+        // Copy custom context variables
+        for (const auto& [key, value] : request.context()) {
+            ctx.custom[key] = value;
+        }
+
+        engine.SetContext(ctx);
+
+        // Set up database callbacks
+        auto* query_stub = query_stub_.get();
+        auto* document_stub = document_stub_.get();
+
+        engine.SetDbCallbacks(
+            // Query callback
+            [query_stub](const std::string& collection,
+                         const std::string& /*filter_json*/,
+                         int limit, int offset) -> std::string {
+                ::smartbotic::database::QueryRequest db_request;
+                db_request.set_collection(collection);
+                db_request.set_limit(limit);
+                db_request.set_offset(offset);
+
+                ::smartbotic::database::QueryResponse db_response;
+                ::grpc::ClientContext context;
+                auto status = query_stub->Query(&context, db_request, &db_response);
+
+                if (!status.ok()) {
+                    spdlog::warn("Database query failed: {}", status.error_message());
+                    return "[]";
+                }
+
+                nlohmann::json arr = nlohmann::json::array();
+                for (const auto& doc : db_response.documents()) {
+                    nlohmann::json doc_json = nlohmann::json::parse(MapValueToJson(doc.data()), nullptr, false);
+                    if (!doc_json.is_discarded()) {
+                        doc_json["_id"] = doc.id();
+                        arr.push_back(doc_json);
+                    }
+                }
+                return arr.dump();
+            },
+            // Get callback
+            [document_stub](const std::string& collection,
+                            const std::string& id) -> std::string {
+                ::smartbotic::database::GetDocumentRequest db_request;
+                db_request.set_collection(collection);
+                db_request.set_id(id);
+
+                ::smartbotic::database::Document db_response;
+                ::grpc::ClientContext context;
+                auto status = document_stub->GetDocument(&context, db_request, &db_response);
+
+                if (!status.ok()) {
+                    return "";
+                }
+
+                return MapValueToJson(db_response.data());
+            },
+            // Count callback
+            [query_stub](const std::string& collection,
+                         const std::string& /*filter_json*/) -> int64_t {
+                ::smartbotic::database::CountRequest db_request;
+                db_request.set_collection(collection);
+
+                ::smartbotic::database::CountResponse db_response;
+                ::grpc::ClientContext context;
+                auto status = query_stub->Count(&context, db_request, &db_response);
+
+                if (!status.ok()) {
+                    return 0;
+                }
+
+                return db_response.count();
+            }
+        );
+
+        // Execute the script
+        auto js_result = engine.Execute(request.script());
+
+        response.set_success(js_result.success);
+        if (js_result.success) {
+            response.set_result(js_result.result);
+        } else {
+            response.set_error(js_result.error);
+        }
+        response.set_execution_time_ms(js_result.execution_time_ms);
+
+        return response;
+    }
+
+private:
+    Config config_;
+    std::string database_address_;
+    std::shared_ptr<::grpc::Channel> db_channel_;
+    std::unique_ptr<::smartbotic::database::QueryService::Stub> query_stub_;
+    std::unique_ptr<::smartbotic::database::DocumentService::Stub> document_stub_;
+};
+
+ScriptServiceImpl::ScriptServiceImpl(const Config& config, const std::string& database_address)
+    : impl_(std::make_unique<Impl>(config, database_address)) {}
+
+ScriptServiceImpl::~ScriptServiceImpl() = default;
+
+::grpc::Status ScriptServiceImpl::ExecuteScript(
+    ::grpc::ServerContext* /*context*/,
+    const ::smartbotic::runner::ExecuteScriptRequest* request,
+    ::smartbotic::runner::ExecuteScriptResponse* response) {
+
+    spdlog::debug("ExecuteScript request for workspace: {}", request->workspace_id());
+
+    *response = impl_->ExecuteScript(*request);
+
+    return ::grpc::Status::OK;
+}
+
+}  // namespace smartbotic::runner::grpc

+ 88 - 0
runner/src/grpc/server.cpp

@@ -0,0 +1,88 @@
+#include "smartbotic/runner/grpc/server.hpp"
+
+#include <grpcpp/grpcpp.h>
+#include <grpcpp/ext/proto_server_reflection_plugin.h>
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/runner/grpc/health_service.hpp"
+#include "smartbotic/runner/grpc/script_service.hpp"
+#include "smartbotic/runner/grpc/template_service.hpp"
+
+namespace smartbotic::runner::grpc {
+
+class Server::Impl {
+public:
+    explicit Impl(const Config& config)
+        : config_(config),
+          address_(config.address + ":" + std::to_string(config.port)) {
+
+        // Create services
+        health_service_ = std::make_unique<HealthServiceImpl>();
+        template_service_ = std::make_unique<TemplateServiceImpl>(config, config.database_address);
+        script_service_ = std::make_unique<ScriptServiceImpl>(config, config.database_address);
+    }
+
+    void Run() {
+        ::grpc::ServerBuilder builder;
+
+        // Listen on the given address
+        builder.AddListeningPort(address_, ::grpc::InsecureServerCredentials());
+
+        // Register services
+        builder.RegisterService(health_service_.get());
+        builder.RegisterService(template_service_.get());
+        builder.RegisterService(script_service_.get());
+
+        // Enable reflection for debugging
+        ::grpc::reflection::InitProtoReflectionServerBuilderPlugin();
+
+        // Build and start the server
+        server_ = builder.BuildAndStart();
+        if (!server_) {
+            spdlog::error("Failed to start runner gRPC server on {}", address_);
+            return;
+        }
+
+        spdlog::info("Runner gRPC server listening on {}", address_);
+        server_->Wait();
+    }
+
+    void Shutdown() {
+        if (server_) {
+            spdlog::info("Shutting down runner gRPC server...");
+            server_->Shutdown();
+        }
+    }
+
+    auto GetAddress() const -> std::string {
+        return address_;
+    }
+
+private:
+    Config config_;
+    std::string address_;
+
+    std::unique_ptr<::grpc::Server> server_;
+    std::unique_ptr<HealthServiceImpl> health_service_;
+    std::unique_ptr<TemplateServiceImpl> template_service_;
+    std::unique_ptr<ScriptServiceImpl> script_service_;
+};
+
+Server::Server(const Config& config)
+    : impl_(std::make_unique<Impl>(config)) {}
+
+Server::~Server() = default;
+
+void Server::Run() {
+    impl_->Run();
+}
+
+void Server::Shutdown() {
+    impl_->Shutdown();
+}
+
+auto Server::GetAddress() const -> std::string {
+    return impl_->GetAddress();
+}
+
+}  // namespace smartbotic::runner::grpc

+ 249 - 0
runner/src/grpc/template_service.cpp

@@ -0,0 +1,249 @@
+#include "smartbotic/runner/grpc/template_service.hpp"
+
+#include <grpcpp/grpcpp.h>
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include "database.grpc.pb.h"
+#include "smartbotic/runner/js_engine.hpp"
+#include "smartbotic/runner/template_parser.hpp"
+
+namespace smartbotic::runner::grpc {
+
+namespace {
+
+// Convert MapValue to JSON string
+auto MapValueToJson(const ::smartbotic::database::MapValue& map_value) -> std::string;
+
+auto ValueToJson(const ::smartbotic::database::Value& value) -> nlohmann::json {
+    switch (value.kind_case()) {
+        case ::smartbotic::database::Value::kNullValue:
+            return nullptr;
+        case ::smartbotic::database::Value::kBoolValue:
+            return value.bool_value();
+        case ::smartbotic::database::Value::kIntValue:
+            return value.int_value();
+        case ::smartbotic::database::Value::kDoubleValue:
+            return value.double_value();
+        case ::smartbotic::database::Value::kStringValue:
+            return value.string_value();
+        case ::smartbotic::database::Value::kArrayValue: {
+            nlohmann::json arr = nlohmann::json::array();
+            for (const auto& item : value.array_value().values()) {
+                arr.push_back(ValueToJson(item));
+            }
+            return arr;
+        }
+        case ::smartbotic::database::Value::kMapValue: {
+            nlohmann::json obj = nlohmann::json::object();
+            for (const auto& [key, val] : value.map_value().fields()) {
+                obj[key] = ValueToJson(val);
+            }
+            return obj;
+        }
+        default:
+            return nullptr;
+    }
+}
+
+auto MapValueToJson(const ::smartbotic::database::MapValue& map_value) -> std::string {
+    nlohmann::json obj = nlohmann::json::object();
+    for (const auto& [key, val] : map_value.fields()) {
+        obj[key] = ValueToJson(val);
+    }
+    return obj.dump();
+}
+
+}  // namespace
+
+class TemplateServiceImpl::Impl {
+public:
+    Impl(const Config& config, const std::string& database_address)
+        : config_(config), database_address_(database_address) {
+        // Create gRPC channel to database service
+        db_channel_ = ::grpc::CreateChannel(database_address, ::grpc::InsecureChannelCredentials());
+        query_stub_ = ::smartbotic::database::QueryService::NewStub(db_channel_);
+        document_stub_ = ::smartbotic::database::DocumentService::NewStub(db_channel_);
+    }
+
+    auto RenderTemplate(const ::smartbotic::runner::RenderTemplateRequest& request)
+        -> ::smartbotic::runner::RenderTemplateResponse {
+        ::smartbotic::runner::RenderTemplateResponse response;
+
+        // Parse the template
+        auto parse_result = TemplateParser::Parse(request.template_());
+
+        if (!parse_result.valid) {
+            response.set_success(false);
+            std::string errors;
+            for (const auto& err : parse_result.errors) {
+                if (!errors.empty()) {
+                    errors += "; ";
+                }
+                errors += "Line " + std::to_string(err.line) + ":" +
+                         std::to_string(err.column) + ": " + err.message;
+            }
+            response.set_error(errors);
+            return response;
+        }
+
+        // Set up JavaScript engine
+        JsEngineConfig js_config;
+        js_config.max_memory = config_.max_memory_mb * 1024 * 1024;
+        js_config.max_execution_time_ms = config_.max_execution_time_ms;
+        js_config.max_db_queries = config_.max_db_queries;
+
+        JsEngine engine(js_config);
+
+        // Set up context
+        JsContext ctx;
+        ctx.user_id = request.user_id();
+        ctx.workspace_id = request.workspace_id();
+
+        // Copy custom variables
+        for (const auto& [key, value] : request.variables()) {
+            ctx.custom[key] = value;
+        }
+
+        engine.SetContext(ctx);
+
+        // Set up database callbacks
+        auto* query_stub = query_stub_.get();
+        auto* document_stub = document_stub_.get();
+
+        engine.SetDbCallbacks(
+            // Query callback
+            [query_stub](const std::string& collection,
+                         const std::string& /*filter_json*/,
+                         int limit, int offset) -> std::string {
+                ::smartbotic::database::QueryRequest db_request;
+                db_request.set_collection(collection);
+                db_request.set_limit(limit);
+                db_request.set_offset(offset);
+                // Note: filter_json parsing not implemented - queries return all docs
+
+                ::smartbotic::database::QueryResponse db_response;
+                ::grpc::ClientContext context;
+                auto status = query_stub->Query(&context, db_request, &db_response);
+
+                if (!status.ok()) {
+                    spdlog::warn("Database query failed: {}", status.error_message());
+                    return "[]";
+                }
+
+                // Convert documents to JSON array
+                nlohmann::json arr = nlohmann::json::array();
+                for (const auto& doc : db_response.documents()) {
+                    nlohmann::json doc_json = nlohmann::json::parse(MapValueToJson(doc.data()), nullptr, false);
+                    if (!doc_json.is_discarded()) {
+                        doc_json["_id"] = doc.id();
+                        arr.push_back(doc_json);
+                    }
+                }
+                return arr.dump();
+            },
+            // Get callback
+            [document_stub](const std::string& collection,
+                            const std::string& id) -> std::string {
+                ::smartbotic::database::GetDocumentRequest db_request;
+                db_request.set_collection(collection);
+                db_request.set_id(id);
+
+                ::smartbotic::database::Document db_response;
+                ::grpc::ClientContext context;
+                auto status = document_stub->GetDocument(&context, db_request, &db_response);
+
+                if (!status.ok()) {
+                    return "";
+                }
+
+                return MapValueToJson(db_response.data());
+            },
+            // Count callback
+            [query_stub](const std::string& collection,
+                         const std::string& /*filter_json*/) -> int64_t {
+                ::smartbotic::database::CountRequest db_request;
+                db_request.set_collection(collection);
+                // Note: filter_json parsing not implemented - counts all docs
+
+                ::smartbotic::database::CountResponse db_response;
+                ::grpc::ClientContext context;
+                auto status = query_stub->Count(&context, db_request, &db_response);
+
+                if (!status.ok()) {
+                    return 0;
+                }
+
+                return db_response.count();
+            }
+        );
+
+        // Execute the generated JavaScript code
+        auto js_result = engine.Execute(parse_result.processed_code);
+
+        if (!js_result.success) {
+            response.set_success(false);
+            response.set_error(js_result.error);
+            return response;
+        }
+
+        response.set_success(true);
+        response.set_result(js_result.result);
+        return response;
+    }
+
+    auto ValidateTemplate(const ::smartbotic::runner::ValidateTemplateRequest& request)
+        -> ::smartbotic::runner::ValidateTemplateResponse {
+        ::smartbotic::runner::ValidateTemplateResponse response;
+
+        auto errors = TemplateParser::Validate(request.template_());
+
+        response.set_valid(errors.empty());
+        for (const auto& err : errors) {
+            auto* proto_err = response.add_errors();
+            proto_err->set_line(err.line);
+            proto_err->set_column(err.column);
+            proto_err->set_message(err.message);
+        }
+
+        return response;
+    }
+
+private:
+    Config config_;
+    std::string database_address_;
+    std::shared_ptr<::grpc::Channel> db_channel_;
+    std::unique_ptr<::smartbotic::database::QueryService::Stub> query_stub_;
+    std::unique_ptr<::smartbotic::database::DocumentService::Stub> document_stub_;
+};
+
+TemplateServiceImpl::TemplateServiceImpl(const Config& config, const std::string& database_address)
+    : impl_(std::make_unique<Impl>(config, database_address)) {}
+
+TemplateServiceImpl::~TemplateServiceImpl() = default;
+
+::grpc::Status TemplateServiceImpl::RenderTemplate(
+    ::grpc::ServerContext* /*context*/,
+    const ::smartbotic::runner::RenderTemplateRequest* request,
+    ::smartbotic::runner::RenderTemplateResponse* response) {
+
+    spdlog::debug("RenderTemplate request for workspace: {}", request->workspace_id());
+
+    *response = impl_->RenderTemplate(*request);
+
+    return ::grpc::Status::OK;
+}
+
+::grpc::Status TemplateServiceImpl::ValidateTemplate(
+    ::grpc::ServerContext* /*context*/,
+    const ::smartbotic::runner::ValidateTemplateRequest* request,
+    ::smartbotic::runner::ValidateTemplateResponse* response) {
+
+    spdlog::debug("ValidateTemplate request");
+
+    *response = impl_->ValidateTemplate(*request);
+
+    return ::grpc::Status::OK;
+}
+
+}  // namespace smartbotic::runner::grpc

+ 144 - 0
runner/src/js_api.cpp

@@ -0,0 +1,144 @@
+#include "smartbotic/runner/js_api.hpp"
+
+#include <chrono>
+#include <ctime>
+#include <iomanip>
+#include <random>
+#include <sstream>
+
+namespace smartbotic::runner {
+
+auto GetJsApiCode() -> std::string {
+    // This returns the JavaScript API code that's injected into the runtime
+    // Most of the API is already set up in JsEngine::InitializeBuiltins()
+    // This function provides additional utilities if needed
+    return R"(
+        // Additional API extensions can be added here
+        // The core API (db, ctx, utils) is set up in the native code
+    )";
+}
+
+auto FormatDate(int64_t timestamp, const std::string& format) -> std::string {
+    std::time_t time = static_cast<std::time_t>(timestamp);
+    std::tm tm_time{};
+#ifdef _WIN32
+    localtime_s(&tm_time, &time);
+#else
+    localtime_r(&time, &tm_time);
+#endif
+
+    std::ostringstream oss;
+    std::string fmt = format;
+
+    // Simple replacements
+    auto replace = [&](const std::string& from, const std::string& to) {
+        size_t pos = 0;
+        while ((pos = fmt.find(from, pos)) != std::string::npos) {
+            fmt.replace(pos, from.length(), to);
+            pos += to.length();
+        }
+    };
+
+    std::ostringstream year_ss;
+    year_ss << std::setw(4) << std::setfill('0') << (tm_time.tm_year + 1900);
+    replace("YYYY", year_ss.str());
+
+    std::ostringstream month_ss;
+    month_ss << std::setw(2) << std::setfill('0') << (tm_time.tm_mon + 1);
+    replace("MM", month_ss.str());
+
+    std::ostringstream day_ss;
+    day_ss << std::setw(2) << std::setfill('0') << tm_time.tm_mday;
+    replace("DD", day_ss.str());
+
+    std::ostringstream hour_ss;
+    hour_ss << std::setw(2) << std::setfill('0') << tm_time.tm_hour;
+    replace("HH", hour_ss.str());
+
+    std::ostringstream min_ss;
+    min_ss << std::setw(2) << std::setfill('0') << tm_time.tm_min;
+    replace("mm", min_ss.str());
+
+    std::ostringstream sec_ss;
+    sec_ss << std::setw(2) << std::setfill('0') << tm_time.tm_sec;
+    replace("ss", sec_ss.str());
+
+    return fmt;
+}
+
+auto FormatNumber(double num, int decimals, bool thousands_sep) -> std::string {
+    std::ostringstream oss;
+    oss << std::fixed << std::setprecision(decimals) << num;
+    std::string result = oss.str();
+
+    if (thousands_sep) {
+        // Find decimal point
+        auto dot_pos = result.find('.');
+        std::string integer_part = (dot_pos != std::string::npos)
+            ? result.substr(0, dot_pos)
+            : result;
+        std::string decimal_part = (dot_pos != std::string::npos)
+            ? result.substr(dot_pos)
+            : "";
+
+        // Insert thousands separators
+        std::string formatted;
+        int count = 0;
+        for (auto it = integer_part.rbegin(); it != integer_part.rend(); ++it) {
+            if (count > 0 && count % 3 == 0 && *it != '-') {
+                formatted = ',' + formatted;
+            }
+            formatted = *it + formatted;
+            count++;
+        }
+
+        result = formatted + decimal_part;
+    }
+
+    return result;
+}
+
+auto Truncate(const std::string& str, size_t max_length) -> std::string {
+    if (str.length() <= max_length) {
+        return str;
+    }
+    if (max_length <= 3) {
+        return str.substr(0, max_length);
+    }
+    return str.substr(0, max_length - 3) + "...";
+}
+
+auto Capitalize(const std::string& str) -> std::string {
+    if (str.empty()) {
+        return str;
+    }
+    std::string result = str;
+    result[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(result[0])));
+    return result;
+}
+
+auto GenerateUuid() -> std::string {
+    static std::random_device rd;
+    static std::mt19937_64 gen(rd());
+    static std::uniform_int_distribution<uint64_t> dis;
+
+    uint64_t ab = dis(gen);
+    uint64_t cd = dis(gen);
+
+    // Set version to 4 and variant to 2
+    ab = (ab & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL;
+    cd = (cd & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL;
+
+    char buf[37];
+    snprintf(buf, sizeof(buf),
+             "%08x-%04x-%04x-%04x-%012llx",
+             static_cast<uint32_t>(ab >> 32),
+             static_cast<uint16_t>((ab >> 16) & 0xFFFF),
+             static_cast<uint16_t>(ab & 0xFFFF),
+             static_cast<uint16_t>(cd >> 48),
+             static_cast<unsigned long long>(cd & 0x0000FFFFFFFFFFFFULL));
+
+    return std::string(buf);
+}
+
+}  // namespace smartbotic::runner

+ 486 - 0
runner/src/js_engine.cpp

@@ -0,0 +1,486 @@
+#include "smartbotic/runner/js_engine.hpp"
+
+#include <chrono>
+
+#include <spdlog/spdlog.h>
+
+// QuickJS header - will be available after FetchContent
+extern "C" {
+#include <quickjs.h>
+}
+
+namespace smartbotic::runner {
+
+class JsEngine::Impl {
+public:
+    explicit Impl(const JsEngineConfig& config)
+        : config_(config) {
+        // Create QuickJS runtime with memory limit
+        rt_ = JS_NewRuntime();
+        if (!rt_) {
+            spdlog::error("Failed to create QuickJS runtime");
+            return;
+        }
+
+        JS_SetMemoryLimit(rt_, config_.max_memory);
+        JS_SetMaxStackSize(rt_, config_.max_stack_size);
+
+        // Create context
+        ctx_ = JS_NewContext(rt_);
+        if (!ctx_) {
+            spdlog::error("Failed to create QuickJS context");
+            return;
+        }
+
+        // Disable eval if configured
+        if (!config_.allow_eval) {
+            // Note: QuickJS doesn't have a direct way to disable eval,
+            // but we can override it in JavaScript
+        }
+
+        InitializeBuiltins();
+    }
+
+    ~Impl() {
+        if (ctx_) {
+            JS_FreeContext(ctx_);
+        }
+        if (rt_) {
+            JS_FreeRuntime(rt_);
+        }
+    }
+
+    void SetContext(const JsContext& ctx) {
+        context_ = ctx;
+        UpdateContextInJs();
+    }
+
+    void SetDbCallbacks(DbQueryCallback query_cb,
+                        DbGetCallback get_cb,
+                        DbCountCallback count_cb) {
+        query_callback_ = std::move(query_cb);
+        get_callback_ = std::move(get_cb);
+        count_callback_ = std::move(count_cb);
+    }
+
+    auto Execute(const std::string& script) -> JsResult {
+        if (!ctx_) {
+            return {false, "", "JavaScript engine not initialized", 0};
+        }
+
+        auto start = std::chrono::steady_clock::now();
+
+        // Set up interrupt handler for timeout
+        interrupt_flag_ = false;
+        JS_SetInterruptHandler(rt_, InterruptHandler, this);
+
+        // Start timeout thread
+        timeout_thread_active_ = true;
+        std::thread timeout_thread([this]() {
+            std::this_thread::sleep_for(std::chrono::milliseconds(config_.max_execution_time_ms));
+            if (timeout_thread_active_) {
+                interrupt_flag_ = true;
+            }
+        });
+        timeout_thread.detach();
+
+        // Execute the script
+        JSValue result = JS_Eval(ctx_, script.c_str(), script.length(),
+                                 "<script>", JS_EVAL_TYPE_GLOBAL);
+
+        timeout_thread_active_ = false;
+
+        auto end = std::chrono::steady_clock::now();
+        auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
+
+        JsResult js_result;
+        js_result.execution_time_ms = duration.count();
+
+        if (JS_IsException(result)) {
+            JSValue exception = JS_GetException(ctx_);
+            const char* error_str = JS_ToCString(ctx_, exception);
+            js_result.success = false;
+            js_result.error = error_str ? error_str : "Unknown error";
+            if (error_str) {
+                JS_FreeCString(ctx_, error_str);
+            }
+            JS_FreeValue(ctx_, exception);
+        } else {
+            js_result.success = true;
+            const char* result_str = JS_ToCString(ctx_, result);
+            js_result.result = result_str ? result_str : "";
+            if (result_str) {
+                JS_FreeCString(ctx_, result_str);
+            }
+        }
+
+        JS_FreeValue(ctx_, result);
+        return js_result;
+    }
+
+    auto Evaluate(const std::string& expression) -> JsResult {
+        // Wrap expression to return its value
+        std::string script = "(" + expression + ")";
+        return Execute(script);
+    }
+
+    auto GetDbQueryCount() const -> int {
+        return db_query_count_;
+    }
+
+    void Reset() {
+        db_query_count_ = 0;
+    }
+
+private:
+    static int InterruptHandler(JSRuntime* rt, void* opaque) {
+        auto* impl = static_cast<Impl*>(opaque);
+        return impl->interrupt_flag_ ? 1 : 0;
+    }
+
+    void InitializeBuiltins() {
+        if (!ctx_) return;
+
+        // Create global 'utils' object with utility functions
+        const char* utils_code = R"(
+            globalThis.utils = {
+                formatDate: function(date, format) {
+                    if (typeof date === 'number') {
+                        date = new Date(date * 1000);
+                    }
+                    // Simple date formatting
+                    const pad = (n) => n.toString().padStart(2, '0');
+                    return format
+                        .replace('YYYY', date.getFullYear())
+                        .replace('MM', pad(date.getMonth() + 1))
+                        .replace('DD', pad(date.getDate()))
+                        .replace('HH', pad(date.getHours()))
+                        .replace('mm', pad(date.getMinutes()))
+                        .replace('ss', pad(date.getSeconds()));
+                },
+                formatNumber: function(num, options) {
+                    options = options || {};
+                    const decimals = options.decimals || 0;
+                    const sep = options.thousandsSeparator !== false;
+                    let str = num.toFixed(decimals);
+                    if (sep) {
+                        const parts = str.split('.');
+                        parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+                        str = parts.join('.');
+                    }
+                    return str;
+                },
+                truncate: function(str, length) {
+                    if (str.length <= length) return str;
+                    return str.substring(0, length - 3) + '...';
+                },
+                capitalize: function(str) {
+                    return str.charAt(0).toUpperCase() + str.slice(1);
+                },
+                now: function() {
+                    return Math.floor(Date.now() / 1000);
+                },
+                uuid: function() {
+                    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+                        const r = Math.random() * 16 | 0;
+                        const v = c === 'x' ? r : (r & 0x3 | 0x8);
+                        return v.toString(16);
+                    });
+                }
+            };
+
+            // Disable eval for security
+            globalThis.eval = function() {
+                throw new Error('eval is disabled for security');
+            };
+            globalThis.Function = function() {
+                throw new Error('Function constructor is disabled for security');
+            };
+        )";
+
+        JSValue result = JS_Eval(ctx_, utils_code, strlen(utils_code),
+                                 "<builtin>", JS_EVAL_TYPE_GLOBAL);
+        JS_FreeValue(ctx_, result);
+
+        // Set up native db functions (we'll implement these with callbacks)
+        SetupDbFunctions();
+    }
+
+    void SetupDbFunctions() {
+        if (!ctx_) return;
+
+        // Store this pointer in context for callbacks
+        JS_SetContextOpaque(ctx_, this);
+
+        // Create db object with native functions
+        JSValue global = JS_GetGlobalObject(ctx_);
+        JSValue db_obj = JS_NewObject(ctx_);
+
+        // db.get function
+        JS_SetPropertyStr(ctx_, db_obj, "get",
+            JS_NewCFunction(ctx_, DbGetNative, "get", 2));
+
+        // db.query function
+        JS_SetPropertyStr(ctx_, db_obj, "query",
+            JS_NewCFunction(ctx_, DbQueryNative, "query", 3));
+
+        // db.count function
+        JS_SetPropertyStr(ctx_, db_obj, "count",
+            JS_NewCFunction(ctx_, DbCountNative, "count", 2));
+
+        JS_SetPropertyStr(ctx_, global, "db", db_obj);
+        JS_FreeValue(ctx_, global);
+    }
+
+    static JSValue DbGetNative(JSContext* ctx, JSValueConst this_val,
+                               int argc, JSValueConst* argv) {
+        auto* impl = static_cast<Impl*>(JS_GetContextOpaque(ctx));
+        if (!impl || !impl->get_callback_) {
+            return JS_ThrowTypeError(ctx, "db.get not available");
+        }
+
+        if (argc < 2) {
+            return JS_ThrowTypeError(ctx, "db.get requires collection and id");
+        }
+
+        impl->db_query_count_++;
+        if (impl->db_query_count_ > impl->config_.max_db_queries) {
+            return JS_ThrowTypeError(ctx, "Maximum database queries exceeded");
+        }
+
+        const char* collection = JS_ToCString(ctx, argv[0]);
+        const char* id = JS_ToCString(ctx, argv[1]);
+
+        if (!collection || !id) {
+            if (collection) JS_FreeCString(ctx, collection);
+            if (id) JS_FreeCString(ctx, id);
+            return JS_ThrowTypeError(ctx, "Invalid arguments");
+        }
+
+        std::string result = impl->get_callback_(collection, id);
+
+        JS_FreeCString(ctx, collection);
+        JS_FreeCString(ctx, id);
+
+        if (result.empty()) {
+            return JS_NULL;
+        }
+
+        return JS_ParseJSON(ctx, result.c_str(), result.length(), "<db.get>");
+    }
+
+    static JSValue DbQueryNative(JSContext* ctx, JSValueConst this_val,
+                                 int argc, JSValueConst* argv) {
+        auto* impl = static_cast<Impl*>(JS_GetContextOpaque(ctx));
+        if (!impl || !impl->query_callback_) {
+            return JS_ThrowTypeError(ctx, "db.query not available");
+        }
+
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "db.query requires collection");
+        }
+
+        impl->db_query_count_++;
+        if (impl->db_query_count_ > impl->config_.max_db_queries) {
+            return JS_ThrowTypeError(ctx, "Maximum database queries exceeded");
+        }
+
+        const char* collection = JS_ToCString(ctx, argv[0]);
+        if (!collection) {
+            return JS_ThrowTypeError(ctx, "Invalid collection");
+        }
+
+        std::string filter_json = "{}";
+        int limit = 100;
+        int offset = 0;
+
+        if (argc >= 2 && !JS_IsUndefined(argv[1])) {
+            JSValue json_str = JS_JSONStringify(ctx, argv[1], JS_UNDEFINED, JS_UNDEFINED);
+            const char* str = JS_ToCString(ctx, json_str);
+            if (str) {
+                filter_json = str;
+                JS_FreeCString(ctx, str);
+            }
+            JS_FreeValue(ctx, json_str);
+        }
+
+        if (argc >= 3 && !JS_IsUndefined(argv[2])) {
+            // Options object with limit and offset
+            JSValue limit_val = JS_GetPropertyStr(ctx, argv[2], "limit");
+            JSValue offset_val = JS_GetPropertyStr(ctx, argv[2], "offset");
+
+            if (!JS_IsUndefined(limit_val)) {
+                int32_t l;
+                if (JS_ToInt32(ctx, &l, limit_val) == 0) {
+                    limit = std::min(l, 1000);  // Cap at 1000
+                }
+            }
+            if (!JS_IsUndefined(offset_val)) {
+                int32_t o;
+                if (JS_ToInt32(ctx, &o, offset_val) == 0) {
+                    offset = o;
+                }
+            }
+
+            JS_FreeValue(ctx, limit_val);
+            JS_FreeValue(ctx, offset_val);
+        }
+
+        std::string result = impl->query_callback_(collection, filter_json, limit, offset);
+        JS_FreeCString(ctx, collection);
+
+        if (result.empty()) {
+            return JS_NewArray(ctx);
+        }
+
+        return JS_ParseJSON(ctx, result.c_str(), result.length(), "<db.query>");
+    }
+
+    static JSValue DbCountNative(JSContext* ctx, JSValueConst this_val,
+                                 int argc, JSValueConst* argv) {
+        auto* impl = static_cast<Impl*>(JS_GetContextOpaque(ctx));
+        if (!impl || !impl->count_callback_) {
+            return JS_ThrowTypeError(ctx, "db.count not available");
+        }
+
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "db.count requires collection");
+        }
+
+        impl->db_query_count_++;
+        if (impl->db_query_count_ > impl->config_.max_db_queries) {
+            return JS_ThrowTypeError(ctx, "Maximum database queries exceeded");
+        }
+
+        const char* collection = JS_ToCString(ctx, argv[0]);
+        if (!collection) {
+            return JS_ThrowTypeError(ctx, "Invalid collection");
+        }
+
+        std::string filter_json = "{}";
+        if (argc >= 2 && !JS_IsUndefined(argv[1])) {
+            JSValue json_str = JS_JSONStringify(ctx, argv[1], JS_UNDEFINED, JS_UNDEFINED);
+            const char* str = JS_ToCString(ctx, json_str);
+            if (str) {
+                filter_json = str;
+                JS_FreeCString(ctx, str);
+            }
+            JS_FreeValue(ctx, json_str);
+        }
+
+        int64_t count = impl->count_callback_(collection, filter_json);
+        JS_FreeCString(ctx, collection);
+
+        return JS_NewInt64(ctx, count);
+    }
+
+    void UpdateContextInJs() {
+        if (!ctx_) return;
+
+        // Build context object as JSON and parse it in JS
+        std::string ctx_json = R"({
+            "user": {
+                "id": ")" + EscapeJson(context_.user_id) + R"(",
+                "name": ")" + EscapeJson(context_.user_name) + R"(",
+                "email": ")" + EscapeJson(context_.user_email) + R"(",
+                "role": ")" + EscapeJson(context_.user_role) + R"("
+            },
+            "workspace": {
+                "id": ")" + EscapeJson(context_.workspace_id) + R"(",
+                "name": ")" + EscapeJson(context_.workspace_name) + R"("
+            },
+            "page": {
+                "path": ")" + EscapeJson(context_.page_path) + R"(",
+                "title": ")" + EscapeJson(context_.page_title) + R"(",
+                "collection": ")" + EscapeJson(context_.collection) + R"(",
+                "view_id": ")" + EscapeJson(context_.view_id) + R"("
+            },
+            "session": {
+                "id": ")" + EscapeJson(context_.session_id) + R"(",
+                "title": ")" + EscapeJson(context_.session_title) + R"(",
+                "model_id": ")" + EscapeJson(context_.model_id) + R"("
+            }
+        })";
+
+        std::string code = "globalThis.ctx = " + ctx_json + ";";
+        // Also expose context objects directly in global scope for template convenience
+        code += "globalThis.user = ctx.user;";
+        code += "globalThis.workspace = ctx.workspace;";
+        code += "globalThis.page = ctx.page;";
+        code += "globalThis.session = ctx.session;";
+        for (const auto& [key, value] : context_.custom) {
+            code += "globalThis.ctx['" + EscapeJson(key) + "'] = '" + EscapeJson(value) + "';";
+            // Also define custom variables directly in global scope for templates
+            code += "globalThis['" + EscapeJson(key) + "'] = '" + EscapeJson(value) + "';";
+        }
+
+        JSValue result = JS_Eval(ctx_, code.c_str(), code.length(),
+                                 "<context>", JS_EVAL_TYPE_GLOBAL);
+        JS_FreeValue(ctx_, result);
+    }
+
+    static std::string EscapeJson(const std::string& str) {
+        std::string result;
+        result.reserve(str.size());
+        for (char c : str) {
+            switch (c) {
+                case '"': result += "\\\""; break;
+                case '\\': result += "\\\\"; break;
+                case '\n': result += "\\n"; break;
+                case '\r': result += "\\r"; break;
+                case '\t': result += "\\t"; break;
+                default: result += c; break;
+            }
+        }
+        return result;
+    }
+
+    JsEngineConfig config_;
+    JSRuntime* rt_ = nullptr;
+    JSContext* ctx_ = nullptr;
+
+    JsContext context_;
+    DbQueryCallback query_callback_;
+    DbGetCallback get_callback_;
+    DbCountCallback count_callback_;
+
+    int db_query_count_ = 0;
+    std::atomic<bool> interrupt_flag_{false};
+    std::atomic<bool> timeout_thread_active_{false};
+};
+
+JsEngine::JsEngine(const JsEngineConfig& config)
+    : impl_(std::make_unique<Impl>(config)) {}
+
+JsEngine::~JsEngine() = default;
+
+JsEngine::JsEngine(JsEngine&&) noexcept = default;
+JsEngine& JsEngine::operator=(JsEngine&&) noexcept = default;
+
+void JsEngine::SetContext(const JsContext& ctx) {
+    impl_->SetContext(ctx);
+}
+
+void JsEngine::SetDbCallbacks(DbQueryCallback query_cb,
+                               DbGetCallback get_cb,
+                               DbCountCallback count_cb) {
+    impl_->SetDbCallbacks(std::move(query_cb), std::move(get_cb), std::move(count_cb));
+}
+
+auto JsEngine::Execute(const std::string& script) -> JsResult {
+    return impl_->Execute(script);
+}
+
+auto JsEngine::Evaluate(const std::string& expression) -> JsResult {
+    return impl_->Evaluate(expression);
+}
+
+auto JsEngine::GetDbQueryCount() const -> int {
+    return impl_->GetDbQueryCount();
+}
+
+void JsEngine::Reset() {
+    impl_->Reset();
+}
+
+}  // namespace smartbotic::runner

+ 109 - 0
runner/src/main.cpp

@@ -0,0 +1,109 @@
+#include <csignal>
+#include <cstdlib>
+#include <iostream>
+
+#include <spdlog/spdlog.h>
+#include <spdlog/sinks/stdout_color_sinks.h>
+
+#include "smartbotic/runner/config.hpp"
+#include "smartbotic/runner/grpc/server.hpp"
+
+namespace {
+
+std::unique_ptr<smartbotic::runner::grpc::Server> g_server;
+
+void SignalHandler(int signal) {
+    spdlog::info("Received signal {}, shutting down...", signal);
+    if (g_server) {
+        g_server->Shutdown();
+    }
+}
+
+void SetupLogging(const std::string& level) {
+    // Set up console logger with colors
+    auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
+    auto logger = std::make_shared<spdlog::logger>("runner", console_sink);
+
+    // Parse log level
+    spdlog::level::level_enum log_level = spdlog::level::info;
+    if (level == "trace") {
+        log_level = spdlog::level::trace;
+    } else if (level == "debug") {
+        log_level = spdlog::level::debug;
+    } else if (level == "info") {
+        log_level = spdlog::level::info;
+    } else if (level == "warn" || level == "warning") {
+        log_level = spdlog::level::warn;
+    } else if (level == "error") {
+        log_level = spdlog::level::err;
+    } else if (level == "critical") {
+        log_level = spdlog::level::critical;
+    }
+
+    logger->set_level(log_level);
+    spdlog::set_default_logger(logger);
+    spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] [runner] %v");
+}
+
+void PrintUsage(const char* program_name) {
+    std::cout << "Usage: " << program_name << " [options]\n\n"
+              << "Options:\n"
+              << "  -c, --config FILE   Configuration file path\n"
+              << "  -h, --help          Show this help message\n"
+              << "\n"
+              << "Environment variables:\n"
+              << "  SMARTBOTIC_CRM_RUNNER_ADDRESS        Listen address (default: 0.0.0.0)\n"
+              << "  SMARTBOTIC_CRM_RUNNER_PORT           Listen port (default: 50053)\n"
+              << "  SMARTBOTIC_CRM_DATABASE_ADDRESS      Database service address\n"
+              << "  SMARTBOTIC_CRM_RUNNER_MAX_MEMORY_MB  Max JS memory in MB (default: 16)\n"
+              << "  SMARTBOTIC_CRM_RUNNER_MAX_EXECUTION_TIME_MS  Max execution time (default: 5000)\n"
+              << "  SMARTBOTIC_CRM_LOG_LEVEL             Log level (default: info)\n"
+              << std::endl;
+}
+
+}  // namespace
+
+int main(int argc, char* argv[]) {
+    std::string config_file;
+
+    // Parse command line arguments
+    for (int i = 1; i < argc; ++i) {
+        std::string arg = argv[i];
+
+        if (arg == "-h" || arg == "--help") {
+            PrintUsage(argv[0]);
+            return 0;
+        } else if ((arg == "-c" || arg == "--config") && i + 1 < argc) {
+            config_file = argv[++i];
+        } else {
+            std::cerr << "Unknown option: " << arg << "\n";
+            PrintUsage(argv[0]);
+            return 1;
+        }
+    }
+
+    // Load configuration
+    auto config = smartbotic::runner::LoadConfig(config_file);
+
+    // Setup logging
+    SetupLogging(config.log_level);
+
+    spdlog::info("SmartBotic CRM Runner Service starting...");
+    spdlog::info("Configuration:");
+    spdlog::info("  Address: {}:{}", config.address, config.port);
+    spdlog::info("  Database: {}", config.database_address);
+    spdlog::info("  Max Memory: {} MB", config.max_memory_mb);
+    spdlog::info("  Max Execution Time: {} ms", config.max_execution_time_ms);
+    spdlog::info("  Max DB Queries: {}", config.max_db_queries);
+
+    // Set up signal handlers
+    std::signal(SIGINT, SignalHandler);
+    std::signal(SIGTERM, SignalHandler);
+
+    // Create and run server
+    g_server = std::make_unique<smartbotic::runner::grpc::Server>(config);
+    g_server->Run();
+
+    spdlog::info("Runner service stopped.");
+    return 0;
+}

+ 286 - 0
runner/src/template_parser.cpp

@@ -0,0 +1,286 @@
+#include "smartbotic/runner/template_parser.hpp"
+
+#include <regex>
+#include <sstream>
+#include <stack>
+
+namespace smartbotic::runner {
+
+namespace {
+
+/// Token types for template parsing
+enum class TokenType {
+    kText,           // Plain text
+    kVariable,       // {{variable}}
+    kExpression,     // {{! expression }}
+    kIfStart,        // {{#if condition}}
+    kIfEnd,          // {{/if}}
+    kEachStart,      // {{#each items}}
+    kEachEnd,        // {{/each}}
+};
+
+struct Token {
+    TokenType type;
+    std::string content;
+    int line = 0;
+    int column = 0;
+};
+
+/// Tokenize a template string
+auto Tokenize(const std::string& template_str) -> std::vector<Token> {
+    std::vector<Token> tokens;
+
+    size_t pos = 0;
+    int line = 1;
+    int column = 1;
+    int last_line = 1;
+    int last_column = 1;
+
+    auto update_position = [&](size_t start, size_t end) {
+        for (size_t i = start; i < end && i < template_str.length(); ++i) {
+            if (template_str[i] == '\n') {
+                line++;
+                column = 1;
+            } else {
+                column++;
+            }
+        }
+    };
+
+    while (pos < template_str.length()) {
+        // Find next {{ marker
+        size_t marker_start = template_str.find("{{", pos);
+
+        if (marker_start == std::string::npos) {
+            // No more markers, rest is plain text
+            if (pos < template_str.length()) {
+                Token token;
+                token.type = TokenType::kText;
+                token.content = template_str.substr(pos);
+                token.line = last_line;
+                token.column = last_column;
+                tokens.push_back(token);
+            }
+            break;
+        }
+
+        // Add text before the marker
+        if (marker_start > pos) {
+            Token token;
+            token.type = TokenType::kText;
+            token.content = template_str.substr(pos, marker_start - pos);
+            token.line = last_line;
+            token.column = last_column;
+            tokens.push_back(token);
+        }
+
+        update_position(pos, marker_start);
+        last_line = line;
+        last_column = column;
+
+        // Find closing }}
+        size_t marker_end = template_str.find("}}", marker_start + 2);
+        if (marker_end == std::string::npos) {
+            // Unclosed marker - treat rest as text
+            Token token;
+            token.type = TokenType::kText;
+            token.content = template_str.substr(marker_start);
+            token.line = last_line;
+            token.column = last_column;
+            tokens.push_back(token);
+            break;
+        }
+
+        // Extract content between {{ and }}
+        std::string content = template_str.substr(marker_start + 2, marker_end - marker_start - 2);
+
+        // Trim whitespace
+        size_t first = content.find_first_not_of(" \t\n\r");
+        size_t last = content.find_last_not_of(" \t\n\r");
+        if (first != std::string::npos) {
+            content = content.substr(first, last - first + 1);
+        } else {
+            content.clear();
+        }
+
+        Token token;
+        token.line = last_line;
+        token.column = last_column;
+
+        if (content.empty()) {
+            // Empty marker - skip
+        } else if (content[0] == '!') {
+            // JavaScript expression
+            token.type = TokenType::kExpression;
+            token.content = content.substr(1);
+            // Trim leading whitespace from expression
+            size_t expr_first = token.content.find_first_not_of(" \t\n\r");
+            if (expr_first != std::string::npos) {
+                token.content = token.content.substr(expr_first);
+            }
+            tokens.push_back(token);
+        } else if (content.starts_with("#if ")) {
+            token.type = TokenType::kIfStart;
+            token.content = content.substr(4);
+            tokens.push_back(token);
+        } else if (content == "/if") {
+            token.type = TokenType::kIfEnd;
+            tokens.push_back(token);
+        } else if (content.starts_with("#each ")) {
+            token.type = TokenType::kEachStart;
+            token.content = content.substr(6);
+            tokens.push_back(token);
+        } else if (content == "/each") {
+            token.type = TokenType::kEachEnd;
+            tokens.push_back(token);
+        } else {
+            // Variable reference
+            token.type = TokenType::kVariable;
+            token.content = content;
+            tokens.push_back(token);
+        }
+
+        update_position(marker_start, marker_end + 2);
+        pos = marker_end + 2;
+        last_line = line;
+        last_column = column;
+    }
+
+    return tokens;
+}
+
+/// Escape a string for JavaScript
+auto EscapeJs(const std::string& str) -> std::string {
+    std::string result;
+    result.reserve(str.size());
+    for (char c : str) {
+        switch (c) {
+            case '\\': result += "\\\\"; break;
+            case '\'': result += "\\'"; break;
+            case '"': result += "\\\""; break;
+            case '\n': result += "\\n"; break;
+            case '\r': result += "\\r"; break;
+            case '\t': result += "\\t"; break;
+            case '\0': result += "\\0"; break;
+            default: result += c; break;
+        }
+    }
+    return result;
+}
+
+/// Generate JavaScript code to access a variable path (e.g., "user.name")
+auto GenerateVarAccess(const std::string& path) -> std::string {
+    // Handle special variable 'this' in each loops
+    if (path == "this") {
+        return "__item";
+    }
+    if (path.starts_with("this.")) {
+        return "__item." + path.substr(5);
+    }
+
+    // Split path and generate safe access
+    std::vector<std::string> parts;
+    std::istringstream iss(path);
+    std::string part;
+    while (std::getline(iss, part, '.')) {
+        parts.push_back(part);
+    }
+
+    if (parts.empty()) {
+        return "undefined";
+    }
+
+    // First part should be a known context variable (ctx, user, workspace, etc.)
+    // or a custom variable
+    std::string result = parts[0];
+    for (size_t i = 1; i < parts.size(); ++i) {
+        result += "?.['" + EscapeJs(parts[i]) + "']";
+    }
+
+    return result;
+}
+
+}  // namespace
+
+auto TemplateParser::Parse(const std::string& template_str) -> ParseResult {
+    ParseResult result;
+    auto tokens = Tokenize(template_str);
+
+    std::ostringstream code;
+    code << "(function() {\n";
+    code << "  let __output = '';\n";
+
+    std::stack<std::string> block_stack;  // Track #if/#each blocks
+
+    for (const auto& token : tokens) {
+        switch (token.type) {
+            case TokenType::kText:
+                code << "  __output += '" << EscapeJs(token.content) << "';\n";
+                break;
+
+            case TokenType::kVariable: {
+                std::string var_access = GenerateVarAccess(token.content);
+                code << "  __output += (" << var_access << " ?? '');\n";
+                break;
+            }
+
+            case TokenType::kExpression:
+                code << "  __output += (function() {\n";
+                code << "    " << token.content << "\n";
+                code << "  })() ?? '';\n";
+                break;
+
+            case TokenType::kIfStart:
+                code << "  if (" << token.content << ") {\n";
+                block_stack.push("if");
+                break;
+
+            case TokenType::kIfEnd:
+                if (block_stack.empty() || block_stack.top() != "if") {
+                    result.errors.push_back({token.line, token.column, "Unexpected {{/if}}"});
+                } else {
+                    code << "  }\n";
+                    block_stack.pop();
+                }
+                break;
+
+            case TokenType::kEachStart: {
+                std::string var_access = GenerateVarAccess(token.content);
+                code << "  for (const __item of (" << var_access << " || [])) {\n";
+                block_stack.push("each");
+                break;
+            }
+
+            case TokenType::kEachEnd:
+                if (block_stack.empty() || block_stack.top() != "each") {
+                    result.errors.push_back({token.line, token.column, "Unexpected {{/each}}"});
+                } else {
+                    code << "  }\n";
+                    block_stack.pop();
+                }
+                break;
+        }
+    }
+
+    // Check for unclosed blocks
+    while (!block_stack.empty()) {
+        std::string block = block_stack.top();
+        block_stack.pop();
+        result.errors.push_back({0, 0, "Unclosed {{#" + block + "}} block"});
+    }
+
+    code << "  return __output;\n";
+    code << "})()";
+
+    result.processed_code = code.str();
+    result.valid = result.errors.empty();
+
+    return result;
+}
+
+auto TemplateParser::Validate(const std::string& template_str) -> std::vector<TemplateParseError> {
+    auto result = Parse(template_str);
+    return result.errors;
+}
+
+}  // namespace smartbotic::runner

+ 1 - 1
systemd/smartbotic-crm-database.service

@@ -6,7 +6,7 @@ After=network.target
 [Service]
 [Service]
 Type=simple
 Type=simple
 WorkingDirectory=/data/smartbotic-crm
 WorkingDirectory=/data/smartbotic-crm
-ExecStart=/data/smartbotic-crm/build/database/smartbotic-crm-database --data-dir /data/smartbotic-crm/data --port 50151
+ExecStart=/data/smartbotic-crm/build/database/smartbotic-crm-database --port 50151
 Restart=on-failure
 Restart=on-failure
 RestartSec=5
 RestartSec=5
 StandardOutput=journal
 StandardOutput=journal

+ 3 - 1
systemd/smartbotic-crm-llm.service

@@ -1,8 +1,9 @@
 [Unit]
 [Unit]
 Description=SmartBotic CRM LLM Service
 Description=SmartBotic CRM LLM Service
 Documentation=https://github.com/smartbotic/smartbotic-crm
 Documentation=https://github.com/smartbotic/smartbotic-crm
-After=network.target smartbotic-crm-database.service
+After=network.target smartbotic-crm-database.service smartbotic-crm-runner.service
 Requires=smartbotic-crm-database.service
 Requires=smartbotic-crm-database.service
+Wants=smartbotic-crm-runner.service
 
 
 [Service]
 [Service]
 Type=simple
 Type=simple
@@ -18,6 +19,7 @@ TimeoutStopSec=10
 Environment="SMARTBOTIC_CRM_LLM_ADDRESS=0.0.0.0"
 Environment="SMARTBOTIC_CRM_LLM_ADDRESS=0.0.0.0"
 Environment="SMARTBOTIC_CRM_LLM_PORT=50152"
 Environment="SMARTBOTIC_CRM_LLM_PORT=50152"
 Environment="SMARTBOTIC_CRM_LLM_DATABASE_ADDRESS=localhost:50151"
 Environment="SMARTBOTIC_CRM_LLM_DATABASE_ADDRESS=localhost:50151"
+Environment="SMARTBOTIC_CRM_LLM_RUNNER_ADDRESS=localhost:50153"
 Environment="SMARTBOTIC_CRM_LLM_LOG_LEVEL=info"
 Environment="SMARTBOTIC_CRM_LLM_LOG_LEVEL=info"
 
 
 [Install]
 [Install]

+ 29 - 0
systemd/smartbotic-crm-runner.service

@@ -0,0 +1,29 @@
+[Unit]
+Description=SmartBotic CRM Runner Service (JavaScript Template Engine)
+Documentation=https://github.com/smartbotic/smartbotic-crm
+After=network.target smartbotic-crm-database.service
+Requires=smartbotic-crm-database.service
+
+[Service]
+Type=simple
+WorkingDirectory=/data/smartbotic-crm
+ExecStart=/data/smartbotic-crm/build/runner/smartbotic-crm-runner
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+TimeoutStopSec=10
+
+# Environment
+Environment="SMARTBOTIC_CRM_RUNNER_ADDRESS=0.0.0.0"
+Environment="SMARTBOTIC_CRM_RUNNER_PORT=50153"
+Environment="SMARTBOTIC_CRM_DATABASE_ADDRESS=localhost:50151"
+Environment="SMARTBOTIC_CRM_LOG_LEVEL=info"
+
+# JavaScript engine limits
+Environment="SMARTBOTIC_CRM_RUNNER_MAX_MEMORY_MB=16"
+Environment="SMARTBOTIC_CRM_RUNNER_MAX_EXECUTION_TIME_MS=5000"
+Environment="SMARTBOTIC_CRM_RUNNER_MAX_DB_QUERIES=100"
+
+[Install]
+WantedBy=default.target

+ 2 - 0
webserver/include/smartbotic/webserver/http_server.hpp

@@ -304,6 +304,8 @@ private:
     void HandleLlmSendMessage(const httplib::Request& req, httplib::Response& res);
     void HandleLlmSendMessage(const httplib::Request& req, httplib::Response& res);
     void HandleLlmStreamMessage(const httplib::Request& req, httplib::Response& res);
     void HandleLlmStreamMessage(const httplib::Request& req, httplib::Response& res);
     void HandleLlmClearMessages(const httplib::Request& req, httplib::Response& res);
     void HandleLlmClearMessages(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmCompressSession(const httplib::Request& req, httplib::Response& res);
+    void HandleLlmGenerateTitle(const httplib::Request& req, httplib::Response& res);
 
 
     // LLM route handlers - Models & Providers
     // LLM route handlers - Models & Providers
     void HandleLlmListProviders(const httplib::Request& req, httplib::Response& res);
     void HandleLlmListProviders(const httplib::Request& req, httplib::Response& res);

+ 8 - 0
webserver/include/smartbotic/webserver/llm_client.hpp

@@ -100,6 +100,14 @@ public:
     [[nodiscard]] auto ClearSessionMessages(const ::smartbotic::llm::ClearSessionMessagesRequest& request)
     [[nodiscard]] auto ClearSessionMessages(const ::smartbotic::llm::ClearSessionMessagesRequest& request)
         -> std::pair<grpc::Status, ::smartbotic::llm::Session>;
         -> std::pair<grpc::Status, ::smartbotic::llm::Session>;
 
 
+    /// Compress a session's messages
+    [[nodiscard]] auto CompressSession(const ::smartbotic::llm::CompressSessionRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::Session>;
+
+    /// Generate a title for a session
+    [[nodiscard]] auto GenerateTitle(const ::smartbotic::llm::GenerateTitleRequest& request)
+        -> std::pair<grpc::Status, ::smartbotic::llm::GenerateTitleResponse>;
+
     // =========================================================================
     // =========================================================================
     // Model Service Operations
     // Model Service Operations
     // =========================================================================
     // =========================================================================

+ 198 - 36
webserver/src/http_server.cpp

@@ -23,6 +23,17 @@ WebSocketServer* WebSocketServer::instance_ = nullptr;
 
 
 namespace {
 namespace {
 
 
+// Convert LLM MessageRole enum to lowercase string for JSON API
+auto MessageRoleToString(::smartbotic::llm::MessageRole role) -> std::string {
+    switch (role) {
+        case ::smartbotic::llm::MESSAGE_ROLE_SYSTEM: return "system";
+        case ::smartbotic::llm::MESSAGE_ROLE_USER: return "user";
+        case ::smartbotic::llm::MESSAGE_ROLE_ASSISTANT: return "assistant";
+        case ::smartbotic::llm::MESSAGE_ROLE_TOOL: return "tool";
+        default: return "unknown";
+    }
+}
+
 auto GenerateSessionId() -> std::string {
 auto GenerateSessionId() -> std::string {
     static std::random_device rd;
     static std::random_device rd;
     static std::mt19937 gen(rd());
     static std::mt19937 gen(rd());
@@ -5610,6 +5621,16 @@ void HttpServer::SetupLlmRoutes() {
             HandleLlmClearMessages(req, res);
             HandleLlmClearMessages(req, res);
         });
         });
 
 
+    httpServer_->Post(R"(/api/llm/sessions/([^/]+)/compress)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmCompressSession(req, res);
+        });
+
+    httpServer_->Post(R"(/api/llm/sessions/([^/]+)/generate-title)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleLlmGenerateTitle(req, res);
+        });
+
     // Models and providers (read-only)
     // Models and providers (read-only)
     httpServer_->Get("/api/llm/providers",
     httpServer_->Get("/api/llm/providers",
         [this](const httplib::Request& req, httplib::Response& res) {
         [this](const httplib::Request& req, httplib::Response& res) {
@@ -5840,11 +5861,13 @@ void HttpServer::HandleLlmGetSession(const httplib::Request& req, httplib::Respo
     try {
     try {
         ::smartbotic::llm::GetSessionRequest request;
         ::smartbotic::llm::GetSessionRequest request;
         request.set_session_id(session_id);
         request.set_session_id(session_id);
+        request.set_user_id(auth_user->user_id);
         request.set_include_messages(true);
         request.set_include_messages(true);
 
 
         auto [status, session] = llmClient_->GetSession(request);
         auto [status, session] = llmClient_->GetSession(request);
 
 
         if (!status.ok()) {
         if (!status.ok()) {
+            // NOT_FOUND includes "Session not found" or "Access denied"
             if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
             if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
                 res.status = 404;
                 res.status = 404;
                 res.set_content(R"({"error":"Session not found"})", "application/json");
                 res.set_content(R"({"error":"Session not found"})", "application/json");
@@ -5856,13 +5879,6 @@ void HttpServer::HandleLlmGetSession(const httplib::Request& req, httplib::Respo
             return;
             return;
         }
         }
 
 
-        // Verify ownership
-        if (session.user_id() != auth_user->user_id) {
-            res.status = 403;
-            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
-            return;
-        }
-
         nlohmann::json response;
         nlohmann::json response;
         response["id"] = session.id();
         response["id"] = session.id();
         response["user_id"] = session.user_id();
         response["user_id"] = session.user_id();
@@ -5877,7 +5893,7 @@ void HttpServer::HandleLlmGetSession(const httplib::Request& req, httplib::Respo
         for (const auto& msg : session.messages()) {
         for (const auto& msg : session.messages()) {
             nlohmann::json m;
             nlohmann::json m;
             m["id"] = msg.id();
             m["id"] = msg.id();
-            m["role"] = ::smartbotic::llm::MessageRole_Name(msg.role());
+            m["role"] = MessageRoleToString(msg.role());
 
 
             // Extract text content from message parts
             // Extract text content from message parts
             std::string msg_text;
             std::string msg_text;
@@ -5935,9 +5951,10 @@ void HttpServer::HandleLlmDeleteSession(const httplib::Request& req, httplib::Re
     std::string session_id = req.matches[1].str();
     std::string session_id = req.matches[1].str();
 
 
     try {
     try {
-        // First get session to verify ownership
+        // First verify session ownership (user_id is verified by session store)
         ::smartbotic::llm::GetSessionRequest get_request;
         ::smartbotic::llm::GetSessionRequest get_request;
         get_request.set_session_id(session_id);
         get_request.set_session_id(session_id);
+        get_request.set_user_id(auth_user->user_id);
 
 
         auto [get_status, session] = llmClient_->GetSession(get_request);
         auto [get_status, session] = llmClient_->GetSession(get_request);
         if (!get_status.ok()) {
         if (!get_status.ok()) {
@@ -5946,14 +5963,9 @@ void HttpServer::HandleLlmDeleteSession(const httplib::Request& req, httplib::Re
             return;
             return;
         }
         }
 
 
-        if (session.user_id() != auth_user->user_id) {
-            res.status = 403;
-            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
-            return;
-        }
-
         ::smartbotic::llm::DeleteSessionRequest request;
         ::smartbotic::llm::DeleteSessionRequest request;
         request.set_session_id(session_id);
         request.set_session_id(session_id);
+        request.set_user_id(auth_user->user_id);
 
 
         auto [status, delete_response] = llmClient_->DeleteSession(request);
         auto [status, delete_response] = llmClient_->DeleteSession(request);
 
 
@@ -6011,23 +6023,20 @@ void HttpServer::HandleLlmSendMessage(const httplib::Request& req, httplib::Resp
     }
     }
 
 
     try {
     try {
-        // Verify session ownership first
+        // Verify session ownership (user_id is verified by session store)
         ::smartbotic::llm::GetSessionRequest get_request;
         ::smartbotic::llm::GetSessionRequest get_request;
         get_request.set_session_id(session_id);
         get_request.set_session_id(session_id);
+        get_request.set_user_id(auth_user->user_id);
+        get_request.set_include_messages(true);
 
 
         auto [get_status, session] = llmClient_->GetSession(get_request);
         auto [get_status, session] = llmClient_->GetSession(get_request);
         if (!get_status.ok()) {
         if (!get_status.ok()) {
+            // NOT_FOUND could be "Session not found" or "Access denied"
             res.status = 404;
             res.status = 404;
             res.set_content(R"({"error":"Session not found"})", "application/json");
             res.set_content(R"({"error":"Session not found"})", "application/json");
             return;
             return;
         }
         }
 
 
-        if (session.user_id() != auth_user->user_id) {
-            res.status = 403;
-            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
-            return;
-        }
-
         ::smartbotic::llm::ChatRequest request;
         ::smartbotic::llm::ChatRequest request;
         request.set_session_id(session_id);
         request.set_session_id(session_id);
         request.set_user_id(auth_user->user_id);
         request.set_user_id(auth_user->user_id);
@@ -6132,23 +6141,20 @@ void HttpServer::HandleLlmStreamMessage(const httplib::Request& req, httplib::Re
     }
     }
 
 
     try {
     try {
-        // Verify session ownership first
+        // Verify session ownership (user_id is verified by session store)
         ::smartbotic::llm::GetSessionRequest get_request;
         ::smartbotic::llm::GetSessionRequest get_request;
         get_request.set_session_id(session_id);
         get_request.set_session_id(session_id);
+        get_request.set_user_id(auth_user->user_id);
+        get_request.set_include_messages(true);
 
 
         auto [get_status, session] = llmClient_->GetSession(get_request);
         auto [get_status, session] = llmClient_->GetSession(get_request);
         if (!get_status.ok()) {
         if (!get_status.ok()) {
+            // NOT_FOUND could be "Session not found" or "Access denied"
             res.status = 404;
             res.status = 404;
             res.set_content(R"({"error":"Session not found"})", "application/json");
             res.set_content(R"({"error":"Session not found"})", "application/json");
             return;
             return;
         }
         }
 
 
-        if (session.user_id() != auth_user->user_id) {
-            res.status = 403;
-            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
-            return;
-        }
-
         ::smartbotic::llm::ChatRequest request;
         ::smartbotic::llm::ChatRequest request;
         request.set_session_id(session_id);
         request.set_session_id(session_id);
         request.set_user_id(auth_user->user_id);
         request.set_user_id(auth_user->user_id);
@@ -6239,25 +6245,23 @@ void HttpServer::HandleLlmClearMessages(const httplib::Request& req, httplib::Re
     std::string session_id = req.matches[1].str();
     std::string session_id = req.matches[1].str();
 
 
     try {
     try {
-        // Verify session ownership first
+        // Verify session ownership (user_id is verified by session store)
         ::smartbotic::llm::GetSessionRequest get_request;
         ::smartbotic::llm::GetSessionRequest get_request;
         get_request.set_session_id(session_id);
         get_request.set_session_id(session_id);
+        get_request.set_user_id(auth_user->user_id);
+        get_request.set_include_messages(true);
 
 
         auto [get_status, session] = llmClient_->GetSession(get_request);
         auto [get_status, session] = llmClient_->GetSession(get_request);
         if (!get_status.ok()) {
         if (!get_status.ok()) {
+            // NOT_FOUND could be "Session not found" or "Access denied"
             res.status = 404;
             res.status = 404;
             res.set_content(R"({"error":"Session not found"})", "application/json");
             res.set_content(R"({"error":"Session not found"})", "application/json");
             return;
             return;
         }
         }
 
 
-        if (session.user_id() != auth_user->user_id) {
-            res.status = 403;
-            res.set_content(R"({"error":"Forbidden - not your session"})", "application/json");
-            return;
-        }
-
         ::smartbotic::llm::ClearSessionMessagesRequest request;
         ::smartbotic::llm::ClearSessionMessagesRequest request;
         request.set_session_id(session_id);
         request.set_session_id(session_id);
+        request.set_user_id(auth_user->user_id);
 
 
         auto [status, updated_session] = llmClient_->ClearSessionMessages(request);
         auto [status, updated_session] = llmClient_->ClearSessionMessages(request);
 
 
@@ -6277,6 +6281,164 @@ void HttpServer::HandleLlmClearMessages(const httplib::Request& req, httplib::Re
     }
     }
 }
 }
 
 
+void HttpServer::HandleLlmCompressSession(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    try {
+        // Verify session ownership
+        ::smartbotic::llm::GetSessionRequest get_request;
+        get_request.set_session_id(session_id);
+        get_request.set_user_id(auth_user->user_id);
+        get_request.set_include_messages(false);
+
+        auto [get_status, session] = llmClient_->GetSession(get_request);
+        if (!get_status.ok()) {
+            res.status = 404;
+            res.set_content(R"({"error":"Session not found"})", "application/json");
+            return;
+        }
+
+        ::smartbotic::llm::CompressSessionRequest request;
+        request.set_session_id(session_id);
+        request.set_user_id(auth_user->user_id);
+        request.set_workspace_id(session.workspace_id());
+
+        auto [status, updated_session] = llmClient_->CompressSession(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        // Convert session to JSON response
+        nlohmann::json response;
+        response["id"] = updated_session.id();
+        response["user_id"] = updated_session.user_id();
+        response["workspace_id"] = updated_session.workspace_id();
+        response["title"] = updated_session.title();
+        response["model_id"] = updated_session.model_id();
+        response["message_count"] = updated_session.messages_size();
+
+        // Include context info if available
+        if (updated_session.has_context_info()) {
+            response["context_info"] = {
+                {"used_tokens", updated_session.context_info().used_tokens()},
+                {"max_tokens", updated_session.context_info().max_tokens()},
+                {"usage_percent", updated_session.context_info().usage_percent()}
+            };
+        }
+
+        // Include messages
+        nlohmann::json messages_array = nlohmann::json::array();
+        for (const auto& msg : updated_session.messages()) {
+            nlohmann::json msg_json;
+            msg_json["id"] = msg.id();
+            msg_json["role"] = MessageRoleToString(msg.role());
+
+            // Extract text content
+            std::string content_text;
+            for (const auto& part : msg.content()) {
+                if (part.has_text()) {
+                    content_text += part.text();
+                }
+            }
+            msg_json["content"] = content_text;
+            msg_json["created_at"] = msg.created_at().seconds();
+            messages_array.push_back(msg_json);
+        }
+        response["messages"] = messages_array;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM compress session error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleLlmGenerateTitle(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    if (!authorizationService_->HasPermission(*auth_user, permissions::kLlmChat)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - no LLM chat access"})", "application/json");
+        return;
+    }
+
+    if (!llmClient_ || !llmClient_->IsConnected()) {
+        res.status = 503;
+        res.set_content(R"({"error":"LLM service not available"})", "application/json");
+        return;
+    }
+
+    std::string session_id = req.matches[1].str();
+
+    try {
+        // Verify session ownership
+        ::smartbotic::llm::GetSessionRequest get_request;
+        get_request.set_session_id(session_id);
+        get_request.set_user_id(auth_user->user_id);
+        get_request.set_include_messages(false);
+
+        auto [get_status, session] = llmClient_->GetSession(get_request);
+        if (!get_status.ok()) {
+            res.status = 404;
+            res.set_content(R"({"error":"Session not found"})", "application/json");
+            return;
+        }
+
+        ::smartbotic::llm::GenerateTitleRequest request;
+        request.set_session_id(session_id);
+        request.set_user_id(auth_user->user_id);
+        request.set_workspace_id(session.workspace_id());
+
+        auto [status, title_response] = llmClient_->GenerateTitle(request);
+
+        if (!status.ok()) {
+            res.status = 500;
+            nlohmann::json error = {{"error", status.error_message()}};
+            res.set_content(error.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["title"] = title_response.title();
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("LLM generate title error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
 void HttpServer::HandleLlmListProviders(const httplib::Request& req, httplib::Response& res) {
 void HttpServer::HandleLlmListProviders(const httplib::Request& req, httplib::Response& res) {
     auto auth_user = AuthenticateRequest(req);
     auto auth_user = AuthenticateRequest(req);
     if (!auth_user) {
     if (!auth_user) {

+ 22 - 0
webserver/src/llm_client.cpp

@@ -181,6 +181,28 @@ auto LlmClient::ClearSessionMessages(const ::smartbotic::llm::ClearSessionMessag
     return {status, response};
     return {status, response};
 }
 }
 
 
+auto LlmClient::CompressSession(const ::smartbotic::llm::CompressSessionRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::Session> {
+    ::smartbotic::llm::Session response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.stream_timeout);  // Use longer timeout
+
+    auto status = session_stub_->CompressSession(&context, request, &response);
+    return {status, response};
+}
+
+auto LlmClient::GenerateTitle(const ::smartbotic::llm::GenerateTitleRequest& request)
+    -> std::pair<grpc::Status, ::smartbotic::llm::GenerateTitleResponse> {
+    ::smartbotic::llm::GenerateTitleResponse response;
+
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+    auto status = session_stub_->GenerateTitle(&context, request, &response);
+    return {status, response};
+}
+
 // =========================================================================
 // =========================================================================
 // Model Service Operations
 // Model Service Operations
 // =========================================================================
 // =========================================================================

+ 29 - 0
webui/package-lock.json

@@ -18,6 +18,7 @@
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@eslint/js": "^9.18.0",
         "@eslint/js": "^9.18.0",
+        "@tailwindcss/typography": "^0.5.19",
         "@types/react": "^19.0.0",
         "@types/react": "^19.0.0",
         "@types/react-dom": "^19.0.0",
         "@types/react-dom": "^19.0.0",
         "@vitejs/plugin-react": "^4.3.4",
         "@vitejs/plugin-react": "^4.3.4",
@@ -1616,6 +1617,33 @@
         "tslib": "^2.8.0"
         "tslib": "^2.8.0"
       }
       }
     },
     },
+    "node_modules/@tailwindcss/typography": {
+      "version": "0.5.19",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
+      "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "postcss-selector-parser": "6.0.10"
+      },
+      "peerDependencies": {
+        "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
+      }
+    },
+    "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
+      "version": "6.0.10",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+      "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/@tanstack/react-virtual": {
     "node_modules/@tanstack/react-virtual": {
       "version": "3.13.18",
       "version": "3.13.18",
       "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
       "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
@@ -6107,6 +6135,7 @@
       "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
       "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@alloc/quick-lru": "^5.2.0",
         "@alloc/quick-lru": "^5.2.0",
         "arg": "^5.0.2",
         "arg": "^5.0.2",

+ 1 - 0
webui/package.json

@@ -24,6 +24,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@eslint/js": "^9.18.0",
     "@eslint/js": "^9.18.0",
+    "@tailwindcss/typography": "^0.5.19",
     "@types/react": "^19.0.0",
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",
     "@types/react-dom": "^19.0.0",
     "@vitejs/plugin-react": "^4.3.4",
     "@vitejs/plugin-react": "^4.3.4",

+ 34 - 1
webui/src/App.tsx

@@ -14,11 +14,43 @@ import Groups from '@/pages/Groups'
 import ApiKeys from '@/pages/ApiKeys'
 import ApiKeys from '@/pages/ApiKeys'
 import Workspaces from '@/pages/Workspaces'
 import Workspaces from '@/pages/Workspaces'
 import LlmProviders from '@/pages/LlmProviders'
 import LlmProviders from '@/pages/LlmProviders'
+import ChatPage from '@/pages/ChatPage'
 import ProtectedRoute from '@/components/ProtectedRoute'
 import ProtectedRoute from '@/components/ProtectedRoute'
 import { DashboardLayout } from '@/components/layout'
 import { DashboardLayout } from '@/components/layout'
 
 
 function App() {
 function App() {
-  const { isAuthenticated } = useAuth()
+  const { isAuthenticated, isLoading } = useAuth()
+
+  // Show loading spinner while checking auth state
+  if (isLoading) {
+    return (
+      <div className="flex min-h-screen items-center justify-center bg-gray-50">
+        <div className="flex flex-col items-center gap-4">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+          <p className="text-gray-600">Loading...</p>
+        </div>
+      </div>
+    )
+  }
 
 
   return (
   return (
     <Routes>
     <Routes>
@@ -74,6 +106,7 @@ function App() {
         <Route path="/api-keys" element={<ApiKeys />} />
         <Route path="/api-keys" element={<ApiKeys />} />
         <Route path="/workspaces" element={<Workspaces />} />
         <Route path="/workspaces" element={<Workspaces />} />
         <Route path="/llm-providers" element={<LlmProviders />} />
         <Route path="/llm-providers" element={<LlmProviders />} />
+        <Route path="/chat" element={<ChatPage />} />
       </Route>
       </Route>
 
 
       {/* Catch-all redirect */}
       {/* Catch-all redirect */}

+ 8 - 1
webui/src/api/chat.ts

@@ -8,7 +8,6 @@ import type {
   LlmModel,
   LlmModel,
   CreateSessionRequest,
   CreateSessionRequest,
   SendMessageRequest,
   SendMessageRequest,
-  PageContext,
   CreateProviderRequest,
   CreateProviderRequest,
   UpdateProviderRequest,
   UpdateProviderRequest,
   SetWorkspaceKeyRequest,
   SetWorkspaceKeyRequest,
@@ -40,6 +39,14 @@ export async function clearSessionMessages(sessionId: string): Promise<{ message
   return apiClient.post<{ message: string }>(`/llm/sessions/${sessionId}/clear`)
   return apiClient.post<{ message: string }>(`/llm/sessions/${sessionId}/clear`)
 }
 }
 
 
+export async function compressSession(sessionId: string): Promise<ChatSession> {
+  return apiClient.post<ChatSession>(`/llm/sessions/${sessionId}/compress`)
+}
+
+export async function generateTitle(sessionId: string): Promise<{ title: string }> {
+  return apiClient.post<{ title: string }>(`/llm/sessions/${sessionId}/generate-title`)
+}
+
 // Chat messages
 // Chat messages
 
 
 export async function sendMessage(sessionId: string, request: SendMessageRequest): Promise<ChatResponse> {
 export async function sendMessage(sessionId: string, request: SendMessageRequest): Promise<ChatResponse> {

+ 1 - 1
webui/src/components/Chat/ChatIcon.tsx

@@ -27,7 +27,7 @@ export function ChatIcon() {
   return (
   return (
     <button
     <button
       onClick={toggleChat}
       onClick={toggleChat}
-      className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition-all hover:bg-blue-700 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
+      className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary-600 text-white shadow-lg transition-all hover:bg-primary-700 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
       aria-label="Open chat assistant"
       aria-label="Open chat assistant"
     >
     >
       <MessageSquareIcon className="h-6 w-6" />
       <MessageSquareIcon className="h-6 w-6" />

+ 2 - 2
webui/src/components/Chat/ChatInput.tsx

@@ -69,12 +69,12 @@ export function ChatInput() {
           placeholder="Type a message..."
           placeholder="Type a message..."
           disabled={isStreaming}
           disabled={isStreaming}
           rows={1}
           rows={1}
-          className="flex-1 resize-none rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:focus:border-blue-400"
+          className="flex-1 resize-none overflow-hidden rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:focus:border-primary-400"
         />
         />
         <button
         <button
           onClick={handleSubmit}
           onClick={handleSubmit}
           disabled={!input.trim() || isStreaming}
           disabled={!input.trim() || isStreaming}
-          className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-600 text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-300 dark:disabled:bg-gray-600"
+          className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary-600 text-white transition-colors hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-300 dark:disabled:bg-gray-600"
           aria-label="Send message"
           aria-label="Send message"
         >
         >
           {isStreaming ? (
           {isStreaming ? (

+ 51 - 3
webui/src/components/Chat/ChatMessage.tsx

@@ -1,6 +1,8 @@
 // Individual chat message bubble
 // Individual chat message bubble
 
 
 import { useMemo } from 'react'
 import { useMemo } from 'react'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
 import type { ChatMessage as ChatMessageType } from '@/types/chat'
 import type { ChatMessage as ChatMessageType } from '@/types/chat'
 
 
 function UserIcon({ className = 'h-4 w-4' }: { className?: string }) {
 function UserIcon({ className = 'h-4 w-4' }: { className?: string }) {
@@ -45,7 +47,7 @@ export function ChatMessage({ message, isStreaming, streamingContent }: ChatMess
       {/* Avatar */}
       {/* Avatar */}
       <div
       <div
         className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
         className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
-          isUser ? 'bg-blue-600' : 'bg-gray-600'
+          isUser ? 'bg-primary-600' : 'bg-gray-600'
         }`}
         }`}
       >
       >
         {isUser ? (
         {isUser ? (
@@ -60,11 +62,57 @@ export function ChatMessage({ message, isStreaming, streamingContent }: ChatMess
         <div
         <div
           className={`rounded-2xl px-4 py-2 ${
           className={`rounded-2xl px-4 py-2 ${
             isUser
             isUser
-              ? 'bg-blue-600 text-white'
+              ? 'bg-primary-600 text-white'
               : 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
               : 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
           }`}
           }`}
         >
         >
-          <p className="whitespace-pre-wrap text-sm">{content}</p>
+          {isUser ? (
+            <p className="whitespace-pre-wrap text-sm">{content}</p>
+          ) : (
+            <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2">
+              <ReactMarkdown
+                remarkPlugins={[remarkGfm]}
+                components={{
+                  code({ className, children, ...props }) {
+                    const match = /language-(\w+)/.exec(className || '')
+                    const isInline = !match && !className
+                    return isInline ? (
+                      <code
+                        className="rounded bg-gray-200 px-1 py-0.5 text-sm dark:bg-gray-600"
+                        {...props}
+                      >
+                        {children}
+                      </code>
+                    ) : (
+                      <pre className="overflow-x-auto rounded-md bg-gray-800 p-3 text-gray-100">
+                        <code className={className} {...props}>
+                          {children}
+                        </code>
+                      </pre>
+                    )
+                  },
+                  a({ href, children }) {
+                    return (
+                      <a
+                        href={href}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="text-blue-600 hover:underline dark:text-blue-400"
+                      >
+                        {children}
+                      </a>
+                    )
+                  },
+                  pre({ children }) {
+                    // pre is handled by code component
+                    return <>{children}</>
+                  },
+                }}
+              >
+                {content}
+              </ReactMarkdown>
+            </div>
+          )}
           {isStreaming && (
           {isStreaming && (
             <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-current opacity-75" />
             <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-current opacity-75" />
           )}
           )}

+ 3 - 18
webui/src/components/Chat/ChatWindow.tsx

@@ -22,7 +22,7 @@ import { ChatMessageList } from './ChatMessageList'
 import { ChatInput } from './ChatInput'
 import { ChatInput } from './ChatInput'
 
 
 export function ChatWindow() {
 export function ChatWindow() {
-  const { isOpen, isMinimized, minimizeChat, closeChat, models, selectedModelId, setSelectedModelId } = useChat()
+  const { isOpen, isMinimized, minimizeChat, closeChat } = useChat()
 
 
   if (!isOpen || isMinimized) {
   if (!isOpen || isMinimized) {
     return null
     return null
@@ -31,23 +31,8 @@ export function ChatWindow() {
   return (
   return (
     <div className="fixed bottom-6 right-6 z-50 flex h-[600px] w-[400px] flex-col overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-800 md:h-[600px] md:w-[400px]">
     <div className="fixed bottom-6 right-6 z-50 flex h-[600px] w-[400px] flex-col overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-800 md:h-[600px] md:w-[400px]">
       {/* Header */}
       {/* Header */}
-      <div className="flex items-center justify-between border-b border-gray-200 bg-blue-600 px-4 py-3 dark:border-gray-700">
-        <div className="flex items-center gap-2">
-          <h2 className="font-semibold text-white">AI Assistant</h2>
-          {models.length > 0 && (
-            <select
-              value={selectedModelId || ''}
-              onChange={(e) => setSelectedModelId(e.target.value)}
-              className="rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-400 focus:outline-none focus:ring-2 focus:ring-white"
-            >
-              {models.map((model) => (
-                <option key={model.id} value={model.id}>
-                  {model.name}
-                </option>
-              ))}
-            </select>
-          )}
-        </div>
+      <div className="flex items-center justify-between border-b border-gray-200 bg-primary-600 px-4 py-3 dark:border-gray-700">
+        <h2 className="font-semibold text-white">AI Assistant</h2>
         <div className="flex items-center gap-1">
         <div className="flex items-center gap-1">
           <button
           <button
             onClick={minimizeChat}
             onClick={minimizeChat}

+ 2 - 2
webui/src/components/Chat/SessionSidebar.tsx

@@ -116,7 +116,7 @@ export function SessionSidebar() {
           {/* New session button */}
           {/* New session button */}
           <button
           <button
             onClick={handleCreateSession}
             onClick={handleCreateSession}
-            className="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:text-blue-400 dark:hover:bg-gray-700"
+            className="flex w-full items-center gap-2 px-4 py-2 text-sm text-primary-600 hover:bg-gray-100 dark:text-primary-400 dark:hover:bg-gray-700"
           >
           >
             <MessageSquarePlusIcon className="h-4 w-4" />
             <MessageSquarePlusIcon className="h-4 w-4" />
             New conversation
             New conversation
@@ -136,7 +136,7 @@ export function SessionSidebar() {
               onClick={() => switchSession(session.id)}
               onClick={() => switchSession(session.id)}
               className={`group flex cursor-pointer items-center justify-between px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 ${
               className={`group flex cursor-pointer items-center justify-between px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 ${
                 currentSession?.id === session.id
                 currentSession?.id === session.id
-                  ? 'bg-blue-50 dark:bg-blue-900/20'
+                  ? 'bg-primary-50 dark:bg-primary-900/20'
                   : ''
                   : ''
               }`}
               }`}
             >
             >

+ 16 - 0
webui/src/components/Chat/index.tsx

@@ -3,15 +3,31 @@
 import { ChatIcon } from './ChatIcon'
 import { ChatIcon } from './ChatIcon'
 import { ChatWindow } from './ChatWindow'
 import { ChatWindow } from './ChatWindow'
 import { usePermissions } from '@/hooks/usePermissions'
 import { usePermissions } from '@/hooks/usePermissions'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+
+interface WorkspaceSettings {
+  llm_provider_id?: string
+  llm_model_id?: string
+  [key: string]: unknown
+}
 
 
 export function ChatWidget() {
 export function ChatWidget() {
   const { hasPermission } = usePermissions()
   const { hasPermission } = usePermissions()
+  const { currentWorkspace } = useWorkspace()
 
 
   // Only show if user has LLM chat permission
   // Only show if user has LLM chat permission
   if (!hasPermission('system:llm:chat')) {
   if (!hasPermission('system:llm:chat')) {
     return null
     return null
   }
   }
 
 
+  // Only show if current workspace has LLM configured
+  const settings = currentWorkspace?.settings as WorkspaceSettings | undefined
+  const hasLlmConfig = settings?.llm_provider_id && settings?.llm_model_id
+
+  if (!hasLlmConfig) {
+    return null
+  }
+
   return (
   return (
     <>
     <>
       <ChatIcon />
       <ChatIcon />

+ 349 - 0
webui/src/components/LlmSettings.tsx

@@ -0,0 +1,349 @@
+// LLM Settings component for workspace configuration
+
+import { useState, useEffect } from 'react'
+
+interface LlmSettingsProps {
+  settings: LlmWorkspaceSettings
+  onChange: (settings: LlmWorkspaceSettings) => void
+  disabled?: boolean
+}
+
+export interface LlmWorkspaceSettings {
+  llm_provider_id?: string
+  llm_model_id?: string
+  llm_temperature?: number
+  llm_top_p?: number
+  llm_top_k?: number
+  llm_frequency_penalty?: number
+  llm_presence_penalty?: number
+  llm_repetition_penalty?: number
+  llm_max_tokens?: number
+  llm_system_prompt?: string
+  llm_helper_provider_id?: string
+  llm_helper_model_id?: string
+  llm_helper_system_prompt?: string
+  llm_context_compaction_threshold?: number
+}
+
+function InfoIcon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        strokeWidth={2}
+        d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+      />
+    </svg>
+  )
+}
+
+function SliderInput({
+  label,
+  description,
+  value,
+  onChange,
+  min,
+  max,
+  step = 0.1,
+  disabled,
+}: {
+  label: string
+  description: string
+  value: number | undefined
+  onChange: (value: number | undefined) => void
+  min: number
+  max: number
+  step?: number
+  disabled?: boolean
+}) {
+  const [localValue, setLocalValue] = useState<string>(value?.toString() ?? '')
+
+  useEffect(() => {
+    setLocalValue(value?.toString() ?? '')
+  }, [value])
+
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const val = e.target.value
+    setLocalValue(val)
+    if (val === '') {
+      onChange(undefined)
+    } else {
+      const num = parseFloat(val)
+      if (!isNaN(num) && num >= min && num <= max) {
+        onChange(num)
+      }
+    }
+  }
+
+  return (
+    <div className="space-y-2">
+      <div className="flex items-center justify-between">
+        <label className="flex items-center gap-1.5 text-sm font-medium text-gray-700 dark:text-gray-300">
+          {label}
+          <span className="group relative">
+            <InfoIcon className="h-4 w-4 text-gray-400 cursor-help" />
+            <span className="absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white group-hover:block">
+              {description}
+            </span>
+          </span>
+        </label>
+        <span className="text-sm text-gray-500 dark:text-gray-400">
+          {localValue || 'Default'}
+        </span>
+      </div>
+      <div className="flex items-center gap-3">
+        <input
+          type="range"
+          min={min}
+          max={max}
+          step={step}
+          value={value ?? (min + max) / 2}
+          onChange={(e) => onChange(parseFloat(e.target.value))}
+          disabled={disabled}
+          className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 disabled:opacity-50"
+        />
+        <input
+          type="number"
+          min={min}
+          max={max}
+          step={step}
+          value={localValue}
+          onChange={handleChange}
+          disabled={disabled}
+          placeholder="Default"
+          className="w-20 rounded-lg border border-gray-300 px-2 py-1 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
+        />
+      </div>
+    </div>
+  )
+}
+
+export function LlmSettings({ settings, onChange, disabled }: LlmSettingsProps) {
+  const [showAdvanced, setShowAdvanced] = useState(false)
+
+  const updateSetting = <K extends keyof LlmWorkspaceSettings>(
+    key: K,
+    value: LlmWorkspaceSettings[K]
+  ) => {
+    onChange({ ...settings, [key]: value })
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Main Model Settings */}
+      <div className="space-y-4">
+        <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
+          Main Chat Model
+        </h3>
+
+        <div className="grid gap-4 sm:grid-cols-2">
+          <div>
+            <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
+              Provider ID
+            </label>
+            <input
+              type="text"
+              value={settings.llm_provider_id || ''}
+              onChange={(e) => updateSetting('llm_provider_id', e.target.value || undefined)}
+              disabled={disabled}
+              placeholder="e.g., openai, anthropic"
+              className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
+            />
+          </div>
+
+          <div>
+            <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
+              Model ID
+            </label>
+            <input
+              type="text"
+              value={settings.llm_model_id || ''}
+              onChange={(e) => updateSetting('llm_model_id', e.target.value || undefined)}
+              disabled={disabled}
+              placeholder="e.g., gpt-4, claude-3-sonnet"
+              className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
+            />
+          </div>
+        </div>
+
+        <div>
+          <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
+            System Prompt
+          </label>
+          <textarea
+            value={settings.llm_system_prompt || ''}
+            onChange={(e) => updateSetting('llm_system_prompt', e.target.value || undefined)}
+            disabled={disabled}
+            placeholder="You are a helpful assistant..."
+            rows={4}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
+          />
+          <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
+            Supports template variables: {'{{user.name}}'}, {'{{workspace.name}}'}, {'{{! db.query(...) }}'}
+          </p>
+        </div>
+      </div>
+
+      {/* Fine-tuning Parameters */}
+      <div className="space-y-4">
+        <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
+          Generation Parameters
+        </h3>
+
+        <SliderInput
+          label="Temperature"
+          description="Controls randomness. Lower = more deterministic, Higher = more creative"
+          value={settings.llm_temperature}
+          onChange={(v) => updateSetting('llm_temperature', v)}
+          min={0}
+          max={2}
+          step={0.1}
+          disabled={disabled}
+        />
+
+        <SliderInput
+          label="Max Tokens"
+          description="Maximum length of the response"
+          value={settings.llm_max_tokens}
+          onChange={(v) => updateSetting('llm_max_tokens', v)}
+          min={256}
+          max={8192}
+          step={256}
+          disabled={disabled}
+        />
+
+        {/* Advanced parameters - collapsible */}
+        <div>
+          <button
+            type="button"
+            onClick={() => setShowAdvanced(!showAdvanced)}
+            className="flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400"
+          >
+            <svg
+              className={`h-4 w-4 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+            >
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
+            </svg>
+            {showAdvanced ? 'Hide' : 'Show'} advanced parameters
+          </button>
+
+          {showAdvanced && (
+            <div className="mt-4 space-y-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
+              <SliderInput
+                label="Top P"
+                description="Nucleus sampling threshold. Consider tokens with top_p probability mass"
+                value={settings.llm_top_p}
+                onChange={(v) => updateSetting('llm_top_p', v)}
+                min={0}
+                max={1}
+                step={0.05}
+                disabled={disabled}
+              />
+
+              <SliderInput
+                label="Top K"
+                description="Consider only the top K most likely tokens (Anthropic/local models)"
+                value={settings.llm_top_k}
+                onChange={(v) => updateSetting('llm_top_k', v)}
+                min={1}
+                max={100}
+                step={1}
+                disabled={disabled}
+              />
+
+              <SliderInput
+                label="Frequency Penalty"
+                description="Penalize tokens based on their frequency in the text (OpenAI)"
+                value={settings.llm_frequency_penalty}
+                onChange={(v) => updateSetting('llm_frequency_penalty', v)}
+                min={-2}
+                max={2}
+                step={0.1}
+                disabled={disabled}
+              />
+
+              <SliderInput
+                label="Presence Penalty"
+                description="Penalize tokens based on whether they appear in the text (OpenAI)"
+                value={settings.llm_presence_penalty}
+                onChange={(v) => updateSetting('llm_presence_penalty', v)}
+                min={-2}
+                max={2}
+                step={0.1}
+                disabled={disabled}
+              />
+
+              <SliderInput
+                label="Repetition Penalty"
+                description="Penalize repeated tokens (local models)"
+                value={settings.llm_repetition_penalty}
+                onChange={(v) => updateSetting('llm_repetition_penalty', v)}
+                min={0}
+                max={2}
+                step={0.1}
+                disabled={disabled}
+              />
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Helper Model Settings */}
+      <div className="space-y-4">
+        <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
+          Helper Model (Summarization & Titles)
+        </h3>
+        <p className="text-xs text-gray-500 dark:text-gray-400">
+          A smaller, faster model used for compressing conversations and generating titles
+        </p>
+
+        <div className="grid gap-4 sm:grid-cols-2">
+          <div>
+            <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
+              Provider ID
+            </label>
+            <input
+              type="text"
+              value={settings.llm_helper_provider_id || ''}
+              onChange={(e) => updateSetting('llm_helper_provider_id', e.target.value || undefined)}
+              disabled={disabled}
+              placeholder="Same as main model if empty"
+              className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
+            />
+          </div>
+
+          <div>
+            <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
+              Model ID
+            </label>
+            <input
+              type="text"
+              value={settings.llm_helper_model_id || ''}
+              onChange={(e) => updateSetting('llm_helper_model_id', e.target.value || undefined)}
+              disabled={disabled}
+              placeholder="e.g., gpt-3.5-turbo, claude-3-haiku"
+              className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
+            />
+          </div>
+        </div>
+
+        <SliderInput
+          label="Context Compaction Threshold"
+          description="Auto-compress conversation when context usage exceeds this percentage"
+          value={settings.llm_context_compaction_threshold}
+          onChange={(v) => updateSetting('llm_context_compaction_threshold', v)}
+          min={50}
+          max={95}
+          step={5}
+          disabled={disabled}
+        />
+      </div>
+    </div>
+  )
+}
+
+export default LlmSettings

+ 241 - 0
webui/src/components/ModelSelector.tsx

@@ -0,0 +1,241 @@
+// Searchable model selector component
+
+import { useState, useRef, useEffect, useMemo } from 'react'
+import type { LlmModel, LlmProvider } from '@/types/chat'
+
+interface ModelSelectorProps {
+  models: LlmModel[]
+  providers: LlmProvider[]
+  selectedModelId: string
+  onChange: (modelId: string) => void
+  isLoading?: boolean
+  placeholder?: string
+}
+
+export function ModelSelector({
+  models,
+  providers,
+  selectedModelId,
+  onChange,
+  isLoading = false,
+  placeholder = 'Select a model',
+}: ModelSelectorProps) {
+  const [isOpen, setIsOpen] = useState(false)
+  const [searchQuery, setSearchQuery] = useState('')
+  const containerRef = useRef<HTMLDivElement>(null)
+  const searchInputRef = useRef<HTMLInputElement>(null)
+
+  // Close dropdown when clicking outside
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+        setIsOpen(false)
+      }
+    }
+
+    document.addEventListener('mousedown', handleClickOutside)
+    return () => document.removeEventListener('mousedown', handleClickOutside)
+  }, [])
+
+  // Focus search input when dropdown opens
+  useEffect(() => {
+    if (isOpen && searchInputRef.current) {
+      searchInputRef.current.focus()
+    }
+  }, [isOpen])
+
+  // Create provider map for quick lookup
+  const providerMap = useMemo(() => {
+    const map = new Map<string, LlmProvider>()
+    providers.forEach(p => map.set(p.id, p))
+    return map
+  }, [providers])
+
+  // Filter and group models
+  const { filteredModels, groupedModels } = useMemo(() => {
+    const query = searchQuery.toLowerCase()
+    const filtered = models.filter(model => {
+      const provider = providerMap.get(model.provider_id)
+      const providerName = provider?.name || model.provider_id
+      return (
+        model.id.toLowerCase().includes(query) ||
+        model.name.toLowerCase().includes(query) ||
+        providerName.toLowerCase().includes(query)
+      )
+    })
+
+    // Group by provider
+    const groups = new Map<string, LlmModel[]>()
+    filtered.forEach(model => {
+      const providerId = model.provider_id
+      if (!groups.has(providerId)) {
+        groups.set(providerId, [])
+      }
+      groups.get(providerId)!.push(model)
+    })
+
+    return { filteredModels: filtered, groupedModels: groups }
+  }, [models, searchQuery, providerMap])
+
+  // Get selected model info
+  const selectedModel = models.find(m => m.id === selectedModelId)
+  const selectedProvider = selectedModel ? providerMap.get(selectedModel.provider_id) : undefined
+
+  // Format context length
+  const formatContextLength = (length: number): string => {
+    if (length >= 1000000) {
+      return `${(length / 1000000).toFixed(1)}M`
+    }
+    if (length >= 1000) {
+      return `${Math.round(length / 1000)}K`
+    }
+    return String(length)
+  }
+
+  const handleSelect = (modelId: string) => {
+    onChange(modelId)
+    setIsOpen(false)
+    setSearchQuery('')
+  }
+
+  return (
+    <div ref={containerRef} className="relative">
+      {/* Selected value display */}
+      <button
+        type="button"
+        onClick={() => setIsOpen(!isOpen)}
+        disabled={isLoading}
+        className="w-full flex items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
+      >
+        <span className={selectedModel ? 'text-gray-900' : 'text-gray-500'}>
+          {isLoading ? (
+            'Loading models...'
+          ) : selectedModel ? (
+            <span className="flex items-center gap-2">
+              <span className="font-medium">{selectedModel.name || selectedModel.id}</span>
+              {selectedProvider && (
+                <span className="text-xs text-gray-500">({selectedProvider.name})</span>
+              )}
+            </span>
+          ) : (
+            placeholder
+          )}
+        </span>
+        <svg
+          className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
+          fill="none"
+          viewBox="0 0 24 24"
+          stroke="currentColor"
+        >
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+        </svg>
+      </button>
+
+      {/* Dropdown */}
+      {isOpen && (
+        <div className="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
+          {/* Search input */}
+          <div className="p-2 border-b border-gray-200">
+            <div className="relative">
+              <svg
+                className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
+                />
+              </svg>
+              <input
+                ref={searchInputRef}
+                type="text"
+                value={searchQuery}
+                onChange={(e) => setSearchQuery(e.target.value)}
+                placeholder="Search models..."
+                className="w-full rounded-md border border-gray-300 py-2 pl-9 pr-3 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+              />
+            </div>
+          </div>
+
+          {/* Model list */}
+          <div className="max-h-64 overflow-y-auto">
+            {filteredModels.length === 0 ? (
+              <div className="px-4 py-8 text-center text-sm text-gray-500">
+                {searchQuery ? 'No models match your search' : 'No models available'}
+              </div>
+            ) : (
+              Array.from(groupedModels.entries()).map(([providerId, providerModels]) => {
+                const provider = providerMap.get(providerId)
+                return (
+                  <div key={providerId}>
+                    {/* Provider header */}
+                    <div className="sticky top-0 bg-gray-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-gray-500 border-b border-gray-100">
+                      {provider?.name || providerId}
+                    </div>
+                    {/* Models */}
+                    {providerModels.map((model) => (
+                      <button
+                        key={model.id}
+                        type="button"
+                        onClick={() => handleSelect(model.id)}
+                        className={`w-full px-3 py-2 text-left hover:bg-gray-50 focus:bg-gray-50 focus:outline-none ${
+                          model.id === selectedModelId ? 'bg-primary-50' : ''
+                        }`}
+                      >
+                        <div className="flex items-start justify-between">
+                          <div className="flex-1 min-w-0">
+                            <div className="font-medium text-gray-900 truncate">
+                              {model.name || model.id}
+                            </div>
+                            <div className="flex items-center gap-2 text-xs text-gray-500 mt-0.5">
+                              <span>Context: {formatContextLength(model.context_length)} tokens</span>
+                              {model.supports_vision && (
+                                <span className="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-blue-700">
+                                  <svg className="mr-0.5 h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+                                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
+                                  </svg>
+                                  Vision
+                                </span>
+                              )}
+                              {model.supports_tools && (
+                                <span className="inline-flex items-center rounded bg-green-100 px-1.5 py-0.5 text-green-700">
+                                  <svg className="mr-0.5 h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+                                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+                                  </svg>
+                                  Tools
+                                </span>
+                              )}
+                            </div>
+                          </div>
+                          {model.id === selectedModelId && (
+                            <svg className="h-5 w-5 text-primary-600 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
+                              <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                            </svg>
+                          )}
+                        </div>
+                      </button>
+                    ))}
+                  </div>
+                )
+              })
+            )}
+          </div>
+
+          {/* Footer with count */}
+          <div className="border-t border-gray-200 px-3 py-2 text-xs text-gray-500">
+            {filteredModels.length} {filteredModels.length === 1 ? 'model' : 'models'}
+            {searchQuery && ` matching "${searchQuery}"`}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default ModelSelector

+ 14 - 0
webui/src/components/layout/Sidebar.tsx

@@ -32,6 +32,20 @@ const navigation: NavItem[] = [
       </svg>
       </svg>
     ),
     ),
   },
   },
+  {
+    name: 'Chat',
+    path: '/chat',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
+        />
+      </svg>
+    ),
+  },
   {
   {
     name: 'Collections',
     name: 'Collections',
     path: '/collections',
     path: '/collections',

+ 25 - 18
webui/src/contexts/AuthContext.tsx

@@ -64,12 +64,13 @@ export function AuthProvider({ children }: AuthProviderProps) {
       saveTokens(tokens)
       saveTokens(tokens)
       return true
       return true
     } catch {
     } catch {
-      clearTokens()
-      setIsAuthenticated(false)
-      setUser(null)
+      // Don't clear tokens here - the access token might still be valid
+      // Just return false and let the caller handle it
+      // Clear only the refresh token to prevent repeated failed attempts
+      localStorage.removeItem(REFRESH_TOKEN_KEY)
       return false
       return false
     }
     }
-  }, [saveTokens, clearTokens])
+  }, [saveTokens])
 
 
   const login = useCallback(
   const login = useCallback(
     async (email: string, password: string) => {
     async (email: string, password: string) => {
@@ -125,24 +126,20 @@ export function AuthProvider({ children }: AuthProviderProps) {
 
 
       apiClient.setAccessToken(accessToken)
       apiClient.setAccessToken(accessToken)
 
 
-      // Check if token is expired or about to expire
-      const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY)
-      const expiry = expiryStr ? parseInt(expiryStr, 10) : 0
-
-      if (Date.now() >= expiry) {
-        // Token expired, try to refresh
-        const refreshed = await refreshTokens()
-        if (!refreshed) {
-          setIsLoading(false)
-          return
-        }
-      }
-
-      // Fetch user info
+      // Always try to fetch user info first - the token might still be valid
+      // even if our stored expiry time says otherwise
       try {
       try {
         const userInfo = await apiClient.get<User>('/auth/me')
         const userInfo = await apiClient.get<User>('/auth/me')
         setUser(userInfo)
         setUser(userInfo)
         setIsAuthenticated(true)
         setIsAuthenticated(true)
+
+        // Token is valid, try to refresh if expiry is close (proactive refresh)
+        const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY)
+        const expiry = expiryStr ? parseInt(expiryStr, 10) : 0
+        if (Date.now() >= expiry - 120000) {
+          // Token expires in less than 2 minutes, try to refresh silently
+          refreshTokens() // Don't await - do it in background
+        }
       } catch {
       } catch {
         // Token invalid, try to refresh
         // Token invalid, try to refresh
         const refreshed = await refreshTokens()
         const refreshed = await refreshTokens()
@@ -155,6 +152,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
             clearTokens()
             clearTokens()
             setIsAuthenticated(false)
             setIsAuthenticated(false)
           }
           }
+        } else {
+          // Refresh also failed, clear tokens and logout
+          clearTokens()
+          setIsAuthenticated(false)
         }
         }
       } finally {
       } finally {
         setIsLoading(false)
         setIsLoading(false)
@@ -171,6 +172,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
     }
     }
 
 
     const checkAndRefresh = () => {
     const checkAndRefresh = () => {
+      // Don't try to refresh if there's no refresh token
+      const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
+      if (!refreshToken) {
+        return
+      }
+
       const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY)
       const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY)
       const expiry = expiryStr ? parseInt(expiryStr, 10) : 0
       const expiry = expiryStr ? parseInt(expiryStr, 10) : 0
 
 

+ 121 - 46
webui/src/contexts/ChatContext.tsx

@@ -2,8 +2,18 @@
 
 
 import { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react'
 import { createContext, useContext, useState, useCallback, useEffect, ReactNode, useRef } from 'react'
 import * as chatApi from '@/api/chat'
 import * as chatApi from '@/api/chat'
-import type { ChatSession, ChatMessage, PageContext, LlmModel } from '@/types/chat'
+import type { ChatSession, ChatMessage, PageContext, ContextInfo } from '@/types/chat'
 import { useAuth } from './AuthContext'
 import { useAuth } from './AuthContext'
+import { useWorkspace } from './WorkspaceContext'
+
+interface WorkspaceSettings {
+  llm_provider_id?: string
+  llm_model_id?: string
+  llm_helper_provider_id?: string
+  llm_helper_model_id?: string
+  llm_context_compaction_threshold?: number
+  [key: string]: unknown
+}
 
 
 interface ChatContextValue {
 interface ChatContextValue {
   // UI State
   // UI State
@@ -30,11 +40,11 @@ interface ChatContextValue {
   sendMessage: (content: string) => Promise<void>
   sendMessage: (content: string) => Promise<void>
   clearMessages: () => Promise<void>
   clearMessages: () => Promise<void>
 
 
-  // Models
-  models: LlmModel[]
-  selectedModelId: string | null
-  setSelectedModelId: (modelId: string) => void
-  loadModels: () => Promise<void>
+  // Context & Compression
+  contextInfo: ContextInfo | null
+  compactionThreshold: number
+  isCompressing: boolean
+  compressConversation: () => Promise<void>
 
 
   // Page Context
   // Page Context
   currentPageContext: PageContext | null
   currentPageContext: PageContext | null
@@ -48,7 +58,13 @@ interface ChatProviderProps {
 }
 }
 
 
 export function ChatProvider({ children }: ChatProviderProps) {
 export function ChatProvider({ children }: ChatProviderProps) {
-  const { isAuthenticated } = useAuth()
+  const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
+  const { currentWorkspace } = useWorkspace()
+
+  // Get workspace LLM settings
+  const workspaceSettings = currentWorkspace?.settings as WorkspaceSettings | undefined
+  const workspaceModelId = workspaceSettings?.llm_model_id
+  const compactionThreshold = workspaceSettings?.llm_context_compaction_threshold ?? 80
 
 
   // UI State
   // UI State
   const [isOpen, setIsOpen] = useState(false)
   const [isOpen, setIsOpen] = useState(false)
@@ -64,9 +80,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
   const [isStreaming, setIsStreaming] = useState(false)
   const [isStreaming, setIsStreaming] = useState(false)
   const [streamingContent, setStreamingContent] = useState('')
   const [streamingContent, setStreamingContent] = useState('')
 
 
-  // Model State
-  const [models, setModels] = useState<LlmModel[]>([])
-  const [selectedModelId, setSelectedModelId] = useState<string | null>(null)
+  // Context & Compression State
+  const [contextInfo, setContextInfo] = useState<ContextInfo | null>(null)
+  const [isCompressing, setIsCompressing] = useState(false)
+  const [userMessageCount, setUserMessageCount] = useState(0)
 
 
   // Page Context
   // Page Context
   const [currentPageContext, setCurrentPageContext] = useState<PageContext | null>(null)
   const [currentPageContext, setCurrentPageContext] = useState<PageContext | null>(null)
@@ -100,29 +117,33 @@ export function ChatProvider({ children }: ChatProviderProps) {
 
 
   // Session Actions
   // Session Actions
   const refreshSessions = useCallback(async () => {
   const refreshSessions = useCallback(async () => {
-    if (!isAuthenticated) return
+    if (!isAuthenticated || isAuthLoading) return
 
 
     setIsLoadingSessions(true)
     setIsLoadingSessions(true)
     try {
     try {
-      const response = await chatApi.listSessions(undefined, 50)
+      // Filter sessions by current workspace
+      const response = await chatApi.listSessions(currentWorkspace?.id, 50)
       setSessions(response.sessions || [])
       setSessions(response.sessions || [])
     } catch (error) {
     } catch (error) {
       console.error('Failed to load sessions:', error)
       console.error('Failed to load sessions:', error)
     } finally {
     } finally {
       setIsLoadingSessions(false)
       setIsLoadingSessions(false)
     }
     }
-  }, [isAuthenticated])
+  }, [isAuthenticated, isAuthLoading, currentWorkspace?.id])
 
 
   const createSession = useCallback(async (workspaceId?: string): Promise<ChatSession> => {
   const createSession = useCallback(async (workspaceId?: string): Promise<ChatSession> => {
+    // Use workspace's configured model
+    const wsId = workspaceId || currentWorkspace?.id
+
     const session = await chatApi.createSession({
     const session = await chatApi.createSession({
-      workspace_id: workspaceId,
-      model_id: selectedModelId || undefined,
+      workspace_id: wsId,
+      model_id: workspaceModelId || undefined,
     })
     })
     setSessions(prev => [session, ...prev])
     setSessions(prev => [session, ...prev])
     setCurrentSession(session)
     setCurrentSession(session)
     setMessages([])
     setMessages([])
     return session
     return session
-  }, [selectedModelId])
+  }, [workspaceModelId, currentWorkspace?.id])
 
 
   const switchSession = useCallback(async (sessionId: string) => {
   const switchSession = useCallback(async (sessionId: string) => {
     try {
     try {
@@ -144,17 +165,55 @@ export function ChatProvider({ children }: ChatProviderProps) {
     }
     }
   }, [currentSession?.id])
   }, [currentSession?.id])
 
 
+  // Compress conversation using helper model
+  const compressConversation = useCallback(async () => {
+    if (!currentSession || isCompressing) return
+
+    setIsCompressing(true)
+    try {
+      const updatedSession = await chatApi.compressSession(currentSession.id)
+      setMessages(updatedSession.messages || [])
+      setCurrentSession(updatedSession)
+      if (updatedSession.context_info) {
+        setContextInfo(updatedSession.context_info)
+      }
+
+      // Update session in list
+      setSessions(prev => prev.map(s =>
+        s.id === currentSession.id ? { ...s, ...updatedSession } : s
+      ))
+    } catch (error) {
+      console.error('Failed to compress conversation:', error)
+    } finally {
+      setIsCompressing(false)
+    }
+  }, [currentSession, isCompressing])
+
+  // Generate title using helper model
+  const generateTitle = useCallback(async (sessionId: string) => {
+    try {
+      const result = await chatApi.generateTitle(sessionId)
+      if (result.title) {
+        setSessions(prev => prev.map(s =>
+          s.id === sessionId ? { ...s, title: result.title } : s
+        ))
+        setCurrentSession(prev => prev?.id === sessionId ? { ...prev, title: result.title } : prev)
+      }
+    } catch (error) {
+      console.error('Failed to generate title:', error)
+    }
+  }, [])
+
   // Message Actions
   // Message Actions
   const sendMessage = useCallback(async (content: string) => {
   const sendMessage = useCallback(async (content: string) => {
-    if (!currentSession) {
-      // Create a new session first
+    let sessionId = currentSession?.id
+
+    // Create a new session if none exists
+    if (!sessionId) {
       const session = await createSession()
       const session = await createSession()
-      setCurrentSession(session)
+      sessionId = session.id
     }
     }
 
 
-    const sessionId = currentSession?.id
-    if (!sessionId) return
-
     // Add user message optimistically
     // Add user message optimistically
     const userMessage: ChatMessage = {
     const userMessage: ChatMessage = {
       id: `temp-${Date.now()}`,
       id: `temp-${Date.now()}`,
@@ -205,10 +264,32 @@ export function ChatProvider({ children }: ChatProviderProps) {
       setMessages(updatedSession.messages || [])
       setMessages(updatedSession.messages || [])
       setCurrentSession(updatedSession)
       setCurrentSession(updatedSession)
 
 
+      // Update context info from session
+      if (updatedSession.context_info) {
+        setContextInfo(updatedSession.context_info)
+      }
+
+      // Track user message count for auto-title generation
+      const newUserMessageCount = userMessageCount + 1
+      setUserMessageCount(newUserMessageCount)
+
+      // Generate title every 5 user messages (starting from the first)
+      if (newUserMessageCount === 1 || newUserMessageCount % 5 === 0) {
+        generateTitle(sessionId)
+      }
+
       // Update session in list
       // Update session in list
       setSessions(prev => prev.map(s =>
       setSessions(prev => prev.map(s =>
         s.id === sessionId ? { ...s, title: updatedSession.title, updated_at: updatedSession.updated_at } : s
         s.id === sessionId ? { ...s, title: updatedSession.title, updated_at: updatedSession.updated_at } : s
       ))
       ))
+
+      // Check if auto-compression is needed
+      if (updatedSession.context_info && updatedSession.context_info.usage_percent >= compactionThreshold) {
+        // Trigger auto-compression in background
+        setTimeout(() => {
+          compressConversation()
+        }, 500)
+      }
     } catch (error) {
     } catch (error) {
       console.error('Failed to send message:', error)
       console.error('Failed to send message:', error)
       // Remove optimistic user message on error
       // Remove optimistic user message on error
@@ -217,36 +298,30 @@ export function ChatProvider({ children }: ChatProviderProps) {
       setIsStreaming(false)
       setIsStreaming(false)
       setStreamingContent('')
       setStreamingContent('')
     }
     }
-  }, [currentSession, currentPageContext, createSession, streamingContent])
+  }, [currentSession?.id, currentPageContext, createSession, streamingContent, userMessageCount, generateTitle, compactionThreshold, compressConversation])
 
 
   const clearMessages = useCallback(async () => {
   const clearMessages = useCallback(async () => {
     if (!currentSession) return
     if (!currentSession) return
 
 
     await chatApi.clearSessionMessages(currentSession.id)
     await chatApi.clearSessionMessages(currentSession.id)
     setMessages([])
     setMessages([])
+    setUserMessageCount(0)
+    setContextInfo(null)
   }, [currentSession])
   }, [currentSession])
 
 
-  // Model Actions
-  const loadModels = useCallback(async () => {
-    try {
-      const response = await chatApi.listModels()
-      setModels(response.models || [])
-      // Select first model if none selected
-      if (!selectedModelId && response.models?.length > 0) {
-        setSelectedModelId(response.models[0].id)
-      }
-    } catch (error) {
-      console.error('Failed to load models:', error)
-    }
-  }, [selectedModelId])
-
-  // Load sessions and models on mount when authenticated
+  // Load sessions on mount when authenticated (and auth loading complete)
   useEffect(() => {
   useEffect(() => {
-    if (isAuthenticated) {
+    if (isAuthenticated && !isAuthLoading) {
       refreshSessions()
       refreshSessions()
-      loadModels()
     }
     }
-  }, [isAuthenticated, refreshSessions, loadModels])
+  }, [isAuthenticated, isAuthLoading, refreshSessions])
+
+  // Reset current session when workspace changes
+  useEffect(() => {
+    // Clear current session when workspace changes, sessions will be refreshed
+    setCurrentSession(null)
+    setMessages([])
+  }, [currentWorkspace?.id])
 
 
   // Cleanup on unmount
   // Cleanup on unmount
   useEffect(() => {
   useEffect(() => {
@@ -282,11 +357,11 @@ export function ChatProvider({ children }: ChatProviderProps) {
     sendMessage,
     sendMessage,
     clearMessages,
     clearMessages,
 
 
-    // Models
-    models,
-    selectedModelId,
-    setSelectedModelId,
-    loadModels,
+    // Context & Compression
+    contextInfo,
+    compactionThreshold,
+    isCompressing,
+    compressConversation,
 
 
     // Page Context
     // Page Context
     currentPageContext,
     currentPageContext,

+ 231 - 0
webui/src/pages/ChatPage.tsx

@@ -0,0 +1,231 @@
+// Full-page chat interface
+
+import { useState } from 'react'
+import { useChat } from '@/contexts/ChatContext'
+import { ChatMessageList, ChatInput } from '@/components/Chat'
+
+function MessageSquarePlusIcon({ className = 'h-5 w-5' }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        strokeWidth={2}
+        d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
+      />
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v6M9 12h6" />
+    </svg>
+  )
+}
+
+function Trash2Icon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        strokeWidth={2}
+        d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+      />
+    </svg>
+  )
+}
+
+function Loader2Icon({ className = 'h-4 w-4' }: { className?: string }) {
+  return (
+    <svg className={`animate-spin ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        strokeWidth={2}
+        d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+      />
+    </svg>
+  )
+}
+
+function FullPageSessionSidebar() {
+  const [deletingId, setDeletingId] = useState<string | null>(null)
+  const { sessions, currentSession, isLoadingSessions, createSession, switchSession, deleteSession } =
+    useChat()
+
+  const handleCreateSession = async () => {
+    try {
+      await createSession()
+    } catch (error) {
+      console.error('Failed to create session:', error)
+    }
+  }
+
+  const handleDeleteSession = async (e: React.MouseEvent, sessionId: string) => {
+    e.stopPropagation()
+    setDeletingId(sessionId)
+    try {
+      await deleteSession(sessionId)
+    } catch (error) {
+      console.error('Failed to delete session:', error)
+    } finally {
+      setDeletingId(null)
+    }
+  }
+
+  const formatDate = (timestamp: number) => {
+    const date = new Date(timestamp * 1000)
+    const now = new Date()
+    const diff = now.getTime() - date.getTime()
+    const days = Math.floor(diff / (1000 * 60 * 60 * 24))
+
+    if (days === 0) {
+      return 'Today'
+    } else if (days === 1) {
+      return 'Yesterday'
+    } else if (days < 7) {
+      return `${days} days ago`
+    } else {
+      return date.toLocaleDateString()
+    }
+  }
+
+  return (
+    <div className="flex h-full w-64 flex-col border-r border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
+      {/* Header */}
+      <div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
+        <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Conversations</h2>
+        <button
+          onClick={handleCreateSession}
+          className="rounded-lg p-1.5 text-primary-600 hover:bg-gray-100 dark:text-primary-400 dark:hover:bg-gray-700"
+          title="New conversation"
+        >
+          <MessageSquarePlusIcon className="h-5 w-5" />
+        </button>
+      </div>
+
+      {/* Session list */}
+      <div className="flex-1 overflow-y-auto">
+        {isLoadingSessions && (
+          <div className="flex items-center justify-center py-8">
+            <Loader2Icon className="h-6 w-6 text-gray-400" />
+          </div>
+        )}
+
+        {!isLoadingSessions && sessions.length === 0 && (
+          <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
+            <p>No conversations yet</p>
+            <button
+              onClick={handleCreateSession}
+              className="mt-2 text-primary-600 hover:text-primary-700 dark:text-primary-400"
+            >
+              Start a new conversation
+            </button>
+          </div>
+        )}
+
+        {!isLoadingSessions &&
+          sessions.map((session) => (
+            <div
+              key={session.id}
+              onClick={() => switchSession(session.id)}
+              className={`group cursor-pointer border-b border-gray-200 p-3 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-700 ${
+                currentSession?.id === session.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''
+              }`}
+            >
+              <div className="flex items-start justify-between">
+                <div className="min-w-0 flex-1">
+                  <p className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
+                    {session.title || 'New conversation'}
+                  </p>
+                  <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                    {formatDate(session.updated_at)} - {session.message_count} messages
+                  </p>
+                </div>
+                <button
+                  onClick={(e) => handleDeleteSession(e, session.id)}
+                  disabled={deletingId === session.id}
+                  className="ml-2 hidden rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-red-500 group-hover:block dark:hover:bg-gray-600 disabled:opacity-50"
+                  aria-label="Delete session"
+                >
+                  {deletingId === session.id ? (
+                    <Loader2Icon className="h-4 w-4" />
+                  ) : (
+                    <Trash2Icon className="h-4 w-4" />
+                  )}
+                </button>
+              </div>
+            </div>
+          ))}
+      </div>
+    </div>
+  )
+}
+
+function ChatHeader() {
+  const { currentSession, contextInfo, isCompressing, compressConversation } = useChat()
+
+  return (
+    <div className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-800">
+      <div>
+        <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
+          {currentSession?.title || 'AI Assistant'}
+        </h1>
+        {currentSession?.model_id && (
+          <p className="text-sm text-gray-500 dark:text-gray-400">
+            Model: {currentSession.model_id}
+          </p>
+        )}
+      </div>
+
+      {/* Context usage indicator */}
+      {contextInfo && (
+        <div className="flex items-center gap-3">
+          <div className="text-right">
+            <div className="text-xs text-gray-500 dark:text-gray-400">Context Usage</div>
+            <div className="flex items-center gap-2">
+              <div className="h-2 w-24 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
+                <div
+                  className={`h-full transition-all ${
+                    contextInfo.usage_percent > 80
+                      ? 'bg-red-500'
+                      : contextInfo.usage_percent > 60
+                        ? 'bg-yellow-500'
+                        : 'bg-green-500'
+                  }`}
+                  style={{ width: `${Math.min(contextInfo.usage_percent, 100)}%` }}
+                />
+              </div>
+              <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
+                {Math.round(contextInfo.usage_percent)}%
+              </span>
+            </div>
+          </div>
+          {contextInfo.usage_percent > 70 && (
+            <button
+              onClick={compressConversation}
+              disabled={isCompressing}
+              className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50"
+            >
+              {isCompressing ? 'Compressing...' : 'Compress'}
+            </button>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default function ChatPage() {
+  return (
+    <div className="flex h-full bg-white dark:bg-gray-900">
+      {/* Session sidebar - always visible on full page */}
+      <FullPageSessionSidebar />
+
+      {/* Main chat area */}
+      <div className="flex flex-1 flex-col">
+        <ChatHeader />
+        <div className="flex-1 overflow-hidden">
+          <ChatMessageList />
+        </div>
+        <ChatInput />
+      </div>
+    </div>
+  )
+}

+ 3 - 3
webui/src/pages/Login.tsx

@@ -22,12 +22,12 @@ function Login() {
 
 
   const from = (location.state as LocationState)?.from?.pathname || '/dashboard'
   const from = (location.state as LocationState)?.from?.pathname || '/dashboard'
 
 
-  // Redirect if already authenticated
+  // Redirect if already authenticated (wait for loading to complete first)
   useEffect(() => {
   useEffect(() => {
-    if (isAuthenticated) {
+    if (!isLoading && isAuthenticated) {
       navigate(from, { replace: true })
       navigate(from, { replace: true })
     }
     }
-  }, [isAuthenticated, navigate, from])
+  }, [isAuthenticated, isLoading, navigate, from])
 
 
   // Clear error on unmount
   // Clear error on unmount
   useEffect(() => {
   useEffect(() => {

+ 295 - 45
webui/src/pages/Workspaces.tsx

@@ -5,12 +5,22 @@ import apiClient from '@/api/client'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
 import { useWorkspace } from '@/contexts/WorkspaceContext'
 import Button from '@/components/Button'
 import Button from '@/components/Button'
 import Input from '@/components/Input'
 import Input from '@/components/Input'
+import ModelSelector from '@/components/ModelSelector'
 import type { Workspace } from '@/types'
 import type { Workspace } from '@/types'
+import type { LlmProvider, LlmModel } from '@/types/chat'
+import * as chatApi from '@/api/chat'
 import { formatDateTime } from '@/utils/format'
 import { formatDateTime } from '@/utils/format'
 
 
 interface WorkspaceFormData {
 interface WorkspaceFormData {
   name: string
   name: string
-  settings: Record<string, unknown>
+  settings: {
+    llm_provider_id?: string
+    llm_model_id?: string
+    llm_helper_provider_id?: string
+    llm_helper_model_id?: string
+    llm_context_compaction_threshold?: number
+    [key: string]: unknown
+  }
 }
 }
 
 
 function WorkspaceModal({
 function WorkspaceModal({
@@ -24,11 +34,119 @@ function WorkspaceModal({
 }) {
 }) {
   const [formData, setFormData] = useState<WorkspaceFormData>({
   const [formData, setFormData] = useState<WorkspaceFormData>({
     name: workspace?.name || '',
     name: workspace?.name || '',
-    settings: workspace?.settings || {},
+    settings: (workspace?.settings as WorkspaceFormData['settings']) || {},
   })
   })
   const [isLoading, setIsLoading] = useState(false)
   const [isLoading, setIsLoading] = useState(false)
   const [error, setError] = useState<string | null>(null)
   const [error, setError] = useState<string | null>(null)
 
 
+  // LLM Provider and Model state
+  const [providers, setProviders] = useState<LlmProvider[]>([])
+  const [models, setModels] = useState<LlmModel[]>([])
+  const [isLoadingProviders, setIsLoadingProviders] = useState(true)
+  const [isLoadingModels, setIsLoadingModels] = useState(false)
+  const [selectedProviderId, setSelectedProviderId] = useState<string>(
+    (workspace?.settings as WorkspaceFormData['settings'])?.llm_provider_id || ''
+  )
+  const [selectedModelId, setSelectedModelId] = useState<string>(
+    (workspace?.settings as WorkspaceFormData['settings'])?.llm_model_id || ''
+  )
+
+  // Helper model state (for compression and title generation)
+  const [helperProviderId, setHelperProviderId] = useState<string>(
+    (workspace?.settings as WorkspaceFormData['settings'])?.llm_helper_provider_id || ''
+  )
+  const [helperModelId, setHelperModelId] = useState<string>(
+    (workspace?.settings as WorkspaceFormData['settings'])?.llm_helper_model_id || ''
+  )
+  const [helperModels, setHelperModels] = useState<LlmModel[]>([])
+  const [isLoadingHelperModels, setIsLoadingHelperModels] = useState(false)
+  const [compactionThreshold, setCompactionThreshold] = useState<number>(
+    (workspace?.settings as WorkspaceFormData['settings'])?.llm_context_compaction_threshold ?? 80
+  )
+
+  // Load providers on mount
+  useEffect(() => {
+    const loadProviders = async () => {
+      try {
+        const response = await chatApi.listProviders()
+        setProviders(response.providers || [])
+      } catch (err) {
+        console.error('Failed to load providers:', err)
+      } finally {
+        setIsLoadingProviders(false)
+      }
+    }
+    loadProviders()
+  }, [])
+
+  // Load models when provider changes
+  useEffect(() => {
+    if (!selectedProviderId) {
+      setModels([])
+      setSelectedModelId('')
+      return
+    }
+
+    const loadModels = async () => {
+      setIsLoadingModels(true)
+      try {
+        const response = await chatApi.listModels(selectedProviderId)
+        setModels(response.models || [])
+        // If currently selected model isn't in the new list, clear it
+        if (selectedModelId && !response.models?.some(m => m.id === selectedModelId)) {
+          setSelectedModelId('')
+        }
+      } catch (err) {
+        console.error('Failed to load models:', err)
+        setModels([])
+      } finally {
+        setIsLoadingModels(false)
+      }
+    }
+    loadModels()
+  }, [selectedProviderId])
+
+  // Load helper models when helper provider changes
+  useEffect(() => {
+    if (!helperProviderId) {
+      setHelperModels([])
+      setHelperModelId('')
+      return
+    }
+
+    const loadHelperModels = async () => {
+      setIsLoadingHelperModels(true)
+      try {
+        const response = await chatApi.listModels(helperProviderId)
+        setHelperModels(response.models || [])
+        if (helperModelId && !response.models?.some(m => m.id === helperModelId)) {
+          setHelperModelId('')
+        }
+      } catch (err) {
+        console.error('Failed to load helper models:', err)
+        setHelperModels([])
+      } finally {
+        setIsLoadingHelperModels(false)
+      }
+    }
+    loadHelperModels()
+  }, [helperProviderId])
+
+  // Update settings when provider/model changes
+  useEffect(() => {
+    setFormData(prev => ({
+      ...prev,
+      settings: {
+        ...prev.settings,
+        llm_provider_id: selectedProviderId || undefined,
+        llm_model_id: selectedModelId || undefined,
+        llm_helper_provider_id: helperProviderId || undefined,
+        llm_helper_model_id: helperModelId || undefined,
+        llm_context_compaction_threshold: compactionThreshold,
+      },
+    }))
+  }, [selectedProviderId, selectedModelId, helperProviderId, helperModelId, compactionThreshold])
+
   const handleSubmit = async (e: React.FormEvent) => {
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
     e.preventDefault()
     setIsLoading(true)
     setIsLoading(true)
@@ -49,8 +167,8 @@ function WorkspaceModal({
   }
   }
 
 
   return (
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
-      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-xl">
         <h2 className="text-xl font-semibold text-gray-900">
         <h2 className="text-xl font-semibold text-gray-900">
           {workspace ? 'Edit Workspace' : 'Create Workspace'}
           {workspace ? 'Edit Workspace' : 'Create Workspace'}
         </h2>
         </h2>
@@ -70,6 +188,122 @@ function WorkspaceModal({
             autoFocus
             autoFocus
           />
           />
 
 
+          {/* LLM Provider Selection */}
+          <div>
+            <label className="block text-sm font-medium text-gray-700 mb-1">
+              LLM Provider
+            </label>
+            <select
+              value={selectedProviderId}
+              onChange={(e) => setSelectedProviderId(e.target.value)}
+              disabled={isLoadingProviders}
+              className="w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100"
+            >
+              <option value="">
+                {isLoadingProviders ? 'Loading providers...' : 'Select a provider (optional)'}
+              </option>
+              {providers.filter(p => p.enabled).map((provider) => (
+                <option key={provider.id} value={provider.id}>
+                  {provider.name} ({provider.type})
+                </option>
+              ))}
+            </select>
+            <p className="mt-1 text-xs text-gray-500">
+              Select an LLM provider to enable AI assistant for this workspace
+            </p>
+          </div>
+
+          {/* LLM Model Selection */}
+          {selectedProviderId && (
+            <div>
+              <label className="block text-sm font-medium text-gray-700 mb-1">
+                LLM Model (Primary)
+              </label>
+              <ModelSelector
+                models={models}
+                providers={providers}
+                selectedModelId={selectedModelId}
+                onChange={setSelectedModelId}
+                isLoading={isLoadingModels}
+                placeholder="Select a model"
+              />
+              {models.length === 0 && !isLoadingModels && (
+                <p className="mt-1 text-xs text-amber-600">
+                  No models available. Try refreshing models in LLM Providers settings.
+                </p>
+              )}
+            </div>
+          )}
+
+          {/* Helper Model Section - for compression and title generation */}
+          {selectedProviderId && selectedModelId && (
+            <div className="border-t border-gray-200 pt-4 mt-4">
+              <h3 className="text-sm font-medium text-gray-900 mb-3">Helper Model Settings</h3>
+              <p className="text-xs text-gray-500 mb-3">
+                A smaller, faster model for conversation compression and title generation.
+              </p>
+
+              {/* Helper Provider */}
+              <div className="mb-3">
+                <label className="block text-sm font-medium text-gray-700 mb-1">
+                  Helper Provider
+                </label>
+                <select
+                  value={helperProviderId}
+                  onChange={(e) => setHelperProviderId(e.target.value)}
+                  disabled={isLoadingProviders}
+                  className="w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100"
+                >
+                  <option value="">Same as primary provider</option>
+                  {providers.filter(p => p.enabled).map((provider) => (
+                    <option key={provider.id} value={provider.id}>
+                      {provider.name} ({provider.type})
+                    </option>
+                  ))}
+                </select>
+              </div>
+
+              {/* Helper Model */}
+              <div className="mb-3">
+                <label className="block text-sm font-medium text-gray-700 mb-1">
+                  Helper Model
+                </label>
+                <ModelSelector
+                  models={helperProviderId ? helperModels : models}
+                  providers={providers}
+                  selectedModelId={helperModelId}
+                  onChange={setHelperModelId}
+                  isLoading={helperProviderId ? isLoadingHelperModels : isLoadingModels}
+                  placeholder="Select a smaller model (optional)"
+                />
+                <p className="mt-1 text-xs text-gray-500">
+                  Used for summarizing conversations and generating titles
+                </p>
+              </div>
+
+              {/* Context Compaction Threshold */}
+              <div>
+                <label className="block text-sm font-medium text-gray-700 mb-1">
+                  Auto-compress at {compactionThreshold}% context usage
+                </label>
+                <input
+                  type="range"
+                  min="50"
+                  max="95"
+                  step="5"
+                  value={compactionThreshold}
+                  onChange={(e) => setCompactionThreshold(Number(e.target.value))}
+                  className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
+                />
+                <div className="flex justify-between text-xs text-gray-500 mt-1">
+                  <span>50%</span>
+                  <span>Auto-compress when context reaches {compactionThreshold}%</span>
+                  <span>95%</span>
+                </div>
+              </div>
+            </div>
+          )}
+
           <div className="flex justify-end gap-3 pt-4">
           <div className="flex justify-end gap-3 pt-4">
             <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
             <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
               Cancel
               Cancel
@@ -256,7 +490,7 @@ function Workspaces() {
                     Name
                     Name
                   </th>
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
                   <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
-                    ID
+                    AI Assistant
                   </th>
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
                   <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
                     Created
                     Created
@@ -270,46 +504,62 @@ function Workspaces() {
                 </tr>
                 </tr>
               </thead>
               </thead>
               <tbody className="divide-y divide-gray-200 bg-white">
               <tbody className="divide-y divide-gray-200 bg-white">
-                {filteredWorkspaces.map((workspace) => (
-                  <tr key={workspace.id} className="hover:bg-gray-50">
-                    <td className="whitespace-nowrap px-6 py-4">
-                      <div className="font-medium text-gray-900">{workspace.name}</div>
-                    </td>
-                    <td className="whitespace-nowrap px-6 py-4">
-                      <code className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600">
-                        {workspace.id.slice(0, 8)}...
-                      </code>
-                    </td>
-                    <td className="px-6 py-4 text-sm text-gray-500">
-                      <div>{formatDateTime(workspace.created_at)}</div>
-                      {workspace.created_by_name && (
-                        <div className="text-xs text-gray-400">by {workspace.created_by_name}</div>
-                      )}
-                    </td>
-                    <td className="px-6 py-4 text-sm text-gray-500">
-                      <div>{formatDateTime(workspace.updated_at)}</div>
-                      {workspace.updated_by_name && (
-                        <div className="text-xs text-gray-400">by {workspace.updated_by_name}</div>
-                      )}
-                    </td>
-                    <td className="whitespace-nowrap px-6 py-4 text-right">
-                      <div className="flex justify-end gap-2">
-                        <button
-                          onClick={() => setEditingWorkspace(workspace)}
-                          className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
-                        >
-                          Edit
-                        </button>
-                        <button
-                          onClick={() => setDeletingWorkspace(workspace)}
-                          className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
-                        >
-                          Delete
-                        </button>
-                      </div>
-                    </td>
-                  </tr>
-                ))}
+                {filteredWorkspaces.map((workspace) => {
+                  const wsSettings = workspace.settings as WorkspaceFormData['settings'] | undefined
+                  const hasLlm = wsSettings?.llm_provider_id && wsSettings?.llm_model_id
+                  return (
+                    <tr key={workspace.id} className="hover:bg-gray-50">
+                      <td className="whitespace-nowrap px-6 py-4">
+                        <div className="font-medium text-gray-900">{workspace.name}</div>
+                        <code className="text-xs text-gray-400">
+                          {workspace.id.slice(0, 8)}...
+                        </code>
+                      </td>
+                      <td className="whitespace-nowrap px-6 py-4">
+                        {hasLlm ? (
+                          <span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
+                            <svg className="mr-1 h-3 w-3" fill="currentColor" viewBox="0 0 24 24">
+                              <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
+                            </svg>
+                            Enabled
+                          </span>
+                        ) : (
+                          <span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
+                            Not configured
+                          </span>
+                        )}
+                      </td>
+                      <td className="px-6 py-4 text-sm text-gray-500">
+                        <div>{formatDateTime(workspace.created_at)}</div>
+                        {workspace.created_by_name && (
+                          <div className="text-xs text-gray-400">by {workspace.created_by_name}</div>
+                        )}
+                      </td>
+                      <td className="px-6 py-4 text-sm text-gray-500">
+                        <div>{formatDateTime(workspace.updated_at)}</div>
+                        {workspace.updated_by_name && (
+                          <div className="text-xs text-gray-400">by {workspace.updated_by_name}</div>
+                        )}
+                      </td>
+                      <td className="whitespace-nowrap px-6 py-4 text-right">
+                        <div className="flex justify-end gap-2">
+                          <button
+                            onClick={() => setEditingWorkspace(workspace)}
+                            className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
+                          >
+                            Edit
+                          </button>
+                          <button
+                            onClick={() => setDeletingWorkspace(workspace)}
+                            className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
+                          >
+                            Delete
+                          </button>
+                        </div>
+                      </td>
+                    </tr>
+                  )
+                })}
               </tbody>
               </tbody>
             </table>
             </table>
           )}
           )}

+ 8 - 0
webui/src/types/chat.ts

@@ -24,6 +24,12 @@ export interface ToolCall {
   arguments: string
   arguments: string
 }
 }
 
 
+export interface ContextInfo {
+  used_tokens: number
+  max_tokens: number
+  usage_percent: number
+}
+
 export interface ChatSession {
 export interface ChatSession {
   id: string
   id: string
   user_id: string
   user_id: string
@@ -33,6 +39,8 @@ export interface ChatSession {
   system_prompt: string
   system_prompt: string
   messages: ChatMessage[]
   messages: ChatMessage[]
   message_count?: number
   message_count?: number
+  user_message_count?: number
+  context_info?: ContextInfo
   created_at: number
   created_at: number
   updated_at: number
   updated_at: number
 }
 }

+ 20 - 1
webui/tailwind.config.js

@@ -21,7 +21,26 @@ export default {
           950: '#172554',
           950: '#172554',
         },
         },
       },
       },
+      typography: {
+        DEFAULT: {
+          css: {
+            maxWidth: 'none',
+            color: 'inherit',
+            a: {
+              color: '#2563eb',
+              '&:hover': {
+                color: '#1d4ed8',
+              },
+            },
+            code: {
+              color: 'inherit',
+            },
+          },
+        },
+      },
     },
     },
   },
   },
-  plugins: [],
+  plugins: [
+    require('@tailwindcss/typography'),
+  ],
 }
 }