From 4ef3dfa312f0be348ffe120306f84996fa273cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Tue, 11 Mar 2025 22:04:46 -0300 Subject: [PATCH] AI Assistant WIP (first working version, no settings available and many things pending but chat working). --- bin/assets/plugins/aiassistant.json | 227 +++++++ src/eepp/network/http.cpp | 6 +- src/tools/ecode/appconfig.cpp | 3 +- src/tools/ecode/ecode.cpp | 2 + .../plugins/aiassistant/aiassistantplugin.cpp | 249 ++++++++ .../plugins/aiassistant/aiassistantplugin.hpp | 46 ++ .../ecode/plugins/aiassistant/chatui.cpp | 604 ++++++++++++++++++ .../ecode/plugins/aiassistant/chatui.hpp | 96 +++ .../aiassistant/llmchatcompletionrequest.cpp | 110 ++++ .../aiassistant/llmchatcompletionrequest.hpp | 47 ++ .../ecode/plugins/aiassistant/protocol.hpp | 40 ++ 11 files changed, 1428 insertions(+), 2 deletions(-) create mode 100644 bin/assets/plugins/aiassistant.json create mode 100644 src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp create mode 100644 src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp create mode 100644 src/tools/ecode/plugins/aiassistant/chatui.cpp create mode 100644 src/tools/ecode/plugins/aiassistant/chatui.hpp create mode 100644 src/tools/ecode/plugins/aiassistant/llmchatcompletionrequest.cpp create mode 100644 src/tools/ecode/plugins/aiassistant/llmchatcompletionrequest.hpp create mode 100644 src/tools/ecode/plugins/aiassistant/protocol.hpp diff --git a/bin/assets/plugins/aiassistant.json b/bin/assets/plugins/aiassistant.json new file mode 100644 index 000000000..2607e11e7 --- /dev/null +++ b/bin/assets/plugins/aiassistant.json @@ -0,0 +1,227 @@ +{ + "config": {}, + "providers": { + "anthropic": { + "api_url": "https://api.anthropic.com/v1/messages", + "models": [ + { + "cache_configuration": { + "max_cache_anchors": 4, + "min_total_token": 2048, + "should_speculate": true + }, + "default_temperature": 1.0, + "display_name": "Claude 3.5 Sonnet", + "max_output_tokens": 8192, + "max_tokens": 200000, + "name": "claude-3-5-sonnet-latest" + }, + { + "cache_configuration": { + "max_cache_anchors": 4, + "min_total_token": 2048, + "should_speculate": true + }, + "default_temperature": 1.0, + "display_name": "Claude 3.7 Sonnet", + "max_output_tokens": 8192, + "max_tokens": 200000, + "name": "claude-3-7-sonnet-latest" + }, + { + "cache_configuration": { + "max_cache_anchors": 4, + "min_total_token": 2048, + "should_speculate": true + }, + "default_temperature": 1.0, + "display_name": "Claude 3.5 Haiku", + "max_output_tokens": 8192, + "max_tokens": 200000, + "name": "claude-3-5-haiku-latest" + }, + { + "cache_configuration": null, + "default_temperature": 1.0, + "display_name": "Claude 3 Opus", + "max_output_tokens": 4096, + "max_tokens": 200000, + "name": "claude-3-opus-latest" + }, + { + "cache_configuration": null, + "default_temperature": 1.0, + "display_name": "Claude 3 Sonnet", + "max_output_tokens": 4096, + "max_tokens": 200000, + "name": "claude-3-sonnet-20240229" + }, + { + "cache_configuration": { + "max_cache_anchors": 4, + "min_total_token": 2048, + "should_speculate": true + }, + "default_temperature": 1.0, + "display_name": "Claude 3 Haiku", + "max_output_tokens": 4096, + "max_tokens": 200000, + "name": "claude-3-haiku-20240307" + } + ], + "version": 1 + }, + "deepseek": { + "api_url": "https://api.deepseek.com/v1/chat/completions", + "display_name": "DeepSeek", + "models": [ + { + "display_name": "DeepSeek Chat", + "max_output_tokens": 8192, + "max_tokens": 64000, + "name": "deepseek-chat" + }, + { + "display_name": "DeepSeek Reasoner", + "max_output_tokens": 8192, + "max_tokens": 64000, + "name": "deepseek-reasoner" + } + ], + "version": 1 + }, + "google": { + "api_url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + "models": [ + { + "display_name": "Gemini 1.5 Pro", + "max_tokens": 2000000, + "name": "gemini-1.5-pro" + }, + { + "display_name": "Gemini 1.5 Flash", + "max_tokens": 1000000, + "name": "gemini-1.5-flash" + }, + { + "display_name": "Gemini 2.0 Pro", + "max_tokens": 2000000, + "name": "gemini-2.0-pro-exp" + }, + { + "display_name": "Gemini 2.0 Flash", + "max_tokens": 1000000, + "name": "gemini-2.0-flash" + }, + { + "display_name": "Gemini 2.0 Flash Thinking", + "max_tokens": 1000000, + "name": "gemini-2.0-flash-thinking-exp" + }, + { + "display_name": "Gemini 2.0 Flash Lite", + "max_tokens": 1000000, + "name": "gemini-2.0-flash-lite-preview" + } + ] + }, + "lmstudio": { + "api_url": "http://localhost:1234/api/v0/chat/completions", + "display_name": "LMStudio", + "enabled": false, + "fetch_models_url": "http://localhost:1234/api/v0/models", + "open_api": true + }, + "mistral": { + "api_url": "https://api.mistral.ai/v1/chat/completions", + "models": [ + { + "display_name": "codestral-latest", + "max_tokens": 256000, + "name": "codestral-latest" + }, + { + "display_name": "mistral-large-latest", + "max_tokens": 131000, + "name": "mistral-large-latest" + }, + { + "display_name": "mistral-small-latest", + "max_tokens": 32000, + "name": "mistral-small-latest" + }, + { + "display_name": "open-mistral-nemo", + "max_tokens": 131000, + "name": "open-mistral-nemo" + }, + { + "display_name": "open-codestral-mamba", + "max_tokens": 256000, + "name": "open-codestral-mamba" + } + ], + "version": 1 + }, + "ollama": { + "api_url": "http://localhost:11434/api/chat", + "fetch_models_url": "http://localhost:11434/api/tags", + "open_api": true + }, + "openai": { + "api_url": "https://api.openai.com/v1/chat/completions", + "display_name": "OpenAI", + "models": [ + { + "max_tokens": 16385, + "name": "gpt-3.5-turbo" + }, + { + "max_tokens": 8192, + "name": "gpt-4" + }, + { + "max_tokens": 128000, + "name": "gpt-4-turbo-preview" + }, + { + "max_tokens": 128000, + "name": "gpt-4o" + }, + { + "max_tokens": 128000, + "name": "gpt-4o-mini" + }, + { + "max_tokens": 200000, + "name": "o1" + }, + { + "max_tokens": 128000, + "name": "o1-preview" + }, + { + "max_tokens": 128000, + "name": "o1-mini" + }, + { + "max_tokens": 200000, + "name": "o3-mini" + } + ], + "version": 1 + }, + "xai": { + "api_url": "https://api.x.ai/v1/chat/completions", + "display_name": "xAI", + "models": [ + { + "name": "grok-2-latest" + }, + { + "name": "grok-3-latest" + } + ] + } + } +} diff --git a/src/eepp/network/http.cpp b/src/eepp/network/http.cpp index 487363e5d..60a0b8259 100644 --- a/src/eepp/network/http.cpp +++ b/src/eepp/network/http.cpp @@ -1107,8 +1107,12 @@ Http::Response Http::downloadRequest( const Http::Request& request, IOStream& wr } // Close the connection - if ( !mConnection->isKeepAlive() ) + if ( !mConnection->isKeepAlive() ) { mConnection->disconnect(); + HttpConnection* connection = mConnection; + eeSAFE_DELETE( connection ); + mConnection = NULL; + } } return received; diff --git a/src/tools/ecode/appconfig.cpp b/src/tools/ecode/appconfig.cpp index aae3bf473..3ff4bd463 100644 --- a/src/tools/ecode/appconfig.cpp +++ b/src/tools/ecode/appconfig.cpp @@ -218,7 +218,8 @@ void AppConfig::load( const std::string& confPath, std::string& keybindingsPath, ini.getValueB( "plugins", creator.first, "autocomplete" == creator.first || "linter" == creator.first || "autoformatter" == creator.first || "lspclient" == creator.first || - "git" == creator.first || "debugger" == creator.first ); + "git" == creator.first || "debugger" == creator.first || + "aiassistant" == creator.first ); } pluginManager->setPluginsEnabled( pluginsEnabled, sync ); diff --git a/src/tools/ecode/ecode.cpp b/src/tools/ecode/ecode.cpp index 372186186..3ce52d878 100644 --- a/src/tools/ecode/ecode.cpp +++ b/src/tools/ecode/ecode.cpp @@ -17,6 +17,7 @@ #include //! Plugins +#include "plugins/aiassistant/aiassistantplugin.hpp" #include "plugins/autocomplete/autocompleteplugin.hpp" #include "plugins/debugger/debuggerplugin.hpp" #include "plugins/discordRPC/discordRPCplugin.hpp" @@ -606,6 +607,7 @@ void App::initPluginManager() { mPluginManager->registerPlugin( LSPClientPlugin::Definition() ); mPluginManager->registerPlugin( XMLToolsPlugin::Definition() ); mPluginManager->registerPlugin( GitPlugin::Definition() ); + mPluginManager->registerPlugin( AIAssistantPlugin::Definition() ); mPluginManager->registerPlugin( DiscordRPCplugin::Definition() ); } diff --git a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp new file mode 100644 index 000000000..be7fd1a76 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp @@ -0,0 +1,249 @@ +#include "aiassistantplugin.hpp" +#include "chatui.hpp" +#include "protocol.hpp" + +#include "../../widgetcommandexecuter.hpp" + +#include +#include + +using json = nlohmann::json; + +#if EE_PLATFORM != EE_PLATFORM_EMSCRIPTEN || defined( __EMSCRIPTEN_PTHREADS__ ) +#define AIASSISTANT_THREADED 1 +#else +#define AIASSISTANT_THREADED 0 +#endif + +namespace ecode { + +static std::initializer_list AIAssistantCommandList = { + "new-ai-assistant", +}; + +static std::map parseLLMProviders( const nlohmann::json& j ) { + std::map providers; + for ( const auto& item : j.items() ) { + std::string providerName = item.key(); + const auto& providerJson = item.value(); + + LLMProvider provider; + provider.name = providerName; + provider.enabled = providerJson.value( "enabled", true ); + provider.openApi = providerJson.value( "open_api", false ); + + if ( providerJson.contains( "display_name" ) ) + provider.displayName = providerJson["display_name"].get(); + + provider.apiUrl = providerJson["api_url"].get(); + + if ( providerJson.contains( "fetch_models_url" ) ) { + provider.fetchModelsUrl = providerJson["fetch_models_url"].get(); + } + + if ( providerJson.contains( "version" ) ) { + provider.version = providerJson["version"].get(); + } + + if ( providerJson.contains( "models" ) ) { + const auto& modelsJson = providerJson["models"]; + for ( const auto& modelJson : modelsJson ) { + LLMModel model; + model.name = modelJson["name"].get(); + model.provider = providerName; + + // Optional fields for the model + if ( modelJson.contains( "display_name" ) ) { + model.displayName = modelJson["display_name"].get(); + } + if ( modelJson.contains( "max_tokens" ) ) { + model.maxTokens = modelJson["max_tokens"].get(); + } + if ( modelJson.contains( "max_output_tokens" ) ) { + model.maxOutputTokens = modelJson["max_output_tokens"].get(); + } + if ( modelJson.contains( "default_temperature" ) ) { + model.defaultTemperature = modelJson["default_temperature"].get(); + } + if ( modelJson.contains( "cache_configuration" ) && + !modelJson["cache_configuration"].is_null() ) { + const auto& cacheJson = modelJson["cache_configuration"]; + LLMCacheConfiguration cache; + cache.maxCacheAnchors = cacheJson["max_cache_anchors"].get(); + cache.minTotalToken = cacheJson["min_total_token"].get(); + cache.shouldSpeculate = cacheJson["should_speculate"].get(); + model.cacheConfiguration = cache; + } + + provider.models.push_back( model ); + } + } + + providers[providerName] = provider; + } + + return providers; +} + +Plugin* AIAssistantPlugin::New( PluginManager* pluginManager ) { + return eeNew( AIAssistantPlugin, ( pluginManager, false ) ); +} + +Plugin* AIAssistantPlugin::NewSync( PluginManager* pluginManager ) { + return eeNew( AIAssistantPlugin, ( pluginManager, true ) ); +} + +AIAssistantPlugin::AIAssistantPlugin( PluginManager* pluginManager, bool sync ) : + PluginBase( pluginManager ) { + if ( sync ) { + load( pluginManager ); + } else { +#if defined( AIASSISTANT_THREADED ) && AIASSISTANT_THREADED == 1 + mThreadPool->run( [this, pluginManager] { load( pluginManager ); } ); +#else + load( pluginManager ); +#endif + } +} + +AIAssistantPlugin::~AIAssistantPlugin() { + waitUntilLoaded(); + mShuttingDown = true; +} + +void AIAssistantPlugin::load( PluginManager* pluginManager ) { + Clock clock; + AtomicBoolScopedOp loading( mLoading, true ); + pluginManager->subscribeMessages( this, + [this]( const auto& notification ) -> PluginRequestHandle { + return processMessage( notification ); + } ); + + std::vector paths; + std::string path( pluginManager->getResourcesPath() + "plugins/aiassistant.json" ); + if ( FileSystem::fileExists( path ) ) + paths.emplace_back( path ); + path = pluginManager->getPluginsPath() + "aiassistant.json"; + if ( FileSystem::fileExists( path ) || + FileSystem::fileWrite( + path, "{\n\"config\":{},\n \"keybindings\":{},\n\"providers\":[]\n}\n" ) ) { + mConfigPath = path; + paths.emplace_back( path ); + } + if ( paths.empty() ) + return; + for ( const auto& tpath : paths ) { + try { + loadAIAssistantConfig( tpath, mConfigPath == tpath ); + } catch ( const json::exception& e ) { + Log::error( "Parsing linter \"%s\" failed:\n%s", tpath.c_str(), e.what() ); + } + } + + subscribeFileSystemListener(); + mReady = !mProviders.empty(); + if ( mReady ) { + fireReadyCbs(); + setReady( clock.getElapsedTime() ); + } +} + +void AIAssistantPlugin::loadAIAssistantConfig( const std::string& path, bool updateConfigFile ) { + std::string data; + if ( !FileSystem::fileGet( path, data ) ) + return; + json j; + try { + j = json::parse( data, nullptr, true, true ); + } catch ( const json::exception& e ) { + Log::error( + "AIAssistantPlugin::loadAIAssistantConfig - Error parsing AI assistant config from " + "path %s, error: %s, config file content:\n%s", + path.c_str(), e.what(), data.c_str() ); + if ( !updateConfigFile ) + return; + // Recreate it + j = json::parse( "{\n\"config\":{},\n \"keybindings\":{},\n\"providers\":[]\n}\n", nullptr, + true, true ); + } + + if ( updateConfigFile ) { + mConfigHash = String::hash( data ); + } + + if ( j.contains( "config" ) ) { + // auto& config = j["config"]; + } + + if ( mKeyBindings.empty() ) { + // mKeyBindings["new-ai-assistant"] = "mod+shift+n"; + } + + auto& kb = j["keybindings"]; + for ( const auto& key : AIAssistantCommandList ) { + if ( kb.contains( key ) ) { + if ( !kb[key].empty() ) + mKeyBindings[key] = kb[key]; + } else if ( updateConfigFile ) + kb[key] = mKeyBindings[key]; + } + + if ( updateConfigFile ) { + std::string newData( j.dump( 2 ) ); + if ( newData != data ) { + FileSystem::fileWrite( path, newData ); + mConfigHash = String::hash( newData ); + } + } + + if ( !j.contains( "providers" ) ) + return; + + auto providers = parseLLMProviders( j["providers"] ); + if ( mProviders.empty() ) { + mProviders = std::move( providers ); + } else { + for ( const auto& [key, value] : providers ) + mProviders.insert_or_assign( key, value ); + } +} + +void AIAssistantPlugin::onRegisterDocument( TextDocument* doc ) { + doc->setCommand( "new-ai-assistant", [this] { + auto splitter = getPluginContext()->getSplitter(); + auto chatUI = eeNew( ChatUI, ( getPluginContext()->getUISceneNode(), mProviders ) ); + if ( !splitter->hasSplit() ) + splitter->split( SplitDirection::Right, splitter->getCurWidget(), false ); + splitter->createWidget( chatUI->getChatUI(), i18n( "ai_assistant", "AI Assistant" ) ); + } ); +} + +void AIAssistantPlugin::onRegisterEditor( UICodeEditor* editor ) { + editor->addUnlockedCommands( AIAssistantCommandList ); + PluginBase::onRegisterEditor( editor ); +} + +void AIAssistantPlugin::onUnregisterEditor( UICodeEditor* editor ) { + editor->removeUnlockedCommands( AIAssistantCommandList ); +} + +PluginRequestHandle AIAssistantPlugin::processMessage( const PluginMessage& msg ) { + switch ( msg.type ) { + case ecode::PluginMessageType::UIReady: { + for ( const auto& kb : mKeyBindings ) { + getPluginContext()->getMainLayout()->getKeyBindings().addKeybindString( kb.second, + kb.first ); + } + + // if ( !mInitialized ) + // updateUI(); + + break; + } + default: + break; + } + return PluginRequestHandle::empty(); +} + +} // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp new file mode 100644 index 000000000..5f49a0f25 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "../plugin.hpp" +#include "../pluginmanager.hpp" +#include "protocol.hpp" + +namespace ecode { + +class AIAssistantPlugin : public PluginBase { + public: + static PluginDefinition Definition() { + return { "aiassistant", "AI Assistant", "Chat with your favorite AI assistant", + AIAssistantPlugin::New, { 0, 0, 1 }, AIAssistantPlugin::NewSync }; + } + + static Plugin* New( PluginManager* pluginManager ); + + static Plugin* NewSync( PluginManager* pluginManager ); + + virtual ~AIAssistantPlugin(); + + std::string getId() override { return Definition().id; } + + std::string getTitle() override { return Definition().name; } + + std::string getDescription() override { return Definition().description; } + + protected: + LLMProviders mProviders; + + AIAssistantPlugin( PluginManager* pluginManager, bool sync ); + + void load( PluginManager* pluginManager ); + + PluginRequestHandle processMessage( const PluginMessage& msg ); + + void loadAIAssistantConfig( const std::string& path, bool updateConfigFile ); + + void onRegisterEditor( UICodeEditor* editor ) override; + + void onUnregisterEditor( UICodeEditor* editor ) override; + + void onRegisterDocument( TextDocument* doc ) override; +}; + +} // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp new file mode 100644 index 000000000..5ce83399b --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -0,0 +1,604 @@ +#include "chatui.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace EE::Window; + +namespace ecode { + +const char* LLMChat::roleToString( Role role ) { + switch ( role ) { + case Role::User: + return "user"; + case Role::Assistant: + return "assistant"; + case Role::System: + return "system"; + case Role::Tool: + return "tool"; + } + return ""; +} + +LLMChat::Role LLMChat::stringToRole( UIPushButton* userBut ) { + if ( userBut->getText() == userBut->i18n( "user", "User" ) ) { + return Role::User; + } else if ( userBut->getText() == userBut->i18n( "assistant", "Assistant" ) ) { + return Role::Assistant; + } else if ( userBut->getText() == userBut->i18n( "system", "System" ) ) { + return Role::System; + } + return Role::User; +} + +static const char* DEFAULT_LAYOUT = R"xml( + + + + + + + + + + + + + + + + + + + + + + + +)xml"; + +static const char* DEFAULT_CHAT_GLOBE = R"xml( + + + + @string(User, user) + @string(Assistant, assistant) + @string(system, System) + + + + + + + +)xml"; + +ChatUI::ChatUI( UISceneNode* ui, LLMProviders providers ) { + setProviders( std::move( providers ) ); + + mChatUI = ui->loadLayoutFromString( DEFAULT_LAYOUT ); + mChatsList = mChatUI->findByClass( "llm_chats" ); + mModelDDL = mChatUI->findByClass( "model_ui" ); + + mChatUI->find( "refresh_model_ui" )->onClick( [this]( auto ) { fillApiModels( mModelDDL ); } ); + + fillModelDropDownList( mModelDDL ); + + mChatScrollView = mChatUI->findByClass( "llm_chat_scrollview" )->asType(); + mChatScrollView->getVerticalScrollBar()->setValue( 1 ); + + mChatInput = mChatUI->findByClass( "llm_chat_input" ); + mChatInput->getKeyBindings().addKeybindString( "mod+return", "prompt" ); + mChatInput->getKeyBindings().addKeybindString( "mod+keypad enter", "prompt" ); + + mChatInput->getKeyBindings().addKeybindString( "mod+shift+return", "add_chat" ); + mChatInput->getKeyBindings().addKeybindString( "mod+shift+keypad enter", "add_chat" ); + + mChatInput->getDocument().setCommand( "add_chat", [this] { + if ( mChatInput->getDocument().isEmpty() ) + return; + + addChat( LLMChat::stringToRole( mChatUserRole ), + mChatInput->getDocument().getText().toUtf8() ); + mChatInput->getDocument().selectAll(); + mChatInput->getDocument().textInput( String{} ); + mChatInput->setFocus(); + } ); + + mChatInput->getDocument().setCommand( "prompt", [this] { + auto chats = mChatUI->findAllByClass( "llm_conversation" ); + + if ( chats.empty() && mChatInput->getDocument().isEmpty() ) + return; + + auto inputUserRole = LLMChat::stringToRole( mChatUserRole ); + if ( ( !chats.empty() && + ( mChatInput->getDocument().isEmpty() || inputUserRole != LLMChat::Role::User ) ) || + ( chats.empty() && inputUserRole != LLMChat::Role::User ) ) { + if ( chats[chats.size() - 1] + ->findByClass( "role_ui" ) + ->asType() + ->getListBox() + ->getItemSelectedIndex() != 0 ) { + showMsg( mChatUI->getUISceneNode()->i18n( + "llm_last_message_must_be_from_user", + "The last chat message must be from a \"User\" role" ) ); + } + } + + mChatInput->getDocument().execute( "add_chat" ); + doRequest(); + } ); + + mChatInput->getDocument().setCommand( "prompt-stop", [this] { + if ( mRequest ) + mRequest->cancel(); + } ); + + mChatUI->find( "llm_add_chat" )->onClick( [this]( auto ) { + mChatInput->getDocument().execute( "add_chat" ); + } ); + + const auto& markdown = SyntaxDefinitionManager::instance()->getByLSPName( "markdown" ); + mChatInput->setShowFoldingRegion( true ); + mChatInput->getDocument().getFoldRangeService().setEnabled( true ); + mChatInput->setFoldDrawable( findIcon( "chevron-down", PixelDensity::dpToPxI( 12 ) ) ); + mChatInput->setFoldedDrawable( findIcon( "chevron-right", PixelDensity::dpToPxI( 12 ) ) ); + + mChatInput->setSyntaxDefinition( markdown ); + + mChatRun = mChatUI->find( "llm_run" ); + mChatRun->onClick( [this]( auto ) { mChatInput->getDocument().execute( "prompt" ); } ); + + mChatStop = mChatUI->find( "llm_stop" ); + mChatStop->onClick( [this]( auto ) { mChatInput->getDocument().execute( "prompt-stop" ); } ); + + mChatUserRole = mChatUI->find( "llm_user" ); + mChatUserRole->onClick( [this]( auto ) { + if ( mChatUserRole->getText() == mChatUserRole->i18n( "user", "User" ) ) { + mChatUserRole->setText( mChatUserRole->i18n( "assistant", "Assistant" ) ); + } else if ( mChatUserRole->getText() == mChatUserRole->i18n( "assistant", "Assistant" ) ) { + mChatUserRole->setText( mChatUserRole->i18n( "system", "System" ) ); + } else if ( mChatUserRole->getText() == mChatUserRole->i18n( "system", "System" ) ) { + mChatUserRole->setText( mChatUserRole->i18n( "user", "User" ) ); + } + } ); + + mChatUI->on( Event::OnClose, [this]( auto ) { eeDelete( this ); } ); +} + +void ChatUI::fillApiModels( UIDropDownList* modelDDL ) { + for ( auto& [name, data] : mProviders ) { + if ( !data.enabled || !data.fetchModelsUrl ) + continue; + + auto res = Http::get( *data.fetchModelsUrl, Milliseconds( 100 ) ); + if ( res.getStatus() != Http::Response::Status::Ok ) + continue; + + nlohmann::json j = nlohmann::json::parse( res.getBody(), nullptr, false, true ); + + if ( !( j.contains( "data" ) && j["data"].is_array() ) && + !( j.contains( "models" ) && j["models"].is_array() ) ) + continue; + + data.models.erase( + std::remove_if( data.models.begin(), data.models.end(), + []( const LLMModel& model ) { return model.isEphemeral; } ), + data.models.end() ); + + const auto& jdata = j.contains( "data" ) ? j["data"] : j["models"]; + + for ( const auto& el : jdata ) { + LLMModel model; + model.provider = name; + model.name = el.contains( "model" ) ? el.value( "model", "" ) : el.value( "id", "" ); + + if ( el.contains( "name" ) ) + model.displayName = el.value( "name", "" ); + + model.isEphemeral = true; + + if ( model.name.empty() ) + continue; + + if ( el.contains( "max_context_length" ) ) + model.maxOutputTokens = el.value( "max_context_length", 0 ); + + data.models.emplace_back( model ); + } + + std::string pname = name; + modelDDL->runOnMainThread( [pname = std::move( pname ), modelDDL, this] { + String providerName( pname ); + std::vector removeValues; + size_t count = modelDDL->getListBox()->getCount(); + for ( size_t i = 0; i < count; i++ ) { + const String& txt = modelDDL->getListBox()->getItemText( i ); + if ( txt.contains( providerName ) ) + removeValues.emplace_back( txt ); + } + + for ( const auto& val : removeValues ) + modelDDL->getListBox()->removeListBoxItem( val ); + + const auto& models = mProviders[pname].models; + std::vector newModels; + for ( const auto& model : models ) { + if ( !model.isEphemeral ) + continue; + newModels.emplace_back( String::format( "%s (%s)", model.name, pname ) ); + mModelsMap[newModels[newModels.size() - 1].getHash()] = model; + } + + modelDDL->getListBox()->addListBoxItems( newModels ); + } ); + } +} + +void ChatUI::fillModelDropDownList( UIDropDownList* modelDDL ) { + std::vector models; + std::size_t selectedIndex = 0; + for ( const auto& [name, data] : mProviders ) { + if ( !data.enabled ) + continue; + + for ( const auto& model : data.models ) { + String modelName( String::format( + "%s (%s)", model.displayName ? *model.displayName : model.name, + data.displayName ? *data.displayName : String::capitalize( data.name ) ) ); + mModelsMap[modelName.getHash()] = model; + if ( model.provider == "openai" && model.name == "gpt-4o" ) { + mCurModel = model; + selectedIndex = models.size(); + } + models.push_back( std::move( modelName ) ); + } + } + modelDDL->getListBox()->addListBoxItems( std::move( models ) ); + modelDDL->getListBox()->setSelected( selectedIndex ); + modelDDL->on( Event::OnValueChange, [this, modelDDL]( auto ) { + auto selectedModel = + mModelsMap.find( modelDDL->getListBox()->getItemSelectedText().getHash() ); + if ( selectedModel != mModelsMap.end() ) { + mCurModel = selectedModel->second; + } + } ); + + modelDDL->getUISceneNode()->getThreadPool()->run( + [this, modelDDL] { fillApiModels( modelDDL ); } ); +} + +void ChatUI::resizeToFit( UICodeEditor* editor ) { + Float visibleLineCount = editor->getDocumentView().getVisibleLinesCount(); + Float lineHeight = editor->getLineHeight(); + Float height = lineHeight * visibleLineCount + editor->getPixelsPadding().Top + + editor->getPixelsPadding().Bottom; + editor->setPixelsSize( editor->getPixelsSize().getWidth(), height ); +} + +nlohmann::json ChatUI::chatToJson( const std::string& /*provider*/ ) { + auto j = nlohmann::json::array(); + auto chats = mChatUI->findAllByClass( "llm_conversation" ); + for ( const auto& chat : chats ) { + UIDropDownList* roleDDL = chat->findByClass( "role_ui" ); + UICodeEditor* codeEditor = chat->findByClass( "data_ui" ); + std::string role = LLMChat::roleToString( + static_cast( roleDDL->getListBox()->getItemSelectedIndex() ) ); + auto text = codeEditor->getDocument().getText().toUtf8(); + + if ( text.empty() ) + continue; + + j.push_back( { { "role", role }, { "content", std::move( text ) } } ); + } + return j; +} + +nlohmann::json ChatUI::serialize( const std::string& /*provider*/ ) { + nlohmann::json j = { { "model", mCurModel.name }, + { "stream", true }, + { "messages", chatToJson( mCurModel.provider ) } }; + if ( mCurModel.maxOutputTokens ) + j["max_tokens"] = *mCurModel.maxOutputTokens; + return j; +} + +void unserialize( const nlohmann::json& /*payload*/ ) {} + +const char* ChatUI::getApiKeyFromProvider( const std::string& provider ) { + static const char* OPEN_API_KEY = ""; + if ( provider == "openai" ) + return getenv( "OPENAI_API_KEY" ); + if ( provider == "anthropic" ) + return getenv( "ANTHROPIC_API_KEY" ); + if ( provider == "google" ) { + const char* apiKey = getenv( "GOOGLE_AI_API_KEY" ); + if ( apiKey != nullptr ) + return apiKey; + return getenv( "GEMINI_API_KEY" ); + } + if ( provider == "deepseek" ) + return getenv( "DEEPSEEK_API_KEY" ); + if ( provider == "mistral" ) + return getenv( "MISTRAL_API_KEY" ); + if ( provider == "lmstudio" || provider == "ollama" ) + return OPEN_API_KEY; + if ( provider == "xai" ) { + const char* apiKey = getenv( "XAI_API_KEY" ); + if ( apiKey != nullptr ) + return apiKey; + return getenv( "GROK_API_KEY" ); + } + return nullptr; +} + +std::string ChatUI::prepareApiUrl( const std::string& apiKey ) { + const auto& provider = mProviders[mCurModel.provider]; + std::string url = provider.apiUrl; + String::replaceAll( url, "${model}", mCurModel.name ); + String::replaceAll( url, "${api_key}", apiKey ); + return url; +} + +void ChatUI::doRequest() { + if ( mRequest ) + return; + + const char* apiKey = getApiKeyFromProvider( mCurModel.provider ); + if ( apiKey == nullptr ) { + showMsg( mChatUI->getUISceneNode()->i18n( + "configure_api_key", "You must first configure your provider api key." ) ); + return; + } + std::string apiKeyStr{ apiKey }; + + mChatRun->setVisible( false )->setEnabled( false ); + mChatStop->setVisible( true )->setEnabled( true ); + + UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); + toggleEnableChats( false ); + + auto* editor = chat->findByClass( "data_ui" ); + mRequest = std::make_unique( prepareApiUrl( apiKeyStr ), apiKeyStr, + serialize( mCurModel.provider ).dump(), + mCurModel.provider ); + mRequest->streamedResponseCb = [this, editor]( const std::string& chunk ) { + auto conversation = chunk; + editor->runOnMainThread( [this, conversation = std::move( conversation ), editor] { + editor->getDocument().textInput( String::fromUtf8( conversation ) ); + editor->setCursorVisible( false ); + resizeToFit( editor ); + } ); + }; + mRequest->doneCb = [this, editor]( const LLMChatCompletionRequest&, Http::Response& response ) { + auto status = response.getStatus(); + auto statusDesc = response.getStatusDescription(); + + mChatUI->runOnMainThread( [this, editor, status, statusDesc] { + if ( status != Http::Response::Ok ) { + auto resp = nlohmann::json::parse( mRequest->getStream(), nullptr, false ); + if ( resp.contains( "error" ) && resp["error"].contains( "message" ) ) { + showMsg( resp["error"].value( "message", "" ) ); + } else if ( resp.contains( "error" ) && resp["error"].is_string() ) { + showMsg( URI::decode( resp.value( "error", "" ) ) ); + } else { + showMsg( statusDesc ); + } + + removeLastChat(); + } + mRequest.reset(); + toggleEnableChats( true ); + + mChatStop->setVisible( false )->setEnabled( false ); + mChatRun->setVisible( true )->setEnabled( true ); + + if ( editor->hasFocus() ) + mChatInput->setFocus(); + } ); + }; + mRequest->requestAsync(); +} + +void ChatUI::toggleEnableChat( UIWidget* chat, bool enabled ) { + chat->findByClass( "role_ui" )->setEnabled( enabled ); + UICodeEditor* editor = chat->findByClass( "data_ui" )->asType(); + editor->setEnabled( enabled ); + editor->setLocked( !enabled ); + chat->findByClass( "erase_but" )->setEnabled( enabled ); + chat->findByClass( "move_up" )->setEnabled( enabled ); + chat->findByClass( "move_down" )->setEnabled( enabled ); +} + +void ChatUI::toggleEnableChats( bool enabled ) { + auto chats = mChatsList->findAllByClass( "llm_conversation" ); + for ( auto chat : chats ) + toggleEnableChat( chat, enabled ); +} + +Drawable* ChatUI::findIcon( const std::string& name, const size_t iconSize ) { + if ( name.empty() ) + return nullptr; + UIIcon* icon = mChatUI->getUISceneNode()->findIcon( name ); + if ( icon ) + return icon->getSize( iconSize ); + return nullptr; +} + +UIWidget* ChatUI::addChatUI( LLMChat::Role role ) { + UIWidget* chat = + mChatsList->getUISceneNode()->loadLayoutFromString( DEFAULT_CHAT_GLOBE, mChatsList ); + auto* roleDDL = chat->findByClass( "role_ui" )->asType(); + auto* roleListBox = chat->findByClass( "role_ui" )->asType()->getListBox(); + switch ( role ) { + case LLMChat::Role::System: + roleListBox->setSelected( 2 ); + break; + case LLMChat::Role::User: + chat->findByClass( "llm_conversation" )->addClass( "user" ); + roleListBox->setSelected( 0 ); + break; + case LLMChat::Role::Assistant: + roleListBox->setSelected( 1 ); + break; + case LLMChat::Role::Tool: + break; + } + roleListBox->on( Event::OnItemSelected, [roleDDL, chat]( auto ) { + if ( roleDDL->getListBox()->getItemSelectedIndex() == 0 ) { + chat->addClass( "user" ); + } else { + chat->removeClass( "user" ); + } + } ); + auto* editor = chat->findByClass( "data_ui" ); + const auto& markdown = SyntaxDefinitionManager::instance()->getByLSPName( "markdown" ); + editor->setDisableCursorBlinkingAfterAMinuteOfInactivity( false ); + editor->setCursorBlinkTime( Time::Zero ); + editor->setSyntaxDefinition( markdown ); + editor->setShowFoldingRegion( true ); + editor->getDocument().getFoldRangeService().setEnabled( true ); + editor->setFoldDrawable( findIcon( "chevron-down", PixelDensity::dpToPxI( 12 ) ) ); + editor->setFoldedDrawable( findIcon( "chevron-right", PixelDensity::dpToPxI( 12 ) ) ); + + editor->on( Event::OnSizeChange, [editor, this]( auto ) { resizeToFit( editor ); } ); + editor->on( Event::OnVisibleLinesCountChange, + [editor, this]( auto ) { resizeToFit( editor ); } ); + chat->findByClass( "erase_but" )->onClick( [chat]( auto ) { chat->close(); } ); + chat->findByClass( "move_up" )->onClick( [chat]( auto ) { + if ( chat->getNodeIndex() > 0 ) + chat->toPosition( chat->getNodeIndex() - 1 ); + } ); + chat->findByClass( "move_down" )->onClick( [chat]( auto ) { + if ( chat->getNodeIndex() < chat->getParent()->getChildCount() - 1 ) + chat->toPosition( chat->getNodeIndex() + 1 ); + } ); + resizeToFit( editor ); + return chat; +} + +void ChatUI::addChat( LLMChat::Role role, std::string conversation ) { + UIWidget* chat = addChatUI( role ); + auto* editor = chat->findByClass( "data_ui" ); + editor->getDocument().textInput( String::fromUtf8( conversation ) ); + editor->setCursorVisible( false ); + resizeToFit( editor ); +} + +void ChatUI::removeLastChat() { + auto chats = mChatsList->findAllByClass( "llm_conversation" ); + if ( !chats.empty() ) { + auto* chat = chats[chats.size() - 1]; + auto* editor = chat->findByClass( "data_ui" ); + if ( editor->getDocument().isEmpty() ) + chat->close(); + } +} + +void ChatUI::setProviders( LLMProviders&& providers ) { + mProviders = std::move( providers ); +} + +UIWidget* ChatUI::getChatUI() { + return mChatUI; +} + +void ChatUI::showMsg( String msg ) { + auto msgBox = UIMessageBox::New( UIMessageBox::OK, msg ); + msgBox->getTextBox()->setTextSelection( true ); + msgBox->getTextBox()->onClick( + []( const MouseEvent* event ) { + auto tv = event->getNode()->asType(); + tv->getUISceneNode()->getWindow()->getClipboard()->setText( tv->getText() ); + }, + EE_BUTTON_RIGHT ); + msgBox->showWhenReady(); +} + +} // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp new file mode 100644 index 000000000..6c545aacc --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include "llmchatcompletionrequest.hpp" +#include "protocol.hpp" + +#include "nlohmann/json_fwd.hpp" + +namespace EE { namespace UI { +class UIWidget; +class UICodeEditor; +class UIScrollView; +class UISceneNode; +class UIDropDownList; +class UIPushButton; +}} // namespace EE::UI + +namespace EE { namespace Graphics { +class Drawable; +}} // namespace EE::Graphics + +using namespace EE; +using namespace EE::UI; +using namespace EE::Graphics; + +namespace ecode { + +class LLMChat { + public: + enum class Role { + User, + Assistant, + System, + Tool, + }; + + static const char* roleToString( Role role ); + + static LLMChat::Role stringToRole( UIPushButton* userBut ); +}; + +class ChatUI { + public: + ChatUI( UISceneNode* ui, LLMProviders providers ); + + nlohmann::json serialize( const std::string& /*provider*/ ); + + void unserialize( const nlohmann::json& /*payload*/ ); + + const char* getApiKeyFromProvider( const std::string& provider ); + + UIWidget* getChatUI(); + + protected: + UIWidget* mChatUI{ nullptr }; + UIWidget* mChatsList{ nullptr }; + UICodeEditor* mChatInput{ nullptr }; + UIPushButton* mChatUserRole{ nullptr }; + UIPushButton* mChatRun{ nullptr }; + UIPushButton* mChatStop{ nullptr }; + UIScrollView* mChatScrollView{ nullptr }; + UIDropDownList* mModelDDL{ nullptr }; + std::unique_ptr mRequest; + LLMProviders mProviders; + LLMModel mCurModel; + std::unordered_map mModelsMap; + + void showMsg( String msg ); + + nlohmann::json chatToJson( const std::string& /*provider*/ ); + + std::string prepareApiUrl( const std::string& apiKey ); + + void doRequest(); + + void toggleEnableChat( UIWidget* chat, bool enabled ); + + void toggleEnableChats( bool enabled ); + + Drawable* findIcon( const std::string& name, const size_t iconSize ); + + UIWidget* addChatUI( LLMChat::Role role ); + + void fillApiModels( UIDropDownList* modelDDL ); + + void fillModelDropDownList( UIDropDownList* modelDDL ); + + void resizeToFit( UICodeEditor* editor ); + + void addChat( LLMChat::Role role, std::string conversation ); + + void removeLastChat(); + + void setProviders( LLMProviders&& providers ); +}; + +} // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/llmchatcompletionrequest.cpp b/src/tools/ecode/plugins/aiassistant/llmchatcompletionrequest.cpp new file mode 100644 index 000000000..db5ca79be --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/llmchatcompletionrequest.cpp @@ -0,0 +1,110 @@ +#include "llmchatcompletionrequest.hpp" + +#include + +namespace ecode { + +LLMChatCompletionRequest::LLMChatCompletionRequest( const std::string& uri, const std::string& auth, + const std::string& reqBody, + const std::string& provider ) : + mUrl( uri ) { + mHttp.setHost( mUrl ); + mRequest.setUri( mUrl.getPathEtc() ); + mRequest.setHeader( "Content-Type", "application/json" ); + if ( provider == "anthropic" ) { + mRequest.setHeader( "x-api-key", auth ); + mRequest.setHeader( "anthropic-version", "2023-06-01" ); + } else if ( !auth.empty() ) { + mRequest.setHeader( "Authorization", String::format( "Bearer %s", auth ) ); + } + mRequest.setBody( reqBody ); + mRequest.setFollowRedirect( true ); + mRequest.setMethod( Http::Request::Method::Post ); + mRequest.setProgressCallback( [this]( const Http&, const Http::Request&, const Http::Response&, + const Http::Request::Status& status, size_t, size_t ) { + if ( mCancel ) { + mRequest.cancel(); + return false; + } + if ( status != Http::Request::ContentReceived ) + return true; + std::string chunk = + mReadBytes ? mStream.getStream().substr( mReadBytes ) : mStream.getStream(); + mReadBytes = mStream.getStream().size(); + String::readBySeparator( chunk, [this]( std::string_view subchunk ) { + if ( subchunk.empty() ) + return; + // OpenAI format? + if ( !String::startsWith( subchunk, "data: " ) ) { + // ollama format? + if ( !subchunk.empty() && subchunk[0] == '{' ) { + nlohmann::json j = nlohmann::json::parse( subchunk.begin(), subchunk.end(), + nullptr, false, true ); + + if ( j.contains( "message" ) && j["message"].contains( "content" ) ) { + const auto& msg = j["message"]; + std::string delta = msg["content"]; + if ( delta.empty() ) + return; + if ( streamedResponseCb ) + streamedResponseCb( delta ); + mResponse += std::move( delta ); + } + } + + return; + } + std::string jsonStr( subchunk.substr( 6 ) ); + if ( jsonStr == "[DONE]" ) + return; + nlohmann::json data = nlohmann::json::parse( jsonStr, nullptr, false, true ); + + // OpenAI + if ( data.contains( "choices" ) && data["choices"].is_array() ) { + nlohmann::json& choices = data["choices"]; + + for ( const auto& choice : choices ) { + if ( choice["delta"].contains( "content" ) ) { + std::string delta = choice["delta"]["content"]; + if ( streamedResponseCb ) + streamedResponseCb( delta ); + mResponse += std::move( delta ); + } + } + // Anthropic + } else if ( data.contains( "delta" ) && + data["delta"].value( "type", "" ) == "text_delta" ) { + std::string delta = data["delta"]["text"]; + if ( streamedResponseCb ) + streamedResponseCb( delta ); + mResponse += std::move( delta ); + } + } ); + return true; + } ); +} + +void LLMChatCompletionRequest::request() { + Http::Response res = mHttp.downloadRequest( mRequest, mStream, Seconds( 5 ) ); + if ( doneCb ) + doneCb( *this, res ); +} + +void LLMChatCompletionRequest::requestAsync() { + mHttp.downloadAsyncRequest( + [this]( const Http&, Http::Request&, Http::Response& res ) { + if ( doneCb ) + doneCb( *this, res ); + }, + mRequest, mStream, Seconds( 5 ) ); +} + +void LLMChatCompletionRequest::cancel() { + mCancel = true; +} + +const std::string& LLMChatCompletionRequest::getStream() { + return mStream.getStream(); +} + +} // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/llmchatcompletionrequest.hpp b/src/tools/ecode/plugins/aiassistant/llmchatcompletionrequest.hpp new file mode 100644 index 000000000..4cc25e12b --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/llmchatcompletionrequest.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#include +#include +#include + +using namespace EE; +using namespace EE::Network; + +namespace ecode { + +class LLMChatCompletionRequest { + public: + using StreamedResponseCb = std::function; + + using StreamedResponseDoneCb = + std::function; + + StreamedResponseCb streamedResponseCb; + + StreamedResponseDoneCb doneCb; + + LLMChatCompletionRequest( const std::string& uri, const std::string& auth, + const std::string& reqBody, const std::string& provider ); + + void request(); + + void requestAsync(); + + void cancel(); + + const std::string& getStream(); + + protected: + URI mUrl; + Http mHttp; + Http::Request mRequest; + IOStreamString mStream; + std::string mResponse; + size_t mReadBytes{ 0 }; + bool mCancel{ false }; +}; + +} // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/protocol.hpp b/src/tools/ecode/plugins/aiassistant/protocol.hpp new file mode 100644 index 000000000..ca7b087b1 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/protocol.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +namespace ecode { + +struct LLMCacheConfiguration { + int maxCacheAnchors; + int minTotalToken; + bool shouldSpeculate; +}; + +struct LLMModel { + std::string name; + std::string provider; + std::optional displayName; + std::optional maxTokens; + std::optional maxOutputTokens; + std::optional defaultTemperature; + std::optional cacheConfiguration; + bool isEphemeral{ false }; +}; + +struct LLMProvider { + bool enabled{ true }; + bool openApi{ false }; + std::string name; + std::optional displayName; + std::string apiUrl; + std::optional fetchModelsUrl; + std::optional version; + std::vector models; +}; + +using LLMProviders = std::map; + +} // namespace ecode