feat(chat): 重做聊天窗口界面
- 消息改成气泡列表:头像、左右分栏、markdown 正文、hover 显示复制/编辑/分支/重试 - 会话列表:应用图标 + 名称、每项显示标题/最后一条预览/时间、hover 与选中态 - 输入区:圆角输入框内嵌发送按钮、快捷键提示 - 空会话欢迎页;消息区域支持拖放 - 头部显示当前会话标题与模型徽章 - 设置/更多按钮移到侧栏底部
This commit is contained in:
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
- 文字:ICU 分词、提取链接、朗读
|
- 文字:ICU 分词、提取链接、朗读
|
||||||
- 文件/图片:打开、复制路径、保存图片、占位 OCR/描述
|
- 文件/图片:打开、复制路径、保存图片、占位 OCR/描述
|
||||||
- 待办窗口:layer-shell、不抢焦点、复选框/删除/新建、持久化
|
- 待办窗口:layer-shell、不抢焦点、复选框/删除/新建、持久化
|
||||||
- 小埃同学对话:DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、
|
- 小埃同学对话:气泡消息/头像/会话列表(标题+预览+时间)/输入区重做;
|
||||||
|
DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、
|
||||||
编辑/重试/分支、上下文条数与自动压缩、Markdown、关于
|
编辑/重试/分支、上下文条数与自动压缩、Markdown、关于
|
||||||
- MCP:stdio JSON-RPC,自动 initialize / tools-list,工具以 function calling 暴露给模型并在对话中执行
|
- MCP:stdio JSON-RPC,自动 initialize / tools-list,工具以 function calling 暴露给模型并在对话中执行
|
||||||
- 合成器 portal(flake-wm / gxde-wlcom):org.gxde.Portal、缩略图坐标映射、
|
- 合成器 portal(flake-wm / gxde-wlcom):org.gxde.Portal、缩略图坐标映射、
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ set(GXDE_AI_SEE_SOURCES
|
|||||||
src/chat/chatclient.cpp
|
src/chat/chatclient.cpp
|
||||||
src/chat/chatwindow.cpp
|
src/chat/chatwindow.cpp
|
||||||
src/chat/mcpclient.cpp
|
src/chat/mcpclient.cpp
|
||||||
|
src/chat/markdown.cpp
|
||||||
|
src/chat/chatview.cpp
|
||||||
src/ui/portalpanel.cpp
|
src/ui/portalpanel.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -4,6 +4,8 @@
|
|||||||
#include "chatwindow.h"
|
#include "chatwindow.h"
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "chatview.h"
|
||||||
|
#include "markdown.h"
|
||||||
#include "mcpclient.h"
|
#include "mcpclient.h"
|
||||||
|
|
||||||
#include <QCheckBox>
|
#include <QCheckBox>
|
||||||
@@ -18,11 +20,14 @@
|
|||||||
#include <QDragEnterEvent>
|
#include <QDragEnterEvent>
|
||||||
#include <QDropEvent>
|
#include <QDropEvent>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFontMetrics>
|
||||||
#include <QFormLayout>
|
#include <QFormLayout>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QImage>
|
#include <QImage>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QStyledItemDelegate>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
@@ -52,72 +57,6 @@ bool mimeOk(const QMimeData *mime)
|
|||||||
return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage());
|
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
|
} // namespace
|
||||||
|
|
||||||
class ChatSidebar : public QListWidget
|
class ChatSidebar : public QListWidget
|
||||||
@@ -133,6 +72,14 @@ public:
|
|||||||
|
|
||||||
signals:
|
signals:
|
||||||
void droppedAt(int index, const QMimeData *mime);
|
void droppedAt(int index, const QMimeData *mime);
|
||||||
|
void left();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void leaveEvent(QEvent *event) override
|
||||||
|
{
|
||||||
|
Q_EMIT left();
|
||||||
|
QListWidget::leaveEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void dragEnterEvent(QDragEnterEvent *event) override
|
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)
|
ChatWindow::ChatWindow(QWidget *parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_client(new ChatClient(this))
|
, m_client(new ChatClient(this))
|
||||||
@@ -231,7 +249,9 @@ ChatWindow::ChatWindow(QWidget *parent)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_conversations[m_current].messages[m_streamingIndex].content += text;
|
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 &) {
|
connect(m_client, &ChatClient::finished, this, [this](const QString &) {
|
||||||
m_streamingIndex = -1;
|
m_streamingIndex = -1;
|
||||||
@@ -263,7 +283,7 @@ ChatWindow::ChatWindow(QWidget *parent)
|
|||||||
message.content += QStringLiteral("%1🔧 调用工具:%2")
|
message.content += QStringLiteral("%1🔧 调用工具:%2")
|
||||||
.arg(message.content.isEmpty() ? QString() : QStringLiteral("\n\n"),
|
.arg(message.content.isEmpty() ? QString() : QStringLiteral("\n\n"),
|
||||||
names.join(QStringLiteral(", ")));
|
names.join(QStringLiteral(", ")));
|
||||||
renderConversation();
|
m_messages->updateMessage(m_streamingIndex, message.content);
|
||||||
|
|
||||||
runToolCalls(calls, 0, QJsonArray());
|
runToolCalls(calls, 0, QJsonArray());
|
||||||
});
|
});
|
||||||
@@ -321,52 +341,197 @@ void ChatWindow::setupUi()
|
|||||||
root->setContentsMargins(0, 0, 0, 0);
|
root->setContentsMargins(0, 0, 0, 0);
|
||||||
root->setSpacing(0);
|
root->setSpacing(0);
|
||||||
|
|
||||||
|
/* ---------------- sidebar ---------------- */
|
||||||
auto *sidebarPane = new QWidget(this);
|
auto *sidebarPane = new QWidget(this);
|
||||||
sidebarPane->setObjectName(QStringLiteral("sidebar"));
|
sidebarPane->setObjectName(QStringLiteral("sidebar"));
|
||||||
sidebarPane->setFixedWidth(250);
|
sidebarPane->setFixedWidth(264);
|
||||||
sidebarPane->setStyleSheet(QStringLiteral(
|
sidebarPane->setStyleSheet(QStringLiteral(
|
||||||
"#sidebar { background: rgba(255,255,255,0.045);"
|
"#sidebar { background: #17171b; border-right: 1px solid rgba(255,255,255,0.06); }"));
|
||||||
" border-right: 1px solid rgba(255,255,255,0.08); }"));
|
|
||||||
|
|
||||||
auto *sidebarLayout = new QVBoxLayout(sidebarPane);
|
auto *sidebarLayout = new QVBoxLayout(sidebarPane);
|
||||||
sidebarLayout->setContentsMargins(12, 14, 12, 12);
|
sidebarLayout->setContentsMargins(14, 16, 14, 12);
|
||||||
sidebarLayout->setSpacing(10);
|
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);
|
auto *brand = new QLabel(tr("小埃同学"), sidebarPane);
|
||||||
QFont brandFont = brand->font();
|
QFont brandFont = brand->font();
|
||||||
brandFont.setPixelSize(17);
|
brandFont.setPixelSize(15);
|
||||||
brandFont.setWeight(QFont::DemiBold);
|
brandFont.setWeight(QFont::DemiBold);
|
||||||
brand->setFont(brandFont);
|
brand->setFont(brandFont);
|
||||||
brand->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.92);"));
|
brand->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.92);"));
|
||||||
sidebarLayout->addWidget(brand);
|
brandColumn->addWidget(brand);
|
||||||
|
auto *brandSub = new QLabel(QStringLiteral("GXDE AI See"), sidebarPane);
|
||||||
auto *subtitle = new QLabel(tr("把内容拖进对话即可提问"), sidebarPane);
|
brandSub->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.35); font-size: 10px;"));
|
||||||
subtitle->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.4); font-size: 11px;"));
|
brandColumn->addWidget(brandSub);
|
||||||
sidebarLayout->addWidget(subtitle);
|
brandRow->addLayout(brandColumn);
|
||||||
|
brandRow->addStretch();
|
||||||
m_sidebar = new ChatSidebar(sidebarPane);
|
sidebarLayout->addLayout(brandRow);
|
||||||
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);
|
|
||||||
|
|
||||||
auto *newButton = new QPushButton(tr("+ 新建对话"), sidebarPane);
|
auto *newButton = new QPushButton(tr("+ 新建对话"), sidebarPane);
|
||||||
newButton->setCursor(Qt::PointingHandCursor);
|
newButton->setCursor(Qt::PointingHandCursor);
|
||||||
|
newButton->setFixedHeight(34);
|
||||||
newButton->setStyleSheet(QStringLiteral(
|
newButton->setStyleSheet(QStringLiteral(
|
||||||
"QPushButton { color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.08);"
|
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
|
||||||
" border: none; border-radius: 8px; padding: 7px; font-size: 12px; }"
|
" border-radius: 9px; font-size: 12px; }"
|
||||||
"QPushButton:hover { background: rgba(120,180,255,0.28); color: white; }"));
|
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
|
||||||
sidebarLayout->addWidget(newButton);
|
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->setCursor(Qt::PointingHandCursor);
|
||||||
menuButton->setStyleSheet(QStringLiteral(
|
menuButton->setFixedWidth(38);
|
||||||
"QPushButton { color: rgba(255,255,255,0.55); background: transparent;"
|
menuButton->setStyleSheet(settingsButton->styleSheet());
|
||||||
" border: none; padding: 2px; font-size: 12px; }"
|
bottomRow->addWidget(menuButton);
|
||||||
"QPushButton:hover { color: white; }"));
|
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);
|
auto *menu = new QMenu(menuButton);
|
||||||
menu->addAction(tr("压缩上下文"), this, [this]() { beginCompression(false); });
|
menu->addAction(tr("压缩上下文"), this, [this]() { beginCompression(false); });
|
||||||
menu->addAction(tr("重试上一条"), this, &ChatWindow::retryLast);
|
menu->addAction(tr("重试上一条"), this, &ChatWindow::retryLast);
|
||||||
@@ -380,113 +545,6 @@ void ChatWindow::setupUi()
|
|||||||
menu->addSeparator();
|
menu->addSeparator();
|
||||||
menu->addAction(tr("关于"), this, &ChatWindow::showAbout);
|
menu->addAction(tr("关于"), this, &ChatWindow::showAbout);
|
||||||
menuButton->setMenu(menu);
|
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);
|
auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input);
|
||||||
connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage);
|
connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage);
|
||||||
@@ -599,78 +657,45 @@ void ChatWindow::updateSidebar()
|
|||||||
const QSignalBlocker blocker(m_sidebar);
|
const QSignalBlocker blocker(m_sidebar);
|
||||||
m_sidebar->clear();
|
m_sidebar->clear();
|
||||||
for (const ChatConversation &conversation : m_conversations) {
|
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()) {
|
if (m_current >= 0 && m_current < m_sidebar->count()) {
|
||||||
m_sidebar->setCurrentRow(m_current);
|
m_sidebar->setCurrentRow(m_current);
|
||||||
}
|
}
|
||||||
|
updateHeader();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatWindow::renderConversation()
|
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()) {
|
if (m_current < 0 || m_conversations.isEmpty()) {
|
||||||
html += QStringLiteral(
|
m_messages->setMessages({});
|
||||||
"<div class='welcome'><h3>你好,我是小埃同学。</h3>"
|
updateHeader();
|
||||||
"<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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
m_messages->setMessages(m_conversations.at(m_current).messages);
|
||||||
|
updateHeader();
|
||||||
|
}
|
||||||
|
|
||||||
const ChatConversation &conversation = m_conversations.at(m_current);
|
void ChatWindow::updateHeader()
|
||||||
if (conversation.messages.isEmpty()) {
|
{
|
||||||
html += QStringLiteral("<div class='welcome'><h3>%1</h3>"
|
const bool valid = m_current >= 0 && m_current < m_conversations.size();
|
||||||
"<p>直接输入问题,或者把内容拖进来。</p></div>")
|
m_titleLabel->setText(valid ? m_conversations.at(m_current).title : tr("小埃同学"));
|
||||||
.arg(escapedHtml(conversation.title));
|
m_modelLabel->setText(Config::instance()
|
||||||
}
|
->get(QStringLiteral("chat/model"),
|
||||||
|
QStringLiteral("deepseek-chat"))
|
||||||
for (int i = 0; i < conversation.messages.size(); ++i) {
|
.toString());
|
||||||
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::sendMessage()
|
void ChatWindow::sendMessage()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class QLabel;
|
|||||||
class QListWidget;
|
class QListWidget;
|
||||||
class QPlainTextEdit;
|
class QPlainTextEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QTextBrowser;
|
class ChatMessageList;
|
||||||
class QMimeData;
|
class QMimeData;
|
||||||
class QUrl;
|
class QUrl;
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ private:
|
|||||||
void newConversation();
|
void newConversation();
|
||||||
void selectConversation(int index);
|
void selectConversation(int index);
|
||||||
void renderConversation();
|
void renderConversation();
|
||||||
|
void updateHeader();
|
||||||
void updateSidebar();
|
void updateSidebar();
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
void requestCompletion();
|
void requestCompletion();
|
||||||
@@ -73,7 +74,9 @@ private:
|
|||||||
int currentIndex() const { return m_current; }
|
int currentIndex() const { return m_current; }
|
||||||
|
|
||||||
QListWidget *m_sidebar = nullptr;
|
QListWidget *m_sidebar = nullptr;
|
||||||
QTextBrowser *m_view = nullptr;
|
ChatMessageList *m_messages = nullptr;
|
||||||
|
QLabel *m_titleLabel = nullptr;
|
||||||
|
QLabel *m_modelLabel = nullptr;
|
||||||
QPlainTextEdit *m_input = nullptr;
|
QPlainTextEdit *m_input = nullptr;
|
||||||
QPushButton *m_sendButton = nullptr;
|
QPushButton *m_sendButton = nullptr;
|
||||||
QLabel *m_status = nullptr;
|
QLabel *m_status = nullptr;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <DApplication>
|
#include <DApplication>
|
||||||
#include <QCommandLineOption>
|
#include <QCommandLineOption>
|
||||||
#include <QCommandLineParser>
|
#include <QCommandLineParser>
|
||||||
|
#include <QIcon>
|
||||||
#include <QLocale>
|
#include <QLocale>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QTranslator>
|
#include <QTranslator>
|
||||||
@@ -19,6 +20,7 @@ int main(int argc, char *argv[])
|
|||||||
app.setApplicationName(QStringLiteral("gxde-ai-see"));
|
app.setApplicationName(QStringLiteral("gxde-ai-see"));
|
||||||
app.setApplicationDisplayName(QStringLiteral("GXDE 小埃同学"));
|
app.setApplicationDisplayName(QStringLiteral("GXDE 小埃同学"));
|
||||||
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
||||||
|
app.setWindowIcon(QIcon::fromTheme(QStringLiteral("gxde-ai-see")));
|
||||||
app.setQuitOnLastWindowClosed(false);
|
app.setQuitOnLastWindowClosed(false);
|
||||||
|
|
||||||
const QString locale = QLocale::system().name();
|
const QString locale = QLocale::system().name();
|
||||||
|
|||||||
Reference in New Issue
Block a user