feat(chat): 重做聊天窗口界面

- 消息改成气泡列表:头像、左右分栏、markdown 正文、hover 显示复制/编辑/分支/重试
- 会话列表:应用图标 + 名称、每项显示标题/最后一条预览/时间、hover 与选中态
- 输入区:圆角输入框内嵌发送按钮、快捷键提示
- 空会话欢迎页;消息区域支持拖放
- 头部显示当前会话标题与模型徽章
- 设置/更多按钮移到侧栏底部
This commit is contained in:
2026-10-30 20:40:45 +08:00
parent 5fbf7d7931
commit 798f5aa7ce
9 changed files with 883 additions and 265 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
- 文字:ICU 分词、提取链接、朗读
- 文件/图片:打开、复制路径、保存图片、占位 OCR/描述
- 待办窗口:layer-shell、不抢焦点、复选框/删除/新建、持久化
- 小埃同学对话:DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、
- 小埃同学对话:气泡消息/头像/会话列表(标题+预览+时间)/输入区重做;
DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、
编辑/重试/分支、上下文条数与自动压缩、Markdown、关于
- MCP:stdio JSON-RPC,自动 initialize / tools-list,工具以 function calling 暴露给模型并在对话中执行
- 合成器 portal(flake-wm / gxde-wlcom):org.gxde.Portal、缩略图坐标映射、
+2
View File
@@ -35,6 +35,8 @@ set(GXDE_AI_SEE_SOURCES
src/chat/chatclient.cpp
src/chat/chatwindow.cpp
src/chat/mcpclient.cpp
src/chat/markdown.cpp
src/chat/chatview.cpp
src/ui/portalpanel.cpp
)
+442
View File
@@ -0,0 +1,442 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "chatview.h"
#include <QMimeData>
#include "markdown.h"
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QHBoxLayout>
#include <QScrollBar>
#include <QLabel>
#include <QTextBrowser>
#include <QTextDocument>
#include <QAbstractTextDocumentLayout>
#include <QMimeData>
#include <QTimer>
#include <QToolButton>
#include <QVBoxLayout>
namespace {
constexpr int kAvatarSize = 30;
constexpr int kBubbleMaxWidth = 580;
QString avatarStyle(bool user)
{
return user ? QStringLiteral("QLabel { background: rgba(74,144,255,0.85); color: white;"
" border-radius: %1px; font-size: 12px; }")
.arg(kAvatarSize / 2)
: QStringLiteral("QLabel { background: rgba(255,255,255,0.14); color: #eaeaea;"
" border-radius: %1px; font-size: 12px; }")
.arg(kAvatarSize / 2);
}
QToolButton *actionButton(const QString &text, QWidget *parent)
{
auto *button = new QToolButton(parent);
button->setText(text);
button->setCursor(Qt::PointingHandCursor);
button->setFocusPolicy(Qt::NoFocus);
button->setAutoRaise(true);
button->setStyleSheet(QStringLiteral(
"QToolButton { color: rgba(160,200,240,0.75); background: transparent; border: none;"
" font-size: 11px; padding: 2px 4px; }"
"QToolButton:hover { color: #cfe5ff; }"));
return button;
}
} // namespace
/** QTextBrowser that grows to fit its document. */
class BubbleText : public QTextBrowser
{
public:
explicit BubbleText(QWidget *parent = nullptr)
: QTextBrowser(parent)
{
setFrameShape(QFrame::NoFrame);
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setOpenExternalLinks(false);
setMinimumWidth(160);
setMaximumWidth(kBubbleMaxWidth);
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
setStyleSheet(QStringLiteral("QTextBrowser { background: transparent; border: none; }"));
document()->setDocumentMargin(0);
document()->setDefaultStyleSheet(QStringLiteral(
"body { color: #ececec; }"
"pre { background: #2b2b31; }"
"code { background: #2b2b31; }"
"a { color: #7aa7d9; }"
"h1, h2, h3 { margin: 4px 0; }"
".li { margin-left: 10px; }"
".gap { height: 6px; }"));
connect(document()->documentLayout(), &QAbstractTextDocumentLayout::documentSizeChanged,
this, [this]() { fitHeight(); });
}
void setContent(const QString &html)
{
setHtml(html);
fitHeight();
}
protected:
void resizeEvent(QResizeEvent *event) override
{
QTextBrowser::resizeEvent(event);
fitHeight();
}
private:
void fitHeight()
{
const int wanted = int(document()->size().height()) + 4;
if (wanted > 0 && height() != wanted) {
setFixedHeight(wanted);
}
}
};
class MessageRow : public QWidget
{
Q_OBJECT
public:
MessageRow(const ChatMessage &message, int index, bool isLast, QWidget *parent = nullptr)
: QWidget(parent)
, m_message(message)
, m_index(index)
, m_isUser(message.role == QStringLiteral("user"))
, m_isLast(isLast)
{
auto *row = new QHBoxLayout(this);
row->setContentsMargins(4, 2, 4, 2);
row->setSpacing(8);
auto *avatar = new QLabel(m_isUser ? tr("你") : tr("埃"), this);
avatar->setFixedSize(kAvatarSize, kAvatarSize);
avatar->setAlignment(Qt::AlignCenter);
avatar->setStyleSheet(avatarStyle(m_isUser));
auto *column = new QVBoxLayout;
column->setContentsMargins(0, 0, 0, 0);
column->setSpacing(3);
auto *meta = new QLabel(m_isUser ? tr("你") : tr("小埃"), this);
meta->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.4); font-size: 10px;"));
meta->setAlignment(m_isUser ? Qt::AlignRight : Qt::AlignLeft);
column->addWidget(meta);
auto *bubble = new QFrame(this);
bubble->setMaximumWidth(kBubbleMaxWidth + 26);
bubble->setStyleSheet(
m_isUser ? QStringLiteral("QFrame { background: rgba(74,144,255,0.20);"
" border-radius: 12px; }")
: QStringLiteral("QFrame { background: rgba(255,255,255,0.065);"
" border-radius: 12px; }"));
auto *bubbleLayout = new QVBoxLayout(bubble);
bubbleLayout->setContentsMargins(13, 9, 13, 9);
bubbleLayout->setSpacing(0);
if (m_isUser) {
auto *label = new QLabel(bubble);
label->setTextFormat(Qt::RichText);
label->setWordWrap(true);
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
label->setMaximumWidth(kBubbleMaxWidth);
label->setStyleSheet(QStringLiteral("QLabel { color: #f2f2f2; font-size: 13px; }"));
label->setText(escapedHtml(message.content));
m_label = label;
bubbleLayout->addWidget(label);
} else {
m_body = new BubbleText(bubble);
m_body->setContent(markdownToHtml(message.content));
connect(m_body, &QTextBrowser::anchorClicked, this,
[this](const QUrl &url) { Q_EMIT linkActivated(url); });
bubbleLayout->addWidget(m_body);
}
column->addWidget(bubble);
m_actions = new QWidget(this);
auto *actions = new QHBoxLayout(m_actions);
actions->setContentsMargins(2, 0, 2, 0);
actions->setSpacing(6);
auto *copy = actionButton(tr("复制"), m_actions);
connect(copy, &QToolButton::clicked, this,
[this]() { Q_EMIT copyRequested(m_index); });
actions->addWidget(copy);
if (m_isUser) {
auto *edit = actionButton(tr("编辑"), m_actions);
connect(edit, &QToolButton::clicked, this,
[this]() { Q_EMIT editRequested(m_index); });
actions->addWidget(edit);
} else {
auto *fork = actionButton(tr("分支"), m_actions);
connect(fork, &QToolButton::clicked, this,
[this]() { Q_EMIT forkRequested(m_index); });
actions->addWidget(fork);
if (m_isLast) {
auto *retry = actionButton(tr("重试"), m_actions);
connect(retry, &QToolButton::clicked, this,
[this]() { Q_EMIT retryRequested(); });
actions->addWidget(retry);
}
}
actions->addStretch();
m_actions->hide();
column->addWidget(m_actions);
if (m_isUser) {
row->addStretch(1);
row->addLayout(column);
row->addWidget(avatar, 0, Qt::AlignTop);
} else {
row->addWidget(avatar, 0, Qt::AlignTop);
row->addLayout(column);
row->addStretch(1);
}
}
int rowIndex() const { return m_index; }
void setContent(const QString &text)
{
m_message.content = text;
if (m_body) {
m_body->setContent(markdownToHtml(text));
} else if (m_label) {
m_label->setText(escapedHtml(text));
}
}
signals:
void copyRequested(int index);
void editRequested(int index);
void forkRequested(int index);
void retryRequested();
void linkActivated(const QUrl &url);
protected:
void enterEvent(QEnterEvent *event) override
{
m_actions->show();
QWidget::enterEvent(event);
}
void leaveEvent(QEvent *event) override
{
m_actions->hide();
QWidget::leaveEvent(event);
}
private:
ChatMessage m_message;
int m_index = -1;
bool m_isUser = false;
bool m_isLast = false;
QWidget *m_actions = nullptr;
BubbleText *m_body = nullptr;
QLabel *m_label = nullptr;
};
ChatMessageList::ChatMessageList(QWidget *parent)
: QScrollArea(parent)
{
setAcceptDrops(true);
setWidgetResizable(true);
setFrameShape(QFrame::NoFrame);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setStyleSheet(QStringLiteral("QScrollArea { background: transparent; border: none; }"));
viewport()->setStyleSheet(QStringLiteral("background: transparent;"));
m_container = new QWidget;
m_container->setStyleSheet(QStringLiteral("background: transparent;"));
m_layout = new QVBoxLayout(m_container);
m_layout->setContentsMargins(8, 10, 8, 10);
m_layout->setSpacing(6);
m_layout->addStretch(1);
setWidget(m_container);
m_welcome = new QWidget(m_container);
auto *welcomeLayout = new QVBoxLayout(m_welcome);
welcomeLayout->setContentsMargins(24, 40, 24, 24);
welcomeLayout->setSpacing(10);
welcomeLayout->setAlignment(Qt::AlignHCenter | Qt::AlignTop);
auto *bigAvatar = new QLabel(tr("埃"), m_welcome);
bigAvatar->setFixedSize(64, 64);
bigAvatar->setAlignment(Qt::AlignCenter);
bigAvatar->setStyleSheet(QStringLiteral(
"QLabel { background: rgba(74,144,255,0.22); color: #cfe5ff; border-radius: 32px;"
" font-size: 26px; }"));
welcomeLayout->addWidget(bigAvatar, 0, Qt::AlignHCenter);
auto *title = new QLabel(tr("你好,我是小埃同学"), m_welcome);
QFont titleFont = title->font();
titleFont.setPixelSize(20);
titleFont.setWeight(QFont::DemiBold);
title->setFont(titleFont);
title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.9);"));
welcomeLayout->addWidget(title, 0, Qt::AlignHCenter);
auto *hint = new QLabel(
tr("把文件、链接或文字拖进窗口,或者直接输入问题。\n"
"第一次使用请在左下角「设置」里填入 DeepSeek API Key。"),
m_welcome);
hint->setAlignment(Qt::AlignCenter);
hint->setWordWrap(true);
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.5); font-size: 12px;"));
welcomeLayout->addWidget(hint, 0, Qt::AlignHCenter);
m_layout->insertWidget(0, m_welcome);
}
void ChatMessageList::setMessages(const QVector<ChatMessage> &messages)
{
m_messages = messages;
rebuild();
}
void ChatMessageList::rebuild()
{
for (MessageRow *row : m_rows) {
m_layout->removeWidget(row);
row->deleteLater();
}
m_rows.clear();
bool hasConversation = false;
for (const ChatMessage &message : m_messages) {
if (message.role == QStringLiteral("system")) {
continue;
}
hasConversation = true;
}
m_welcome->setVisible(!hasConversation);
const int lastAssistant = [this]() {
int last = -1;
for (int i = 0; i < m_messages.size(); ++i) {
if (m_messages.at(i).role == QStringLiteral("assistant")) {
last = i;
}
}
return last;
}();
for (int i = 0; i < m_messages.size(); ++i) {
const ChatMessage &message = m_messages.at(i);
if (message.role == QStringLiteral("system")) {
if (!message.content.isEmpty()) {
auto *note = new QLabel(message.content, m_container);
note->setWordWrap(true);
note->setAlignment(Qt::AlignCenter);
note->setStyleSheet(QStringLiteral(
"QLabel { color: rgba(255,255,255,0.42); font-size: 11px;"
" background: rgba(255,255,255,0.04); border-radius: 8px;"
" padding: 6px 10px; }"));
m_layout->insertWidget(m_layout->count() - 1, note);
}
continue;
}
auto *row = new MessageRow(message, i, i == lastAssistant, m_container);
connect(row, &MessageRow::copyRequested, this, &ChatMessageList::copyRequested);
connect(row, &MessageRow::editRequested, this, &ChatMessageList::editRequested);
connect(row, &MessageRow::forkRequested, this, &ChatMessageList::forkRequested);
connect(row, &MessageRow::retryRequested, this, &ChatMessageList::retryRequested);
connect(row, &MessageRow::linkActivated, this, &ChatMessageList::linkActivated);
m_layout->insertWidget(m_layout->count() - 1, row);
m_rows.append(row);
}
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
}
void ChatMessageList::appendMessage(const ChatMessage &message)
{
m_messages.append(message);
rebuild();
}
void ChatMessageList::updateMessage(int index, const QString &content)
{
if (index < 0 || index >= m_messages.size()) {
return;
}
m_messages[index].content = content;
for (MessageRow *row : m_rows) {
if (row->rowIndex() == index) {
row->setContent(content);
break;
}
}
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
}
void ChatMessageList::appendNote(const QString &text)
{
auto *note = new QLabel(text, m_container);
note->setWordWrap(true);
note->setAlignment(Qt::AlignCenter);
note->setStyleSheet(QStringLiteral(
"QLabel { color: rgba(255,255,255,0.45); font-size: 11px;"
" background: rgba(255,255,255,0.04); border-radius: 8px; padding: 5px 10px; }"));
m_layout->insertWidget(m_layout->count() - 1, note);
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
}
void ChatMessageList::dragEnterEvent(QDragEnterEvent *event)
{
if (event->mimeData() &&
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
event->acceptProposedAction();
return;
}
QScrollArea::dragEnterEvent(event);
}
void ChatMessageList::dragMoveEvent(QDragMoveEvent *event)
{
if (event->mimeData() &&
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
event->acceptProposedAction();
return;
}
QScrollArea::dragMoveEvent(event);
}
void ChatMessageList::dropEvent(QDropEvent *event)
{
if (event->mimeData() &&
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
Q_EMIT dropped(event->mimeData());
event->acceptProposedAction();
return;
}
QScrollArea::dropEvent(event);
}
void ChatMessageList::clear()
{
m_messages.clear();
rebuild();
}
void ChatMessageList::scrollToBottom()
{
verticalScrollBar()->setValue(verticalScrollBar()->maximum());
}
#include "chatview.moc"
+55
View File
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QScrollArea>
#include <QVector>
#include "chatclient.h"
class QLabel;
class QMimeData;
class QUrl;
class QVBoxLayout;
/**
* Scrollable message list with chat bubbles, avatars and inline actions.
*/
class ChatMessageList : public QScrollArea
{
Q_OBJECT
public:
explicit ChatMessageList(QWidget *parent = nullptr);
void setMessages(const QVector<ChatMessage> &messages);
void appendMessage(const ChatMessage &message);
void updateMessage(int index, const QString &content);
void appendNote(const QString &text);
void clear();
void scrollToBottom();
signals:
void dropped(const QMimeData *mime);
void copyRequested(int index);
void editRequested(int index);
void forkRequested(int index);
void retryRequested();
void linkActivated(const QUrl &url);
protected:
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private:
void rebuild();
QWidget *m_container = nullptr;
QVBoxLayout *m_layout = nullptr;
QWidget *m_welcome = nullptr;
QVector<class MessageRow *> m_rows;
QVector<ChatMessage> m_messages;
};
+287 -262
View File
@@ -4,6 +4,8 @@
#include "chatwindow.h"
#include "config.h"
#include "chatview.h"
#include "markdown.h"
#include "mcpclient.h"
#include <QCheckBox>
@@ -18,11 +20,14 @@
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFile>
#include <QFontMetrics>
#include <QFormLayout>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QImage>
#include <QInputDialog>
#include <QPainter>
#include <QStyledItemDelegate>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@@ -52,72 +57,6 @@ bool mimeOk(const QMimeData *mime)
return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage());
}
QString escapedHtml(const QString &text)
{
QString html = text.toHtmlEscaped();
html.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
return html;
}
/** Lightweight Markdown to HTML for assistant messages. */
QString renderInline(QString text)
{
text = text.toHtmlEscaped();
text.replace(QRegularExpression(QStringLiteral("`([^`]+)`")),
QStringLiteral("<code>\\1</code>"));
text.replace(QRegularExpression(QStringLiteral("\\*\\*([^*]+)\\*\\*")),
QStringLiteral("<b>\\1</b>"));
text.replace(QRegularExpression(QStringLiteral("\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)")),
QStringLiteral("<a href=\"\\2\">\\1</a>"));
text.replace(QRegularExpression(QStringLiteral("(^|[\\s(])(https?://[^\\s<]+)")),
QStringLiteral("\\1<a href=\"\\2\">\\2</a>"));
return text;
}
QString markdownToHtml(const QString &markdown)
{
QString html;
QString code;
bool inCode = false;
const QStringList lines = markdown.split(QLatin1Char('\n'));
for (const QString &line : lines) {
if (line.trimmed().startsWith(QStringLiteral("```"))) {
if (inCode) {
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
code.clear();
}
inCode = !inCode;
continue;
}
if (inCode) {
code += line;
code += QLatin1Char('\n');
continue;
}
if (line.startsWith(QStringLiteral("### "))) {
html += QStringLiteral("<h3>%1</h3>").arg(renderInline(line.mid(4)));
} else if (line.startsWith(QStringLiteral("## "))) {
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(3)));
} else if (line.startsWith(QStringLiteral("# "))) {
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(2)));
} else if (line.startsWith(QStringLiteral("- ")) ||
line.startsWith(QStringLiteral("* "))) {
html += QStringLiteral("<div class='li'>• %1</div>").arg(renderInline(line.mid(2)));
} else if (line.trimmed().isEmpty()) {
html += QStringLiteral("<div class='gap'></div>");
} else {
html += QStringLiteral("<div>%1</div>").arg(renderInline(line));
}
}
if (inCode) {
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
}
return html;
}
} // namespace
class ChatSidebar : public QListWidget
@@ -133,6 +72,14 @@ public:
signals:
void droppedAt(int index, const QMimeData *mime);
void left();
protected:
void leaveEvent(QEvent *event) override
{
Q_EMIT left();
QListWidget::leaveEvent(event);
}
protected:
void dragEnterEvent(QDragEnterEvent *event) override
@@ -205,6 +152,77 @@ protected:
}
};
namespace {
constexpr int kPreviewRole = Qt::UserRole + 1;
constexpr int kTimeRole = Qt::UserRole + 2;
}
class ConversationDelegate : public QStyledItemDelegate
{
public:
explicit ConversationDelegate(QObject *parent = nullptr)
: QStyledItemDelegate(parent)
{
}
void setHoverRow(int row) { m_hoverRow = row; }
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override
{
Q_UNUSED(option)
Q_UNUSED(index)
return QSize(0, 62);
}
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override
{
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
const QRect row = option.rect.adjusted(2, 2, -2, -2);
const bool selected = option.state & QStyle::State_Selected;
if (selected || index.row() == m_hoverRow) {
painter->setPen(Qt::NoPen);
painter->setBrush(selected ? QColor(120, 180, 255, 62) : QColor(255, 255, 255, 16));
painter->drawRoundedRect(row, 8, 8);
}
QFont titleFont = option.font;
titleFont.setPixelSize(13);
titleFont.setWeight(QFont::DemiBold);
painter->setFont(titleFont);
painter->setPen(selected ? QColor(255, 255, 255, 245) : QColor(255, 255, 255, 215));
const QFontMetrics titleMetrics(titleFont);
painter->drawText(QRect(row.left() + 10, row.top() + 9, row.width() - 70, 18),
Qt::AlignLeft | Qt::AlignVCenter,
titleMetrics.elidedText(index.data(Qt::DisplayRole).toString(),
Qt::ElideRight, row.width() - 70));
QFont smallFont = option.font;
smallFont.setPixelSize(10);
painter->setFont(smallFont);
painter->setPen(QColor(255, 255, 255, 95));
painter->drawText(QRect(row.right() - 56, row.top() + 10, 46, 14),
Qt::AlignRight | Qt::AlignVCenter,
index.data(kTimeRole).toString());
smallFont.setPixelSize(11);
painter->setFont(smallFont);
painter->setPen(QColor(255, 255, 255, 115));
const QFontMetrics previewMetrics(smallFont);
painter->drawText(QRect(row.left() + 10, row.top() + 30, row.width() - 20, 16),
Qt::AlignLeft | Qt::AlignVCenter,
previewMetrics.elidedText(index.data(kPreviewRole).toString(),
Qt::ElideRight, row.width() - 20));
painter->restore();
}
private:
int m_hoverRow = -1;
};
ChatWindow::ChatWindow(QWidget *parent)
: QWidget(parent)
, m_client(new ChatClient(this))
@@ -231,7 +249,9 @@ ChatWindow::ChatWindow(QWidget *parent)
return;
}
m_conversations[m_current].messages[m_streamingIndex].content += text;
renderConversation();
m_messages->updateMessage(
m_streamingIndex,
m_conversations[m_current].messages[m_streamingIndex].content);
});
connect(m_client, &ChatClient::finished, this, [this](const QString &) {
m_streamingIndex = -1;
@@ -263,7 +283,7 @@ ChatWindow::ChatWindow(QWidget *parent)
message.content += QStringLiteral("%1🔧 调用工具:%2")
.arg(message.content.isEmpty() ? QString() : QStringLiteral("\n\n"),
names.join(QStringLiteral(", ")));
renderConversation();
m_messages->updateMessage(m_streamingIndex, message.content);
runToolCalls(calls, 0, QJsonArray());
});
@@ -321,52 +341,197 @@ void ChatWindow::setupUi()
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
/* ---------------- sidebar ---------------- */
auto *sidebarPane = new QWidget(this);
sidebarPane->setObjectName(QStringLiteral("sidebar"));
sidebarPane->setFixedWidth(250);
sidebarPane->setFixedWidth(264);
sidebarPane->setStyleSheet(QStringLiteral(
"#sidebar { background: rgba(255,255,255,0.045);"
" border-right: 1px solid rgba(255,255,255,0.08); }"));
"#sidebar { background: #17171b; border-right: 1px solid rgba(255,255,255,0.06); }"));
auto *sidebarLayout = new QVBoxLayout(sidebarPane);
sidebarLayout->setContentsMargins(12, 14, 12, 12);
sidebarLayout->setContentsMargins(14, 16, 14, 12);
sidebarLayout->setSpacing(10);
auto *brandRow = new QHBoxLayout;
brandRow->setSpacing(10);
auto *logo = new QLabel(sidebarPane);
logo->setFixedSize(34, 34);
logo->setPixmap(QApplication::windowIcon().pixmap(34, 34));
logo->setScaledContents(true);
logo->setStyleSheet(QStringLiteral("border-radius: 8px;"));
brandRow->addWidget(logo);
auto *brandColumn = new QVBoxLayout;
brandColumn->setSpacing(1);
auto *brand = new QLabel(tr("小埃同学"), sidebarPane);
QFont brandFont = brand->font();
brandFont.setPixelSize(17);
brandFont.setPixelSize(15);
brandFont.setWeight(QFont::DemiBold);
brand->setFont(brandFont);
brand->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.92);"));
sidebarLayout->addWidget(brand);
auto *subtitle = new QLabel(tr("把内容拖进对话即可提问"), sidebarPane);
subtitle->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.4); font-size: 11px;"));
sidebarLayout->addWidget(subtitle);
m_sidebar = new ChatSidebar(sidebarPane);
m_sidebar->setFrameShape(QFrame::NoFrame);
m_sidebar->setStyleSheet(QStringLiteral(
"QListWidget { background: transparent; border: none; color: rgba(255,255,255,0.82); }"
"QListWidget::item { padding: 8px 6px; border-radius: 7px; }"
"QListWidget::item:hover { background: rgba(255,255,255,0.07); }"
"QListWidget::item:selected { background: rgba(120,180,255,0.24); color: white; }"));
sidebarLayout->addWidget(m_sidebar, 1);
brandColumn->addWidget(brand);
auto *brandSub = new QLabel(QStringLiteral("GXDE AI See"), sidebarPane);
brandSub->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.35); font-size: 10px;"));
brandColumn->addWidget(brandSub);
brandRow->addLayout(brandColumn);
brandRow->addStretch();
sidebarLayout->addLayout(brandRow);
auto *newButton = new QPushButton(tr("+ 新建对话"), sidebarPane);
newButton->setCursor(Qt::PointingHandCursor);
newButton->setFixedHeight(34);
newButton->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.08);"
" border: none; border-radius: 8px; padding: 7px; font-size: 12px; }"
"QPushButton:hover { background: rgba(120,180,255,0.28); color: white; }"));
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 9px; font-size: 12px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
sidebarLayout->addWidget(newButton);
auto *menuButton = new QPushButton(tr("⋯ 更多"), sidebarPane);
m_sidebar = new ChatSidebar(sidebarPane);
m_sidebar->setFrameShape(QFrame::NoFrame);
m_sidebar->setMouseTracking(true);
m_sidebar->setStyleSheet(
QStringLiteral("QListWidget { background: transparent; border: none; }"));
auto *conversationDelegate = new ConversationDelegate(m_sidebar);
m_sidebar->setItemDelegate(conversationDelegate);
connect(m_sidebar, &QListWidget::entered, this,
[this, conversationDelegate](const QModelIndex &index) {
conversationDelegate->setHoverRow(index.row());
m_sidebar->viewport()->update();
});
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::left, this,
[this, conversationDelegate]() {
conversationDelegate->setHoverRow(-1);
m_sidebar->viewport()->update();
});
sidebarLayout->addWidget(m_sidebar, 1);
auto *bottomRow = new QHBoxLayout;
bottomRow->setSpacing(6);
auto *settingsButton = new QPushButton(tr("⚙ 设置"), sidebarPane);
settingsButton->setCursor(Qt::PointingHandCursor);
settingsButton->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.06);"
" border: none; border-radius: 8px; padding: 6px 8px; font-size: 12px; }"
"QPushButton:hover { background: rgba(255,255,255,0.12); color: white; }"));
bottomRow->addWidget(settingsButton, 1);
auto *menuButton = new QPushButton(QStringLiteral("⋯"), sidebarPane);
menuButton->setCursor(Qt::PointingHandCursor);
menuButton->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.55); background: transparent;"
" border: none; padding: 2px; font-size: 12px; }"
"QPushButton:hover { color: white; }"));
menuButton->setFixedWidth(38);
menuButton->setStyleSheet(settingsButton->styleSheet());
bottomRow->addWidget(menuButton);
sidebarLayout->addLayout(bottomRow);
root->addWidget(sidebarPane);
/* ---------------- chat pane ---------------- */
auto *pane = new QWidget(this);
pane->setStyleSheet(QStringLiteral("background: #1e1e22;"));
auto *paneLayout = new QVBoxLayout(pane);
paneLayout->setContentsMargins(0, 0, 0, 0);
paneLayout->setSpacing(0);
auto *header = new QWidget(pane);
header->setFixedHeight(56);
header->setStyleSheet(QStringLiteral(
"QWidget { background: rgba(255,255,255,0.02);"
" border-bottom: 1px solid rgba(255,255,255,0.06); }"));
auto *headerLayout = new QHBoxLayout(header);
headerLayout->setContentsMargins(20, 0, 16, 0);
headerLayout->setSpacing(10);
m_titleLabel = new QLabel(tr("小埃同学"), header);
QFont titleFont = m_titleLabel->font();
titleFont.setPixelSize(15);
titleFont.setWeight(QFont::DemiBold);
m_titleLabel->setFont(titleFont);
m_titleLabel->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.92);"));
headerLayout->addWidget(m_titleLabel);
m_modelLabel = new QLabel(QStringLiteral("deepseek-chat"), header);
m_modelLabel->setStyleSheet(QStringLiteral(
"QLabel { color: rgba(160,200,240,0.75); background: rgba(74,144,255,0.13);"
" border-radius: 8px; padding: 2px 8px; font-size: 10px; }"));
headerLayout->addWidget(m_modelLabel);
headerLayout->addStretch();
paneLayout->addWidget(header);
m_messages = new ChatMessageList(pane);
paneLayout->addWidget(m_messages, 1);
m_status = new QLabel(pane);
m_status->setContentsMargins(20, 0, 20, 0);
m_status->setStyleSheet(QStringLiteral("color: rgba(255,170,120,0.9); font-size: 12px;"));
m_status->hide();
paneLayout->addWidget(m_status);
auto *inputPane = new QWidget(pane);
auto *inputLayout = new QVBoxLayout(inputPane);
inputLayout->setContentsMargins(20, 6, 20, 14);
inputLayout->setSpacing(5);
auto *inputFrame = new QFrame(inputPane);
inputFrame->setStyleSheet(QStringLiteral(
"QFrame { background: rgba(255,255,255,0.055);"
" border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; }"));
auto *frameLayout = new QHBoxLayout(inputFrame);
frameLayout->setContentsMargins(12, 8, 8, 8);
frameLayout->setSpacing(8);
m_input = new QPlainTextEdit(inputFrame);
m_input->setPlaceholderText(tr("输入消息,Ctrl+Enter 发送;也可以把文件/文字拖进窗口"));
m_input->setFixedHeight(64);
m_input->setStyleSheet(QStringLiteral(
"QPlainTextEdit { background: transparent; color: rgba(245,245,245,0.95);"
" border: none; font-size: 13px; }"));
frameLayout->addWidget(m_input, 1);
m_sendButton = new QPushButton(tr("发送"), inputFrame);
m_sendButton->setFixedSize(72, 34);
m_sendButton->setCursor(Qt::PointingHandCursor);
m_sendButton->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.9); border: none;"
" border-radius: 9px; font-size: 13px; }"
"QPushButton:hover { background: rgba(90,160,255,1.0); }"
"QPushButton:disabled { background: rgba(255,255,255,0.1);"
" color: rgba(255,255,255,0.35); }"));
frameLayout->addWidget(m_sendButton, 0, Qt::AlignBottom);
inputLayout->addWidget(inputFrame);
auto *hint = new QLabel(tr("Enter 换行 · Ctrl+Enter 发送 · 支持拖入文件/链接/图片"), inputPane);
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.3); font-size: 10px;"));
hint->setAlignment(Qt::AlignRight);
inputLayout->addWidget(hint);
paneLayout->addWidget(inputPane);
root->addWidget(pane, 1);
/* ---------------- connections ---------------- */
connect(newButton, &QPushButton::clicked, this, &ChatWindow::newConversation);
connect(settingsButton, &QPushButton::clicked, this, &ChatWindow::openSettings);
connect(m_sendButton, &QPushButton::clicked, this, &ChatWindow::sendMessage);
connect(m_input, &QPlainTextEdit::textChanged, this, [this]() {
m_sendButton->setEnabled(!m_input->toPlainText().trimmed().isEmpty() &&
!m_client->busy());
});
m_sendButton->setEnabled(false);
connect(m_sidebar, &QListWidget::currentRowChanged, this, &ChatWindow::selectConversation);
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::droppedAt, this,
&ChatWindow::handleDropOnConversation);
connect(m_messages, &ChatMessageList::dropped, this, &ChatWindow::handleDroppedMime);
connect(m_messages, &ChatMessageList::linkActivated, this, &ChatWindow::handleAnchor);
connect(m_messages, &ChatMessageList::copyRequested, this, [this](int index) {
if (m_current >= 0 && index >= 0 &&
index < m_conversations.at(m_current).messages.size()) {
QApplication::clipboard()->setText(
m_conversations.at(m_current).messages.at(index).content);
}
});
connect(m_messages, &ChatMessageList::editRequested, this, &ChatWindow::editMessage);
connect(m_messages, &ChatMessageList::forkRequested, this, &ChatWindow::forkFrom);
connect(m_messages, &ChatMessageList::retryRequested, this, &ChatWindow::retryLast);
auto *menu = new QMenu(menuButton);
menu->addAction(tr("压缩上下文"), this, [this]() { beginCompression(false); });
menu->addAction(tr("重试上一条"), this, &ChatWindow::retryLast);
@@ -380,113 +545,6 @@ void ChatWindow::setupUi()
menu->addSeparator();
menu->addAction(tr("关于"), this, &ChatWindow::showAbout);
menuButton->setMenu(menu);
sidebarLayout->addWidget(menuButton);
auto *settingsButton = new QPushButton(tr("设置"), sidebarPane);
settingsButton->setCursor(Qt::PointingHandCursor);
settingsButton->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.55); background: transparent;"
" border: none; padding: 4px; font-size: 12px; }"
"QPushButton:hover { color: white; }"));
sidebarLayout->addWidget(settingsButton);
root->addWidget(sidebarPane);
auto *right = new QWidget(this);
auto *rightLayout = new QVBoxLayout(right);
rightLayout->setContentsMargins(14, 14, 14, 12);
rightLayout->setSpacing(10);
m_view = new ChatView(right);
m_view->setOpenExternalLinks(false);
m_view->setFrameShape(QFrame::NoFrame);
m_view->setStyleSheet(QStringLiteral(
"QTextBrowser { background: transparent; color: rgba(240,240,240,0.94);"
" border: none; font-size: 14px; }"));
rightLayout->addWidget(m_view, 1);
m_status = new QLabel(right);
m_status->setStyleSheet(QStringLiteral("color: rgba(255,170,120,0.9); font-size: 12px;"));
m_status->hide();
rightLayout->addWidget(m_status);
auto *inputRow = new QHBoxLayout;
inputRow->setSpacing(10);
m_input = new QPlainTextEdit(right);
m_input->setPlaceholderText(tr("输入消息,Ctrl+Enter 发送;也可以把文件/文字拖进窗口"));
m_input->setFixedHeight(84);
m_input->setStyleSheet(QStringLiteral(
"QPlainTextEdit { background: rgba(255,255,255,0.06); color: rgba(245,245,245,0.95);"
" border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 8px;"
" font-size: 13px; }"));
inputRow->addWidget(m_input, 1);
m_sendButton = new QPushButton(tr("发送"), right);
m_sendButton->setFixedSize(76, 40);
m_sendButton->setCursor(Qt::PointingHandCursor);
m_sendButton->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 10px; font-size: 13px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"
"QPushButton:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.4); }"));
inputRow->addWidget(m_sendButton);
rightLayout->addLayout(inputRow);
root->addWidget(right, 1);
connect(newButton, &QPushButton::clicked, this, &ChatWindow::newConversation);
connect(settingsButton, &QPushButton::clicked, this, &ChatWindow::openSettings);
connect(m_sendButton, &QPushButton::clicked, this, &ChatWindow::sendMessage);
connect(m_input, &QPlainTextEdit::textChanged, this, [this]() {
m_sendButton->setEnabled(!m_input->toPlainText().trimmed().isEmpty() &&
!m_client->busy());
});
m_sendButton->setEnabled(false);
connect(m_sidebar, &QListWidget::currentRowChanged, this, &ChatWindow::selectConversation);
m_sidebar->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_sidebar, &QListWidget::customContextMenuRequested, this,
[this](const QPoint &pos) {
const QModelIndex index = m_sidebar->indexAt(pos);
if (!index.isValid() || index.row() >= m_conversations.size()) {
return;
}
const int row = index.row();
QMenu menu(this);
QAction *rename = menu.addAction(tr("重命名对话"));
QAction *remove = menu.addAction(tr("删除对话"));
QAction *chosen = menu.exec(m_sidebar->viewport()->mapToGlobal(pos));
if (chosen == rename) {
bool ok = false;
const QString title = QInputDialog::getText(
nullptr, tr("重命名对话"), tr("名称"), QLineEdit::Normal,
m_conversations.at(row).title, &ok);
if (ok && !title.trimmed().isEmpty()) {
m_conversations[row].title = title.trimmed();
save();
updateSidebar();
renderConversation();
}
} else if (chosen == remove) {
if (m_client->busy()) {
m_client->cancel();
m_streamingIndex = -1;
}
m_conversations.removeAt(row);
m_current = m_conversations.isEmpty() ? -1
: qBound(0, row, m_conversations.size() - 1);
save();
updateSidebar();
renderConversation();
}
});
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::droppedAt, this,
&ChatWindow::handleDropOnConversation);
connect(static_cast<ChatView *>(m_view), &ChatView::dropped, this,
&ChatWindow::handleDroppedMime);
connect(m_view, &QTextBrowser::anchorClicked, this, &ChatWindow::handleAnchor);
auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input);
connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage);
@@ -599,78 +657,45 @@ void ChatWindow::updateSidebar()
const QSignalBlocker blocker(m_sidebar);
m_sidebar->clear();
for (const ChatConversation &conversation : m_conversations) {
m_sidebar->addItem(conversation.title);
auto *item = new QListWidgetItem(conversation.title, m_sidebar);
QString preview = tr("新对话");
for (int i = conversation.messages.size() - 1; i >= 0; --i) {
const ChatMessage &message = conversation.messages.at(i);
if (!message.content.isEmpty()) {
preview = message.content.simplified().left(60);
break;
}
}
item->setData(kPreviewRole, preview);
item->setData(kTimeRole,
QDateTime::fromMSecsSinceEpoch(conversation.updatedAt)
.toString(QStringLiteral("HH:mm")));
}
if (m_current >= 0 && m_current < m_sidebar->count()) {
m_sidebar->setCurrentRow(m_current);
}
updateHeader();
}
void ChatWindow::renderConversation()
{
QString html;
html += QStringLiteral(
"<style>body{color:#e9e9e9;font-size:14px;} .role{font-size:11px;color:#8a8a8a;"
"margin-top:14px;} .user{color:#9ecbff;} .assistant{color:#efefef;}"
".welcome{color:#b9b9b9;line-height:1.6;} pre{background:rgba(255,255,255,0.07);"
"padding:8px;border-radius:6px;} h1,h2,h3{margin:8px 0 4px 0;} .li{margin-left:10px;} "
".gap{height:6px;} code{background:rgba(255,255,255,0.1);"
"border-radius:4px;padding:1px 4px;}</style>");
if (m_current < 0 || m_conversations.isEmpty()) {
html += QStringLiteral(
"<div class='welcome'><h3>你好,我是小埃同学。</h3>"
"<p>把小埃当成桌面上的一个抽屉:<br/>"
"· 把文件、链接、文字拖到左侧列表空白处 = 新建对话<br/>"
"· 拖到左侧某个对话 = 给那个对话补充内容<br/>"
"· 拖到右侧对话区 = 发给当前对话</p>"
"<p>第一次使用需要配置 DeepSeek API Key:点左下角「设置」,"
"在 <a href='https://platform.deepseek.com'>platform.deepseek.com</a> "
"创建 Key 后填进来。</p></div>");
m_view->setHtml(html);
m_messages->setMessages({});
updateHeader();
return;
}
m_messages->setMessages(m_conversations.at(m_current).messages);
updateHeader();
}
const ChatConversation &conversation = m_conversations.at(m_current);
if (conversation.messages.isEmpty()) {
html += QStringLiteral("<div class='welcome'><h3>%1</h3>"
"<p>直接输入问题,或者把内容拖进来。</p></div>")
.arg(escapedHtml(conversation.title));
}
for (int i = 0; i < conversation.messages.size(); ++i) {
const ChatMessage &message = conversation.messages.at(i);
const QString role = message.role == QStringLiteral("user") ? tr("你") : tr("小埃");
const QString cssClass =
message.role == QStringLiteral("user") ? QStringLiteral("user")
: QStringLiteral("assistant");
html += QStringLiteral("<div class='role'>%1</div>").arg(role);
const QString body = message.role == QStringLiteral("assistant")
? markdownToHtml(message.content)
: escapedHtml(message.content);
html += QStringLiteral("<div class='%1'>%2</div>").arg(cssClass, body);
html += QStringLiteral("<div class='actions'>");
if (message.role == QStringLiteral("user")) {
html += QStringLiteral("<a href='gxde-ai-see:edit/%1'>编辑</a>").arg(i);
} else if (message.role == QStringLiteral("assistant")) {
html += QStringLiteral("<a href='gxde-ai-see:copy/%1'>复制</a> · ").arg(i);
html += QStringLiteral("<a href='gxde-ai-see:fork/%1'>分支</a>").arg(i);
if (i == conversation.messages.size() - 1) {
html += QStringLiteral(" · <a href='gxde-ai-see:retry'>重试</a>");
}
}
html += QStringLiteral("</div>");
}
if (m_client->busy() && m_streamingIndex >= 0) {
html += QStringLiteral("<div class='role'>%1</div><div class='assistant'>…</div>")
.arg(tr("小埃"));
}
m_view->setHtml(html);
m_view->moveCursor(QTextCursor::End);
m_view->ensureCursorVisible();
void ChatWindow::updateHeader()
{
const bool valid = m_current >= 0 && m_current < m_conversations.size();
m_titleLabel->setText(valid ? m_conversations.at(m_current).title : tr("小埃同学"));
m_modelLabel->setText(Config::instance()
->get(QStringLiteral("chat/model"),
QStringLiteral("deepseek-chat"))
.toString());
}
void ChatWindow::sendMessage()
+5 -2
View File
@@ -13,7 +13,7 @@ class QLabel;
class QListWidget;
class QPlainTextEdit;
class QPushButton;
class QTextBrowser;
class ChatMessageList;
class QMimeData;
class QUrl;
@@ -56,6 +56,7 @@ private:
void newConversation();
void selectConversation(int index);
void renderConversation();
void updateHeader();
void updateSidebar();
void sendMessage();
void requestCompletion();
@@ -73,7 +74,9 @@ private:
int currentIndex() const { return m_current; }
QListWidget *m_sidebar = nullptr;
QTextBrowser *m_view = nullptr;
ChatMessageList *m_messages = nullptr;
QLabel *m_titleLabel = nullptr;
QLabel *m_modelLabel = nullptr;
QPlainTextEdit *m_input = nullptr;
QPushButton *m_sendButton = nullptr;
QLabel *m_status = nullptr;
+76
View File
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "markdown.h"
#include <QRegularExpression>
QString escapedHtml(const QString &text)
{
QString html = text.toHtmlEscaped();
html.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
return html;
}
namespace {
QString renderInline(QString text)
{
text = text.toHtmlEscaped();
text.replace(QRegularExpression(QStringLiteral("`([^`]+)`")),
QStringLiteral("<code>\\1</code>"));
text.replace(QRegularExpression(QStringLiteral("\\*\\*([^*]+)\\*\\*")),
QStringLiteral("<b>\\1</b>"));
text.replace(
QRegularExpression(QStringLiteral("\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)")),
QStringLiteral("<a href=\"\\2\">\\1</a>"));
text.replace(QRegularExpression(QStringLiteral("(^|[\\s(])(https?://[^\\s<]+)")),
QStringLiteral("\\1<a href=\"\\2\">\\2</a>"));
return text;
}
} // namespace
QString markdownToHtml(const QString &markdown)
{
QString html;
QString code;
bool inCode = false;
const QStringList lines = markdown.split(QLatin1Char('\n'));
for (const QString &line : lines) {
if (line.trimmed().startsWith(QStringLiteral("```"))) {
if (inCode) {
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
code.clear();
}
inCode = !inCode;
continue;
}
if (inCode) {
code += line;
code += QLatin1Char('\n');
continue;
}
if (line.startsWith(QStringLiteral("### "))) {
html += QStringLiteral("<h3>%1</h3>").arg(renderInline(line.mid(4)));
} else if (line.startsWith(QStringLiteral("## "))) {
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(3)));
} else if (line.startsWith(QStringLiteral("# "))) {
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(2)));
} else if (line.startsWith(QStringLiteral("- ")) ||
line.startsWith(QStringLiteral("* "))) {
html += QStringLiteral("<div class='li'>• %1</div>").arg(renderInline(line.mid(2)));
} else if (line.trimmed().isEmpty()) {
html += QStringLiteral("<div class='gap'></div>");
} else {
html += QStringLiteral("<div>%1</div>").arg(renderInline(line));
}
}
if (inCode) {
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
}
return html;
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
/** Lightweight Markdown to HTML used by assistant bubbles. */
QString markdownToHtml(const QString &markdown);
/** Escape plain text and keep line breaks. */
QString escapedHtml(const QString &text);
+2
View File
@@ -4,6 +4,7 @@
#include <DApplication>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QIcon>
#include <QLocale>
#include <QStandardPaths>
#include <QTranslator>
@@ -19,6 +20,7 @@ int main(int argc, char *argv[])
app.setApplicationName(QStringLiteral("gxde-ai-see"));
app.setApplicationDisplayName(QStringLiteral("GXDE 小埃同学"));
app.setApplicationVersion(QStringLiteral("0.1.0"));
app.setWindowIcon(QIcon::fromTheme(QStringLiteral("gxde-ai-see")));
app.setQuitOnLastWindowClosed(false);
const QString locale = QLocale::system().name();