Skip to content

Commit ef73e74

Browse files
committed
Implemented attaching files to conversation
* Update the storage database to support them. * Updated the UI bits to display the list of attached files o either in the chat message o or in the input editor o support for text, image and audio files
1 parent fa0b2c8 commit ef73e74

File tree

7 files changed

+429
-29
lines changed

7 files changed

+429
-29
lines changed

llamachateditor.cpp

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,15 +237,15 @@ void ChatEditor::onPendingMessageChanged(const Message &pm)
237237
scrollToBottom();
238238
}
239239

240-
void ChatEditor::onSendRequested(const QString &text)
240+
void ChatEditor::onSendRequested(const QString &text, const QList<QVariantMap> &extra)
241241
{
242242
const Conversation conv = ChatManager::instance().currentConversation();
243243

244244
if (m_editedMessage) {
245245
ChatManager::instance().replaceMessageAndGenerate(m_editedMessage->convId,
246246
m_editedMessage->parent,
247247
text,
248-
m_editedMessage->extra,
248+
extra,
249249
[this](qint64 leafId) {
250250
scrollToBottom();
251251
});
@@ -254,7 +254,7 @@ void ChatEditor::onSendRequested(const QString &text)
254254
ChatManager::instance().sendMessage(conv.id,
255255
conv.currNode,
256256
text,
257-
{}, // extra context
257+
extra,
258258
[this](qint64 leafId) { scrollToBottom(); });
259259
}
260260
scrollToBottom();
@@ -277,13 +277,13 @@ void ChatEditor::onFileDropped(const QStringList &files)
277277
void ChatEditor::onEditRequested(const Message &msg)
278278
{
279279
m_editedMessage = msg;
280-
m_input->setEditingText(msg.content);
280+
m_input->setEditingText(msg.content, msg.extra);
281281
}
282282

283283
void ChatEditor::onEditingCancelled()
284284
{
285285
m_editedMessage.reset();
286-
m_input->setEditingText({});
286+
m_input->setEditingText({}, {});
287287
}
288288

289289
void ChatEditor::onRegenerateRequested(const Message &msg)

llamachateditor.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class ChatEditor : public Core::IEditor
3636
public slots:
3737
void onMessageAppended(const LlamaCpp::Message &msg);
3838
void onPendingMessageChanged(const LlamaCpp::Message &pm);
39-
void onSendRequested(const QString &text);
39+
void onSendRequested(const QString &text, const QList<QVariantMap> &extra);
4040
void onStopRequested();
4141
void onFileDropped(const QStringList &files);
4242
void onEditRequested(const LlamaCpp::Message &msg);

llamachatinput.cpp

Lines changed: 235 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,51 @@
33
#include <QFileDialog>
44
#include <QHBoxLayout>
55
#include <QMimeData>
6+
#include <QTabBar>
7+
#include <QTabWidget>
68
#include <QTextEdit>
79
#include <QToolButton>
810

11+
#include <utils/filepath.h>
12+
#include <utils/fsengine/fileiconprovider.h>
13+
914
#include "llamachatinput.h"
1015
#include "llamatheme.h"
1116
#include "llamatr.h"
1217

18+
using namespace Utils;
19+
1320
namespace LlamaCpp {
1421

22+
static bool isImageFile(const QString &fileName)
23+
{
24+
QString ext = QFileInfo(fileName).suffix().toLower();
25+
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp" || ext == "svg";
26+
}
27+
28+
static bool isAudioFile(const QString &fileName)
29+
{
30+
QString ext = QFileInfo(fileName).suffix().toLower();
31+
return ext == "wav" || ext == "mp3";
32+
}
33+
34+
static QString mimeNameFromExtension(const QString &ext)
35+
{
36+
if (ext == "png")
37+
return "image/png";
38+
if (ext == "jpg" || ext == "jpeg")
39+
return "image/jpeg";
40+
if (ext == "webp")
41+
return "image/webp";
42+
if (ext == "svg")
43+
return "image/svg+xml";
44+
if (ext == "wav")
45+
return "audio/wav";
46+
if (ext == "mp3")
47+
return "audio/mpeg";
48+
return "application/octet-stream";
49+
}
50+
1551
ChatInput::ChatInput(QWidget *parent)
1652
: QWidget(parent)
1753
{
@@ -23,12 +59,16 @@ ChatInput::ChatInput(QWidget *parent)
2359

2460
void ChatInput::buildUI()
2561
{
26-
auto main = new QHBoxLayout(this);
27-
main->setContentsMargins(10, 10, 10, 10);
62+
auto main = new QVBoxLayout(this);
63+
main->setContentsMargins(0, 0, 0, 0);
64+
65+
auto textAndButtonLayout = new QHBoxLayout;
66+
textAndButtonLayout->setContentsMargins(10, 10, 10, 10);
2867

2968
m_txt = new QTextEdit(this);
3069
m_txt->setPlaceholderText(Tr::tr("Type a message (Shift+Enter for new line)"));
3170
m_txt->setAcceptRichText(false);
71+
m_txt->setAcceptDrops(false);
3272

3373
m_txt->installEventFilter(this);
3474
installEventFilter(this);
@@ -40,9 +80,9 @@ void ChatInput::buildUI()
4080
m_attachButton->setIcon(QIcon::fromTheme("mail-attachment"));
4181
m_attachButton->setToolTip(Tr::tr("Attach file"));
4282
connect(m_attachButton, &QToolButton::clicked, [this]() {
43-
QStringList files = QFileDialog::getOpenFileNames(this);
83+
const QStringList files = QFileDialog::getOpenFileNames(this);
4484
if (!files.isEmpty())
45-
emit fileDropped(files);
85+
addFilesFromLocalPaths(files);
4686
});
4787

4888
m_sendStopButton = new QToolButton(this);
@@ -56,8 +96,35 @@ void ChatInput::buildUI()
5696
btnLayout->addWidget(m_attachButton);
5797
btnLayout->addWidget(m_sendStopButton);
5898

59-
main->addWidget(m_txt);
60-
main->addLayout(btnLayout);
99+
textAndButtonLayout->addWidget(m_txt);
100+
textAndButtonLayout->addLayout(btnLayout);
101+
102+
m_attachedFilesBar = new QTabBar(this);
103+
m_attachedFilesBar->setAcceptDrops(false);
104+
m_attachedFilesBar->setVisible(false);
105+
m_attachedFilesBar->setTabsClosable(true);
106+
m_attachedFilesBar->setDocumentMode(true);
107+
m_attachedFilesBar->setExpanding(false);
108+
m_attachedFilesBar->setDrawBase(false);
109+
m_attachedFilesBar->setElideMode(Qt::ElideMiddle);
110+
111+
QSizePolicy sp(QSizePolicy::Preferred, QSizePolicy::Fixed);
112+
sp.setHorizontalStretch(1);
113+
sp.setVerticalStretch(0);
114+
sp.setHeightForWidth(sizePolicy().hasHeightForWidth());
115+
m_attachedFilesBar->setSizePolicy(sp);
116+
117+
connect(m_attachedFilesBar, &QTabBar::tabCloseRequested, this, [this](int index) {
118+
m_attachedFilesBar->removeTab(index);
119+
m_attachedFiles.removeAt(index);
120+
if (m_attachedFilesBar->count() == 0) {
121+
m_attachedFilesBar->setVisible(false);
122+
updateMaximumHeight();
123+
}
124+
});
125+
126+
main->addLayout(textAndButtonLayout);
127+
main->addWidget(m_attachedFilesBar);
61128

62129
updateUI();
63130
}
@@ -76,7 +143,6 @@ void ChatInput::updateUI()
76143
void ChatInput::applyStyleSheet()
77144
{
78145
setAttribute(Qt::WA_StyledBackground, true);
79-
80146
setStyleSheet(replaceThemeColorNamesWithRGBNames(R"(
81147
QWidget#ChatInput {
82148
background-color: Token_Background_Muted;
@@ -96,25 +162,107 @@ void ChatInput::applyStyleSheet()
96162
border-radius: 6px;
97163
padding: 4px 4px;
98164
}
165+
99166
QToolButton:hover {
100167
background-color: Token_Foreground_Muted;
101168
}
169+
170+
QTabBar {
171+
background-color: Token_Background_Muted;
172+
height: 22px;
173+
margin-left: 10px;
174+
margin-right: 10px;
175+
margin-bottom: 1px;
176+
}
177+
178+
QTabBar::tear {
179+
width: 0px;
180+
}
181+
182+
QTabBar::close-button {
183+
subcontrol-position: right;
184+
}
185+
186+
QTabBar::tab {
187+
background-color: Token_Background_Muted;
188+
color: Token_Text_Default;
189+
190+
border: 1px solid Token_Foreground_Muted;
191+
border-radius: 6px;
192+
height: 20px;
193+
194+
min-width: 5ex;
195+
196+
margin-top: 4px;
197+
margin-bottom: 4px;
198+
margin-left: 4px;
199+
}
200+
201+
QTabBar::tab:hover {
202+
background-color: Token_Foreground_Muted;
203+
}
204+
102205
)"));
103206
}
104207

208+
void ChatInput::cleanUp()
209+
{
210+
m_txt->clear();
211+
212+
while (m_attachedFilesBar->count() > 0) {
213+
m_attachedFilesBar->removeTab(0);
214+
}
215+
m_attachedFilesBar->setVisible(false);
216+
217+
m_attachedFiles.clear();
218+
219+
updateMaximumHeight();
220+
}
221+
105222
void ChatInput::onSendClicked()
106223
{
107224
QString message = m_txt->toPlainText().trimmed();
108225
if (!message.isEmpty())
109-
emit sendRequested(message);
110-
m_txt->clear();
226+
emit sendRequested(message, getExtraFromAttachedFiles());
227+
cleanUp();
111228
}
112229

113230
void ChatInput::onStopClicked()
114231
{
115232
emit stopRequested();
116233
}
117234

235+
void ChatInput::addFilesFromLocalPaths(const QStringList &filePaths)
236+
{
237+
for (const QString &path : filePaths) {
238+
FilePath localFile = FilePath::fromString(path);
239+
if (!localFile.isLocal())
240+
continue; // skip non‑local files
241+
242+
const QString fileName = localFile.fileName();
243+
244+
// If a file with the same name already exists, just replace its
245+
// contents – we don't want duplicate tabs.
246+
auto existing = std::ranges::find_if(m_attachedFiles, [fileName](const auto &pair) {
247+
return pair.first == fileName;
248+
});
249+
if (existing != m_attachedFiles.end()) {
250+
existing->second = localFile.fileContents().value_or("");
251+
continue;
252+
}
253+
254+
const QIcon icon = FileIconProvider::icon(localFile);
255+
m_attachedFilesBar->addTab(icon, localFile.fileName());
256+
257+
m_attachedFiles.append({localFile.fileName(), localFile.fileContents().value_or("")});
258+
}
259+
260+
if (!filePaths.isEmpty()) {
261+
m_attachedFilesBar->setVisible(true);
262+
updateMaximumHeight();
263+
}
264+
}
265+
118266
void ChatInput::dragEnterEvent(QDragEnterEvent *e)
119267
{
120268
if (e->mimeData()->hasUrls())
@@ -123,10 +271,16 @@ void ChatInput::dragEnterEvent(QDragEnterEvent *e)
123271

124272
void ChatInput::dropEvent(QDropEvent *e)
125273
{
126-
QStringList fileList;
127-
for (const QUrl &url : e->mimeData()->urls())
128-
fileList << url.toLocalFile();
129-
emit fileDropped(fileList);
274+
QStringList localPaths;
275+
for (const QUrl &url : e->mimeData()->urls()) {
276+
FilePath fp = FilePath::fromUrl(url);
277+
if (fp.isLocal())
278+
localPaths.append(fp.toFSPathString());
279+
}
280+
281+
addFilesFromLocalPaths(localPaths);
282+
283+
e->acceptProposedAction();
130284
}
131285

132286
bool ChatInput::eventFilter(QObject *obj, QEvent *event)
@@ -147,6 +301,7 @@ bool ChatInput::eventFilter(QObject *obj, QEvent *event)
147301
return true;
148302
}
149303
}
304+
150305
if (obj == this && event->type() == QEvent::FocusIn) {
151306
m_txt->setFocus();
152307
return true;
@@ -161,10 +316,76 @@ void ChatInput::setIsGenerating(bool newIsGenerating)
161316
updateUI();
162317
}
163318

164-
void ChatInput::setEditingText(const QString &editingText)
319+
void ChatInput::setEditingText(const QString &editingText, const QList<QVariantMap> &extra)
165320
{
321+
cleanUp();
322+
323+
for (const QVariantMap &e : extra) {
324+
if (e.value("type").toString() == "textFile") {
325+
const QString fileName = e.value("name").toString();
326+
m_attachedFiles.append({fileName, e.value("content").toByteArray()});
327+
328+
const QIcon icon = FileIconProvider::icon(FilePath::fromString(fileName));
329+
m_attachedFilesBar->addTab(icon, fileName);
330+
} else if (e.value("type").toString() == "imageFile") {
331+
const QString fileName = e.value("name").toString();
332+
m_attachedFiles.append({fileName, e.value("content").toByteArray()});
333+
334+
const QIcon icon = FileIconProvider::icon(FilePath::fromString(fileName));
335+
m_attachedFilesBar->addTab(icon, fileName);
336+
} else if (e.value("type").toString() == "audioFile") {
337+
const QString fileName = e.value("name").toString();
338+
m_attachedFiles.append({fileName, e.value("content").toByteArray()});
339+
340+
const QIcon icon = FileIconProvider::icon(FilePath::fromString(fileName));
341+
m_attachedFilesBar->addTab(icon, fileName);
342+
}
343+
}
344+
345+
m_attachedFilesBar->setVisible(m_attachedFilesBar->count() > 0);
346+
updateMaximumHeight();
347+
166348
m_txt->setText(editingText);
167349
m_txt->setFocus();
168350
}
169351

352+
void ChatInput::updateMaximumHeight()
353+
{
354+
int maximumHeight = 80;
355+
if (m_attachedFilesBar->isVisible())
356+
maximumHeight += 20;
357+
setMaximumHeight(maximumHeight);
358+
}
359+
360+
QList<QVariantMap> ChatInput::getExtraFromAttachedFiles()
361+
{
362+
QList<QVariantMap> extraList;
363+
for (const auto &fileAndContent : std::as_const(m_attachedFiles)) {
364+
QVariantMap extra;
365+
const QString fileName = fileAndContent.first;
366+
const QByteArray content = fileAndContent.second;
367+
QString ext = QFileInfo(fileName).suffix().toLower();
368+
369+
if (isImageFile(fileName)) {
370+
QString base64Url = "data:" + mimeNameFromExtension(ext) + ";base64,"
371+
+ content.toBase64();
372+
extra["type"] = "imageFile";
373+
extra["name"] = fileName;
374+
extra["base64Url"] = base64Url;
375+
} else if (isAudioFile(fileName)) {
376+
QString base64Url = "data:" + mimeNameFromExtension(ext) + ";base64,"
377+
+ content.toBase64();
378+
extra["type"] = "audioFile";
379+
extra["name"] = fileName;
380+
extra["base64Url"] = base64Url;
381+
} else {
382+
extra["type"] = "textFile";
383+
extra["name"] = fileName;
384+
extra["content"] = content;
385+
}
386+
extraList << extra;
387+
}
388+
return extraList;
389+
}
390+
170391
} // namespace LlamaCpp

0 commit comments

Comments
 (0)