mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-08-11 17:17:42 +03:00
* A crash during debugging that happens due to invalid memory access (UIAbstractView + VariablesModel changes). * A crash on ChatUI when closing a chat that was about to receive a server response.
1693 lines
60 KiB
C++
1693 lines
60 KiB
C++
#include "chatui.hpp"
|
|
#include "../../appconfig.hpp"
|
|
#include "../../notificationcenter.hpp"
|
|
#include "../../projectdirectorytree.hpp"
|
|
#include "../../widgetcommandexecuter.hpp"
|
|
#include "aiassistantplugin.hpp"
|
|
#include "chathistory.hpp"
|
|
|
|
#include <eepp/system/filesystem.hpp>
|
|
#include <eepp/ui/doc/syntaxdefinitionmanager.hpp>
|
|
#include <eepp/ui/uicodeeditor.hpp>
|
|
#include <eepp/ui/uidropdownlist.hpp>
|
|
#include <eepp/ui/uiicon.hpp>
|
|
#include <eepp/ui/uiloader.hpp>
|
|
#include <eepp/ui/uimessagebox.hpp>
|
|
#include <eepp/ui/uipopupmenu.hpp>
|
|
#include <eepp/ui/uipushbutton.hpp>
|
|
#include <eepp/ui/uiscenenode.hpp>
|
|
#include <eepp/ui/uiscrollview.hpp>
|
|
#include <eepp/ui/uitableview.hpp>
|
|
#include <eepp/ui/uitooltip.hpp>
|
|
#include <eepp/window/clipboard.hpp>
|
|
#include <eepp/window/window.hpp>
|
|
|
|
#include <chrono>
|
|
#include <nlohmann/json.hpp>
|
|
|
|
using namespace EE::System;
|
|
using namespace EE::Window;
|
|
|
|
namespace ecode {
|
|
|
|
static const char* DEFAULT_PROVIDER = "google";
|
|
static const char* DEFAULT_MODEL = "gemini-2.5-flash";
|
|
|
|
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( const std::string& roleStr ) {
|
|
if ( roleStr == "user" ) {
|
|
return Role::User;
|
|
} else if ( roleStr == "assistant" ) {
|
|
return Role::Assistant;
|
|
} else if ( roleStr == "system" ) {
|
|
return Role::System;
|
|
}
|
|
return Role::User;
|
|
}
|
|
|
|
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(
|
|
<style>
|
|
.llm_chatui DropDownList {
|
|
border-color: transparent;
|
|
background-color: var(--tab-back);
|
|
}
|
|
.llm_chatui DropDownList:hover {
|
|
border-color: var(--primary);
|
|
}
|
|
.llm_conversation {
|
|
margin-bottom: 8dp;
|
|
}
|
|
.llm_conversation > .llm_conversation_opt {
|
|
layout-gravity: left;
|
|
}
|
|
.llm_conversation.user > .llm_conversation_opt {
|
|
layout-gravity: right;
|
|
}
|
|
.llm_conversation {
|
|
margin-left: 0dp;
|
|
margin-right: 32dp;
|
|
}
|
|
.llm_conversation.user {
|
|
margin-left: 32dp;
|
|
margin-right: 0dp;
|
|
}
|
|
.llm_conversation CodeEditor,
|
|
.llm_chat_input {
|
|
padding: 8dp;
|
|
disable-editor-flags: editorfeatures|defaultstyle;
|
|
border-radius: 0dp 8dp 8dp 8dp;
|
|
line-wrap-mode: word;
|
|
line-wrap-type: viewport;
|
|
}
|
|
.llm_conversation.user CodeEditor,
|
|
.llm_chat_input {
|
|
border-radius: 8dp 0dp 8dp 8dp;
|
|
}
|
|
CodeEditor.llm_chat_input {
|
|
font-family: monospace;
|
|
border-radius: 8dp;
|
|
padding-bottom: 38dp;
|
|
}
|
|
.llm_chatui PushButton,
|
|
.llm_chatui SelectButton {
|
|
border-color: transparent;
|
|
text-as-fallback: true;
|
|
}
|
|
.llm_chatui PushButton:hover,
|
|
.llm_chatui SelectButton:hover {
|
|
border-color: var(--primary);
|
|
}
|
|
.llm_conversation_opt PushButton {
|
|
lh: 16dp;
|
|
text-as-fallback: true;
|
|
}
|
|
.llm_button {
|
|
text-as-fallback: true;
|
|
}
|
|
PushButton.llm_button.primary {
|
|
text-as-fallback: false;
|
|
}
|
|
DropDownList.role_ui {
|
|
gravity: left|center_vertical;
|
|
}
|
|
.llm_chatui .image {
|
|
tint: var(--font);
|
|
}
|
|
.llm_chat_attach {
|
|
padding: 8dp 8dp 38dp 8dp;
|
|
}
|
|
.llm_chat_locate_input {
|
|
margin-bottom: 2dp;
|
|
padding: 0 0 0 4dp;
|
|
}
|
|
.llm_chat_attach_locate {
|
|
border-radius: 8dp;
|
|
margin-bottom: 4dp;
|
|
}
|
|
.llm_chat_input {
|
|
background-image: none;
|
|
}
|
|
.llm_chat_input.incognito {
|
|
background-image: icon(spy-line, 16dp);
|
|
background-position: top 8dp right 8dp;
|
|
}
|
|
</style>
|
|
<Splitter lw="mp" lh="mp" orientation="vertical" splitter-partition="75%" padding="4dp">
|
|
<RelativeLayout lw="mp">
|
|
<ScrollView lw="mp" lh="mp" class="llm_chat_scrollview">
|
|
<vbox lw="mp" lh="wc" class="llm_chats"></vbox>
|
|
</ScrollView>
|
|
<vbox id="chat_presentation" lw="wc" lh="wc" layout-gravity="center" gravity="center">
|
|
<Image class="image" icon="icon(robot-2, 72dp)" gravity="center" layout-gravity="center" margin-bottom="16dp" />
|
|
<TextView text="@string(ai_llm_presentation, What can I help with?)" font-size="24dp" />
|
|
</vbox>
|
|
</RelativeLayout>
|
|
<RelativeLayout lw="mp" class="llm_controls" clip="true">
|
|
<CodeEditor class="llm_chat_input" lw="mp" lh="mp" />
|
|
<vboxce class="llm_chat_attach" lw="mp" lh="mp" visible="false">
|
|
<TableView lw="mp" lh="0dp" lw8="1" class="llm_chat_attach_locate" />
|
|
<TextInput class="llm_chat_locate_input" lw="mp" lh="18dp" hint='@string(type_to_locate, "Type to Locate")' />
|
|
</vboxce>
|
|
<hbox lw="mp" lh="wc" layout_gravity="bottom|left" layout_margin="8dp" clip="false">
|
|
<PushButton id="llm_user" class="llm_button" text="@string(user, User)" tooltip="@string(change_role, Change Role)" min-width="60dp" margin-right="4dp" />
|
|
<PushButton id="llm_attach" class="llm_button" text="@string(add_context, Add Context)" tooltip="@string(add_context, Add Context)" icon="icon(attach, 14dp)" min-width="32dp" margin-right="4dp" />
|
|
<PushButton id="llm_chat_history" class="llm_button" text="@string(chat_history, Chat History)" tooltip="@string(chat_history, Chat History)" icon="icon(chat-history, 14dp)" min-width="32dp" margin-right="4dp" />
|
|
<PushButton id="llm_more" class="llm_button" tooltip="@string(more_options, More Options)" icon="icon(more-fill, 14dp)" min-width="32dp" />
|
|
<hbox lw="0" lw8="1" lh="mp" layout_gravity="center" padding-left="4dp" padding-right="4dp">
|
|
<DropDownList class="model_ui" menu-width-mode="expand-if-needed-centered" lw="0" lw8="1" selected-index="0"></DropDownList>
|
|
<!-- <PushButton id="refresh_model_ui" tooltip="@string(refresh_model_ui, Refresh Local Models)" icon="icon(refresh, 14dp)" /> -->
|
|
</hbox>
|
|
<PushButton id="llm_settings_but" class="llm_button" text="@string(settings, Settings)" tooltip="@string(settings, Settings)" icon="icon(settings, 14dp)" min-width="32dp" margin-right="4dp" />
|
|
<!-- <SelectButton id="llm_private_chat" class="llm_button" tooltip="@string(private_chat, Toggle Private Chat)" icon="icon(chat-private, 14dp)" min-width="32dp" margin-right="8dp" select-on-click="true" /> -->
|
|
<PushButton id="llm_add_chat" class="llm_button" text="@string(add, Add)" tooltip="@string(add_message, Add Message)" icon="icon(add, 15dp)" min-width="32dp" margin-right="4dp" />
|
|
<PushButton id="llm_run" class="llm_button primary" text="@string(run, Run)" tooltip="@string(add_message_and_run_prompt, Add Message and Run Prompt)" icon="icon(play-filled, 14dp)" />
|
|
<PushButton id="llm_stop" class="llm_button primary" text="@string(stop, Stop)" icon="icon(stop, 12dp)" min-width="32dp" visible="false" enabled="false" />
|
|
</hbox>
|
|
</RelativeLayout>
|
|
</Splitter>
|
|
)xml";
|
|
|
|
static const char* DEFAULT_CHAT_GLOBE = R"xml(
|
|
<vbox class="llm_conversation" lw="mp" lh="wc">
|
|
<hbox class="llm_conversation_opt">
|
|
<DropDownList class="role_ui" lw="150dp" lh="16dp" selected-index="1">
|
|
<item>@string(user, User)</item>
|
|
<item>@string(assistant, Assistant)</item>
|
|
<item>@string(system, System)</item>
|
|
</DropDownList>
|
|
<PushButton class="copy_contents" text="@string(copy_contents, Copy Contents)" icon="icon(copy, 12dp)" tooltip="@string(copy_contents, Copy Contents)" />
|
|
<PushButton class="move_up" text="@string(move_up, Move Up)" icon="icon(arrow-up-s, 12dp)" tooltip="@string(move_up, Move Up)" />
|
|
<PushButton class="move_down" text="@string(move_down, Move Down)" icon="icon(arrow-down-s, 12dp)" tooltip="@string(move_down, Move Down)" />
|
|
<PushButton class="erase_but" text="@string(remove_chat, Remove Chat)" icon="icon(chrome-close, 10dp)" tooltip="@string(remove_chat, Remove Chat)" />
|
|
</hbox>
|
|
<CodeEditor class="data_ui" lw="mp" lh="32dp">
|
|
<Image class="image thinking" icon="icon(loader-2, 24dp)" visible="false" />
|
|
</CodeEditor>
|
|
</vbox>
|
|
)xml";
|
|
|
|
LLMChatUI::LLMChatUI( PluginManager* manager ) :
|
|
UILinearLayout(), WidgetCommandExecuter( getInput() ), mManager( manager ) {
|
|
setClass( "llm_chatui" );
|
|
setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent );
|
|
|
|
mChatSplitter = getUISceneNode()
|
|
->loadLayoutFromString( DEFAULT_LAYOUT, this,
|
|
String::hash( "ai_assistant_plugin_chat_ui" ) )
|
|
->asType<UISplitter>();
|
|
|
|
mChatsList = findByClass( "llm_chats" );
|
|
mModelDDL = findByClass<UIDropDownList>( "model_ui" );
|
|
|
|
// mRefreshModels = find<UIPushButton>( "refresh_model_ui" );
|
|
// mRefreshModels->onClick( [this]( auto ) { execute( "ai-refresh-local-models" ); } );
|
|
|
|
mChatMore = find<UIPushButton>( "llm_more" );
|
|
mChatMore->onClick( [this]( auto ) { execute( "ai-show-menu" ); } );
|
|
|
|
mChatSettings = find<UIPushButton>( "llm_settings_but" );
|
|
mChatSettings->onClick( [this]( auto ) { execute( "ai-settings" ); } );
|
|
|
|
mChatScrollView = findByClass( "llm_chat_scrollview" )->asType<UIScrollView>();
|
|
mChatScrollView->getVerticalScrollBar()->setValue( 1 );
|
|
|
|
mChatInput = findByClass<UICodeEditor>( "llm_chat_input" );
|
|
|
|
on( Event::OnFocus, [this]( auto ) { mChatInput->setFocus(); } );
|
|
|
|
if ( getPlugin() ) {
|
|
mChatInput->setColorScheme(
|
|
getPlugin()->getPluginContext()->getSplitter()->getCurrentColorScheme() );
|
|
}
|
|
|
|
mChatAdd = find<UIPushButton>( "llm_add_chat" );
|
|
mChatAdd->onClick( [this]( auto ) {
|
|
execute( "ai-add-chat" );
|
|
if ( mChatAdd->getTooltip() && mChatAdd->getTooltip()->isVisible() )
|
|
mChatAdd->getTooltip()->hide();
|
|
} );
|
|
|
|
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->setAllowSelectingTextFromGutter( false );
|
|
mChatInput->setFindReplaceEnabled( true );
|
|
|
|
mChatInput->setSyntaxDefinition( markdown );
|
|
|
|
mChatRun = find<UIPushButton>( "llm_run" );
|
|
mChatRun->onClick( [this]( auto ) {
|
|
execute( "ai-prompt" );
|
|
if ( mChatRun->getTooltip() && mChatRun->getTooltip()->isVisible() )
|
|
mChatRun->getTooltip()->hide();
|
|
} );
|
|
|
|
mChatStop = find<UIPushButton>( "llm_stop" );
|
|
mChatStop->onClick( [this]( auto ) {
|
|
execute( "ai-prompt-stop" );
|
|
if ( mChatStop->getTooltip() && mChatStop->getTooltip()->isVisible() )
|
|
mChatStop->getTooltip()->hide();
|
|
} );
|
|
|
|
mChatUserRole = find<UIPushButton>( "llm_user" );
|
|
mChatUserRole->onClick( [this]( auto ) { execute( "ai-chat-toggle-role" ); } );
|
|
|
|
/* mChatPrivate = find<UISelectButton>( "llm_private_chat" );
|
|
mChatPrivate->on( Event::OnValueChange,
|
|
[this]( auto ) { mChatIsPrivate = mChatPrivate->isSelected(); } ); */
|
|
|
|
auto setCmd = [this]( const std::string& name, const CommandCallback& cb ) {
|
|
setCommand( name, cb );
|
|
mChatInput->getDocument().setCommand( name, cb );
|
|
};
|
|
|
|
setCmd( "ai-chat-toggle-role", [this] {
|
|
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" ) );
|
|
}
|
|
} );
|
|
|
|
setCmd( "ai-settings", [this] {
|
|
if ( getPlugin() )
|
|
getPlugin()->getPluginContext()->focusOrLoadFile( getPlugin()->getFileConfigPath() );
|
|
} );
|
|
|
|
setCmd( "ai-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();
|
|
mChatScrollView->getVerticalScrollBar()->setValue( 1 );
|
|
} );
|
|
|
|
setCmd( "ai-prompt", [this] {
|
|
// "ai-prompt-stop"
|
|
if ( mRequest ) {
|
|
if ( !mRequest->isCancelled() ) {
|
|
mRequest->cancel();
|
|
return;
|
|
} else
|
|
mRequest.reset();
|
|
}
|
|
|
|
auto chats = 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<UIDropDownList>()
|
|
->getListBox()
|
|
->getItemSelectedIndex() != 0 ) {
|
|
showMsg( getUISceneNode()->i18n(
|
|
"llm_last_message_must_be_from_user",
|
|
"The last chat message must be from a \"User\" role" ) );
|
|
return;
|
|
}
|
|
}
|
|
|
|
execute( "ai-add-chat" );
|
|
doRequest();
|
|
} );
|
|
|
|
setCmd( "ai-prompt-stop", [this] {
|
|
if ( mRequest )
|
|
mRequest->cancel();
|
|
} );
|
|
|
|
setCmd( "ai-clone-chat", [this] {
|
|
if ( getPlugin() == nullptr )
|
|
return;
|
|
auto chats = findAllByClass( "llm_conversation" );
|
|
if ( chats.empty() ) {
|
|
getPlugin()->getPluginContext()->getNotificationCenter()->addNotification(
|
|
i18n( "nothing_to_clone", "Nothing to Clone" ) );
|
|
return;
|
|
}
|
|
auto* chatUI = getPlugin()->newAIAssistant();
|
|
auto input = chatUI->unserialize( serialize() );
|
|
chatUI->mUUID = UUID();
|
|
chatUI->mSummary += i18n( "chat_cloned", " (cloned)" );
|
|
if ( !input.empty() )
|
|
chatUI->mChatInput->getDocument().textInput( input );
|
|
chatUI->updateTabTitle();
|
|
chatUI->setFocus();
|
|
} );
|
|
|
|
setCmd( "ai-save-chat", [this] { saveChat(); } );
|
|
|
|
setCmd( "ai-chat-history", [this] { showChatHistory(); } );
|
|
|
|
setCmd( "ai-attach-file", [this] {
|
|
mLinkMode = false;
|
|
showAttachFile();
|
|
mLocateInput->getDocument().selectAll();
|
|
} );
|
|
|
|
setCmd( "ai-link-file", [this] {
|
|
mLinkMode = true;
|
|
showAttachFile();
|
|
mLocateInput->getDocument().selectAll();
|
|
} );
|
|
|
|
setCmd( "ai-toggle-private-chat", [this] {
|
|
mChatIsPrivate = !mChatIsPrivate;
|
|
/* mChatPrivate->toggleSelection(); */
|
|
|
|
if ( mChatIsPrivate )
|
|
mChatInput->addClass( "incognito" );
|
|
else
|
|
mChatInput->removeClass( "incognito" );
|
|
} );
|
|
|
|
setCmd( "ai-toggle-lock-chat", [this] { renameChat( mSummary, true ); } );
|
|
|
|
setCmd( "ai-rename-chat", [this] {
|
|
UIMessageBox* msgBox = UIMessageBox::New(
|
|
UIMessageBox::INPUT, i18n( "rename_conversation", "Rename Conversation" ) );
|
|
msgBox->on( Event::OnConfirm, [this, msgBox]( const Event* ) {
|
|
std::string newName( msgBox->getTextInput()->getText().toUtf8() );
|
|
if ( newName.empty() || mSummary == newName )
|
|
return;
|
|
msgBox->closeWindow();
|
|
renameChat( newName );
|
|
} );
|
|
msgBox->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } );
|
|
msgBox->center();
|
|
auto summary = mSummary;
|
|
String::replaceAll( summary, "\n", " " );
|
|
String::trimInPlace( summary );
|
|
msgBox->getTextInput()->setText( summary );
|
|
msgBox->showWhenReady();
|
|
} );
|
|
|
|
setCmd( "ai-regenerate-title", [this] { regenerateChatName(); } );
|
|
|
|
setCmd( "new-ai-assistant", [this] {
|
|
if ( getPlugin() == nullptr )
|
|
return;
|
|
getPlugin()->newAIAssistant()->setFocus();
|
|
} );
|
|
|
|
setCmd( "ai-show-add-context-menu", [this] {
|
|
if ( getPlugin() == nullptr )
|
|
return;
|
|
UIPopUpMenu* menu = UIPopUpMenu::New();
|
|
|
|
menu->add( i18n( "attach_file", "Attach File" ),
|
|
getUISceneNode()->findIconDrawable( "attach", PixelDensity::dpToPxI( 12 ) ),
|
|
getKeyBindings().getCommandKeybindString( "ai-attach-file" ) )
|
|
->setId( "ai-attach-file" );
|
|
|
|
menu->add( i18n( "link_file", "Link File" ),
|
|
getUISceneNode()->findIconDrawable( "link", PixelDensity::dpToPxI( 12 ) ),
|
|
getKeyBindings().getCommandKeybindString( "ai-link-file" ) )
|
|
->setTooltipText( i18n( "link_file_tooltip",
|
|
"Unlike Attach File (which captures a snapshot),\n"
|
|
"Link File always refers to the current version of the file,\n"
|
|
"even when you re-run the same prompt later.\n"
|
|
"Acts like a symbolic link." ) )
|
|
->setId( "ai-link-file" );
|
|
|
|
menu->on( Event::OnItemClicked, [this]( const Event* event ) {
|
|
UIMenuItem* item = event->getNode()->asType<UIMenuItem>();
|
|
std::string id( item->getId() );
|
|
execute( id );
|
|
} );
|
|
|
|
menu->runOnMainThread( [this, menu] {
|
|
auto pos( mChatAttach->getScreenPos() );
|
|
UIMenu::findBestMenuPos( pos, menu, nullptr, nullptr, mChatAttach );
|
|
menu->showAtScreenPosition( pos );
|
|
} );
|
|
} );
|
|
|
|
setCmd( "ai-show-menu", [this] {
|
|
if ( getPlugin() == nullptr )
|
|
return;
|
|
UIPopUpMenu* menu = UIPopUpMenu::New();
|
|
|
|
menu->add(
|
|
i18n( "new_conversation", "New Conversation" ),
|
|
getUISceneNode()->findIconDrawable( "chat-sparkle", PixelDensity::dpToPxI( 12 ) ),
|
|
getPlugin()
|
|
->getPluginContext()
|
|
->getMainLayout()
|
|
->getKeyBindings()
|
|
.getCommandKeybindString( "new-ai-assistant" ) )
|
|
->setId( "new-ai-assistant" );
|
|
|
|
menu->add(
|
|
i18n( "save_conversation", "Save Conversation" ),
|
|
getUISceneNode()->findIconDrawable( "document-save", PixelDensity::dpToPxI( 12 ) ),
|
|
getKeyBindings().getCommandKeybindString( "ai-save-chat" ) )
|
|
->setId( "ai-save-chat" );
|
|
|
|
menu->add( i18n( "rename_conversation", "Rename Conversation" ),
|
|
getUISceneNode()->findIconDrawable( "file-edit", PixelDensity::dpToPxI( 12 ) ),
|
|
getKeyBindings().getCommandKeybindString( "ai-rename-chat" ) )
|
|
->setId( "ai-rename-chat" );
|
|
|
|
menu->add( i18n( "clone_current_conversation", "Clone Current Conversation" ),
|
|
getUISceneNode()->findIconDrawable( "file-copy", PixelDensity::dpToPxI( 12 ) ),
|
|
getKeyBindings().getCommandKeybindString( "ai-clone-chat" ) )
|
|
->setId( "ai-clone-chat" );
|
|
|
|
menu->addSeparator();
|
|
|
|
menu->addCheckBox( i18n( "lock_chat_memory", "Lock Chat Memory" ), mChatLocked,
|
|
getKeyBindings().getCommandKeybindString( "ai-toggle-lock-chat" ) )
|
|
->setTooltipText(
|
|
i18n( "lock_chat_memory_tooltip",
|
|
"Lock a chat memory to avoid being deleted during memory clean-ups.\nChat "
|
|
"memory will only be able to be deleted manually in the chat history." ) )
|
|
->setId( "ai-toggle-lock-chat" );
|
|
|
|
menu->addCheckBox( i18n( "toggle_private_chat", "Toggle Private Chat" ), mChatIsPrivate,
|
|
getKeyBindings().getCommandKeybindString( "ai-toggle-private-chat" ) )
|
|
->setTooltipText( i18n( "toggle_private_chat_tooltip",
|
|
"Private chats won't be saved into the chat history." ) )
|
|
->setId( "ai-toggle-private-chat" );
|
|
|
|
menu->addSeparator();
|
|
|
|
if ( chatExistsInDisk() ) {
|
|
menu->add( i18n( "regenerate_conversation_title", "Regenerate Conversation Title" ),
|
|
getUISceneNode()->findIconDrawable( "refresh", PixelDensity::dpToPxI( 12 ) ),
|
|
getKeyBindings().getCommandKeybindString( "ai-regenerate-title" ) )
|
|
->setId( "ai-regenerate-title" );
|
|
}
|
|
|
|
menu->add( i18n( "refresh_model_ui", "Refresh Local Models" ),
|
|
getUISceneNode()->findIconDrawable( "refresh", PixelDensity::dpToPxI( 12 ) ),
|
|
getKeyBindings().getCommandKeybindString( "ai-refresh-local-models" ) )
|
|
->setId( "ai-refresh-local-models" );
|
|
|
|
menu->on( Event::OnItemClicked, [this]( const Event* event ) {
|
|
UIMenuItem* item = event->getNode()->asType<UIMenuItem>();
|
|
std::string id( item->getId() );
|
|
execute( id );
|
|
} );
|
|
|
|
menu->runOnMainThread( [this, menu] {
|
|
auto pos( mChatMore->getScreenPos() );
|
|
UIMenu::findBestMenuPos( pos, menu, nullptr, nullptr, mChatMore );
|
|
menu->showAtScreenPosition( pos );
|
|
} );
|
|
} );
|
|
|
|
setCmd( "ai-refresh-local-models", [this] { fillApiModels( mModelDDL ); } );
|
|
|
|
mChatHistory = find<UIPushButton>( "llm_chat_history" );
|
|
mChatHistory->onClick( [this]( auto ) { showChatHistory(); } );
|
|
|
|
mChatAttach = find<UIPushButton>( "llm_attach" );
|
|
mChatAttach->onClick( [this]( auto ) { execute( "ai-show-add-context-menu" ); } );
|
|
|
|
if ( getPlugin() == nullptr )
|
|
return;
|
|
|
|
initAttachFile();
|
|
|
|
auto providers = getPlugin()->getProviders();
|
|
setProviders( std::move( providers ) );
|
|
mCurModel = getDefaultModel();
|
|
|
|
AppConfig& config = getPlugin()->getPluginContext()->getConfig();
|
|
auto partition = config.iniState.getValue( "aiassistant", "split_partition", "" );
|
|
|
|
if ( !partition.empty() )
|
|
mChatSplitter->setSplitPartition( StyleSheetLength::fromString( partition ) );
|
|
|
|
auto modelProvider = config.iniState.getValue( "aiassistant", "default_provider", "" );
|
|
auto modelName = config.iniState.getValue( "aiassistant", "default_model", "" );
|
|
|
|
if ( !modelProvider.empty() && !modelName.empty() ) {
|
|
auto modelOpt = getModel( modelProvider, modelName );
|
|
if ( modelOpt ) {
|
|
mCurModel = *modelOpt;
|
|
}
|
|
}
|
|
|
|
fillModelDropDownList( mModelDDL );
|
|
|
|
const auto appendShortcutToTooltip = [this]( UIPushButton* but, const std::string& cmd ) {
|
|
auto kb = getKeyBindings().getCommandKeybindString( cmd );
|
|
if ( kb.empty() )
|
|
return;
|
|
but->setTooltipText( but->getTooltipText() + " (" + kb + ")" );
|
|
};
|
|
|
|
bindCmds( mChatInput, true );
|
|
|
|
appendShortcutToTooltip( mChatHistory, "ai-chat-history" );
|
|
appendShortcutToTooltip( mChatAttach, "ai-show-add-context-menu" );
|
|
appendShortcutToTooltip( mChatRun, "ai-prompt" );
|
|
appendShortcutToTooltip( mChatStop, "ai-prompt" );
|
|
appendShortcutToTooltip( mChatAdd, "ai-add-chat" );
|
|
appendShortcutToTooltip( mChatSettings, "ai-settings" );
|
|
// appendShortcutToTooltip( mChatPrivate, "ai-toggle-private-chat" );
|
|
appendShortcutToTooltip( mChatMore, "ai-show-menu" );
|
|
appendShortcutToTooltip( mChatUserRole, "ai-chat-toggle-role" );
|
|
// appendShortcutToTooltip( mRefreshModels, "ai-refresh-local-models" );
|
|
|
|
addKb( mChatInput, "mod+keypad enter", "ai-prompt", true, false );
|
|
addKb( mChatInput, "mod+shift+keypad enter", "ai-add-chat", true, false );
|
|
}
|
|
|
|
LLMChatUI::~LLMChatUI() {
|
|
if ( mRequest ) {
|
|
mRequest->cancelCb = nullptr;
|
|
mRequest->doneCb = nullptr;
|
|
mRequest->streamedResponseCb = nullptr;
|
|
}
|
|
if ( mSummaryRequest ) {
|
|
mSummaryRequest->cancelCb = nullptr;
|
|
mSummaryRequest->doneCb = nullptr;
|
|
mSummaryRequest->streamedResponseCb = nullptr;
|
|
}
|
|
if ( getPlugin() ) {
|
|
AIAssistantPlugin::AIAssistantConfig config;
|
|
config.partition = getSplitter()->getSplitPartition();
|
|
config.modelProvider = mCurModel.provider;
|
|
config.modelName = mCurModel.name;
|
|
getPlugin()->setConfig( std::move( config ) );
|
|
}
|
|
}
|
|
|
|
void LLMChatUI::addKb( UICodeEditor* editor, std::string kb, const std::string& cmd,
|
|
bool bindToChatUI, bool searchDefined ) {
|
|
if ( searchDefined && getPlugin() ) {
|
|
const auto& find = getPlugin()->getKeybindings().find( cmd );
|
|
if ( find != getPlugin()->getKeybindings().end() && !find->second.empty() )
|
|
kb = find->second;
|
|
}
|
|
if ( bindToChatUI ) {
|
|
getKeyBindings().addKeybindString( kb, cmd );
|
|
editor->addKeyBindingString( kb, cmd, true );
|
|
} else {
|
|
// Chat UI editors besides the main chat input need to actually remove the keybindings
|
|
// to let the parent take over the command
|
|
editor->removeUnlockedCommand( cmd );
|
|
editor->getKeyBindings().removeKeybind( kb );
|
|
}
|
|
}
|
|
|
|
void LLMChatUI::bindCmds( UICodeEditor* editor, bool bindToChatUI ) {
|
|
addKb( editor, "mod+return", "ai-prompt", bindToChatUI );
|
|
addKb( editor, "mod+h", "ai-chat-history", bindToChatUI );
|
|
addKb( editor, "mod+shift+c", "ai-clone-chat", bindToChatUI );
|
|
addKb( editor, "mod+shift+s", "ai-settings", bindToChatUI );
|
|
addKb( editor, "mod+shift+p", "ai-toggle-private-chat", bindToChatUI );
|
|
addKb( editor, "mod+s", "ai-save-chat", bindToChatUI );
|
|
addKb( editor, "f2", "ai-rename-chat", bindToChatUI );
|
|
addKb( editor, "mod+m", "ai-show-menu", bindToChatUI );
|
|
addKb( editor, "mod+shift+r", "ai-chat-toggle-role", bindToChatUI );
|
|
addKb( editor, "mod+shift+l", "ai-refresh-local-models", bindToChatUI );
|
|
addKb( editor, "mod+shift+a", "ai-attach-file", bindToChatUI );
|
|
addKb( editor, "mod+shift+z", "ai-link-file", bindToChatUI );
|
|
|
|
if ( bindToChatUI )
|
|
addKb( editor, "mod+shift+return", "ai-add-chat", bindToChatUI );
|
|
}
|
|
|
|
std::optional<LLMModel> LLMChatUI::getModel( const std::string& provider,
|
|
const std::string& modelName ) {
|
|
auto providerIt = mProviders.find( provider );
|
|
if ( providerIt != mProviders.end() ) {
|
|
const auto& models = providerIt->second.models;
|
|
auto modelIt =
|
|
std::find_if( models.begin(), models.end(), [&modelName]( const LLMModel& model ) {
|
|
return model.name == modelName;
|
|
} );
|
|
if ( modelIt != models.end() )
|
|
return *modelIt;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
void LLMChatUI::showChatHistory() {
|
|
auto plugin = getPlugin();
|
|
if ( plugin == nullptr )
|
|
return;
|
|
|
|
hideAttachFile();
|
|
|
|
static const char* CHAT_HISTORY_LAYOUT = R"xml(
|
|
<window window-flags="default|shadow|modal|ephemeral|maximize"
|
|
window-title="@string(ai_conversations_history, AI Conversations History)">
|
|
<Loader id="loader" radius="48" outline-thickness="6dp" indeterminate="true" />
|
|
<vbox lw="mp" lh="mp">
|
|
<hbox id="top_bar" lw="mp" lh="wc">
|
|
<TextInput lw="0dp" lw8="1" lh="wc" id="search_input" hint="@string(search_chat_ellipsis, Search Chat...)" visible="false" />
|
|
<PushButton lw="wc" lh="wc" id="clear_history" text="@string(ai_clear_history, Clear History...)" visible="false" margin-left="4dp" />
|
|
</hbox>
|
|
<hbox id="delete_history_cont" lw="mp" lh="wc" visible="false">
|
|
<TextView text="@string(delete_history_older_than_ellipsis, Delete Conversations Older Than...)" layout-gravity="center_vertical" />
|
|
<TextInput id="days_num" lw="0dp" lw8="1" lh="wc" hint="@string(number_of_days, Number of Days)" margin-left="4dp" margin-right="4dp" layout-gravity="center_vertical" numeric="true" />
|
|
<PushButton id="delete_but" text="@string(delete, Delete)" layout-gravity="center_vertical" />
|
|
</hbox>
|
|
<TableView id="table" lw="mp" lh="0" lw8="1" visible="false" />
|
|
</vbox>
|
|
</window>
|
|
)xml";
|
|
|
|
UIWindow* win =
|
|
getUISceneNode()->loadLayoutFromString( CHAT_HISTORY_LAYOUT )->asType<UIWindow>();
|
|
UITextInput* input = win->find( "search_input" )->asType<UITextInput>();
|
|
UILoader* loader = win->find( "loader" )->asType<UILoader>();
|
|
UITableView* tv = win->find( "table" )->asType<UITableView>();
|
|
UIPushButton* clearHistory = win->find( "clear_history" )->asType<UIPushButton>();
|
|
UILinearLayout* deleteCont = win->find( "delete_history_cont" )->asType<UILinearLayout>();
|
|
UITextInput* daysNum = win->find( "days_num" )->asType<UITextInput>();
|
|
UIPushButton* deleteChatsBut = win->find( "delete_but" )->asType<UIPushButton>();
|
|
|
|
win->setMinWindowSize( getUISceneNode()->getSize().getWidth() * 0.5f,
|
|
getUISceneNode()->getSize().getHeight() * 0.7f );
|
|
win->setKeyBindingCommand( "closeWindow", [win, this] {
|
|
win->closeWindow();
|
|
setFocus();
|
|
} );
|
|
win->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" );
|
|
win->center();
|
|
win->getModalWidget()->addClass( "shadowbg" );
|
|
win->setId( UUID().toString() );
|
|
win->on( Event::OnWindowReady, [win]( auto ) { win->setFocus(); } );
|
|
|
|
loader->center();
|
|
|
|
tv->setAutoColumnsWidth( true );
|
|
tv->setFitAllColumnsToWidget( true );
|
|
tv->setSetupCellCb( [this, tv, win, input]( UITableCell* cell ) {
|
|
if ( cell->getCurIndex().column() != ChatHistoryModel::Delete )
|
|
return;
|
|
|
|
cell->onClick( [this, tv, cell, win, input]( const MouseEvent* ) {
|
|
ChatHistoryModel* model = static_cast<ChatHistoryModel*>( tv->getModel() );
|
|
auto summary =
|
|
model->data( model->index( cell->getCurIndex().row(), ChatHistoryModel::Summary ) )
|
|
.toString();
|
|
auto msgBox =
|
|
UIMessageBox::New( UIMessageBox::OK_CANCEL,
|
|
String::format( i18n( "confirm_del_llm_chat",
|
|
"Are you sure you want to remove \"%s?\"" )
|
|
.toUtf8(),
|
|
summary ) );
|
|
msgBox->setParent( win );
|
|
msgBox->center();
|
|
msgBox->showWhenReady();
|
|
msgBox->on( Event::OnConfirm, [model, cell, input]( auto ) {
|
|
model->remove( cell->getCurIndex() );
|
|
input->setFocus();
|
|
} );
|
|
} );
|
|
} );
|
|
|
|
input->on( Event::KeyDown, [tv, input]( const Event* event ) {
|
|
tv->forceKeyDown( *event->asKeyEvent() );
|
|
input->setFocus();
|
|
} );
|
|
|
|
input->on( Event::OnTextChanged, [tv, input]( const Event* ) {
|
|
ChatHistoryModel* model = static_cast<ChatHistoryModel*>( tv->getModel() );
|
|
model->setFilter( input->getText().toUtf8() );
|
|
if ( tv->getSelection().isEmpty() && !model->getCurHistory().empty() )
|
|
tv->setSelection( model->index( 0, 0 ) );
|
|
} );
|
|
|
|
const auto openCurrentSelectedModelItem = [this, tv] {
|
|
auto* model = static_cast<const ChatHistoryModel*>( tv->getModel() );
|
|
ModelIndex index;
|
|
if ( !tv->getSelection().isEmpty() )
|
|
index = tv->getSelection().first();
|
|
else if ( !model->getCurHistory().empty() )
|
|
index = model->index( 0, 0 );
|
|
if ( getPlugin() == nullptr || !index.isValid() )
|
|
return;
|
|
auto* chatUI = getPlugin()->newAIAssistant();
|
|
auto path = model->data( model->index( index.row(), ChatHistoryModel::Path ) );
|
|
std::string data;
|
|
FileSystem::fileGet( path.toString(), data );
|
|
nlohmann::json j = nlohmann::json::parse( data, nullptr, false );
|
|
auto input = chatUI->unserialize( j );
|
|
if ( !input.empty() )
|
|
chatUI->mChatInput->getDocument().textInput( input );
|
|
chatUI->setFocus();
|
|
};
|
|
|
|
input->on( Event::OnPressEnter, [win, openCurrentSelectedModelItem]( const Event* ) {
|
|
win->executeKeyBindingCommand( "closeWindow" );
|
|
openCurrentSelectedModelItem();
|
|
} );
|
|
|
|
tv->on( Event::OnModelEvent, [win, openCurrentSelectedModelItem]( const Event* event ) {
|
|
const ModelEvent* modelEvent = static_cast<const ModelEvent*>( event );
|
|
if ( modelEvent->getModelEventType() == ModelEventType::Open ) {
|
|
win->executeKeyBindingCommand( "closeWindow" );
|
|
openCurrentSelectedModelItem();
|
|
}
|
|
} );
|
|
|
|
clearHistory->onClick(
|
|
[deleteCont]( auto ) { deleteCont->setVisible( !deleteCont->isVisible() ); } );
|
|
|
|
std::string winId = win->getId();
|
|
UISceneNode* uiSceneNode = getUISceneNode();
|
|
getUISceneNode()->getThreadPool()->run(
|
|
[plugin, loader, input, tv, winId = std::move( winId ), uiSceneNode, clearHistory] {
|
|
std::string conversationsPath = plugin->getConversationsPath();
|
|
auto model = std::make_shared<ChatHistoryModel>(
|
|
ChatHistory::getHistory( conversationsPath ), uiSceneNode );
|
|
if ( uiSceneNode->find( winId ) == nullptr ) // Window closed?
|
|
return;
|
|
tv->runOnMainThread( [tv, loader, input, model, clearHistory] {
|
|
loader->setVisible( false );
|
|
input->setVisible( true );
|
|
tv->setVisible( true );
|
|
clearHistory->setVisible( true );
|
|
tv->setModel( model );
|
|
tv->setColumnsVisible( { ChatHistoryModel::Summary, ChatHistoryModel::DateTime,
|
|
ChatHistoryModel::Delete } );
|
|
input->setFocus();
|
|
} );
|
|
} );
|
|
|
|
deleteChatsBut->onClick( [this, daysNum]( auto ) {
|
|
int days = 0;
|
|
if ( daysNum->getText().empty() || !String::fromString( days, daysNum->getText() ) ||
|
|
days < 0 )
|
|
return;
|
|
auto msgBox = UIMessageBox::New(
|
|
UIMessageBox::OK_CANCEL,
|
|
String::format( i18n( "ai_confirm_delete_chats",
|
|
"Are you sure you want to delete the conversations older than %d "
|
|
"days?\nThis operation cannot be reverted!" )
|
|
.toUtf8(),
|
|
days ) );
|
|
|
|
msgBox->on( Event::OnConfirm, [this, days]( auto ) {
|
|
deleteOldConversations( days );
|
|
showChatHistory();
|
|
} );
|
|
|
|
msgBox->center();
|
|
msgBox->showWhenReady();
|
|
} );
|
|
}
|
|
|
|
void LLMChatUI::fillApiModels( UIDropDownList* modelDDL ) {
|
|
mPendingModelsToLoad = 0;
|
|
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.contains( "id" ) ? el.value( "id", "" )
|
|
: el.value( "key", "" ) );
|
|
|
|
if ( el.contains( "name" ) )
|
|
model.displayName = el.value( "name", "" );
|
|
|
|
if ( !model.displayName && el.contains( "display_name" ) )
|
|
model.displayName = el.value( "display_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 );
|
|
}
|
|
|
|
mPendingModelsToLoad++;
|
|
|
|
std::string pname = name;
|
|
modelDDL->runOnMainThread( [pname = std::move( pname ), modelDDL, this] {
|
|
String providerName( pname );
|
|
std::vector<String> removeValues;
|
|
size_t count = modelDDL->getListBox()->getItemsCount();
|
|
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<String> 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 );
|
|
|
|
mPendingModelsToLoad--;
|
|
|
|
if ( mPendingModelsToLoad == 0 )
|
|
onInit();
|
|
} );
|
|
}
|
|
|
|
if ( mPendingModelsToLoad == 0 )
|
|
onInit();
|
|
}
|
|
|
|
String LLMChatUI::getModelDisplayName( const LLMModel& model ) const {
|
|
auto providerIt = mProviders.find( model.provider );
|
|
if ( providerIt == mProviders.end() )
|
|
return "";
|
|
const auto& data = providerIt->second;
|
|
return String::format( "%s (%s)", model.displayName ? *model.displayName : model.name,
|
|
data.displayName ? *data.displayName : String::capitalize( data.name ) );
|
|
}
|
|
|
|
bool LLMChatUI::selectModel( UIDropDownList* modelDDL, const LLMModel& model ) {
|
|
auto modelName = getModelDisplayName( model );
|
|
auto index = modelDDL->getListBox()->getItemIndex( modelName );
|
|
if ( index != eeINDEX_NOT_FOUND ) {
|
|
modelDDL->getListBox()->setSelected( index );
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void LLMChatUI::fillModelDropDownList( UIDropDownList* modelDDL ) {
|
|
std::vector<String> 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 == mCurModel.provider && model.name == mCurModel.name )
|
|
selectedIndex = models.size();
|
|
models.push_back( std::move( modelName ) );
|
|
}
|
|
}
|
|
modelDDL->getListBox()->clear();
|
|
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 LLMChatUI::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 );
|
|
}
|
|
|
|
void LLMChatUI::replaceFileLinksToContents( std::string& text ) {
|
|
LuaPattern ptrn( "\n```file://([^`]*)```\n?" );
|
|
PatternMatcher::Range matches[2];
|
|
while ( ptrn.matches( text, matches ) ) {
|
|
std::string path( text.substr( matches[1].start, matches[1].length() ) );
|
|
if ( FileSystem::isRelativePath( path ) ) {
|
|
std::string prjPath( getPlugin()->getPluginContext()->getCurrentProject() );
|
|
path = prjPath + path;
|
|
}
|
|
std::string fileBuffer;
|
|
TextDocument doc;
|
|
if ( FileSystem::fileExists( path ) &&
|
|
doc.loadFromFile( path ) == TextDocument::LoadStatus::Loaded ) {
|
|
fileBuffer += "\n`" + doc.getFilename() + "`:\n";
|
|
fileBuffer += "```" + doc.getSyntaxDefinition().getLSPName();
|
|
if ( doc.linesCount() >= 1 && !String::startsWith( doc.line( 0 ).getText(), "\n" ) ) {
|
|
fileBuffer += "\n";
|
|
}
|
|
fileBuffer += doc.getText().toUtf8();
|
|
if ( doc.linesCount() >= 1 &&
|
|
doc.line( doc.linesCount() - 1 ).getText() != String( "\n" ) ) {
|
|
fileBuffer += "\n";
|
|
}
|
|
fileBuffer += "```\n";
|
|
} else {
|
|
fileBuffer = path + "has been deleted from the file system.";
|
|
}
|
|
text.replace( matches[0].start, matches[0].length(), fileBuffer );
|
|
}
|
|
}
|
|
|
|
nlohmann::json LLMChatUI::chatToJson( bool forRequest ) {
|
|
auto j = nlohmann::json::array();
|
|
auto chats = findAllByClass( "llm_conversation" );
|
|
for ( const auto& chat : chats ) {
|
|
UIDropDownList* roleDDL = chat->findByClass<UIDropDownList>( "role_ui" );
|
|
UICodeEditor* codeEditor = chat->findByClass<UICodeEditor>( "data_ui" );
|
|
std::string role = LLMChat::roleToString(
|
|
static_cast<LLMChat::Role>( roleDDL->getListBox()->getItemSelectedIndex() ) );
|
|
auto text = codeEditor->getDocument().getText().toUtf8();
|
|
|
|
if ( text.empty() )
|
|
continue;
|
|
|
|
if ( forRequest )
|
|
replaceFileLinksToContents( text );
|
|
|
|
j.push_back( { { "role", role }, { "content", std::move( text ) } } );
|
|
}
|
|
return j;
|
|
}
|
|
|
|
Uint32 LLMChatUI::onMessage( const NodeMessage* msg ) {
|
|
AIAssistantPlugin* plugin = getPlugin();
|
|
// plugin can be null since plugin could be re-loading or unloaded
|
|
if ( plugin && msg->getMsg() == NodeMessage::Focus ) {
|
|
getPlugin()->getPluginContext()->getSplitter()->setCurrentWidget( this );
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
nlohmann::json LLMChatUI::serializeChat( const LLMModel& model, bool forRequest ) {
|
|
nlohmann::json j = {
|
|
{ "model", model.name }, { "stream", true }, { "messages", chatToJson( forRequest ) } };
|
|
if ( model.maxOutputTokens )
|
|
j["max_tokens"] = *model.maxOutputTokens;
|
|
return j;
|
|
}
|
|
|
|
nlohmann::json LLMChatUI::serialize() {
|
|
mTimestamp = std::chrono::system_clock::to_time_t( std::chrono::system_clock::now() );
|
|
nlohmann::json j;
|
|
j["uuid"] = mUUID.toString();
|
|
j["chat"] = serializeChat( mCurModel );
|
|
j["provider"] = mCurModel.provider;
|
|
j["timestamp"] = mTimestamp;
|
|
j["summary"] = mSummary;
|
|
std::string inputText( mChatInput->getDocument().getText().toUtf8() );
|
|
j["input"] = std::move( inputText );
|
|
j["locked"] = mChatLocked;
|
|
return j;
|
|
}
|
|
|
|
std::string LLMChatUI::unserialize( const nlohmann::json& payload ) {
|
|
auto uuid = UUID::fromString( payload.value( "uuid", "" ) );
|
|
if ( uuid )
|
|
mUUID = *uuid;
|
|
mTimestamp = payload.value( "timestamp", 0 );
|
|
mSummary = payload.value( "summary", "" );
|
|
mChatLocked = payload.value( "locked", false );
|
|
|
|
std::string provider = payload.value( "provider", "" );
|
|
if ( payload.contains( "chat" ) && payload["chat"].is_object() ) {
|
|
const auto& chat = payload["chat"];
|
|
std::string model = chat.value( "model", "" );
|
|
mCurModel = findModel( provider, model );
|
|
}
|
|
|
|
if ( mCurModel.name.empty() )
|
|
return payload.value( "input", "" );
|
|
|
|
if ( !selectModel( mModelDDL, mCurModel ) )
|
|
fillModelDropDownList( mModelDDL );
|
|
|
|
if ( payload.contains( "chat" ) && payload["chat"].is_object() ) {
|
|
const auto& chat = payload["chat"];
|
|
const auto& messages = chat["messages"];
|
|
for ( const auto& chat : messages ) {
|
|
addChat( LLMChat::stringToRole( chat.value( "role", "" ) ),
|
|
chat.value( "content", "" ) );
|
|
}
|
|
}
|
|
|
|
updateTabTitle();
|
|
|
|
return payload.value( "input", "" );
|
|
}
|
|
|
|
LLMModel LLMChatUI::findModel( const std::string& provider, const std::string& model ) {
|
|
auto providerIt = mProviders.find( provider );
|
|
if ( providerIt != mProviders.end() ) {
|
|
auto modelIt =
|
|
std::find_if( providerIt->second.models.begin(), providerIt->second.models.end(),
|
|
[&model]( const LLMModel& cmodel ) { return cmodel.name == model; } );
|
|
if ( modelIt != providerIt->second.models.end() ) {
|
|
return *modelIt;
|
|
}
|
|
}
|
|
if ( provider == DEFAULT_PROVIDER && model == DEFAULT_MODEL )
|
|
return mCurModel; // Do not stack-overflow if something is really wrong
|
|
return getDefaultModel();
|
|
}
|
|
|
|
LLMModel LLMChatUI::getDefaultModel() {
|
|
return findModel( DEFAULT_PROVIDER, DEFAULT_MODEL );
|
|
}
|
|
|
|
std::string LLMChatUI::getNewFilePath( const std::string& uuid, const std::string& summary,
|
|
bool isLocked ) const {
|
|
auto plugin = getPlugin();
|
|
if ( plugin == nullptr || mChatIsPrivate )
|
|
return "";
|
|
std::string conversationsPath = plugin->getConversationsPath();
|
|
if ( !FileSystem::fileExists( conversationsPath ) )
|
|
FileSystem::makeDir( conversationsPath, true );
|
|
return conversationsPath + uuid + " - " + summary + ( isLocked ? ".locked" : "" ) + ".json";
|
|
}
|
|
|
|
std::string LLMChatUI::getFilePath() const {
|
|
return getNewFilePath( mUUID.toString(), mSummary, mChatLocked );
|
|
}
|
|
|
|
void LLMChatUI::saveChat() {
|
|
auto plugin = getPlugin();
|
|
if ( plugin == nullptr || mChatIsPrivate )
|
|
return;
|
|
FileSystem::fileWrite( getFilePath(), serialize().dump( 2 ) );
|
|
}
|
|
|
|
std::string LLMChatUI::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;
|
|
}
|
|
|
|
static bool allNewLines( const std::string& s ) {
|
|
return !s.empty() && std::all_of( s.begin(), s.end(), []( char c ) { return c == '\n'; } );
|
|
}
|
|
|
|
void LLMChatUI::generateChatName( bool isRenaming ) {
|
|
auto apiKey = AIAssistantPlugin::getApiKeyFromProvider( mCurModel.provider, getPlugin() );
|
|
if ( !apiKey ) {
|
|
showMsg( getUISceneNode()->i18n( "configure_api_key",
|
|
"You must first configure your provider api key." ) );
|
|
return;
|
|
}
|
|
std::string apiKeyStr{ *apiKey };
|
|
std::string apiUrl( prepareApiUrl( apiKeyStr ) );
|
|
auto model = mCurModel;
|
|
|
|
static const std::string SummaryPrompt =
|
|
"Generate a concise 3-7 word title for this conversation, omitting punctuation. Go "
|
|
"straight to the title, without any preamble and prefix like `Here's a concise "
|
|
"suggestion:...` or `Title:`. Ignore this message for the summary generation.";
|
|
|
|
auto jchat = serializeChat( getCheapestModelFromCurrentProvider(), true );
|
|
|
|
jchat["messages"].push_back( { { "role", LLMChat::roleToString( LLMChat::Role::User ) },
|
|
{ "content", SummaryPrompt } } );
|
|
|
|
auto chatstr = jchat.dump();
|
|
|
|
mSummaryRequest =
|
|
std::make_unique<LLMChatCompletionRequest>( apiUrl, apiKeyStr, chatstr, model.provider );
|
|
|
|
mSummaryRequest->doneCb = [this, isRenaming]( const LLMChatCompletionRequest& req,
|
|
Http::Response& response ) {
|
|
auto status = response.getStatus();
|
|
runOnMainThread( [this, isRenaming, status, responseText = req.getResponse()] {
|
|
String oldSummary = std::move( mSummary );
|
|
|
|
if ( status == Http::Response::Ok ) {
|
|
mSummary = String::trim( responseText );
|
|
String::trimInPlace( mSummary, '\n' );
|
|
String::trimInPlace( mSummary, ' ' );
|
|
String::trimInPlace( mSummary, '"' );
|
|
} else {
|
|
// TODO: Implement generating a summary based on the user prompt (take the
|
|
// first few words)
|
|
mSummary = i18n( "untitled_conversation", "Untitled Conversation" );
|
|
}
|
|
|
|
if ( isRenaming ) {
|
|
String newSummary = std::move( mSummary );
|
|
mSummary = std::move( oldSummary );
|
|
renameChat( newSummary );
|
|
} else
|
|
saveChat();
|
|
|
|
updateTabTitle();
|
|
mSummaryRequest.reset();
|
|
} );
|
|
};
|
|
mSummaryRequest->requestAsync();
|
|
}
|
|
|
|
bool LLMChatUI::chatExistsInDisk() const {
|
|
return FileSystem::fileExists( getFilePath() );
|
|
}
|
|
|
|
void LLMChatUI::regenerateChatName() {
|
|
if ( chatExistsInDisk() )
|
|
generateChatName( true );
|
|
}
|
|
|
|
void LLMChatUI::doRequest() {
|
|
if ( mRequest )
|
|
return;
|
|
|
|
auto apiKey = AIAssistantPlugin::getApiKeyFromProvider( mCurModel.provider, getPlugin() );
|
|
if ( !apiKey ) {
|
|
showMsg( 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 ); // editor is not disabled (to allow copy with locked document)
|
|
|
|
auto model = mCurModel;
|
|
auto* editor = chat->findByClass<UICodeEditor>( "data_ui" );
|
|
|
|
// new editor must be disabled because even on locked document it's possible to move the cursor
|
|
editor->setEnabled( false );
|
|
|
|
auto* thinking = editor->findByClass<UIImage>( "thinking" );
|
|
auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) );
|
|
thinking->setVisible( true );
|
|
thinking->setPosition( { PixelDensity::dpToPx( 8 ), PixelDensity::dpToPx( 3 ) } );
|
|
thinking->setInterval( [thinking] { thinking->rotate( 360 / 32 ); }, Seconds( 0.125 ),
|
|
thinkingID );
|
|
|
|
std::string apiUrl( prepareApiUrl( apiKeyStr ) );
|
|
mRequest = std::make_unique<LLMChatCompletionRequest>(
|
|
apiUrl, apiKeyStr, serializeChat( model, true ).dump(), model.provider );
|
|
|
|
mRequest->streamedResponseCb = [this, editor, thinking, thinkingID]( const std::string& chunk,
|
|
bool fromReasoning ) {
|
|
if ( fromReasoning ||
|
|
( mRequest && mRequest->getResponse().empty() && allNewLines( chunk ) ) )
|
|
return;
|
|
auto conversation = chunk;
|
|
editor->runOnMainThread(
|
|
[this, conversation = std::move( conversation ), editor, thinking, thinkingID] {
|
|
editor->getDocument().textInput( String::fromUtf8( conversation ) );
|
|
editor->setCursorVisible( false );
|
|
thinking->removeActionsByTag( thinkingID );
|
|
thinking->setVisible( false );
|
|
resizeToFit( editor );
|
|
} );
|
|
};
|
|
|
|
mRequest->cancelCb = [this, thinking, thinkingID, editor]( const LLMChatCompletionRequest& ) {
|
|
runOnMainThread( [this, thinking, thinkingID, editor] {
|
|
thinking->removeActionsByTag( thinkingID );
|
|
thinking->setVisible( false );
|
|
mChatStop->setVisible( false )->setEnabled( false );
|
|
mChatRun->setVisible( true )->setEnabled( true );
|
|
toggleEnableChats( true );
|
|
editor->setEnabled( true );
|
|
if ( editor->hasFocus() )
|
|
mChatInput->setFocus();
|
|
removeLastChat();
|
|
} );
|
|
};
|
|
|
|
mRequest->doneCb =
|
|
[this, editor, apiUrl = std::move( apiUrl ), apiKeyStr = std::move( apiKeyStr ),
|
|
model = std::move( model )]( const LLMChatCompletionRequest&, Http::Response& response ) {
|
|
auto status = response.getStatus();
|
|
auto statusDesc = response.getStatusDescription();
|
|
|
|
runOnMainThread( [this, editor, status, statusDesc, apiUrl = std::move( apiUrl ),
|
|
apiKeyStr = std::move( apiKeyStr ), model = std::move( model )] {
|
|
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 if ( resp.is_array() && !resp.empty() ) {
|
|
const auto& first = resp.at( 0 );
|
|
if ( first.contains( "error" ) && first["error"].contains( "message" ) )
|
|
showMsg( first["error"].value( "message", "" ) );
|
|
else
|
|
showMsg( statusDesc );
|
|
} else {
|
|
showMsg( statusDesc );
|
|
}
|
|
|
|
removeLastChat();
|
|
}
|
|
mRequest.reset();
|
|
toggleEnableChats( true );
|
|
editor->setEnabled( true );
|
|
|
|
mChatStop->setVisible( false )->setEnabled( false );
|
|
mChatRun->setVisible( true )->setEnabled( true );
|
|
|
|
if ( editor->hasFocus() )
|
|
mChatInput->setFocus();
|
|
|
|
if ( !mChatIsPrivate && !mSummaryRequest && mSummary.empty() &&
|
|
status == Http::Response::Ok ) {
|
|
generateChatName( false );
|
|
} else {
|
|
saveChat();
|
|
}
|
|
} );
|
|
};
|
|
mRequest->requestAsync();
|
|
}
|
|
|
|
void LLMChatUI::toggleEnableChat( UIWidget* chat, bool enabled ) {
|
|
chat->findByClass( "role_ui" )->setEnabled( enabled );
|
|
UICodeEditor* editor = chat->findByClass( "data_ui" )->asType<UICodeEditor>();
|
|
// editor->setEnabled( enabled );
|
|
editor->setLocked( !enabled );
|
|
chat->findByClass( "erase_but" )->setEnabled( enabled );
|
|
chat->findByClass( "move_up" )->setEnabled( enabled );
|
|
chat->findByClass( "move_down" )->setEnabled( enabled );
|
|
// chat->findByClass( "copy_contents" )->setEnabled( enabled );
|
|
}
|
|
|
|
void LLMChatUI::toggleEnableChats( bool enabled ) {
|
|
auto chats = mChatsList->findAllByClass( "llm_conversation" );
|
|
for ( auto chat : chats )
|
|
toggleEnableChat( chat, enabled );
|
|
}
|
|
|
|
Drawable* LLMChatUI::findIcon( const std::string& name, const size_t iconSize ) {
|
|
if ( name.empty() )
|
|
return nullptr;
|
|
UIIcon* icon = getUISceneNode()->findIcon( name );
|
|
if ( icon )
|
|
return icon->getSize( iconSize );
|
|
return nullptr;
|
|
}
|
|
|
|
UIWidget* LLMChatUI::addChatUI( LLMChat::Role role ) {
|
|
find( "chat_presentation" )->setVisible( false );
|
|
|
|
UIWidget* chat =
|
|
mChatsList->getUISceneNode()->loadLayoutFromString( DEFAULT_CHAT_GLOBE, mChatsList );
|
|
auto* roleDDL = chat->findByClass( "role_ui" )->asType<UIDropDownList>();
|
|
auto* roleListBox = chat->findByClass( "role_ui" )->asType<UIDropDownList>()->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<UICodeEditor>( "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 ) ) );
|
|
|
|
if ( getPlugin() ) {
|
|
editor->setColorScheme(
|
|
getPlugin()->getPluginContext()->getSplitter()->getCurrentColorScheme() );
|
|
}
|
|
|
|
editor->on( Event::OnSizeChange, [editor, this]( auto ) { resizeToFit( editor ); } );
|
|
editor->on( Event::OnVisibleLinesCountChange,
|
|
[editor, this]( auto ) { resizeToFit( editor ); } );
|
|
editor->on( Event::OnBeforeFoldUnfoldRange,
|
|
[this]( auto ) { mChatScrollView->setAnchorScroll( true ); } );
|
|
editor->on( Event::OnFoldUnfoldRange,
|
|
[this]( auto ) { mChatScrollView->setAnchorScroll( false ); } );
|
|
chat->findByClass( "erase_but" )->onClick( [chat, this]( auto ) {
|
|
chat->close();
|
|
auto chats = findAllByClass( "llm_conversation" );
|
|
if ( chats.empty() )
|
|
find( "chat_presentation" )->setVisible( true );
|
|
} );
|
|
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 );
|
|
} );
|
|
chat->findByClass( "copy_contents" )->onClick( [this, editor]( auto ) {
|
|
auto text = editor->getDocument().getText().toUtf8();
|
|
if ( text.empty() )
|
|
return;
|
|
replaceFileLinksToContents( text );
|
|
getUISceneNode()->getWindow()->getClipboard()->setText( text );
|
|
getPlugin()->getPluginContext()->getNotificationCenter()->addNotification(
|
|
i18n( "chat_copied", "Chat Copied" ) );
|
|
} );
|
|
resizeToFit( editor );
|
|
return chat;
|
|
}
|
|
|
|
void LLMChatUI::addChat( LLMChat::Role role, std::string conversation ) {
|
|
UIWidget* chat = addChatUI( role );
|
|
auto* editor = chat->findByClass<UICodeEditor>( "data_ui" );
|
|
editor->getDocument().textInput( String::fromUtf8( conversation ) );
|
|
editor->setCursorVisible( false );
|
|
editor->setAllowSelectingTextFromGutter( false );
|
|
bindCmds( editor, false );
|
|
resizeToFit( editor );
|
|
}
|
|
|
|
void LLMChatUI::removeLastChat() {
|
|
auto chats = mChatsList->findAllByClass( "llm_conversation" );
|
|
if ( !chats.empty() ) {
|
|
auto* chat = chats[chats.size() - 1];
|
|
auto* editor = chat->findByClass<UICodeEditor>( "data_ui" );
|
|
if ( editor->getDocument().isEmpty() )
|
|
chat->close();
|
|
}
|
|
}
|
|
|
|
void LLMChatUI::setProviders( LLMProviders&& providers ) {
|
|
mProviders = std::move( providers );
|
|
}
|
|
|
|
void LLMChatUI::showMsg( String msg ) {
|
|
auto msgBox = UIMessageBox::New( UIMessageBox::OK, msg );
|
|
msgBox->getTextBox()->setLayoutWidthPolicy( SizePolicy::Fixed );
|
|
msgBox->getTextBox()->setPixelsSize(
|
|
{ PixelDensity::dpToPx( 600 ), msgBox->getTextBox()->getPixelsSize().getHeight() } );
|
|
msgBox->getTextBox()->setWordWrap( true );
|
|
msgBox->getTextBox()->setTextSelectionEnabled( true );
|
|
msgBox->getTextBox()->onClick(
|
|
[]( const MouseEvent* event ) {
|
|
auto tv = event->getNode()->asType<UITextView>();
|
|
tv->getUISceneNode()->getWindow()->getClipboard()->setText( tv->getText() );
|
|
},
|
|
EE_BUTTON_RIGHT );
|
|
msgBox->showWhenReady();
|
|
}
|
|
|
|
AIAssistantPlugin* LLMChatUI::getPlugin() const {
|
|
if ( mManager != nullptr ) {
|
|
auto plugin = mManager->get( "aiassistant" );
|
|
if ( plugin )
|
|
return reinterpret_cast<AIAssistantPlugin*>( plugin );
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
const LLMModel& LLMChatUI::getCheapestModelFromCurrentProvider() const {
|
|
auto providerIt = mProviders.find( mCurModel.provider );
|
|
if ( providerIt != mProviders.end() ) {
|
|
for ( const auto& model : providerIt->second.models )
|
|
if ( model.cheapest )
|
|
return model;
|
|
}
|
|
return mCurModel;
|
|
}
|
|
|
|
void LLMChatUI::onInit() {
|
|
if ( !mModelDDL )
|
|
return;
|
|
if ( getModelDisplayName( mCurModel ) != mModelDDL->getListBox()->getItemSelectedText() )
|
|
selectModel( mModelDDL, mCurModel );
|
|
}
|
|
|
|
void LLMChatUI::updateTabTitle() {
|
|
if ( getData() == 0 )
|
|
return;
|
|
UITab* tab = reinterpret_cast<UITab*>( getData() );
|
|
auto title = i18n( "ai_assistant", "AI Assistant" );
|
|
if ( !mSummary.empty() )
|
|
title += " - " + mSummary;
|
|
tab->setText( title );
|
|
tab->setTooltipText( title );
|
|
}
|
|
|
|
void LLMChatUI::renameChat( const std::string& newName, bool invertLockedState ) {
|
|
auto oldPath = getNewFilePath( mUUID.toString(), mSummary, mChatLocked );
|
|
auto newPath =
|
|
getNewFilePath( mUUID.toString(), newName, invertLockedState ? !mChatLocked : mChatLocked );
|
|
if ( FileSystem::fileMove( oldPath, newPath ) ) {
|
|
mSummary = newName;
|
|
if ( invertLockedState )
|
|
mChatLocked = !mChatLocked;
|
|
saveChat();
|
|
updateTabTitle();
|
|
}
|
|
}
|
|
|
|
UISplitter* LLMChatUI::getSplitter() const {
|
|
return mChatSplitter;
|
|
}
|
|
|
|
const LLMModel& LLMChatUI::getCurModel() const {
|
|
return mCurModel;
|
|
}
|
|
|
|
void LLMChatUI::deleteOldConversations( int days ) {
|
|
auto plugin = getPlugin();
|
|
if ( plugin == nullptr )
|
|
return;
|
|
std::string conversationsPath = plugin->getConversationsPath();
|
|
auto history = ChatHistory::getHistory( conversationsPath );
|
|
|
|
Int64 olderThanTime = std::chrono::system_clock::to_time_t( std::chrono::system_clock::now() ) -
|
|
( 60 * 60 * 24 * days );
|
|
|
|
for ( const auto& chat : history )
|
|
if ( !chat.locked && chat.file.getModificationTime() < olderThanTime )
|
|
FileSystem::fileRemove( chat.file.getFilepath() );
|
|
}
|
|
|
|
void LLMChatUI::updateLocateBarColumns() {
|
|
Float width = eeceil( mLocateTable->getPixelsSize().getWidth() );
|
|
width -= mLocateTable->getVerticalScrollBar()->getPixelsSize().getWidth();
|
|
mLocateTable->setColumnsVisible( { 0, 1 } );
|
|
mLocateTable->setColumnWidth( 0, eeceil( width * 0.5 ) );
|
|
mLocateTable->setColumnWidth( 1, width - mLocateTable->getColumnWidth( 0 ) );
|
|
}
|
|
|
|
void LLMChatUI::showAttachFile() {
|
|
if ( getPlugin() == nullptr )
|
|
return;
|
|
auto text = mLocateInput->getText();
|
|
auto ctx = getPlugin()->getPluginContext();
|
|
if ( !ctx->isDirTreeReady() ) {
|
|
mLocateTable->setModel( ProjectDirectoryTree::emptyModel( {}, ctx->getCurrentProject() ) );
|
|
mLocateTable->getSelection().set( mLocateTable->getModel()->index( 0 ) );
|
|
} else if ( !mLocateInput->getText().empty() ) {
|
|
ctx->getDirTree()->asyncMatchTree(
|
|
ProjectDirectoryTree::MatchType::Fuzzy, text, 100,
|
|
[this, text]( auto res ) {
|
|
mUISceneNode->runOnMainThread( [this, res] {
|
|
mLocateTable->setModel( res );
|
|
mLocateTable->getSelection().set( mLocateTable->getModel()->index( 0 ) );
|
|
mLocateTable->scrollToTop();
|
|
updateLocateBarColumns();
|
|
} );
|
|
},
|
|
ctx->getCurrentProject() );
|
|
} else {
|
|
mLocateTable->setModel( ctx->getDirTree()->asModel(
|
|
100, {}, ctx->getCurrentProject(), Image::getImageExtensionsSupported() ) );
|
|
mLocateTable->getSelection().set( mLocateTable->getModel()->index( 0 ) );
|
|
}
|
|
mLocateBarLayout->setVisible( true );
|
|
mLocateInput->setFocus();
|
|
updateLocateBarColumns();
|
|
}
|
|
|
|
void LLMChatUI::hideAttachFile() {
|
|
mLocateBarLayout->setVisible( false );
|
|
}
|
|
|
|
void LLMChatUI::insertFileToDocument( std::string path, std::shared_ptr<TextDocument> cdoc ) {
|
|
std::string prjPath( getPlugin()->getPluginContext()->getCurrentProject() );
|
|
|
|
if ( FileSystem::isRelativePath( path ) )
|
|
path = prjPath + path;
|
|
|
|
TextDocument doc;
|
|
if ( doc.loadFromFile( path ) == TextDocument::LoadStatus::Loaded ) {
|
|
std::string nameToDisplay = doc.getFilename();
|
|
if ( !prjPath.empty() && String::startsWith( doc.getFilePath(), prjPath ) ) {
|
|
nameToDisplay = doc.getFilePath();
|
|
FileSystem::filePathRemoveBasePath( prjPath, nameToDisplay );
|
|
}
|
|
cdoc->resetSelection();
|
|
cdoc->moveToEndOfLine();
|
|
cdoc->textInput( "\n`" + nameToDisplay + "`:\n" );
|
|
cdoc->textInput( "```" + doc.getSyntaxDefinition().getLSPName() );
|
|
auto lineToFold = cdoc->getSelection().end().line();
|
|
if ( doc.linesCount() >= 1 && !String::startsWith( doc.line( 0 ).getText(), "\n" ) ) {
|
|
cdoc->textInput( "\n" );
|
|
}
|
|
cdoc->textInput( doc.getText() );
|
|
if ( doc.linesCount() >= 1 &&
|
|
doc.line( doc.linesCount() - 1 ).getText() != String( "\n" ) ) {
|
|
cdoc->textInput( "\n" );
|
|
}
|
|
cdoc->textInput( "```\n" );
|
|
cdoc->getFoldRangeService().findRegionsNative();
|
|
if ( doc.linesCount() > 30 &&
|
|
cdoc->getFoldRangeService().isFoldingRegionInLine( lineToFold ) ) {
|
|
mChatInput->toggleFoldUnfold( lineToFold );
|
|
}
|
|
}
|
|
}
|
|
|
|
void LLMChatUI::initAttachFile() {
|
|
mLocateBarLayout = findByClass<UIVLinearLayoutCommandExecuter>( "llm_chat_attach" );
|
|
mLocateInput = findByClass<UITextInput>( "llm_chat_locate_input" );
|
|
mLocateTable = findByClass<UITableView>( "llm_chat_attach_locate" );
|
|
mLocateTable->setHeadersVisible( false );
|
|
|
|
mLocateTable->on( Event::OnSizeChange, [this]( const Event* ) { updateLocateBarColumns(); } );
|
|
|
|
mLocateInput->on( Event::OnTextChanged, [this]( const Event* ) {
|
|
showAttachFile();
|
|
updateLocateBarColumns();
|
|
} );
|
|
mLocateInput->on( Event::OnPressEnter, [this]( const Event* ) {
|
|
KeyEvent keyEvent( mLocateTable, Event::KeyDown, KEY_RETURN, SCANCODE_UNKNOWN, 0, 0 );
|
|
mLocateTable->forceKeyDown( keyEvent );
|
|
} );
|
|
mLocateInput->on( Event::KeyDown, [this]( const Event* event ) {
|
|
const KeyEvent* keyEvent = static_cast<const KeyEvent*>( event );
|
|
mLocateTable->forceKeyDown( *keyEvent );
|
|
} );
|
|
mLocateBarLayout->setCommand( "close-locatebar", [this] {
|
|
hideAttachFile();
|
|
if ( mChatInput )
|
|
mChatInput->setFocus();
|
|
} );
|
|
mLocateBarLayout->getKeyBindings().addKeybindsString( {
|
|
{ "escape", "close-locatebar" },
|
|
} );
|
|
mLocateTable->on( Event::KeyDown, [this]( const Event* event ) {
|
|
const KeyEvent* keyEvent = static_cast<const KeyEvent*>( event );
|
|
if ( keyEvent->getKeyCode() == KEY_ESCAPE )
|
|
mLocateBarLayout->execute( "close-locatebar" );
|
|
} );
|
|
mLocateTable->on( Event::OnModelEvent, [this]( const Event* event ) {
|
|
const ModelEvent* modelEvent = static_cast<const ModelEvent*>( event );
|
|
if ( modelEvent->getModelEventType() == ModelEventType::Open ) {
|
|
Variant vName( modelEvent->getModel()->data(
|
|
modelEvent->getModel()->index( modelEvent->getModelIndex().row(), 0 ),
|
|
ModelRole::Display ) );
|
|
Variant vPath( modelEvent->getModel()->data(
|
|
modelEvent->getModel()->index( modelEvent->getModelIndex().row(), 1 ),
|
|
ModelRole::Display ) );
|
|
|
|
if ( !vPath.isValid() )
|
|
return;
|
|
|
|
std::string path( vPath.toString() );
|
|
if ( path.empty() )
|
|
return;
|
|
|
|
Variant rangeStr( modelEvent->getModel()->data(
|
|
modelEvent->getModel()->index( modelEvent->getModelIndex().row(), 1 ),
|
|
ModelRole::Custom ) );
|
|
|
|
auto cdoc = mChatInput->getDocumentRef();
|
|
|
|
if ( mLinkMode ) {
|
|
cdoc->resetSelection();
|
|
cdoc->moveToEndOfLine();
|
|
cdoc->textInput( "\n```file://" + path + "```\n" );
|
|
} else {
|
|
insertFileToDocument( path, cdoc );
|
|
}
|
|
|
|
mLocateBarLayout->execute( "close-locatebar" );
|
|
}
|
|
} );
|
|
}
|
|
|
|
} // namespace ecode
|