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
+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()