feat(chat,todo): 小埃同学对话窗口与待办窗口

Chat:
- DTK2 窗口:左侧会话列表 + 右侧对话区 + 输入框
- DeepSeek / 自定义 API,SSE 流式输出,首字即显示
- 会话持久化到 chat.json,新建/切换/标题自动生成
- 设置对话框:API Key、API 地址、模型、系统提示,含开通指引
- 拖放:左侧空白=新建对话,左侧会话=指定会话,右侧对话区=当前会话
- Ctrl+Enter 发送,错误以消息形式回显

Todo:
- 待办窗口(layer-shell,不抢焦点、不进 dock),复选框/删除/新建
- 待办持久化 todo.json,--todo 打开,独立 desktop 入口

其他:
- desktop 入口改为打开对话窗口,--chat 参数
- 单实例消息支持 show/todo/chat
This commit is contained in:
2026-10-30 20:12:31 +08:00
parent 150a49af2f
commit e3c8da1a3f
12 changed files with 1363 additions and 10 deletions
+4 -1
View File
@@ -31,6 +31,9 @@ set(GXDE_AI_SEE_SOURCES
src/actions/downloader.cpp
src/actions/texttools.cpp
src/todo/todomodel.cpp
src/todo/todowindow.cpp
src/chat/chatclient.cpp
src/chat/chatwindow.cpp
src/ui/portalpanel.cpp
)
@@ -71,7 +74,7 @@ target_include_directories(gxde-ai-see-selftest PRIVATE src ${ICU_INCLUDE_DIRS})
target_link_libraries(gxde-ai-see-selftest PRIVATE Qt6::Core Qt6::Gui ${ICU_LIBRARIES})
install(TARGETS gxde-ai-see RUNTIME DESTINATION bin)
install(FILES data/gxde-ai-see.desktop DESTINATION share/applications)
install(FILES data/gxde-ai-see.desktop data/gxde-ai-see-todo.desktop DESTINATION share/applications)
install(DIRECTORY data/icons/ DESTINATION share/icons/hicolor)
include(GNUInstallDirs)
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=GXDE Todo
Name[zh_CN]=GXDE 待办
Comment=Floating todo list of GXDE AI See
Comment[zh_CN]=小埃同学的待办
Exec=gxde-ai-see --todo
Icon=gxde-ai-see
Terminal=false
Categories=Utility;
StartupNotify=false
+1 -1
View File
@@ -7,7 +7,7 @@ GenericName=AI Assistant
GenericName[zh_CN]=AI 助手
Comment=Drag to a window and it lands in the input box
Comment[zh_CN]=拖一下,世界就到你手边
Exec=gxde-ai-see --show
Exec=gxde-ai-see --chat
Icon=gxde-ai-see
Terminal=false
Categories=Utility;Office;
+34 -6
View File
@@ -3,9 +3,12 @@
#include "app.h"
#include "chat/chatwindow.h"
#include "config.h"
#include "portal/portalclient.h"
#include "staging/stagingmodel.h"
#include "todo/todomodel.h"
#include "todo/todowindow.h"
#include "ui/portalpanel.h"
#include <QLocalServer>
@@ -19,7 +22,7 @@ App::App(QObject *parent)
{
}
bool App::init(bool daemon, bool forceShow)
bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
{
Config::instance()->init();
m_daemon = daemon;
@@ -29,7 +32,7 @@ bool App::init(bool daemon, bool forceShow)
QLocalSocket probe;
probe.connectToServer(serverName);
if (probe.waitForConnected(200)) {
probe.write("show");
probe.write(showChat ? "chat" : (showTodo ? "todo" : "show"));
probe.waitForBytesWritten(200);
probe.disconnectFromServer();
return false;
@@ -41,7 +44,12 @@ bool App::init(bool daemon, bool forceShow)
connect(m_server, &QLocalServer::newConnection, this, [this]() {
while (QLocalSocket *socket = m_server->nextPendingConnection()) {
connect(socket, &QLocalSocket::readyRead, this, [this, socket]() {
if (socket->readAll().contains("show")) {
const QByteArray data = socket->readAll();
if (data.contains("chat")) {
showChatWindow();
} else if (data.contains("todo")) {
showTodoWindow();
} else if (data.contains("show")) {
showPanel();
}
});
@@ -80,6 +88,12 @@ bool App::init(bool daemon, bool forceShow)
}
});
if (showChat) {
showChatWindow();
}
if (showTodo) {
showTodoWindow();
}
if (!daemon || forceShow || !m_model->isEmpty()) {
showPanel();
}
@@ -94,9 +108,7 @@ void App::ensurePanel()
}
m_panel = new PortalPanel(m_model);
connect(m_panel, &PortalPanel::openChatRequested, this, []() {
// TODO(M4): open the AI See chat window.
});
connect(m_panel, &PortalPanel::openChatRequested, this, &App::showChatWindow);
}
void App::showPanel()
@@ -104,3 +116,19 @@ void App::showPanel()
ensurePanel();
m_panel->popup();
}
void App::showChatWindow()
{
if (!m_chatWindow) {
m_chatWindow = new ChatWindow;
}
m_chatWindow->activateForAsk();
}
void App::showTodoWindow()
{
if (!m_todoWindow) {
m_todoWindow = new TodoWindow(TodoModel::instance());
}
m_todoWindow->popup();
}
+7 -1
View File
@@ -5,9 +5,11 @@
#include <QObject>
class ChatWindow;
class PortalPanel;
class QLocalServer;
class StagingModel;
class TodoWindow;
/**
* Application controller: owns the staging model, the panel and the single
@@ -25,14 +27,18 @@ public:
* @param forceShow always show the panel on startup
* @return false when another instance is already running
*/
bool init(bool daemon, bool forceShow);
bool init(bool daemon, bool forceShow, bool showTodo = false, bool showChat = false);
private:
void ensurePanel();
void showPanel();
void showTodoWindow();
void showChatWindow();
StagingModel *m_model = nullptr;
PortalPanel *m_panel = nullptr;
TodoWindow *m_todoWindow = nullptr;
ChatWindow *m_chatWindow = nullptr;
QLocalServer *m_server = nullptr;
bool m_daemon = false;
bool m_dragging = false;
+182
View File
@@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "chatclient.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
#include "config.h"
ChatClient::ChatClient(QObject *parent)
: QObject(parent)
{
m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
}
void ChatClient::send(const QVector<ChatMessage> &messages)
{
cancel();
m_buffer.clear();
m_fullText.clear();
m_done = false;
const QString apiKey =
Config::instance()->get(QStringLiteral("chat/apiKey")).toString().trimmed();
if (apiKey.isEmpty()) {
Q_EMIT failed(tr("还没有配置 API Key。请先点右上角设置,填入 DeepSeek API Key。"));
return;
}
QString baseUrl = Config::instance()
->get(QStringLiteral("chat/baseUrl"),
QStringLiteral("https://api.deepseek.com"))
.toString()
.trimmed();
while (baseUrl.endsWith(QLatin1Char('/'))) {
baseUrl.chop(1);
}
if (!baseUrl.endsWith(QStringLiteral("/chat/completions"))) {
baseUrl += QStringLiteral("/chat/completions");
}
const QString model = Config::instance()
->get(QStringLiteral("chat/model"),
QStringLiteral("deepseek-chat"))
.toString();
QJsonArray jsonMessages;
for (const ChatMessage &message : messages) {
QJsonObject object;
object.insert(QStringLiteral("role"), message.role);
object.insert(QStringLiteral("content"), message.content);
jsonMessages.append(object);
}
QJsonObject body;
body.insert(QStringLiteral("model"), model);
body.insert(QStringLiteral("messages"), jsonMessages);
body.insert(QStringLiteral("stream"), true);
body.insert(QStringLiteral("temperature"), 0.7);
QNetworkRequest request{QUrl(baseUrl)};
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
request.setRawHeader("Authorization", "Bearer " + apiKey.toUtf8());
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
m_reply = m_manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(m_reply, &QNetworkReply::readyRead, this, &ChatClient::handleReadyRead);
connect(m_reply, &QNetworkReply::finished, this, &ChatClient::handleFinished);
}
void ChatClient::cancel()
{
if (!m_reply) {
return;
}
QNetworkReply *reply = m_reply;
m_reply = nullptr;
reply->disconnect(this);
reply->abort();
reply->deleteLater();
}
void ChatClient::handleReadyRead()
{
if (!m_reply) {
return;
}
m_buffer.append(m_reply->readAll());
int newline;
while ((newline = m_buffer.indexOf('\n')) >= 0) {
QByteArray line = m_buffer.left(newline);
m_buffer.remove(0, newline + 1);
if (line.endsWith('\r')) {
line.chop(1);
}
if (!line.startsWith("data:")) {
continue;
}
QByteArray payload = line.mid(5).trimmed();
if (payload == "[DONE]") {
m_done = true;
continue;
}
handlePayload(payload);
}
}
void ChatClient::handlePayload(const QByteArray &payload)
{
const QJsonDocument document = QJsonDocument::fromJson(payload);
if (!document.isObject()) {
return;
}
const QJsonObject object = document.object();
if (object.contains(QStringLiteral("error"))) {
const QJsonObject error = object.value(QStringLiteral("error")).toObject();
Q_EMIT failed(error.value(QStringLiteral("message")).toString(tr("请求失败")));
return;
}
const QJsonArray choices = object.value(QStringLiteral("choices")).toArray();
if (choices.isEmpty()) {
return;
}
const QJsonObject deltaObject =
choices.first().toObject().value(QStringLiteral("delta")).toObject();
const QString content = deltaObject.value(QStringLiteral("content")).toString();
if (!content.isEmpty()) {
m_fullText += content;
Q_EMIT delta(content);
}
}
void ChatClient::handleFinished()
{
if (!m_reply) {
return;
}
QNetworkReply *reply = m_reply;
m_reply = nullptr;
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError && reply->error() != QNetworkReply::OperationCanceledError) {
const QByteArray body = reply->readAll();
QString message = reply->errorString();
const QJsonDocument document = QJsonDocument::fromJson(body);
if (document.isObject()) {
const QJsonObject error =
document.object().value(QStringLiteral("error")).toObject();
const QString detail = error.value(QStringLiteral("message")).toString();
if (!detail.isEmpty()) {
message = detail;
}
}
if (status == 401) {
message = tr("API Key 无效或已过期");
}
Q_EMIT failed(message);
reply->deleteLater();
return;
}
/* flush a final partial line */
if (!m_buffer.isEmpty()) {
QByteArray line = m_buffer.trimmed();
if (line.startsWith("data:")) {
handlePayload(line.mid(5).trimmed());
}
m_buffer.clear();
}
Q_EMIT finished(m_fullText);
reply->deleteLater();
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QNetworkAccessManager>
#include <QObject>
#include <QString>
#include <QVector>
struct ChatMessage
{
QString role; // system / user / assistant
QString content;
};
/**
* Minimal streaming client for the DeepSeek (OpenAI compatible) chat API.
*/
class ChatClient : public QObject
{
Q_OBJECT
public:
explicit ChatClient(QObject *parent = nullptr);
bool busy() const { return m_reply != nullptr; }
void send(const QVector<ChatMessage> &messages);
void cancel();
signals:
void delta(const QString &text);
void finished(const QString &text);
void failed(const QString &error);
private:
void handleReadyRead();
void handleFinished();
void handlePayload(const QByteArray &payload);
QNetworkAccessManager m_manager;
class QNetworkReply *m_reply = nullptr;
QByteArray m_buffer;
QString m_fullText;
bool m_done = false;
};
+650
View File
@@ -0,0 +1,650 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "chatwindow.h"
#include "config.h"
#include <QComboBox>
#include <QDateTime>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDir>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFile>
#include <QFormLayout>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QImage>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMimeData>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QSaveFile>
#include <QScreen>
#include <QShortcut>
#include <QTextBrowser>
#include <QUrl>
#include <QUuid>
#include <QVBoxLayout>
namespace {
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;
}
} // namespace
class ChatSidebar : public QListWidget
{
Q_OBJECT
public:
explicit ChatSidebar(QWidget *parent = nullptr)
: QListWidget(parent)
{
setAcceptDrops(true);
}
signals:
void droppedAt(int index, const QMimeData *mime);
protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (mimeOk(event->mimeData())) {
event->acceptProposedAction();
} else {
event->ignore();
}
}
void dragMoveEvent(QDragMoveEvent *event) override
{
if (mimeOk(event->mimeData())) {
event->acceptProposedAction();
}
}
void dropEvent(QDropEvent *event) override
{
if (!mimeOk(event->mimeData())) {
event->ignore();
return;
}
const QModelIndex index = indexAt(event->position().toPoint());
Q_EMIT droppedAt(index.isValid() ? index.row() : -1, event->mimeData());
event->acceptProposedAction();
}
};
class ChatView : public QTextBrowser
{
Q_OBJECT
public:
explicit ChatView(QWidget *parent = nullptr)
: QTextBrowser(parent)
{
setAcceptDrops(true);
}
signals:
void dropped(const QMimeData *mime);
protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (mimeOk(event->mimeData())) {
event->acceptProposedAction();
} else {
event->ignore();
}
}
void dragMoveEvent(QDragMoveEvent *event) override
{
if (mimeOk(event->mimeData())) {
event->acceptProposedAction();
}
}
void dropEvent(QDropEvent *event) override
{
if (!mimeOk(event->mimeData())) {
event->ignore();
return;
}
Q_EMIT dropped(event->mimeData());
event->acceptProposedAction();
}
};
ChatWindow::ChatWindow(QWidget *parent)
: QWidget(parent)
, m_client(new ChatClient(this))
{
setWindowTitle(tr("小埃同学"));
setMinimumSize(760, 520);
resize(1000, 700);
m_storePath = Config::instance()->dataDir() + QStringLiteral("/chat.json");
setupUi();
load();
m_current = m_conversations.isEmpty() ? -1 : qBound(0, m_current, m_conversations.size() - 1);
updateSidebar();
renderConversation();
if (QScreen *screen = QGuiApplication::primaryScreen()) {
move(screen->availableGeometry().center() - rect().center());
}
connect(m_client, &ChatClient::delta, this, [this](const QString &text) {
if (m_current < 0 || m_streamingIndex < 0 ||
m_streamingIndex >= m_conversations[m_current].messages.size()) {
return;
}
m_conversations[m_current].messages[m_streamingIndex].content += text;
renderConversation();
});
connect(m_client, &ChatClient::finished, this, [this](const QString &) {
m_streamingIndex = -1;
if (m_current >= 0) {
m_conversations[m_current].updatedAt = QDateTime::currentMSecsSinceEpoch();
}
save();
renderConversation();
});
connect(m_client, &ChatClient::failed, this, [this](const QString &error) {
if (m_current >= 0 && m_streamingIndex >= 0 &&
m_streamingIndex < m_conversations[m_current].messages.size()) {
m_conversations[m_current].messages[m_streamingIndex].content =
tr("⚠️ %1").arg(error);
}
m_streamingIndex = -1;
save();
renderConversation();
});
}
void ChatWindow::setupUi()
{
auto *root = new QHBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
auto *sidebarPane = new QWidget(this);
sidebarPane->setObjectName(QStringLiteral("sidebar"));
sidebarPane->setFixedWidth(250);
sidebarPane->setStyleSheet(QStringLiteral(
"#sidebar { background: rgba(255,255,255,0.045);"
" border-right: 1px solid rgba(255,255,255,0.08); }"));
auto *sidebarLayout = new QVBoxLayout(sidebarPane);
sidebarLayout->setContentsMargins(12, 14, 12, 12);
sidebarLayout->setSpacing(10);
auto *brand = new QLabel(tr("小埃同学"), sidebarPane);
QFont brandFont = brand->font();
brandFont.setPixelSize(17);
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);
auto *newButton = new QPushButton(tr("+ 新建对话"), sidebarPane);
newButton->setCursor(Qt::PointingHandCursor);
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; }"));
sidebarLayout->addWidget(newButton);
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(true);
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);
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::droppedAt, this,
&ChatWindow::handleDropOnConversation);
connect(static_cast<ChatView *>(m_view), &ChatView::dropped, this,
&ChatWindow::handleDroppedMime);
auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input);
connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage);
auto *sendShortcut2 = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Enter), m_input);
connect(sendShortcut2, &QShortcut::activated, this, &ChatWindow::sendMessage);
}
void ChatWindow::load()
{
QFile file(m_storePath);
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
if (!document.isObject()) {
return;
}
const QJsonObject root = document.object();
m_conversations.clear();
for (const QJsonValue &value :
root.value(QStringLiteral("conversations")).toArray()) {
const QJsonObject object = value.toObject();
ChatConversation conversation;
conversation.id = object.value(QStringLiteral("id")).toString();
conversation.title = object.value(QStringLiteral("title")).toString();
conversation.updatedAt =
static_cast<qint64>(object.value(QStringLiteral("updatedAt")).toDouble());
for (const QJsonValue &messageValue :
object.value(QStringLiteral("messages")).toArray()) {
const QJsonObject messageObject = messageValue.toObject();
ChatMessage message;
message.role = messageObject.value(QStringLiteral("role")).toString();
message.content = messageObject.value(QStringLiteral("content")).toString();
conversation.messages.append(message);
}
m_conversations.append(conversation);
}
m_current = root.value(QStringLiteral("current")).toInt(-1);
}
void ChatWindow::save() const
{
QJsonArray conversations;
for (const ChatConversation &conversation : m_conversations) {
QJsonArray messages;
for (const ChatMessage &message : conversation.messages) {
QJsonObject object;
object.insert(QStringLiteral("role"), message.role);
object.insert(QStringLiteral("content"), message.content);
messages.append(object);
}
QJsonObject object;
object.insert(QStringLiteral("id"), conversation.id);
object.insert(QStringLiteral("title"), conversation.title);
object.insert(QStringLiteral("updatedAt"),
static_cast<double>(conversation.updatedAt));
object.insert(QStringLiteral("messages"), messages);
conversations.append(object);
}
QJsonObject root;
root.insert(QStringLiteral("version"), 1);
root.insert(QStringLiteral("current"), m_current);
root.insert(QStringLiteral("conversations"), conversations);
QSaveFile file(m_storePath);
if (!file.open(QIODevice::WriteOnly)) {
return;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
file.commit();
}
void ChatWindow::newConversation()
{
if (m_client->busy()) {
m_client->cancel();
m_streamingIndex = -1;
}
ChatConversation conversation;
conversation.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
conversation.title = tr("新对话");
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
m_conversations.prepend(conversation);
m_current = 0;
updateSidebar();
renderConversation();
save();
m_input->setFocus();
}
void ChatWindow::selectConversation(int index)
{
if (index < 0 || index >= m_conversations.size() || index == m_current) {
return;
}
if (m_client->busy()) {
m_client->cancel();
m_streamingIndex = -1;
}
m_current = index;
renderConversation();
}
void ChatWindow::updateSidebar()
{
const QSignalBlocker blocker(m_sidebar);
m_sidebar->clear();
for (const ChatConversation &conversation : m_conversations) {
m_sidebar->addItem(conversation.title);
}
if (m_current >= 0 && m_current < m_sidebar->count()) {
m_sidebar->setCurrentRow(m_current);
}
}
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;} 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);
return;
}
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 (const ChatMessage &message : conversation.messages) {
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);
html += QStringLiteral("<div class='%1'>%2</div>")
.arg(cssClass, escapedHtml(message.content));
}
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()
{
if (m_client->busy()) {
return;
}
const QString text = m_input->toPlainText().trimmed();
if (text.isEmpty()) {
return;
}
if (m_current < 0) {
newConversation();
}
ChatConversation &conversation = m_conversations[m_current];
if (conversation.title == tr("新对话")) {
conversation.title = text.left(24);
}
conversation.messages.append(ChatMessage{ QStringLiteral("user"), text });
conversation.messages.append(ChatMessage{ QStringLiteral("assistant"), QString() });
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
m_streamingIndex = conversation.messages.size() - 1;
m_input->clear();
m_status->hide();
m_sendButton->setEnabled(false);
updateSidebar();
renderConversation();
QVector<ChatMessage> payload;
const QString systemPrompt =
Config::instance()->get(QStringLiteral("chat/systemPrompt")).toString().trimmed();
if (!systemPrompt.isEmpty()) {
payload.append(ChatMessage{ QStringLiteral("system"), systemPrompt });
}
for (int i = 0; i < conversation.messages.size() - 1; ++i) {
payload.append(conversation.messages.at(i));
}
m_client->send(payload);
}
void ChatWindow::appendError(const QString &message)
{
m_status->setText(message);
m_status->show();
}
void ChatWindow::openSettings()
{
QDialog dialog(this);
dialog.setWindowTitle(tr("小埃同学设置"));
dialog.setMinimumWidth(460);
auto *layout = new QVBoxLayout(&dialog);
auto *form = new QFormLayout;
auto *keyEdit = new QLineEdit(Config::instance()
->get(QStringLiteral("chat/apiKey"))
.toString(),
&dialog);
keyEdit->setEchoMode(QLineEdit::Password);
keyEdit->setPlaceholderText(QStringLiteral("sk-…"));
form->addRow(tr("DeepSeek API Key"), keyEdit);
auto *baseEdit = new QLineEdit(Config::instance()
->get(QStringLiteral("chat/baseUrl"),
QStringLiteral("https://api.deepseek.com"))
.toString(),
&dialog);
form->addRow(tr("API 地址"), baseEdit);
auto *modelBox = new QComboBox(&dialog);
modelBox->setEditable(true);
modelBox->addItems({ QStringLiteral("deepseek-chat"),
QStringLiteral("deepseek-reasoner") });
modelBox->setCurrentText(Config::instance()
->get(QStringLiteral("chat/model"),
QStringLiteral("deepseek-chat"))
.toString());
form->addRow(tr("模型"), modelBox);
auto *systemEdit = new QPlainTextEdit(&dialog);
systemEdit->setFixedHeight(80);
systemEdit->setPlainText(Config::instance()
->get(QStringLiteral("chat/systemPrompt"))
.toString());
form->addRow(tr("系统提示"), systemEdit);
layout->addLayout(form);
auto *hint = new QLabel(
tr("还没有账号?前往 <a href='https://platform.deepseek.com'>platform.deepseek.com</a> "
"开通并创建 API Key。Key 只保存在本机配置文件中。"),
&dialog);
hint->setWordWrap(true);
hint->setOpenExternalLinks(true);
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.55); font-size: 12px;"));
layout->addWidget(hint);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel,
&dialog);
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
layout->addWidget(buttons);
if (dialog.exec() == QDialog::Accepted) {
Config::instance()->set(QStringLiteral("chat/apiKey"), keyEdit->text().trimmed());
Config::instance()->set(QStringLiteral("chat/baseUrl"), baseEdit->text().trimmed());
Config::instance()->set(QStringLiteral("chat/model"), modelBox->currentText());
Config::instance()->set(QStringLiteral("chat/systemPrompt"),
systemEdit->toPlainText());
Config::instance()->sync();
renderConversation();
}
}
void ChatWindow::handleDroppedMime(const QMimeData *mime)
{
if (!mime) {
return;
}
QString text;
if (mime->hasUrls()) {
for (const QUrl &url : mime->urls()) {
if (!text.isEmpty()) {
text += QLatin1Char('\n');
}
text += url.isLocalFile() ? QStringLiteral("[文件] %1").arg(url.toLocalFile())
: url.toString();
}
} else if (mime->hasImage()) {
const QImage image = qvariant_cast<QImage>(mime->imageData());
if (!image.isNull()) {
const QString dir = Config::instance()->cacheDir() + QStringLiteral("/chat");
QDir().mkpath(dir);
const QString path = dir + QLatin1Char('/')
+ QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".png");
image.save(path);
text = QStringLiteral("[图片] %1").arg(path);
}
} else if (mime->hasText()) {
text = mime->text();
}
if (text.isEmpty()) {
return;
}
m_input->moveCursor(QTextCursor::End);
if (!m_input->toPlainText().isEmpty()) {
m_input->insertPlainText(QStringLiteral("\n"));
}
m_input->insertPlainText(text);
m_input->setFocus();
}
void ChatWindow::handleDropOnConversation(int index, const QMimeData *mime)
{
if (index < 0) {
newConversation();
} else {
selectConversation(index);
}
handleDroppedMime(mime);
show();
raise();
activateWindow();
}
void ChatWindow::activateForAsk()
{
show();
raise();
activateWindow();
m_input->setFocus();
}
void ChatWindow::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
m_input->setFocus();
}
#include "chatwindow.moc"
+74
View File
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QVector>
#include <QWidget>
#include "chatclient.h"
class QLabel;
class QListWidget;
class QPlainTextEdit;
class QPushButton;
class QTextBrowser;
class QMimeData;
struct ChatConversation
{
QString id;
QString title;
qint64 updatedAt = 0;
QVector<ChatMessage> messages;
};
/**
* GXDE AI See chat window.
*
* Left: conversation list (also a drop target: empty area creates a new
* conversation, an item targets that conversation).
* Right: conversation view (drop targets the current conversation) and input.
*/
class ChatWindow : public QWidget
{
Q_OBJECT
public:
explicit ChatWindow(QWidget *parent = nullptr);
/** Bring the window up for the "ask AI" action. */
void activateForAsk();
/** Insert dropped content into the input of the current/new conversation. */
void handleDroppedMime(const QMimeData *mime);
void handleDropOnConversation(int index, const QMimeData *mime);
protected:
void showEvent(QShowEvent *event) override;
private:
void setupUi();
void load();
void save() const;
void newConversation();
void selectConversation(int index);
void renderConversation();
void updateSidebar();
void sendMessage();
void openSettings();
void appendError(const QString &message);
int currentIndex() const { return m_current; }
QListWidget *m_sidebar = nullptr;
QTextBrowser *m_view = nullptr;
QPlainTextEdit *m_input = nullptr;
QPushButton *m_sendButton = nullptr;
QLabel *m_status = nullptr;
ChatClient *m_client = nullptr;
QVector<ChatConversation> m_conversations;
int m_current = -1;
int m_streamingIndex = -1;
QString m_storePath;
};
+8 -1
View File
@@ -27,12 +27,19 @@ int main(int argc, char *argv[])
QStringLiteral("后台常驻,需要时再显示面板"));
QCommandLineOption showOption(QStringLiteral("show"),
QStringLiteral("启动后立即显示传送门面板"));
QCommandLineOption todoOption(QStringLiteral("todo"),
QStringLiteral("打开待办窗口"));
QCommandLineOption chatOption(QStringLiteral("chat"),
QStringLiteral("打开小埃同学对话窗口"));
parser.addOption(daemonOption);
parser.addOption(showOption);
parser.addOption(todoOption);
parser.addOption(chatOption);
parser.process(app);
App gxde;
if (!gxde.init(parser.isSet(daemonOption), parser.isSet(showOption))) {
if (!gxde.init(parser.isSet(daemonOption), parser.isSet(showOption),
parser.isSet(todoOption), parser.isSet(chatOption))) {
return 0;
}
+304
View File
@@ -0,0 +1,304 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "todowindow.h"
#include "todomodel.h"
#include <QApplication>
#include <QDialog>
#include <QDialogButtonBox>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListView>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QScreen>
#include <QStyledItemDelegate>
#include <QVBoxLayout>
#ifdef GXDE_HAVE_LAYERSHELLQT
#include <LayerShellQt/Window>
#endif
namespace {
constexpr int kWidth = 320;
constexpr int kRowHeight = 40;
QRect checkRect(const QRect &row)
{
return QRect(row.left() + 12, row.center().y() - 9, 18, 18);
}
QRect deleteRect(const QRect &row)
{
return QRect(row.right() - 34, row.center().y() - 13, 26, 26);
}
} // namespace
class TodoDelegate : public QStyledItemDelegate
{
public:
explicit TodoDelegate(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, kRowHeight);
}
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(4, 2, -4, -2);
const bool hovered = index.row() == m_hoverRow;
const bool done = index.data(TodoModel::DoneRole).toBool();
if (hovered) {
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(255, 255, 255, 24));
painter->drawRoundedRect(row, 8, 8);
}
const QRect check = checkRect(option.rect);
painter->setPen(QPen(done ? QColor(74, 144, 255) : QColor(255, 255, 255, 90), 1.4));
painter->setBrush(done ? QColor(74, 144, 255) : QColor(255, 255, 255, 12));
painter->drawRoundedRect(check, 5, 5);
if (done) {
QPainterPath mark;
mark.moveTo(check.left() + 4.5, check.center().y());
mark.lineTo(check.left() + 7.5, check.bottom() - 5);
mark.lineTo(check.right() - 4.0, check.top() + 5.0);
painter->setPen(QPen(Qt::white, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter->setBrush(Qt::NoBrush);
painter->drawPath(mark);
}
QFont font = option.font;
font.setPixelSize(13);
font.setStrikeOut(done);
painter->setFont(font);
painter->setPen(done ? QColor(255, 255, 255, 100) : QColor(255, 255, 255, 232));
const QRect textRect(check.right() + 12, option.rect.top(),
deleteRect(option.rect).left() - check.right() - 22,
option.rect.height());
const QString text = index.data(TodoModel::TextRole).toString();
const QFontMetrics metrics(font);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter,
metrics.elidedText(text, Qt::ElideRight, textRect.width()));
if (hovered) {
const QRect del = deleteRect(option.rect).adjusted(8, 8, -8, -8);
painter->setPen(QPen(QColor(255, 255, 255, 165), 1.6, Qt::SolidLine, Qt::RoundCap));
painter->drawLine(del.topLeft(), del.bottomRight());
painter->drawLine(del.topRight(), del.bottomLeft());
}
painter->restore();
}
bool editorEvent(QEvent *event, QAbstractItemModel *model,
const QStyleOptionViewItem &option, const QModelIndex &index) override
{
if (event->type() != QEvent::MouseButtonRelease) {
return false;
}
auto *mouse = static_cast<QMouseEvent *>(event);
const QPoint pos = mouse->position().toPoint();
auto *todo = static_cast<TodoModel *>(model);
if (checkRect(option.rect).contains(pos)) {
todo->setDone(index.row(), !index.data(TodoModel::DoneRole).toBool());
return true;
}
if (deleteRect(option.rect).contains(pos)) {
todo->removeRow(index.row());
return true;
}
return false;
}
private:
int m_hoverRow = -1;
};
TodoWindow::TodoWindow(TodoModel *model, QWidget *parent)
: Dtk::Widget::DBlurEffectWidget(parent)
, m_model(model)
{
setWindowTitle(tr("待办"));
setWindowFlags(Qt::FramelessWindowHint | Qt::Tool);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_ShowWithoutActivating);
setMaskColor(QColor(24, 24, 28, 190));
setBlurRectXRadius(16);
setBlurRectYRadius(16);
setFixedWidth(kWidth);
setupWindow();
setupUi();
connect(m_model, &TodoModel::countChanged, this, [this]() {
m_countLabel->setText(tr("%1 项").arg(m_model->count()));
refreshLayout();
m_view->viewport()->update();
});
}
void TodoWindow::setupWindow()
{
#ifdef GXDE_HAVE_LAYERSHELLQT
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
createWinId();
if (auto *layer = LayerShellQt::Window::get(windowHandle())) {
LayerShellQt::Window::Anchors anchors;
anchors |= LayerShellQt::Window::AnchorRight;
anchors |= LayerShellQt::Window::AnchorTop;
layer->setAnchors(anchors);
layer->setMargins(QMargins(0, 72, 16, 0));
layer->setLayer(LayerShellQt::Window::LayerOverlay);
layer->setKeyboardInteractivity(LayerShellQt::Window::KeyboardInteractivityNone);
layer->setExclusiveZone(0);
layer->setScope(QStringLiteral("gxde-ai-see-todo"));
layer->setScreenConfiguration(LayerShellQt::Window::ScreenFromQWindow);
}
}
#endif
}
void TodoWindow::setupUi()
{
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(12, 10, 12, 10);
outer->setSpacing(8);
auto *header = new QHBoxLayout;
header->setContentsMargins(4, 0, 4, 0);
header->setSpacing(8);
auto *title = new QLabel(tr("待办"), this);
QFont titleFont = title->font();
titleFont.setPixelSize(13);
titleFont.setWeight(QFont::DemiBold);
title->setFont(titleFont);
title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.9);"));
header->addWidget(title);
m_countLabel = new QLabel(tr("%1 项").arg(m_model->count()), this);
m_countLabel->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.45); font-size: 11px;"));
header->addWidget(m_countLabel);
header->addStretch();
auto *addButton = new QPushButton(tr("+"), this);
addButton->setFixedSize(26, 26);
addButton->setCursor(Qt::PointingHandCursor);
addButton->setFocusPolicy(Qt::NoFocus);
addButton->setToolTip(tr("新建待办"));
addButton->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.08);"
" border: none; border-radius: 6px; font-size: 14px; }"
"QPushButton:hover { background: rgba(120,180,255,0.3); color: white; }"));
connect(addButton, &QPushButton::clicked, this, &TodoWindow::addEntry);
header->addWidget(addButton);
auto *closeButton = new QPushButton(tr("×"), this);
closeButton->setFixedSize(26, 26);
closeButton->setCursor(Qt::PointingHandCursor);
closeButton->setFocusPolicy(Qt::NoFocus);
closeButton->setToolTip(tr("隐藏"));
closeButton->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.7); background: transparent;"
" border: none; font-size: 16px; }"
"QPushButton:hover { color: white; }"));
connect(closeButton, &QPushButton::clicked, this, &QWidget::hide);
header->addWidget(closeButton);
outer->addLayout(header);
m_view = new QListView(this);
m_view->setFrameShape(QFrame::NoFrame);
m_view->setSelectionMode(QAbstractItemView::NoSelection);
m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_view->setMouseTracking(true);
m_view->viewport()->setMouseTracking(true);
m_view->setStyleSheet(QStringLiteral("QListView { background: transparent; }"));
auto *delegate = new TodoDelegate(m_view);
m_view->setModel(m_model);
m_view->setItemDelegate(delegate);
m_view->setUniformItemSizes(true);
connect(m_view, &QListView::entered, this, [delegate](const QModelIndex &index) {
delegate->setHoverRow(index.row());
});
outer->addWidget(m_view, 1);
}
void TodoWindow::popup()
{
refreshLayout();
show();
raise();
}
void TodoWindow::refreshLayout()
{
QScreen *screen = this->screen();
if (!screen) {
screen = QGuiApplication::primaryScreen();
}
const int screenHeight = screen ? screen->availableGeometry().height() : 900;
const int content = m_model->count() * kRowHeight + 8;
const int listHeight = qBound(80, content, qMax(160, screenHeight / 2));
if (m_view) {
m_view->setFixedHeight(listHeight);
}
resize(kWidth, listHeight + 56);
}
void TodoWindow::addEntry()
{
QDialog dialog(nullptr);
dialog.setWindowTitle(tr("新建待办"));
dialog.setMinimumWidth(320);
auto *layout = new QVBoxLayout(&dialog);
auto *edit = new QLineEdit(&dialog);
edit->setPlaceholderText(tr("要做的事…"));
layout->addWidget(edit);
auto *buttons =
new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog);
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
layout->addWidget(buttons);
if (dialog.exec() == QDialog::Accepted) {
m_model->add(edit->text());
refreshLayout();
}
}
void TodoWindow::showEvent(QShowEvent *event)
{
Dtk::Widget::DBlurEffectWidget::showEvent(event);
refreshLayout();
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <DBlurEffectWidget>
#include <QWidget>
class QLabel;
class QListView;
class TodoModel;
/**
* Floating todo window (optional keep-above, draggable, hidden from the dock).
*
* On Wayland it is a layer-shell overlay surface (keyboard interactivity is
* none so it never steals focus); new entries are added from the panel or the
* "+" button which opens a normal dialog.
*/
class TodoWindow : public Dtk::Widget::DBlurEffectWidget
{
Q_OBJECT
public:
explicit TodoWindow(TodoModel *model, QWidget *parent = nullptr);
void popup();
void refreshLayout();
protected:
void showEvent(QShowEvent *event) override;
private:
void setupWindow();
void setupUi();
void addEntry();
TodoModel *m_model = nullptr;
QListView *m_view = nullptr;
QLabel *m_countLabel = nullptr;
};