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:
+4
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user