5 Commits
Author SHA1 Message Date
shenmo 4291ee286c chore(release): 1.0.0~beta2
Building GXDE Arch Package / detect (push) Skipped
Building GXDE Arch Package / aarch64-arch (push) Skipped
Building GXDE Arch Package / x86_64-arch (push) Skipped
Building GXDE Testing Package / call-arch (push) Skipped
Building Program / call (push) Skipped
Building GXDE Testing Package / call-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-i386-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-amd64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-arm64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-riscv64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-loong64-zhuangzhuang (push) Skipped
Building GXDE Package / ${{ inputs.display-name || 'Build Job' }} (push) Skipped
- 会话列表拖拽排序/多选/批量删除
- 128k/1024k token 上下文上限
- 待办取消置顶;全屏隐藏联动
- 英文回退翻译补齐;新应用图标
- DTK 关于窗口、分词/朗读对话框、消息常驻操作
2026-10-30 22:37:05 +08:00
shenmo c8a0391abe chore(release): 1.0.0~beta
Building GXDE Arch Package / detect (push) Skipped
Building GXDE Arch Package / aarch64-arch (push) Skipped
Building GXDE Arch Package / x86_64-arch (push) Skipped
Building GXDE Testing Package / call-arch (push) Skipped
Building Program / call (push) Skipped
Building GXDE Testing Package / call-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-i386-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-amd64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-arm64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-riscv64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-loong64-zhuangzhuang (push) Skipped
Building GXDE Package / ${{ inputs.display-name || 'Build Job' }} (push) Skipped
- 版本号 1.0.0~beta(CMake / 程序 / debian)
- 新增 GitHub Actions 构建(复用 GXDE-OS/GXDE building-deb)
- CHANGELOG 1.0.0~beta
- docs/TODO.md 记录后续待办
2026-10-30 22:27:58 +08:00
shenmo ca088fbaa6 feat(panel/chat): 显隐切换、暂存清空隐藏、菜单避让、待办联动、DTK 关于
- 传送门 desktop 入口,--show 再点一次回 daemon;右键菜单加隐藏
- 暂存区清空后整个面板隐藏
- 右键菜单弹到面板左侧,避免被自己遮挡
- 启动悬停 80ms
- 添加待办后立即打开待办窗口;待办独立图标
- 分词胶囊按浅/深色取色,修复透明不可见
- 关于改用 DAboutDialog
- 聊天消息下方常驻 复制/编辑/重试;助手消息可编辑、可从任意一条重试
2026-10-30 22:27:16 +08:00
shenmo 630ca6cadc fix(panel): 暂存优先模式;工厂自身接收拖拽;去掉 drop 提示
- 暂存区非空且未拖拽时只显示暂存列表,隐藏传送门与内容工厂并收窄窗口
- 内容工厂自身区域拖入也会展开动作
- 面板矩形改用屏幕可用区 + 边距计算(layer-shell 下 mapToGlobal 不可靠)
- 离开概览只复位传送门状态,不关闭面板;125ms
- 移除“在哪里 drop”提示
2026-10-30 22:00:39 +08:00
shenmo 3b92202f2d feat(panel): 内容工厂常驻、问问小埃大按钮、分词/朗读 Dialog
- 内容工厂固定高度常驻,面板尺寸/位置不再随拖拽变化
- 问问小埃改成与工厂项一致的大按钮,可点击/可 drop,drop 后新开对话并填入内容
- 工厂动作按内容过滤:无 CJK 不出分词,无链接不出提取,朗读仅文本
- 朗读打开语速 Dialog,分词改为可滑动选择的胶囊 + 复制
- BeginPortal 传面板矩形,供概览避让
2026-10-30 21:45:10 +08:00
47 changed files with 1695 additions and 303 deletions
+13
View File
@@ -0,0 +1,13 @@
name: Building Program
run-name: Building ${{ GITHUB.REPOSITORY }}
on:
workflow_dispatch:
push:
tags:
- "*"
jobs:
call:
uses: GXDE-OS/GXDE/.github/workflows/building-deb.yml@master
secrets: inherit
+24
View File
@@ -1,3 +1,27 @@
# 更新日志
## 1.0.0~beta2 — 2026-10-30
- 会话列表:拖拽排序、多选、批量删除
- 上下文压缩支持 128k / 1024k token 上限
- 待办取消置顶(普通窗口)
- 全屏时隐藏联动合成器信号
- 非中文环境回退英文;英文翻译补齐
- 新应用图标、DTK 关于窗口
## 1.0.0~beta — 2026-10-30
首个 beta 版本。
- 传送门:合成器 portal 原生 drop,拖到窗口即落在光标位置
- 面板:layer-shell 双栏、暂存区、拖拽/删除/排序动画、位置与大小持久化
- 内容工厂:常驻面板底部,按拖拽内容分类展示动作,动作可直接 drop
- 待办:独立浮窗与图标,添加待办后自动打开
- 小埃同学:DTK 主窗口、气泡消息、多会话、流式输出、MCP、上下文压缩
- 分词胶囊选择、朗读 Dialog、DTK 原生关于窗口
- 传送门桌面入口:再次点击回到后台模式
- 英文翻译与本地化加载
# Changelog
## 0.1.0 - 2026-10-30
+7 -2
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.20)
project(gxde-ai-see
VERSION 0.1.0
VERSION 1.0.0
DESCRIPTION "GXDE AI See - 小埃同学"
LANGUAGES CXX
)
@@ -28,6 +28,9 @@ set(GXDE_AI_SEE_SOURCES
src/factory/contentclassifier.cpp
src/factory/factoryactions.cpp
src/factory/contentfactory.cpp
src/dialogs/flowlayout.cpp
src/dialogs/segmentdialog.cpp
src/dialogs/speechdialog.cpp
src/actions/downloader.cpp
src/actions/texttools.cpp
src/todo/todomodel.cpp
@@ -38,6 +41,7 @@ set(GXDE_AI_SEE_SOURCES
src/chat/markdown.cpp
src/chat/chatview.cpp
src/ui/portalpanel.cpp
src/ui/aboutdialog.cpp
)
add_executable(gxde-ai-see ${GXDE_AI_SEE_SOURCES})
@@ -116,7 +120,8 @@ if (LRELEASE_EXECUTABLE AND TS_FILES)
endif()
install(TARGETS gxde-ai-see RUNTIME DESTINATION bin)
install(FILES data/gxde-ai-see.desktop data/gxde-ai-see-todo.desktop DESTINATION share/applications)
install(FILES data/gxde-ai-see.desktop data/gxde-ai-see-todo.desktop
data/gxde-ai-see-portal.desktop DESTINATION share/applications)
install(DIRECTORY data/icons/hicolor/ DESTINATION share/icons/hicolor)
include(GNUInstallDirs)
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=GXDE Portal
Name[zh_CN]=传送门
Comment=Show or hide the GXDE AI See portal panel
Comment[zh_CN]=显示或隐藏小埃同学的传送门面板
Exec=gxde-ai-see --show
Icon=gxde-ai-see
Terminal=false
Categories=Utility;
StartupNotify=false
+1 -1
View File
@@ -6,7 +6,7 @@ Name[zh_CN]=GXDE 待办
Comment=Floating todo list of GXDE AI See
Comment[zh_CN]=小埃同学的待办
Exec=gxde-ai-see --todo
Icon=gxde-ai-see
Icon=gxde-ai-see-todo
Terminal=false
Categories=Utility;
StartupNotify=false
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4a90ff"/>
<stop offset="1" stop-color="#2f6bd8"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="240" height="240" rx="54" fill="url(#g)"/>
<rect x="64" y="56" width="128" height="152" rx="16" fill="#ffffff" opacity="0.96"/>
<rect x="96" y="40" width="64" height="30" rx="10" fill="#dbe8ff"/>
<g stroke="#3d7fe8" stroke-width="9" stroke-linecap="round" fill="none">
<path d="M84 114 l14 14 26 -28"/>
<path d="M84 158 l14 14 26 -28"/>
<path d="M146 118 h30"/>
<path d="M146 162 h30"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+22
View File
@@ -1,3 +1,25 @@
gxde-ai-see (1.0.0~beta2) unstable; urgency=medium
* 会话列表支持拖拽排序、多选、批量删除
* 上下文压缩支持 128k / 1024k token 上限
* 待办窗口可取消置顶(普通窗口)
* 全屏时隐藏:合成器 FullscreenChanged 信号联动
* 非中文环境回退英文翻译;补齐全部英文翻译
* 全新应用图标(清理背景、圆角渐变底)
* DTK 关于窗口;分词/朗读对话框;消息下方常驻操作
-- shenmo <shenmo@spark-app.store> Fri, 30 Oct 2026 23:30:00 +0800
gxde-ai-see (1.0.0~beta) unstable; urgency=medium
* 首个 beta:传送门原生 drop、暂存区、内容工厂、待办、小埃同学对话
* 对话改为 DTK 主窗口,气泡消息、会话管理、MCP、上下文压缩
* 内容工厂常驻并支持拖拽展开,动作按内容过滤
* 分词胶囊选择、朗读 Dialog、DTK 原生关于窗口
* 传送门桌面入口与显隐切换;暂存区清空自动隐藏面板
-- shenmo <shenmo@spark-app.store> Fri, 30 Oct 2026 22:30:00 +0800
gxde-ai-see (0.1.0) unstable; urgency=medium
* Initial release: 传送门面板、暂存区、内容工厂、待办与小埃同学对话。
+14
View File
@@ -0,0 +1,14 @@
# 1.0.0~beta2 之后待办
已完成:会话列表拖拽排序/多选/批量删除(可复用控件已加入 dtk2widget-qt6)、
token 压缩上限、待办取消置顶、全屏隐藏联动、英文回退翻译、新图标、DTK 关于窗口。
## 仍需单独排期
- [ ] 对话浅色/深色主题跟随 DTK(当前仍是深色固定配色)
- [ ] 消息版本树:重试/编辑保留历史并可左右切换
- [ ] OCR / TTS / 翻译对接 GXDE Daemon(当前仓库未找到对应 D-Bus 接口,需先定义)
- [ ] X11 / 非 GXDE 合成器窗口图标网格降级方案
- [ ] DTK Wayland 文本拖拽预览(合成器侧渲染,工作量大)
- [ ] 更多窗口改用 DTK 控件(聊天设置、待办窗口等)
- [ ] dtk2widget-qt6 的 DReorderListWidget 需要随库发布后,应用改为链接该控件
+70 -5
View File
@@ -52,7 +52,7 @@ bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
} else if (data.contains("todo")) {
showTodoWindow();
} else if (data.contains("show")) {
showPanel();
togglePanel();
}
});
connect(socket, &QLocalSocket::disconnected, socket,
@@ -88,28 +88,60 @@ bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
return;
}
m_dragging = true;
if (m_panel) {
m_panel->setDragging(true);
}
if (!m_model->isEmpty()) {
if (m_panel && m_panel->isVisible()) {
m_panel->suppressPortalIfUnderCursor();
}
return;
}
QTimer::singleShot(120, this, [this]() {
QTimer::singleShot(80, this, [this]() {
if (m_dragging && m_model->isEmpty()) {
showPanel();
if (m_panel) {
m_panel->suppressPortalIfUnderCursor();
}
}
});
});
connect(portal, &PortalClient::dragEnded, this, [this]() {
m_dragging = false;
if (m_model->isEmpty() && m_panel && m_panel->isVisible()) {
if (m_panel) {
m_panel->stopPortalSuppression();
m_panel->setDragging(false);
}
if (m_model->isEmpty() && !m_panelPinned && m_panel && m_panel->isVisible()) {
m_panel->dismiss();
}
});
connect(m_model, &StagingModel::countChanged, this, [this]() {
if (!m_daemon && !m_model->isEmpty() && m_panel) {
connect(portal, &PortalClient::fullscreenChanged, this, [this](bool fullscreen) {
if (!Config::instance()->getBool(QStringLiteral("panel/hideOnFullscreen"), false)) {
return;
}
if (fullscreen) {
if (m_panel && m_panel->isVisible()) {
m_panel->dismiss();
}
} else if (m_panelPinned || !m_model->isEmpty()) {
ensurePanel();
m_panel->popup();
}
});
connect(m_model, &StagingModel::countChanged, this, [this]() {
if (!m_model->isEmpty() && m_panel) {
m_panel->popup();
return;
}
/* cleared: the panel exists for the staging list, hide it again */
if (!m_dragging && !m_panelPinned && m_panel && m_panel->isVisible()) {
m_panel->dismiss();
}
});
if (showChat) {
showChatWindow();
}
@@ -118,6 +150,7 @@ bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
}
if (!daemon || forceShow || !m_model->isEmpty()) {
showPanel();
m_panelPinned = forceShow;
}
return true;
@@ -131,6 +164,18 @@ void App::ensurePanel()
m_panel = new PortalPanel(m_model);
connect(m_panel, &PortalPanel::openChatRequested, this, &App::showChatWindow);
connect(m_panel, &PortalPanel::hideRequested, this, [this]() {
m_panelPinned = false;
m_panel->dismiss();
});
connect(m_panel, &PortalPanel::todoAdded, this, &App::showTodoWindow);
connect(m_panel, &PortalPanel::askWithContent, this,
[this](const QString &text, const QStringList &files) {
showChatWindow();
if (m_chatWindow) {
m_chatWindow->startConversationWith(text, files);
}
});
}
void App::showPanel()
@@ -139,6 +184,18 @@ void App::showPanel()
m_panel->popup();
}
void App::togglePanel()
{
ensurePanel();
if (m_panel->isVisible() && m_panelPinned) {
m_panel->dismiss();
m_panelPinned = false;
return;
}
m_panelPinned = true;
m_panel->popup();
}
void App::showChatWindow()
{
if (!m_chatWindow) {
@@ -151,6 +208,14 @@ void App::showTodoWindow()
{
if (!m_todoWindow) {
m_todoWindow = new TodoWindow(TodoModel::instance());
connect(m_todoWindow, &TodoWindow::recreateRequested, this, [this]() {
if (m_todoWindow) {
m_todoWindow->close();
m_todoWindow->deleteLater();
m_todoWindow = nullptr;
}
showTodoWindow();
});
}
m_todoWindow->popup();
}
+2
View File
@@ -32,6 +32,7 @@ public:
private:
void ensurePanel();
void showPanel();
void togglePanel();
void showTodoWindow();
void showChatWindow();
@@ -42,4 +43,5 @@ private:
QLocalServer *m_server = nullptr;
bool m_daemon = false;
bool m_dragging = false;
bool m_panelPinned = false;
};
+9 -19
View File
@@ -182,15 +182,16 @@ public:
connect(fork, &QToolButton::clicked, this,
[this]() { Q_EMIT forkRequested(m_index); });
actions->addWidget(fork);
if (m_isLast) {
auto *retry = actionButton(tr("重试"), m_actions);
connect(retry, &QToolButton::clicked, this,
[this]() { Q_EMIT retryRequested(); });
actions->addWidget(retry);
}
auto *edit = actionButton(tr("编辑"), m_actions);
connect(edit, &QToolButton::clicked, this,
[this]() { Q_EMIT editRequested(m_index); });
actions->addWidget(edit);
auto *retry = actionButton(tr("重试"), m_actions);
connect(retry, &QToolButton::clicked, this,
[this]() { Q_EMIT retryRequested(m_index); });
actions->addWidget(retry);
}
actions->addStretch();
m_actions->hide();
column->addWidget(m_actions);
if (m_isUser) {
@@ -220,21 +221,10 @@ signals:
void copyRequested(int index);
void editRequested(int index);
void forkRequested(int index);
void retryRequested();
void retryRequested(int index);
void linkActivated(const QUrl &url);
protected:
void enterEvent(QEnterEvent *event) override
{
m_actions->show();
QWidget::enterEvent(event);
}
void leaveEvent(QEvent *event) override
{
m_actions->hide();
QWidget::leaveEvent(event);
}
private:
ChatMessage m_message;
+1 -1
View File
@@ -36,7 +36,7 @@ signals:
void copyRequested(int index);
void editRequested(int index);
void forkRequested(int index);
void retryRequested();
void retryRequested(int index);
void linkActivated(const QUrl &url);
protected:
+219 -17
View File
@@ -5,6 +5,8 @@
#include "config.h"
#include "chatview.h"
#include "../ui/aboutdialog.h"
#include "markdown.h"
#include "mcpclient.h"
@@ -73,11 +75,20 @@ public:
: QListWidget(parent)
{
setAcceptDrops(true);
setDragEnabled(true);
setDragDropMode(QAbstractItemView::DragDrop);
setDefaultDropAction(Qt::MoveAction);
setDropIndicatorShown(true);
setDragDropOverwriteMode(false);
setSelectionMode(QAbstractItemView::ExtendedSelection);
}
signals:
void droppedAt(int index, const QMimeData *mime);
void left();
void reordered();
void deleteRequested(const QList<int> &rows);
void renameRequested(int row);
protected:
void leaveEvent(QEvent *event) override
@@ -86,10 +97,9 @@ protected:
QListWidget::leaveEvent(event);
}
protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (mimeOk(event->mimeData())) {
if (event->source() == this || mimeOk(event->mimeData())) {
event->acceptProposedAction();
} else {
event->ignore();
@@ -98,13 +108,27 @@ protected:
void dragMoveEvent(QDragMoveEvent *event) override
{
if (mimeOk(event->mimeData())) {
if (event->source() == this || mimeOk(event->mimeData())) {
event->acceptProposedAction();
}
}
void dropEvent(QDropEvent *event) override
{
if (event->source() == this) {
const QModelIndex dropIndex = indexAt(event->position().toPoint());
const int to = dropIndex.isValid() ? dropIndex.row() : count();
const int from = currentRow();
if (from >= 0 && to >= 0 && to != from && to != from + 1) {
QListWidgetItem *item = takeItem(from);
const int target = to > from ? to - 1 : to;
insertItem(target, item);
setCurrentRow(target);
Q_EMIT reordered();
}
event->acceptProposedAction();
return;
}
if (!mimeOk(event->mimeData())) {
event->ignore();
return;
@@ -113,6 +137,37 @@ protected:
Q_EMIT droppedAt(index.isValid() ? index.row() : -1, event->mimeData());
event->acceptProposedAction();
}
void contextMenuEvent(QContextMenuEvent *event) override
{
const QModelIndex index = indexAt(event->pos());
QMenu menu(this);
QAction *rename = nullptr;
if (index.isValid()) {
rename = menu.addAction(tr("重命名对话"));
}
QAction *remove = menu.addAction(tr("删除选中"));
QAction *chosen = menu.exec(event->globalPos());
if (!chosen) {
return;
}
if (chosen == rename) {
Q_EMIT renameRequested(index.row());
return;
}
if (chosen == remove) {
QList<int> rows;
for (const QModelIndex &selected : selectionModel()->selectedRows()) {
rows.append(selected.row());
}
if (rows.isEmpty() && index.isValid()) {
rows.append(index.row());
}
if (!rows.isEmpty()) {
Q_EMIT deleteRequested(rows);
}
}
}
};
class ChatView : public QTextBrowser
@@ -544,6 +599,12 @@ void ChatWindow::setupUi()
connect(m_sidebar, &QListWidget::currentRowChanged, this, &ChatWindow::selectConversation);
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::droppedAt, this,
&ChatWindow::handleDropOnConversation);
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::reordered, this,
&ChatWindow::reorderConversations);
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::deleteRequested, this,
&ChatWindow::deleteConversations);
connect(static_cast<ChatSidebar *>(m_sidebar), &ChatSidebar::renameRequested, this,
&ChatWindow::renameConversation);
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) {
@@ -555,7 +616,9 @@ void ChatWindow::setupUi()
});
connect(m_messages, &ChatMessageList::editRequested, this, &ChatWindow::editMessage);
connect(m_messages, &ChatMessageList::forkRequested, this, &ChatWindow::forkFrom);
connect(m_messages, &ChatMessageList::retryRequested, this, &ChatWindow::retryLast);
connect(m_messages, &ChatMessageList::retryRequested, this, [this](int index) {
retryFrom(index);
});
auto *menu = new QMenu(menuButton);
menu->addAction(tr("压缩上下文"), this, [this]() { beginCompression(false); });
@@ -677,12 +740,83 @@ void ChatWindow::selectConversation(int index)
renderConversation();
}
void ChatWindow::reorderConversations()
{
if (m_conversations.isEmpty()) {
return;
}
QHash<QString, ChatConversation> byId;
for (const ChatConversation &conversation : m_conversations) {
byId.insert(conversation.id, conversation);
}
QVector<ChatConversation> ordered;
for (int i = 0; i < m_sidebar->count(); ++i) {
const QString id = m_sidebar->item(i)->data(Qt::UserRole).toString();
auto it = byId.find(id);
if (it != byId.end()) {
ordered.append(it.value());
byId.erase(it);
}
}
for (auto it = byId.begin(); it != byId.end(); ++it) {
ordered.append(it.value());
}
m_conversations = ordered;
const int row = m_sidebar->currentRow();
if (row >= 0 && row < m_conversations.size()) {
m_current = row;
}
save();
updateSidebar();
}
void ChatWindow::deleteConversations(const QList<int> &rows)
{
if (rows.isEmpty()) {
return;
}
if (m_client->busy()) {
m_client->cancel();
m_streamingIndex = -1;
}
QList<int> sorted = rows;
std::sort(sorted.begin(), sorted.end(), std::greater<int>());
for (int row : sorted) {
if (row >= 0 && row < m_conversations.size()) {
m_conversations.removeAt(row);
}
}
m_current = m_conversations.isEmpty() ? -1
: qBound(0, m_current, m_conversations.size() - 1);
save();
updateSidebar();
renderConversation();
}
void ChatWindow::renameConversation(int row)
{
if (row < 0 || row >= m_conversations.size()) {
return;
}
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();
}
}
void ChatWindow::updateSidebar()
{
const QSignalBlocker blocker(m_sidebar);
m_sidebar->clear();
for (const ChatConversation &conversation : m_conversations) {
auto *item = new QListWidgetItem(conversation.title, m_sidebar);
item->setData(Qt::UserRole, conversation.id);
QString preview = tr("新对话");
for (int i = conversation.messages.size() - 1; i >= 0; --i) {
const ChatMessage &message = conversation.messages.at(i);
@@ -870,8 +1004,26 @@ bool ChatWindow::needsCompression() const
if (!Config::instance()->getBool(QStringLiteral("chat/autoCompress"), true)) {
return false;
}
const QVector<ChatMessage> &messages = m_conversations.at(m_current).messages;
const int keep = Config::instance()->getInt(QStringLiteral("chat/contextLength"), 40);
return m_conversations.at(m_current).messages.size() > keep + 4;
if (messages.size() > keep + 4) {
return true;
}
/* also honour a token budget (128k / 1024k); a rough estimate is enough */
const qint64 tokenLimit =
Config::instance()->getInt(QStringLiteral("chat/contextTokens"), 128000);
if (tokenLimit <= 0) {
return false;
}
qint64 tokens = 0;
for (const ChatMessage &message : messages) {
for (const QChar &ch : message.content) {
tokens += ch.unicode() < 128 ? 1 : 2;
}
}
return tokens / 4 > tokenLimit;
}
void ChatWindow::beginCompression(bool thenSend)
@@ -925,9 +1077,6 @@ void ChatWindow::editMessage(int index)
if (index < 0 || index >= conversation.messages.size()) {
return;
}
if (conversation.messages.at(index).role != QStringLiteral("user")) {
return;
}
QDialog dialog(this);
dialog.setWindowTitle(tr("编辑消息"));
@@ -959,6 +1108,27 @@ void ChatWindow::editMessage(int index)
requestCompletion();
}
void ChatWindow::retryFrom(int index)
{
if (m_client->busy() || m_compressing || m_current < 0) {
return;
}
ChatConversation &conversation = m_conversations[m_current];
if (index < 0 || index >= conversation.messages.size()) {
return;
}
if (conversation.messages.at(index).role != QStringLiteral("assistant")) {
return;
}
/* drop everything from the assistant answer on, then regenerate it */
conversation.messages.resize(index);
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
save();
renderConversation();
requestCompletion();
}
void ChatWindow::retryLast()
{
if (m_client->busy() || m_compressing || m_current < 0) {
@@ -1028,15 +1198,7 @@ void ChatWindow::handleAnchor(const QUrl &url)
void ChatWindow::showAbout()
{
QMessageBox::about(
this, tr("关于小埃同学"),
tr("<h3>GXDE 小埃同学</h3>"
"<p>版本 %1</p>"
"<p>GXDE 桌面的 AI 助手:传送门、内容工厂、待办与对话。</p>"
"<p>许可证:GPL-3.0-or-later<br/>"
"仓库:<a href='https://gitee.com/GXDE-OS/gxde-ai-see'>"
"gitee.com/GXDE-OS/gxde-ai-see</a></p>")
.arg(QCoreApplication::applicationVersion()));
showAboutDialog(this, tr("GXDE 桌面的 AI 助手:传送门、内容工厂、待办与对话。"));
}
void ChatWindow::appendError(const QString &message)
@@ -1139,6 +1301,17 @@ void ChatWindow::openSettings()
Config::instance()->getInt(QStringLiteral("chat/contextLength"), 40));
form->addRow(tr("上下文条数"), contextSpin);
auto *tokenBox = new QComboBox(&dialog);
tokenBox->addItem(tr("128k"), 128000);
tokenBox->addItem(tr("1024k"), 1024000);
const int storedTokens =
Config::instance()->getInt(QStringLiteral("chat/contextTokens"), 128000);
const int tokenIndex = tokenBox->findData(storedTokens);
if (tokenIndex >= 0) {
tokenBox->setCurrentIndex(tokenIndex);
}
form->addRow(tr("上下文长度"), tokenBox);
auto *autoCompressBox = new QCheckBox(tr("超出后自动压缩旧消息"), &dialog);
autoCompressBox->setChecked(
Config::instance()->getBool(QStringLiteral("chat/autoCompress"), true));
@@ -1187,6 +1360,8 @@ void ChatWindow::openSettings()
Config::instance()->set(QStringLiteral("chat/contextLength"), contextSpin->value());
Config::instance()->set(QStringLiteral("chat/autoCompress"),
autoCompressBox->isChecked());
Config::instance()->set(QStringLiteral("chat/contextTokens"),
tokenBox->currentData().toInt());
const QJsonDocument mcpDocument =
QJsonDocument::fromJson(mcpEdit->toPlainText().toUtf8());
if (mcpDocument.isArray() || mcpEdit->toPlainText().trimmed().isEmpty()) {
@@ -1256,6 +1431,33 @@ void ChatWindow::handleDropOnConversation(int index, const QMimeData *mime)
activateWindow();
}
void ChatWindow::startConversationWith(const QString &text, const QStringList &files)
{
activateForAsk();
newConversation();
QString content = text;
if (!files.isEmpty()) {
if (!content.isEmpty()) {
content += QLatin1Char('\n');
}
content += files.join(QLatin1Char('\n'));
}
if (!content.isEmpty()) {
m_input->setPlainText(content);
m_input->moveCursor(QTextCursor::End);
const QString title = text.isEmpty() ? QFileInfo(files.value(0)).fileName()
: text.simplified().left(24);
if (m_current >= 0 && !title.isEmpty()) {
m_conversations[m_current].title = title;
save();
updateSidebar();
renderConversation();
}
}
m_input->setFocus();
}
void ChatWindow::activateForAsk()
{
show();
+7
View File
@@ -43,6 +43,9 @@ public:
/** Bring the window up for the "ask AI" action. */
void activateForAsk();
/** New conversation pre-filled from a portal drop on the ask tile. */
void startConversationWith(const QString &text, const QStringList &files);
/** Insert dropped content into the input of the current/new conversation. */
void handleDroppedMime(const QMimeData *mime);
void handleDropOnConversation(int index, const QMimeData *mime);
@@ -59,12 +62,16 @@ private:
void renderConversation();
void updateHeader();
void updateSidebar();
void reorderConversations();
void deleteConversations(const QList<int> &rows);
void renameConversation(int row);
void sendMessage();
void requestCompletion();
void runToolCalls(const QVector<ToolCall> &calls, int index, const QJsonArray &results);
void sendPayload();
void editMessage(int index);
void retryLast();
void retryFrom(int index);
void forkFrom(int index);
void beginCompression(bool thenSend);
void handleAnchor(const QUrl &url);
+87
View File
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "flowlayout.h"
#include <QStyle>
#include <QWidget>
FlowLayout::FlowLayout(QWidget *parent, int margin, int spacing)
: QLayout(parent)
, m_spacing(spacing)
{
setContentsMargins(margin, margin, margin, margin);
}
FlowLayout::~FlowLayout()
{
while (QLayoutItem *item = takeAt(0)) {
delete item;
}
}
void FlowLayout::addItem(QLayoutItem *item) { m_items.append(item); }
int FlowLayout::count() const { return m_items.size(); }
QLayoutItem *FlowLayout::itemAt(int index) const { return m_items.value(index); }
QLayoutItem *FlowLayout::takeAt(int index)
{
return index >= 0 && index < m_items.size() ? m_items.takeAt(index) : nullptr;
}
Qt::Orientations FlowLayout::expandingDirections() const { return {}; }
bool FlowLayout::hasHeightForWidth() const { return true; }
int FlowLayout::heightForWidth(int width) const { return doLayout(QRect(0, 0, width, 0), true); }
void FlowLayout::setGeometry(const QRect &rect)
{
QLayout::setGeometry(rect);
doLayout(rect, false);
}
QSize FlowLayout::sizeHint() const { return minimumSize(); }
QSize FlowLayout::minimumSize() const
{
QSize size;
for (QLayoutItem *item : m_items) {
size = size.expandedTo(item->minimumSize());
}
const QMargins margins = contentsMargins();
return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom());
}
int FlowLayout::doLayout(const QRect &rect, bool testOnly) const
{
const QMargins margins = contentsMargins();
const QRect effective = rect.adjusted(margins.left(), margins.top(), -margins.right(),
-margins.bottom());
int x = effective.x();
int y = effective.y();
int lineHeight = 0;
for (QLayoutItem *item : m_items) {
QWidget *widget = item->widget();
int spaceX = m_spacing;
int spaceY = m_spacing;
if (widget) {
spaceX = widget->style()->layoutSpacing(QSizePolicy::PushButton,
QSizePolicy::PushButton, Qt::Horizontal);
spaceY = widget->style()->layoutSpacing(QSizePolicy::PushButton,
QSizePolicy::PushButton, Qt::Vertical);
}
int nextX = x + item->sizeHint().width() + spaceX;
if (nextX - spaceX > effective.right() && lineHeight > 0) {
x = effective.x();
y = y + lineHeight + spaceY;
nextX = x + item->sizeHint().width() + spaceX;
lineHeight = 0;
}
if (!testOnly) {
item->setGeometry(QRect(QPoint(x, y), item->sizeHint()));
}
x = nextX;
lineHeight = qMax(lineHeight, item->sizeHint().height());
}
return y + lineHeight - rect.y() + margins.bottom();
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QLayout>
#include <QList>
/** Simple wrapping layout (Qt flow layout example). */
class FlowLayout : public QLayout
{
public:
explicit FlowLayout(QWidget *parent = nullptr, int margin = 0, int spacing = 6);
~FlowLayout() override;
void addItem(QLayoutItem *item) override;
int count() const override;
QLayoutItem *itemAt(int index) const override;
QLayoutItem *takeAt(int index) override;
Qt::Orientations expandingDirections() const override;
bool hasHeightForWidth() const override;
int heightForWidth(int width) const override;
QSize minimumSize() const override;
void setGeometry(const QRect &rect) override;
QSize sizeHint() const override;
private:
int doLayout(const QRect &rect, bool testOnly) const;
QList<QLayoutItem *> m_items;
int m_spacing;
};
+164
View File
@@ -0,0 +1,164 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "segmentdialog.h"
#include "flowlayout.h"
#include <QApplication>
#include <QPalette>
#include <QClipboard>
#include <QEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
SegmentDialog::SegmentDialog(const QStringList &words, QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("分词"));
setAttribute(Qt::WA_DeleteOnClose);
resize(560, 420);
const bool dark = palette().color(QPalette::Window).lightness() < 128;
const QColor capsuleBg = dark ? QColor(255, 255, 255, 20) : QColor(0, 0, 0, 18);
const QColor capsuleBorder = dark ? QColor(255, 255, 255, 30) : QColor(0, 0, 0, 28);
const QColor textColor = palette().color(QPalette::WindowText);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 14, 16, 14);
layout->setSpacing(10);
auto *hint = new QLabel(tr("按住并滑过词语可以连续选择,选中后复制。"), this);
hint->setStyleSheet(QStringLiteral("font-size: 12px;"));
layout->addWidget(hint);
auto *scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto *container = new QWidget(scroll);
auto *flow = new FlowLayout(container, 6, 6);
container->setLayout(flow);
scroll->setWidget(container);
layout->addWidget(scroll, 1);
for (const QString &word : words) {
auto *capsule = new QPushButton(word, container);
capsule->setCheckable(true);
capsule->setCursor(Qt::PointingHandCursor);
capsule->setFocusPolicy(Qt::NoFocus);
capsule->setStyleSheet(QStringLiteral(
"QPushButton { color: %1; background: %2; border: 1px solid %3;"
" border-radius: 11px; padding: 3px 10px; font-size: 13px; }"
"QPushButton:hover { background: %4; }"
"QPushButton:checked { background: rgba(74,144,255,0.85);"
" border-color: rgba(74,144,255,1.0); color: white; }")
.arg(textColor.name(),
QStringLiteral("rgba(%1,%2,%3,%4)")
.arg(capsuleBg.red())
.arg(capsuleBg.green())
.arg(capsuleBg.blue())
.arg(capsuleBg.alpha()),
capsuleBorder.name(),
dark ? QStringLiteral("rgba(255,255,255,30)")
: QStringLiteral("rgba(0,0,0,28)")));
capsule->installEventFilter(this);
connect(capsule, &QPushButton::released, this, [this]() { m_selecting = false; });
flow->addWidget(capsule);
m_capsules.append(capsule);
}
auto *bottom = new QHBoxLayout;
auto *all = new QPushButton(tr("全选"), this);
auto *none = new QPushButton(tr("清空"), this);
m_count = new QLabel(tr("已选 0 个"), this);
m_count->setStyleSheet(QStringLiteral("font-size: 12px;"));
auto *copy = new QPushButton(tr("复制选中"), this);
copy->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 8px; padding: 6px 14px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
bottom->addWidget(all);
bottom->addWidget(none);
bottom->addStretch();
bottom->addWidget(m_count);
bottom->addSpacing(10);
bottom->addWidget(copy);
layout->addLayout(bottom);
connect(all, &QPushButton::clicked, this, [this]() { setAll(true); });
connect(none, &QPushButton::clicked, this, [this]() { setAll(false); });
connect(copy, &QPushButton::clicked, this, &SegmentDialog::copySelected);
auto *close = new QPushButton(tr("关闭"), this);
layout->addWidget(close, 0, Qt::AlignRight);
connect(close, &QPushButton::clicked, this, &QDialog::close);
}
void SegmentDialog::setAll(bool checked)
{
for (QPushButton *capsule : m_capsules) {
capsule->setChecked(checked);
}
updateCount();
}
void SegmentDialog::updateCount()
{
int count = 0;
for (QPushButton *capsule : m_capsules) {
if (capsule->isChecked()) {
++count;
}
}
m_count->setText(tr("已选 %1 个").arg(count));
}
void SegmentDialog::copySelected()
{
QStringList selected;
for (QPushButton *capsule : m_capsules) {
if (capsule->isChecked()) {
selected.append(capsule->text());
}
}
if (selected.isEmpty()) {
m_count->setText(tr("还没有选中词语"));
return;
}
QApplication::clipboard()->setText(selected.join(QLatin1Char(' ')));
m_count->setText(tr("已复制 %1 个").arg(selected.size()));
}
bool SegmentDialog::eventFilter(QObject *watched, QEvent *event)
{
auto *capsule = qobject_cast<QPushButton *>(watched);
if (!capsule) {
return QDialog::eventFilter(watched, event);
}
if (event->type() == QEvent::MouseButtonPress) {
auto *mouse = static_cast<QMouseEvent *>(event);
if (mouse->button() == Qt::LeftButton) {
m_selecting = true;
m_selectValue = !capsule->isChecked();
capsule->setChecked(m_selectValue);
updateCount();
}
} else if (event->type() == QEvent::Enter && m_selecting &&
(QApplication::mouseButtons() & Qt::LeftButton)) {
if (capsule->isChecked() != m_selectValue) {
capsule->setChecked(m_selectValue);
updateCount();
}
}
return QDialog::eventFilter(watched, event);
}
void SegmentDialog::mouseReleaseEvent(QMouseEvent *event)
{
m_selecting = false;
QDialog::mouseReleaseEvent(event);
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QStringList>
#include <QVector>
class QLabel;
class QPushButton;
/** Word segmentation result as selectable capsules. */
class SegmentDialog : public QDialog
{
Q_OBJECT
public:
explicit SegmentDialog(const QStringList &words, QWidget *parent = nullptr);
private:
void updateCount();
void copySelected();
void setAll(bool checked);
QVector<QPushButton *> m_capsules;
QLabel *m_count = nullptr;
bool m_selecting = false;
bool m_selectValue = true;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
};
+111
View File
@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "speechdialog.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPlainTextEdit>
#include <QProcess>
#include <QPushButton>
#include <QSlider>
#include <QStandardPaths>
#include <QVBoxLayout>
SpeechDialog::SpeechDialog(const QString &text, QWidget *parent)
: QDialog(parent)
, m_text(text)
{
setWindowTitle(tr("朗读"));
setAttribute(Qt::WA_DeleteOnClose);
resize(480, 380);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 14, 16, 14);
layout->setSpacing(10);
m_preview = new QPlainTextEdit(this);
m_preview->setReadOnly(true);
m_preview->setPlainText(text.left(4000));
m_preview->setStyleSheet(QStringLiteral(
"QPlainTextEdit { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.9);"
" border: none; border-radius: 8px; padding: 8px; }"));
layout->addWidget(m_preview, 1);
auto *rateRow = new QHBoxLayout;
rateRow->addWidget(new QLabel(tr("语速"), this));
m_rate = new QSlider(Qt::Horizontal, this);
m_rate->setRange(50, 200);
m_rate->setValue(100);
rateRow->addWidget(m_rate, 1);
layout->addLayout(rateRow);
m_status = new QLabel(this);
m_status->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.55); font-size: 12px;"));
layout->addWidget(m_status);
auto *buttons = new QHBoxLayout;
auto *play = new QPushButton(tr("开始朗读"), this);
play->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 8px; padding: 7px 16px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
auto *stopButton = new QPushButton(tr("停止"), this);
auto *close = new QPushButton(tr("关闭"), this);
buttons->addWidget(play);
buttons->addWidget(stopButton);
buttons->addStretch();
buttons->addWidget(close);
layout->addLayout(buttons);
connect(play, &QPushButton::clicked, this, &SpeechDialog::start);
connect(stopButton, &QPushButton::clicked, this, &SpeechDialog::stop);
connect(close, &QPushButton::clicked, this, &QDialog::close);
}
SpeechDialog::~SpeechDialog()
{
stop();
}
void SpeechDialog::start()
{
stop();
QString program = QStandardPaths::findExecutable(QStringLiteral("spd-say"));
QStringList args;
if (!program.isEmpty()) {
args = { QStringLiteral("-r"), QString::number(m_rate->value() - 100),
QStringLiteral("-w"), m_text.left(4000) };
} else {
program = QStandardPaths::findExecutable(QStringLiteral("espeak-ng"));
if (program.isEmpty()) {
m_status->setText(tr("未找到 spd-say 或 espeak-ng"));
return;
}
args = { QStringLiteral("-s"), QString::number(m_rate->value()),
m_text.left(4000) };
}
m_process = new QProcess(this);
m_process->setProcessChannelMode(QProcess::MergedChannels);
connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
m_status->setText(tr("朗读失败:%1").arg(m_process->errorString()));
});
connect(m_process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
[this](int, QProcess::ExitStatus) {
m_status->setText(tr("朗读结束"));
});
m_status->setText(tr("正在朗读…"));
m_process->start(program, args);
}
void SpeechDialog::stop()
{
if (m_process) {
m_process->kill();
m_process->waitForFinished(300);
m_process->deleteLater();
m_process = nullptr;
m_status->setText(tr("已停止"));
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QString>
class QLabel;
class QPlainTextEdit;
class QProcess;
class QSlider;
/** Text-to-speech dialog with a rate control. */
class SpeechDialog : public QDialog
{
Q_OBJECT
public:
explicit SpeechDialog(const QString &text, QWidget *parent = nullptr);
~SpeechDialog() override;
private:
void start();
void stop();
QString m_text;
QPlainTextEdit *m_preview = nullptr;
QSlider *m_rate = nullptr;
QLabel *m_status = nullptr;
QProcess *m_process = nullptr;
};
+109 -13
View File
@@ -125,6 +125,67 @@ private:
QString m_id;
};
class AskTile : public QToolButton
{
Q_OBJECT
public:
explicit AskTile(QWidget *parent = nullptr)
: QToolButton(parent)
{
setText(tr("问问\n小埃同学"));
setToolTip(tr("把内容放到这里:新开一个对话并填入内容"));
setCursor(Qt::PointingHandCursor);
setFocusPolicy(Qt::NoFocus);
setAcceptDrops(true);
setFixedWidth(96);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
setStyleSheet(QStringLiteral(
"QToolButton { color: rgba(255,255,255,0.9); background: rgba(74,144,255,0.16);"
" border: 1px solid rgba(120,180,255,0.28); border-radius: 9px;"
" font-size: 12px; padding: 4px; }"
"QToolButton:hover { background: rgba(74,144,255,0.32); color: white;"
" border-color: rgba(160,205,255,0.7); }"
"QToolButton[dragover=\"true\"] { background: rgba(74,144,255,0.45);"
" border: 1px solid rgba(180,215,255,0.95); }"));
}
signals:
void dropped(const QMimeData *mime);
protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (!event->mimeData() ||
!(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
event->ignore();
return;
}
setProperty("dragover", true);
style()->unpolish(this);
style()->polish(this);
event->acceptProposedAction();
}
void dragLeaveEvent(QDragLeaveEvent *event) override
{
Q_UNUSED(event)
setProperty("dragover", false);
style()->unpolish(this);
style()->polish(this);
}
void dropEvent(QDropEvent *event) override
{
setProperty("dragover", false);
style()->unpolish(this);
style()->polish(this);
event->acceptProposedAction();
Q_EMIT dropped(event->mimeData());
}
};
ContentFactory::ContentFactory(QWidget *parent)
: QWidget(parent)
{
@@ -172,19 +233,15 @@ ContentFactory::ContentFactory(QWidget *parent)
outer->addLayout(left, 1);
m_askButton = new QPushButton(tr("问问\n小埃同学"), this);
m_askButton->setCursor(Qt::PointingHandCursor);
m_askButton->setFocusPolicy(Qt::NoFocus);
m_askButton->setFixedWidth(92);
m_askButton->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
m_askButton->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.22);"
" border: 1px solid rgba(120,180,255,0.45); border-radius: 10px;"
" font-size: 12px; }"
"QPushButton:hover { background: rgba(74,144,255,0.38);"
" border-color: rgba(160,205,255,0.8); }"));
connect(m_askButton, &QPushButton::clicked, this, &ContentFactory::askRequested);
outer->addWidget(m_askButton);
m_askTile = new AskTile(this);
connect(m_askTile, &QToolButton::clicked, this, &ContentFactory::askRequested);
connect(m_askTile, &AskTile::dropped, this, &ContentFactory::askDropped);
outer->addWidget(m_askTile);
}
QSize ContentFactory::sizeHint() const
{
return QSize(size().width(), 148);
}
void ContentFactory::setContent(const DropContent &content)
@@ -260,6 +317,45 @@ void ContentFactory::paintEvent(QPaintEvent *event)
painter.drawPath(path);
}
void ContentFactory::dragEnterEvent(QDragEnterEvent *event)
{
if (!event->mimeData() ||
!(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
event->ignore();
return;
}
Q_EMIT dragContent(event->mimeData());
Q_EMIT dragActivity();
event->acceptProposedAction();
}
void ContentFactory::dragMoveEvent(QDragMoveEvent *event)
{
if (event->mimeData() &&
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
Q_EMIT dragContent(event->mimeData());
Q_EMIT dragActivity();
event->acceptProposedAction();
return;
}
event->ignore();
}
void ContentFactory::dragLeaveEvent(QDragLeaveEvent *event)
{
Q_UNUSED(event)
Q_EMIT dragActivity();
}
void ContentFactory::dropEvent(QDropEvent *event)
{
if (event->mimeData()) {
event->acceptProposedAction();
}
}
bool ContentFactory::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() == QEvent::DragEnter || event->type() == QEvent::DragMove) {
+10 -1
View File
@@ -33,17 +33,26 @@ signals:
void actionRequested(const QString &id, const DropContent &content);
void heightChanged();
void dragActivity();
void dragContent(const QMimeData *mime);
void askRequested();
void askDropped(const QMimeData *mime);
public:
QSize sizeHint() const override;
protected:
void paintEvent(QPaintEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dragLeaveEvent(QDragLeaveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private:
void rebuild();
QLabel *m_summary = nullptr;
class QPushButton *m_askButton = nullptr;
class AskTile *m_askTile = nullptr;
QWidget *m_actions = nullptr;
QGridLayout *m_grid = nullptr;
DropContent m_content;
+38 -26
View File
@@ -4,6 +4,9 @@
#include "factoryactions.h"
#include <QFileInfo>
#include <QRegularExpression>
#include "../actions/texttools.h"
namespace {
@@ -41,8 +44,6 @@ QVector<FactoryAction> linkActions(const DropContent &content)
QStringLiteral("edit-copy")));
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")));
actions.append(action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
QStringLiteral("dialog-question")));
return actions;
}
@@ -84,12 +85,20 @@ QVector<FactoryAction> fileActions(const DropContent &content)
actions.append(action(QStringLiteral("transcribe"), QStringLiteral("转文字"),
QStringLiteral("insert-text"), QStringLiteral("尚未接入"), false));
} else if (type == QStringLiteral("document") || type == QStringLiteral("code")) {
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")));
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")));
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")));
const QString fileText = TextTools::readTextFile(path);
if (!fileText.isEmpty() &&
fileText.contains(QRegularExpression(QStringLiteral("[\\x{4e00}-\\x{9fff}]")))) {
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")));
}
if (!fileText.isEmpty() && !TextTools::extractLinks(fileText).isEmpty()) {
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")));
}
if (!fileText.isEmpty()) {
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")));
}
}
actions.append(action(QStringLiteral("copy_path"), QStringLiteral("复制路径"),
@@ -120,25 +129,28 @@ QVector<FactoryAction> actionsFor(const DropContent &content)
QStringLiteral("dialog-question")),
action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")),
action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
QStringLiteral("dialog-question")),
};
case DropContent::Text:
return {
action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")),
action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")),
action(QStringLiteral("translate"), QStringLiteral("翻译"),
QStringLiteral("accessories-dictionary")),
action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")),
action(QStringLiteral("copy"), QStringLiteral("复制"), QStringLiteral("edit-copy")),
action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")),
action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
QStringLiteral("dialog-question")),
};
case DropContent::Text: {
QVector<FactoryAction> actions;
const QString &text = content.text;
if (text.contains(QRegularExpression(QStringLiteral("[\\x{4e00}-\\x{9fff}]")))) {
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")));
}
if (!TextTools::extractLinks(text).isEmpty()) {
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")));
}
actions.append(action(QStringLiteral("translate"), QStringLiteral("翻译"),
QStringLiteral("accessories-dictionary")));
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")));
actions.append(action(QStringLiteral("copy"), QStringLiteral("复制"),
QStringLiteral("edit-copy")));
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")));
return actions;
}
case DropContent::Link:
case DropContent::CustomLink:
return linkActions(content);
+16 -8
View File
@@ -19,7 +19,7 @@ int main(int argc, char *argv[])
app.setOrganizationName(QStringLiteral("GXDE"));
app.setApplicationName(QStringLiteral("gxde-ai-see"));
app.setApplicationDisplayName(QStringLiteral("GXDE 小埃同学"));
app.setApplicationVersion(QStringLiteral("0.1.0"));
app.setApplicationVersion(QStringLiteral("1.0.0~beta2"));
QIcon appIcon = QIcon::fromTheme(QStringLiteral("gxde-ai-see"));
if (appIcon.isNull()) {
appIcon = QIcon(QStringLiteral("/usr/share/icons/hicolor/256x256/apps/gxde-ai-see.png"));
@@ -35,17 +35,25 @@ int main(int argc, char *argv[])
QStringLiteral("/usr/share/gxde-ai-see/translations"),
QStringLiteral("/usr/local/share/gxde-ai-see/translations"),
};
bool translated = false;
QStringList candidates{ locale, locale.section(QLatin1Char('_'), 0, 0) };
/* ensure unsupported locales fall back to English, never to the Chinese
* source strings */
if (!locale.startsWith(QLatin1String("zh"))) {
candidates.append(QStringLiteral("en"));
}
for (const QString &dir : translationDirs) {
if (translator->load(QStringLiteral("gxde-ai-see_") + locale, dir) ||
translator->load(QStringLiteral("gxde-ai-see_") + locale.section(QLatin1Char('_'), 0, 0),
dir)) {
app.installTranslator(translator);
translated = true;
bool loaded = false;
for (const QString &candidate : candidates) {
if (translator->load(QStringLiteral("gxde-ai-see_") + candidate, dir)) {
app.installTranslator(translator);
loaded = true;
break;
}
}
if (loaded) {
break;
}
}
Q_UNUSED(translated)
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("GXDE 小埃同学"));
+18 -1
View File
@@ -50,15 +50,22 @@ PortalClient::PortalClient(QObject *parent)
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
QString::fromLatin1(kInterface), QStringLiteral("PortalDropped"), this,
SLOT(onDropped(QString, bool)));
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
QString::fromLatin1(kInterface), QStringLiteral("PortalClosed"), this,
SLOT(onPortalClosed()));
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
QString::fromLatin1(kInterface), QStringLiteral("FullscreenChanged"), this,
SLOT(onFullscreenChanged(bool)));
}
bool PortalClient::beginPortal()
bool PortalClient::beginPortal(const QRect &panelRect)
{
QDBusMessage message =
QDBusMessage::createMethodCall(QString::fromLatin1(kService),
QString::fromLatin1(kPath),
QString::fromLatin1(kInterface),
QStringLiteral("BeginPortal"));
message << panelRect.x() << panelRect.y() << panelRect.width() << panelRect.height();
const QDBusMessage reply = QDBusConnection::sessionBus().call(message, QDBus::Block, 500);
if (reply.type() != QDBusMessage::ReplyMessage || reply.arguments().isEmpty()) {
return false;
@@ -92,6 +99,16 @@ void PortalClient::onDragStarted(const QString &mimeTypes)
Q_EMIT dragStarted(mimeTypes);
}
void PortalClient::onFullscreenChanged(bool fullscreen)
{
Q_EMIT fullscreenChanged(fullscreen);
}
void PortalClient::onPortalClosed()
{
Q_EMIT portalClosed();
}
void PortalClient::onDragEnded()
{
Q_EMIT dragEnded();
+6 -1
View File
@@ -4,6 +4,7 @@
#pragma once
#include <QObject>
#include <QRect>
/**
* Client for the compositor side "portal" (org.gxde.Portal).
@@ -23,7 +24,7 @@ public:
bool available() const { return m_available; }
/** @return true when the compositor opened the overview. */
bool beginPortal();
bool beginPortal(const QRect &panelRect = QRect());
void cancelPortal();
/** Filter the drag mime types we want to react to. */
@@ -32,11 +33,15 @@ public:
signals:
void dragStarted(const QString &mimeTypes);
void dragEnded();
void portalClosed();
void fullscreenChanged(bool fullscreen);
void dropped(const QString &uuid, bool accepted);
private slots:
void onDragStarted(const QString &mimeTypes);
void onDragEnded();
void onPortalClosed();
void onFullscreenChanged(bool fullscreen);
void onDropped(const QString &uuid, bool accepted);
private:
+43
View File
@@ -7,11 +7,15 @@
#include "config.h"
#include <QContextMenuEvent>
#include <QMenu>
#include <QApplication>
#include <QDialog>
#include <QDialogButtonBox>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QLineEdit>
#include <QListView>
@@ -145,6 +149,9 @@ TodoWindow::TodoWindow(TodoModel *model, QWidget *parent)
, m_model(model)
{
setWindowTitle(tr("待办"));
setWindowIcon(QIcon::fromTheme(
QStringLiteral("gxde-ai-see-todo"),
QIcon(QStringLiteral("/usr/share/icons/hicolor/256x256/apps/gxde-ai-see-todo.png"))));
setWindowFlags(Qt::FramelessWindowHint | Qt::Tool);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_ShowWithoutActivating);
@@ -155,6 +162,7 @@ TodoWindow::TodoWindow(TodoModel *model, QWidget *parent)
m_rightMargin = Config::instance()->getInt(QStringLiteral("todo/rightMargin"), 16);
m_topMargin = Config::instance()->getInt(QStringLiteral("todo/topMargin"), 72);
m_keepAbove = Config::instance()->getBool(QStringLiteral("todo/keepAbove"), true);
setupWindow();
setupUi();
@@ -168,6 +176,11 @@ TodoWindow::TodoWindow(TodoModel *model, QWidget *parent)
void TodoWindow::setupWindow()
{
if (!m_keepAbove) {
/* plain toplevel: shows in the taskbar and can be covered/moved */
setWindowFlag(Qt::Tool, false);
return;
}
#ifdef GXDE_HAVE_LAYERSHELLQT
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
createWinId();
@@ -304,6 +317,9 @@ void TodoWindow::addEntry()
void TodoWindow::applyMargins()
{
if (!m_keepAbove) {
return;
}
#ifdef GXDE_HAVE_LAYERSHELLQT
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
if (auto *layer = LayerShellQt::Window::get(windowHandle())) {
@@ -318,6 +334,26 @@ void TodoWindow::applyMargins()
}
}
void TodoWindow::contextMenuEvent(QContextMenuEvent *event)
{
QMenu menu(this);
QAction *pin = menu.addAction(tr("置顶"));
pin->setCheckable(true);
pin->setChecked(m_keepAbove);
QAction *hideAction = menu.addAction(tr("隐藏"));
QAction *chosen = menu.exec(event->globalPos());
if (chosen == hideAction) {
hide();
return;
}
if (chosen == pin) {
const bool keep = pin->isChecked();
Config::instance()->set(QStringLiteral("todo/keepAbove"), keep);
Config::instance()->sync();
Q_EMIT recreateRequested();
}
}
void TodoWindow::saveMargins()
{
Config::instance()->set(QStringLiteral("todo/rightMargin"), m_rightMargin);
@@ -328,6 +364,13 @@ void TodoWindow::saveMargins()
void TodoWindow::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton && event->position().y() < 48) {
if (!m_keepAbove) {
if (windowHandle()) {
windowHandle()->startSystemMove();
}
event->accept();
return;
}
m_dragging = true;
m_dragStart = event->position().toPoint();
m_dragStartRight = m_rightMargin;
+5
View File
@@ -28,11 +28,15 @@ public:
void refreshLayout();
protected:
void contextMenuEvent(QContextMenuEvent *event) override;
void showEvent(QShowEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
signals:
void recreateRequested();
private:
void setupWindow();
void setupUi();
@@ -45,6 +49,7 @@ private:
QLabel *m_countLabel = nullptr;
int m_rightMargin = 16;
int m_topMargin = 72;
bool m_keepAbove = true;
bool m_dragging = false;
QPoint m_dragStart;
int m_dragStartRight = 0;
+25
View File
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "aboutdialog.h"
#include <QApplication>
#include <QCoreApplication>
#include <QIcon>
#include <DAboutDialog>
void showAboutDialog(QWidget *parent, const QString &description)
{
auto *dialog = new Dtk::Widget::DAboutDialog(parent);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->setWindowTitle(QObject::tr("关于小埃同学"));
dialog->setProductIcon(QApplication::windowIcon());
dialog->setProductName(QObject::tr("GXDE 小埃同学"));
dialog->setVersion(QCoreApplication::applicationVersion());
dialog->setDescription(description);
dialog->setLicense(QStringLiteral("GPL-3.0-or-later"));
dialog->setWebsiteName(QStringLiteral("gitee.com/GXDE-OS/gxde-ai-see"));
dialog->setWebsiteLink(QStringLiteral("https://gitee.com/GXDE-OS/gxde-ai-see"));
dialog->show();
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
class QWidget;
/** DTK native about dialog shared by the panel and the chat window. */
void showAboutDialog(QWidget *parent, const QString &description);
+141 -35
View File
@@ -10,6 +10,10 @@
#include "actions/texttools.h"
#include "factory/contentclassifier.h"
#include "factory/contentfactory.h"
#include "aboutdialog.h"
#include "dialogs/segmentdialog.h"
#include "dialogs/speechdialog.h"
#include "todo/todomodel.h"
#include <QApplication>
@@ -577,9 +581,9 @@ public:
setMinimumHeight(190);
m_portalTimer.setSingleShot(true);
m_portalTimer.setInterval(150);
m_portalTimer.setInterval(80);
connect(&m_portalTimer, &QTimer::timeout, this, [this]() {
if (m_active) {
if (m_active && !m_suppressed) {
Q_EMIT portalRequested();
}
});
@@ -598,6 +602,18 @@ public:
}
void showMessage(const QString &message) { showHint(message); }
/* Set when the drag started while the pointer was already over the portal:
* do not open the overview until the pointer leaves the panel. */
void setSuppressPortal(bool suppress)
{
m_suppressed = suppress;
if (suppress) {
m_portalTimer.stop();
}
}
bool suppressed() const { return m_suppressed; }
void triggerPortal() { Q_EMIT portalRequested(); }
void setPortalActive(bool active)
{
m_portalActive = active;
@@ -684,7 +700,7 @@ protected:
}
m_active = true;
update();
if (!m_portalActive) {
if (!m_portalActive && !m_suppressed) {
m_portalTimer.start();
}
Q_EMIT dragContent(event->mimeData());
@@ -736,6 +752,7 @@ private:
bool m_active = false;
bool m_hover = false;
bool m_portalActive = false;
bool m_suppressed = false;
};
/* ------------------------------------------------------------------------- */
@@ -768,6 +785,7 @@ PortalPanel::PortalPanel(StagingModel *model, QWidget *parent)
if (m_countLabel) {
m_countLabel->setText(tr("%1 项").arg(m_model->count()));
}
updateMode();
refreshLayout();
});
connect(m_model, &StagingModel::countChanged, m_view->viewport(),
@@ -815,7 +833,7 @@ void PortalPanel::setupWindow()
anchors |= LayerShellQt::Window::AnchorBottom;
layer->setAnchors(anchors);
layer->setMargins(
QMargins(0, 0, m_rightMargin, m_bottomMargin + m_factoryOffset));
QMargins(0, 0, m_rightMargin, m_bottomMargin));
layer->setLayer(LayerShellQt::Window::LayerOverlay);
layer->setKeyboardInteractivity(
LayerShellQt::Window::KeyboardInteractivityNone);
@@ -840,9 +858,32 @@ void PortalPanel::setupUi()
m_zone = new PortalZone(this);
m_zone->setFixedWidth(kZoneWidth);
outer->addWidget(m_zone);
m_suppressWatch = new QTimer(this);
m_suppressWatch->setInterval(80);
connect(m_suppressWatch, &QTimer::timeout, this, [this]() {
if (!m_zone->suppressed()) {
m_suppressWatch->stop();
return;
}
if (rect().contains(mapFromGlobal(QCursor::pos()))) {
return;
}
m_suppressWatch->stop();
m_zone->setSuppressPortal(false);
m_zone->triggerPortal();
});
connect(m_zone, &PortalZone::portalRequested, this, [this]() {
PortalClient *portal = PortalClient::instance();
if (portal->beginPortal()) {
QScreen *scr = screen() ? screen() : QGuiApplication::primaryScreen();
QRect panelRect = geometry();
if (scr) {
const QRect avail = scr->availableGeometry();
panelRect = QRect(avail.x() + avail.width() - width() - m_rightMargin,
avail.y() + avail.height() - height() - m_bottomMargin,
width(), height());
}
if (portal->beginPortal(panelRect)) {
m_zone->setPortalActive(true);
m_zone->showMessage(tr("移动到目标窗口上松手"));
} else {
@@ -855,6 +896,8 @@ void PortalPanel::setupUi()
});
connect(PortalClient::instance(), &PortalClient::dragEnded, this,
[this]() { m_zone->setPortalActive(false); });
connect(PortalClient::instance(), &PortalClient::portalClosed, this,
[this]() { m_zone->setPortalActive(false); });
auto *right = new QVBoxLayout;
right->setContentsMargins(0, 0, 0, 0);
@@ -912,7 +955,7 @@ void PortalPanel::setupUi()
root->addLayout(outer);
m_factory = new ContentFactory(this);
m_factory->hide();
m_factory->show();
root->addWidget(m_factory);
m_toast = new QLabel(this);
@@ -936,7 +979,6 @@ void PortalPanel::setupUi()
return;
}
m_factory->clearContent();
m_factory->hide();
refreshLayout();
});
@@ -946,7 +988,21 @@ void PortalPanel::setupUi()
connect(m_view, &StagingView::dragFinished, this, &PortalPanel::noteDragFinished);
connect(m_factory, &ContentFactory::actionRequested, this, &PortalPanel::runFactoryAction);
connect(m_factory, &ContentFactory::askRequested, this, &PortalPanel::openChatRequested);
connect(m_factory, &ContentFactory::askDropped, this, [this](const QMimeData *mime) {
if (!mime) {
return;
}
QStringList files;
for (const QUrl &url : mime->urls()) {
if (url.isLocalFile()) {
files.append(url.toLocalFile());
}
}
const QString text = mime->hasText() ? mime->text() : QString();
Q_EMIT askWithContent(text, files);
});
connect(m_factory, &ContentFactory::heightChanged, this, [this]() { refreshLayout(); });
connect(m_factory, &ContentFactory::dragContent, this, &PortalPanel::noteDragContent);
connect(m_factory, &ContentFactory::dragActivity, this, [this]() {
if (m_factoryTimer) {
m_factoryTimer->start();
@@ -959,6 +1015,32 @@ void PortalPanel::refreshLayout()
updateGeometryForContent();
}
void PortalPanel::setDragging(bool dragging)
{
if (m_dragging == dragging) {
return;
}
m_dragging = dragging;
updateMode();
}
void PortalPanel::updateMode()
{
const bool stagingOnly = !m_dragging && m_model && m_model->count() > 0;
if (stagingOnly == m_stagingOnly) {
return;
}
m_stagingOnly = stagingOnly;
if (m_zone) {
m_zone->setVisible(!stagingOnly);
}
if (m_factory) {
m_factory->setVisible(!stagingOnly);
}
setFixedWidth(stagingOnly ? 360 : kPanelWidth);
refreshLayout();
}
void PortalPanel::updateGeometryForContent()
{
QScreen *scr = screen();
@@ -975,18 +1057,13 @@ void PortalPanel::updateGeometryForContent()
const int headerHeight = 26;
const int selectionHeight = (m_selectionBar && m_selectionBar->isVisible()) ? 32 : 0;
/* The factory always occupies the same strip, so neither the panel size nor
* its position changes when a drag starts or ends. */
const int factoryHeight =
(m_factory && m_factory->isVisible()) ? m_factory->sizeHint().height() + 8 : 0;
(!m_stagingOnly && m_factory) ? m_factory->sizeHint().height() + 6 : 0;
const int totalHeight =
kMargin * 2 + headerHeight + 6 + selectionHeight + listHeight + factoryHeight;
resize(kPanelWidth, totalHeight);
/* Growing downwards would push the portal/s. Staging area off screen: raise
* the panel by the factory height so the two columns stay where they are. */
if (m_factoryOffset != factoryHeight) {
m_factoryOffset = factoryHeight;
applyMargins();
}
resize(m_stagingOnly ? 360 : kPanelWidth, totalHeight);
}
void PortalPanel::noteDragContent(const QMimeData *mime)
@@ -1008,8 +1085,6 @@ void PortalPanel::noteDragContent(const QMimeData *mime)
}
m_factory->setContent(content);
m_factory->show();
refreshLayout();
}
void PortalPanel::noteDragFinished()
@@ -1106,7 +1181,10 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
}
if (id == QLatin1String("segment")) {
const QStringList words = TextTools::segment(sourceText.left(20000));
showTextDialog(tr("分词(%1)").arg(words.size()), words.join(QStringLiteral(" / ")));
auto *dialog = new SegmentDialog(words);
dialog->show();
dialog->raise();
dialog->activateWindow();
return;
}
if (id == QLatin1String("extract_links")) {
@@ -1115,13 +1193,10 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
return;
}
if (id == QLatin1String("speak")) {
const QString program = QStandardPaths::findExecutable(QStringLiteral("spd-say"));
if (program.isEmpty()) {
toast(tr("未找到语音朗读程序"));
return;
}
QProcess::startDetached(program, { sourceText.left(4000) });
toast(tr("开始朗读"));
auto *dialog = new SpeechDialog(sourceText.left(20000));
dialog->show();
dialog->raise();
dialog->activateWindow();
return;
}
if (id == QLatin1String("todo")) {
@@ -1129,6 +1204,7 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
: content.summary();
TodoModel::instance()->add(text);
toast(tr("已添加到待办"));
Q_EMIT todoAdded();
return;
}
if (id == QLatin1String("save_image")) {
@@ -1202,7 +1278,7 @@ void PortalPanel::applyMargins()
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
if (auto *layer = LayerShellQt::Window::get(windowHandle())) {
layer->setMargins(
QMargins(0, 0, m_rightMargin, m_bottomMargin + m_factoryOffset));
QMargins(0, 0, m_rightMargin, m_bottomMargin));
return;
}
}
@@ -1210,7 +1286,7 @@ void PortalPanel::applyMargins()
if (QScreen *scr = screen() ? screen() : QGuiApplication::primaryScreen()) {
const QRect available = scr->availableGeometry();
move(available.right() - width() - m_rightMargin,
available.bottom() - height() - m_bottomMargin - m_factoryOffset);
available.bottom() - height() - m_bottomMargin);
}
}
@@ -1327,6 +1403,7 @@ void PortalPanel::showContextMenu(const QPoint &globalPos)
menu.addSeparator();
QAction *clear = menu.addAction(tr("清空暂存区"));
QAction *hide = menu.addAction(tr("隐藏"));
QAction *about = menu.addAction(tr("关于"));
QAction *quit = menu.addAction(tr("退出"));
@@ -1346,11 +1423,22 @@ void PortalPanel::showContextMenu(const QPoint &globalPos)
menuPos.setX(available.right() - menuSize.width());
}
}
menuPos.setX(globalPos.x() - menuSize.width() - 10);
if (scr) {
const QRect available = scr->availableGeometry();
if (menuPos.x() < available.left()) {
menuPos.setX(available.left());
}
}
QAction *chosen = menu.exec(menuPos);
if (!chosen) {
return;
}
if (chosen == hide) {
Q_EMIT hideRequested();
return;
}
if (chosen == fixedPosition) {
m_fixedPosition = fixedPosition->isChecked();
saveWindowState();
@@ -1395,18 +1483,36 @@ void PortalPanel::showContextMenu(const QPoint &globalPos)
} else if (chosen == clear) {
m_model->clearAll();
} else if (chosen == about) {
QMessageBox::about(
this, tr("关于小埃同学"),
tr("<h3>GXDE 小埃同学</h3><p>版本 %1</p>"
"<p>传送门、内容工厂、待办与小埃同学对话。</p>"
"<p>GPL-3.0-or-later · "
"<a href='https://gitee.com/GXDE-OS/gxde-ai-see'>gxde-ai-see</a></p>")
.arg(QCoreApplication::applicationVersion()));
showAboutDialog(nullptr, tr("传送门、内容工厂、待办与小埃同学对话。"));
} else if (chosen == quit) {
QCoreApplication::quit();
}
}
void PortalPanel::suppressPortalIfUnderCursor()
{
if (!m_zone || !isVisible()) {
return;
}
const bool under = m_zone->rect().contains(m_zone->mapFromGlobal(QCursor::pos()));
m_zone->setSuppressPortal(under);
if (under) {
m_suppressWatch->start();
} else {
m_suppressWatch->stop();
}
}
void PortalPanel::stopPortalSuppression()
{
if (m_suppressWatch) {
m_suppressWatch->stop();
}
if (m_zone) {
m_zone->setSuppressPortal(false);
}
}
void PortalPanel::showEvent(QShowEvent *event)
{
Dtk::Widget::DBlurEffectWidget::showEvent(event);
+10 -1
View File
@@ -33,10 +33,16 @@ public:
void refreshLayout();
void popup();
void suppressPortalIfUnderCursor();
void setDragging(bool dragging);
void stopPortalSuppression();
void dismiss();
signals:
void openChatRequested();
void askWithContent(const QString &text, const QStringList &files);
void hideRequested();
void todoAdded();
protected:
void showEvent(QShowEvent *event) override;
@@ -49,6 +55,7 @@ private:
void setupUi();
void setupWindow();
void updateGeometryForContent();
void updateMode();
void updateSelectionBar();
void noteDragContent(const QMimeData *mime);
void noteDragFinished();
@@ -75,7 +82,9 @@ private:
int m_rightMargin = 16;
int m_bottomMargin = 16;
int m_manualListHeight = 0;
int m_factoryOffset = 0;
QTimer *m_suppressWatch = nullptr;
bool m_stagingOnly = false;
bool m_dragging = false;
bool m_fixedPosition = true;
bool m_fixedSize = false;
int m_dragMode = 0; // 0 none, 1 move, 2 resize
+385 -172
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1">
<TS version="2.1" language="en">
<context>
<name>App</name>
<message>
@@ -14,6 +14,21 @@
<translation>This desktop does not support the portal (a GXDE Wayland compositor is required). AI See will stay idle in the background; staging, the content factory and chat remain available.</translation>
</message>
</context>
<context>
<name>AskTile</name>
<message>
<location filename="../src/factory/contentfactory.cpp" line="136"/>
<source>问问
小埃同学</source>
<translation>Ask
AI See</translation>
</message>
<message>
<location filename="../src/factory/contentfactory.cpp" line="137"/>
<source>把内容放到这里:新开一个对话并填入内容</source>
<translation>Drop here to start a new conversation with this content</translation>
</message>
</context>
<context>
<name>ChatClient</name>
<message>
@@ -32,168 +47,224 @@
<translation>The API key is invalid or expired</translation>
</message>
</context>
<context>
<name>ChatMessageList</name>
<message>
<location filename="../src/chat/chatview.cpp" line="263"/>
<source>埃</source>
<translation>AI</translation>
</message>
<message>
<location filename="../src/chat/chatview.cpp" line="271"/>
<source>你好,我是小埃同学</source>
<translation>Hi, I&apos;m AI See</translation>
</message>
<message>
<location filename="../src/chat/chatview.cpp" line="280"/>
<source>把文件、链接或文字拖进窗口,或者直接输入问题。
第一次使用请在左下角「设置」里填入 DeepSeek API Key。</source>
<translation>Drop files, links or text here, or just type a question.
On first use open Settings at the bottom left and enter your DeepSeek API key.</translation>
</message>
</context>
<context>
<name>ChatSidebar</name>
<message>
<location filename="../src/chat/chatwindow.cpp" line="147"/>
<source>重命名对话</source>
<translation>Rename conversation</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="149"/>
<source>删除选中</source>
<translation>Delete selected</translation>
</message>
</context>
<context>
<name>ChatWindow</name>
<message>
<location filename="../src/chat/chatwindow.cpp" line="211"/>
<location filename="../src/chat/chatwindow.cpp" line="334"/>
<location filename="../src/chat/chatwindow.cpp" line="290"/>
<location filename="../src/chat/chatwindow.cpp" line="291"/>
<location filename="../src/chat/chatwindow.cpp" line="446"/>
<location filename="../src/chat/chatwindow.cpp" line="523"/>
<location filename="../src/chat/chatwindow.cpp" line="853"/>
<source>小埃同学</source>
<translation>AI See</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="250"/>
<location filename="../src/chat/chatwindow.cpp" line="346"/>
<source>工具调用次数过多,已停止</source>
<translation>Too many tool calls, stopped</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="309"/>
<location filename="../src/chat/chatwindow.cpp" line="405"/>
<source>⚠️ %1</source>
<translation>⚠️ %1</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="342"/>
<source>把内容拖进对话即可提问</source>
<translation>Drop content into a conversation to ask about it</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="355"/>
<location filename="../src/chat/chatwindow.cpp" line="460"/>
<source>+ 新建对话</source>
<translation>+ New conversation</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="363"/>
<source>⋯ 更多</source>
<translation>⋯ More</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="370"/>
<location filename="../src/chat/chatwindow.cpp" line="624"/>
<source>压缩上下文</source>
<translation>Compress context</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="371"/>
<location filename="../src/chat/chatwindow.cpp" line="625"/>
<source>重试上一条</source>
<translation>Retry last message</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="372"/>
<location filename="../src/chat/chatwindow.cpp" line="626"/>
<source>重新加载 MCP</source>
<translation>Reload MCP</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="375"/>
<location filename="../src/chat/chatwindow.cpp" line="629"/>
<source>没有配置 MCP 服务器</source>
<translation>No MCP server configured</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="380"/>
<location filename="../src/chat/chatwindow.cpp" line="634"/>
<source>关于</source>
<translation>About</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="384"/>
<source>设置</source>
<translation>Settings</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="416"/>
<location filename="../src/chat/chatwindow.cpp" line="562"/>
<source>输入消息,Ctrl+Enter 发送;也可以把文件/文字拖进窗口</source>
<translation>Type a message, Ctrl+Enter to send; you can also drop files or text here</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="424"/>
<location filename="../src/chat/chatwindow.cpp" line="569"/>
<source>发送</source>
<translation>Send</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="535"/>
<location filename="../src/chat/chatwindow.cpp" line="652"/>
<location filename="../src/chat/chatwindow.cpp" line="719"/>
<location filename="../src/chat/chatwindow.cpp" line="820"/>
<location filename="../src/chat/chatwindow.cpp" line="874"/>
<source>新对话</source>
<translation>New conversation</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="605"/>
<source>你</source>
<translation>You</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="605"/>
<location filename="../src/chat/chatwindow.cpp" line="630"/>
<source>小埃</source>
<translation>AI See</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="826"/>
<location filename="../src/chat/chatwindow.cpp" line="1066"/>
<source>正在压缩上下文…</source>
<translation>Compressing context…</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="845"/>
<location filename="../src/chat/chatwindow.cpp" line="1082"/>
<source>编辑消息</source>
<translation>Edit message</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="906"/>
<location filename="../src/chat/chatwindow.cpp" line="1164"/>
<source>%1 · 分支</source>
<translation>%1 · branch</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="944"/>
<source>关于小埃同学</source>
<translation>About AI See</translation>
<location filename="../src/chat/chatwindow.cpp" line="490"/>
<source>⚙ 设置</source>
<translation>⚙ Settings</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="945"/>
<source>&lt;h3&gt;GXDE 小埃同学&lt;/h3&gt;&lt;p&gt;版本 %1&lt;/p&gt;&lt;p&gt;GXDE 桌面的 AI 助手:传送门、内容工厂、待办与对话。&lt;/p&gt;&lt;p&gt;许可证:GPL-3.0-or-later&lt;br/&gt;仓库:&lt;a href=&apos;https://gitee.com/GXDE-OS/gxde-ai-see&apos;&gt;gitee.com/GXDE-OS/gxde-ai-see&lt;/a&gt;&lt;/p&gt;</source>
<translation>&lt;h3&gt;GXDE AI See&lt;/h3&gt;&lt;p&gt;Version %1&lt;/p&gt;&lt;p&gt;The AI assistant for the GXDE desktop: portal, content factory, todo and chat.&lt;/p&gt;&lt;p&gt;License: GPL-3.0-or-later&lt;br/&gt;Repository: &lt;a href=&apos;https://gitee.com/GXDE-OS/gxde-ai-see&apos;&gt;gitee.com/GXDE-OS/gxde-ai-see&lt;/a&gt;&lt;/p&gt;</translation>
<location filename="../src/chat/chatwindow.cpp" line="581"/>
<source>Enter 换行 · Ctrl+Enter 发送 · 支持拖入文件/链接/图片</source>
<translation>Enter for newline · Ctrl+Enter to send · files, links and images can be dropped here</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="963"/>
<location filename="../src/chat/chatwindow.cpp" line="802"/>
<source>重命名对话</source>
<translation>Rename conversation</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="802"/>
<source>名称</source>
<translation>Name</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1201"/>
<source>GXDE 桌面的 AI 助手:传送门、内容工厂、待办与对话。</source>
<translation>The AI assistant for the GXDE desktop: portal, content factory, todo and chat.</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1213"/>
<source>小埃同学设置</source>
<translation>AI See settings</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="975"/>
<location filename="../src/chat/chatwindow.cpp" line="1225"/>
<source>DeepSeek API Key</source>
<translation>DeepSeek API key</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="982"/>
<location filename="../src/chat/chatwindow.cpp" line="1232"/>
<source>API 地址</source>
<translation>API endpoint</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="992"/>
<location filename="../src/chat/chatwindow.cpp" line="1245"/>
<source>获取模型列表</source>
<translation>Fetch models</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1247"/>
<source>模型</source>
<translation>Model</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="998"/>
<location filename="../src/chat/chatwindow.cpp" line="1270"/>
<source>获取失败</source>
<translation>Fetch failed</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1293"/>
<source>已更新</source>
<translation>Updated</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1302"/>
<source>上下文条数</source>
<translation>Context messages</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1000"/>
<location filename="../src/chat/chatwindow.cpp" line="1305"/>
<source>128k</source>
<translation>128k</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1306"/>
<source>1024k</source>
<translation>1024k</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1313"/>
<source>上下文长度</source>
<translation>Context window</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1315"/>
<source>超出后自动压缩旧消息</source>
<translation>Automatically compress older messages when exceeded</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1013"/>
<location filename="../src/chat/chatwindow.cpp" line="1328"/>
<source>MCP 服务器(JSON)</source>
<translation>MCP servers (JSON)</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1020"/>
<location filename="../src/chat/chatwindow.cpp" line="1335"/>
<source>系统提示</source>
<translation>System prompt</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1025"/>
<location filename="../src/chat/chatwindow.cpp" line="1340"/>
<source>还没有账号?前往 &lt;a href=&apos;https://platform.deepseek.com&apos;&gt;platform.deepseek.com&lt;/a&gt; 开通并创建 API Key。Key 只保存在本机配置文件中。</source>
<translation>No account yet? Sign up and create an API key at &lt;a href=&apos;https://platform.deepseek.com&apos;&gt;platform.deepseek.com&lt;/a&gt;. The key is stored only in the local configuration file.</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="1055"/>
<location filename="../src/chat/chatwindow.cpp" line="1372"/>
<source>MCP 配置不是合法的 JSON 数组,已忽略</source>
<translation>The MCP configuration is not a valid JSON array; ignored</translation>
</message>
@@ -201,13 +272,13 @@
<context>
<name>ContentFactory</name>
<message>
<location filename="../src/factory/contentfactory.cpp" line="151"/>
<location filename="../src/factory/contentfactory.cpp" line="181"/>
<location filename="../src/factory/contentfactory.cpp" line="217"/>
<location filename="../src/factory/contentfactory.cpp" line="259"/>
<source>拖到这里,按类型处理</source>
<translation>Drop here to process by type</translation>
</message>
<message>
<location filename="../src/factory/contentfactory.cpp" line="154"/>
<location filename="../src/factory/contentfactory.cpp" line="220"/>
<source>拖到动作上松手</source>
<translation>Drop onto an action</translation>
</message>
@@ -241,211 +312,231 @@
</message>
</context>
<context>
<name>PortalPanel</name>
<name>MessageRow</name>
<message>
<location filename="../src/ui/portalpanel.cpp" line="761"/>
<source>GXDE 小埃同学</source>
<translation>GXDE AI See</translation>
<location filename="../src/chat/chatview.cpp" line="121"/>
<location filename="../src/chat/chatview.cpp" line="130"/>
<source>你</source>
<translation>You</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="781"/>
<location filename="../src/ui/portalpanel.cpp" line="889"/>
<source>%1 项</source>
<translation>%1 items</translation>
<location filename="../src/chat/chatview.cpp" line="121"/>
<source>埃</source>
<translation>AI</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="859"/>
<source>移动到目标窗口上松手</source>
<translation>Move onto a target window and release</translation>
<location filename="../src/chat/chatview.cpp" line="130"/>
<source>小埃</source>
<translation>AI See</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="861"/>
<source>当前无已打开的窗口</source>
<translation>No open windows</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="862"/>
<source>当前环境不支持传送门</source>
<translation>The portal is not supported in this environment</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="880"/>
<source>暂存区</source>
<translation>Staging</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="913"/>
<source>全选</source>
<translation>Select all</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="914"/>
<source>反选</source>
<translation>Invert</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="915"/>
<source>删除选中</source>
<translation>Delete selected</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1031"/>
<location filename="../src/chat/chatview.cpp" line="170"/>
<source>复制</source>
<translation>Copy</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1035"/>
<location filename="../src/ui/portalpanel.cpp" line="1043"/>
<location filename="../src/chat/chatview.cpp" line="176"/>
<location filename="../src/chat/chatview.cpp" line="185"/>
<source>编辑</source>
<translation>Edit</translation>
</message>
<message>
<location filename="../src/chat/chatview.cpp" line="181"/>
<source>分支</source>
<translation>Branch</translation>
</message>
<message>
<location filename="../src/chat/chatview.cpp" line="189"/>
<source>重试</source>
<translation>Retry</translation>
</message>
</context>
<context>
<name>PortalPanel</name>
<message>
<location filename="../src/ui/portalpanel.cpp" line="766"/>
<source>GXDE 小埃同学</source>
<translation>GXDE AI See</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="786"/>
<location filename="../src/ui/portalpanel.cpp" line="920"/>
<source>%1 项</source>
<translation>%1 items</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="888"/>
<source>移动到目标窗口上松手</source>
<translation>Move onto a target window and release</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="890"/>
<source>当前无已打开的窗口</source>
<translation>No open windows</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="891"/>
<source>当前环境不支持传送门</source>
<translation>The portal is not supported in this environment</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="911"/>
<source>暂存区</source>
<translation>Staging</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="944"/>
<source>全选</source>
<translation>Select all</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="945"/>
<source>反选</source>
<translation>Invert</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="946"/>
<source>删除选中</source>
<translation>Delete selected</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1113"/>
<source>复制</source>
<translation>Copy</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1117"/>
<location filename="../src/ui/portalpanel.cpp" line="1125"/>
<source>已复制</source>
<translation>Copied</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1097"/>
<location filename="../src/ui/portalpanel.cpp" line="1179"/>
<source>真实地址</source>
<translation>Real address</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1102"/>
<source>分词(%1)</source>
<translation>Segmented (%1)</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1107"/>
<location filename="../src/ui/portalpanel.cpp" line="1192"/>
<source>提取链接(%1)</source>
<translation>Links (%1)</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1113"/>
<source>未找到语音朗读程序</source>
<translation>No speech program found</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1117"/>
<source>开始朗读</source>
<translation>Reading aloud</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1124"/>
<location filename="../src/ui/portalpanel.cpp" line="1206"/>
<source>已添加到待办</source>
<translation>Added to todo</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1129"/>
<location filename="../src/ui/portalpanel.cpp" line="1212"/>
<source>没有可保存的图片</source>
<translation>No image to save</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1133"/>
<location filename="../src/ui/portalpanel.cpp" line="1216"/>
<source>保存图片</source>
<translation>Save image</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1133"/>
<location filename="../src/ui/portalpanel.cpp" line="1216"/>
<source>图片 (*.png *.jpg)</source>
<translation>Images (*.png *.jpg)</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1136"/>
<location filename="../src/ui/portalpanel.cpp" line="1219"/>
<source>已保存:%1</source>
<translation>Saved: %1</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1145"/>
<location filename="../src/ui/portalpanel.cpp" line="1228"/>
<source>翻译接口将在小埃同学接入后可用</source>
<translation>Translation will be available once the AI backend is wired up</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1150"/>
<location filename="../src/ui/portalpanel.cpp" line="1233"/>
<source>该能力尚未接入 GXDE Daemon</source>
<translation>This capability is not wired to GXDE Daemon yet</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1154"/>
<location filename="../src/ui/portalpanel.cpp" line="1237"/>
<source>未知操作:%1</source>
<translation>Unknown action: %1</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1297"/>
<location filename="../src/ui/portalpanel.cpp" line="1381"/>
<source>固定位置</source>
<translation>Pin position</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1301"/>
<location filename="../src/ui/portalpanel.cpp" line="1385"/>
<source>恢复默认位置</source>
<translation>Restore default position</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1303"/>
<location filename="../src/ui/portalpanel.cpp" line="1387"/>
<source>固定大小</source>
<translation>Pin size</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1307"/>
<location filename="../src/ui/portalpanel.cpp" line="1391"/>
<source>全屏时隐藏</source>
<translation>Hide on fullscreen</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1316"/>
<location filename="../src/ui/portalpanel.cpp" line="1400"/>
<source>开机自启与后台保活</source>
<translation>Start at login and keep alive</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1321"/>
<location filename="../src/ui/portalpanel.cpp" line="1405"/>
<source>清空暂存区</source>
<translation>Clear staging area</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1322"/>
<location filename="../src/ui/portalpanel.cpp" line="1406"/>
<source>隐藏</source>
<translation>Hide</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1407"/>
<source>关于</source>
<translation>About</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1323"/>
<location filename="../src/ui/portalpanel.cpp" line="1408"/>
<source>退出</source>
<translation>Quit</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1370"/>
<location filename="../src/ui/portalpanel.cpp" line="1482"/>
<source>已开启开机自启与保活</source>
<translation>Startup and keep-alive enabled</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1370"/>
<location filename="../src/ui/portalpanel.cpp" line="1482"/>
<source>已关闭开机自启与保活</source>
<translation>Startup and keep-alive disabled</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1375"/>
<source>关于小埃同学</source>
<translation>About AI See</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="1376"/>
<source>&lt;h3&gt;GXDE 小埃同学&lt;/h3&gt;&lt;p&gt;版本 %1&lt;/p&gt;&lt;p&gt;传送门、内容工厂、待办与小埃同学对话。&lt;/p&gt;&lt;p&gt;GPL-3.0-or-later · &lt;a href=&apos;https://gitee.com/GXDE-OS/gxde-ai-see&apos;&gt;gxde-ai-see&lt;/a&gt;&lt;/p&gt;</source>
<translation>&lt;h3&gt;GXDE AI See&lt;/h3&gt;&lt;p&gt;Version %1&lt;/p&gt;&lt;p&gt;Portal, content factory, todo and AI See chat.&lt;/p&gt;&lt;p&gt;GPL-3.0-or-later · &lt;a href=&apos;https://gitee.com/GXDE-OS/gxde-ai-see&apos;&gt;gxde-ai-see&lt;/a&gt;&lt;/p&gt;</translation>
<location filename="../src/ui/portalpanel.cpp" line="1486"/>
<source>传送门、内容工厂、待办与小埃同学对话。</source>
<translation>Portal, content factory, todo and AI See chat.</translation>
</message>
</context>
<context>
<name>PortalZone</name>
<message>
<location filename="../src/ui/portalpanel.cpp" line="587"/>
<source>问问小埃 →</source>
<translation>Ask AI See →</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="664"/>
<location filename="../src/ui/portalpanel.cpp" line="669"/>
<source>传送门</source>
<translation>Portal</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="672"/>
<location filename="../src/ui/portalpanel.cpp" line="677"/>
<source>拖到这里
选择窗口跃迁</source>
<translation>Drop here
to choose a window</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="729"/>
<location filename="../src/ui/portalpanel.cpp" line="734"/>
<source>悬停在「传送门」上,再移动到目标窗口</source>
<translation>Hover the portal, then move onto a target window</translation>
</message>
@@ -458,25 +549,141 @@ to choose a window</translation>
<translation>No API key configured yet. Open Settings and enter your DeepSeek API key.</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="118"/>
<location filename="../src/ui/portalpanel.cpp" line="122"/>
<source>文件</source>
<translation>File</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="120"/>
<location filename="../src/ui/portalpanel.cpp" line="124"/>
<source>图片</source>
<translation>Image</translation>
</message>
<message>
<location filename="../src/ui/portalpanel.cpp" line="122"/>
<location filename="../src/ui/portalpanel.cpp" line="126"/>
<source>文字 · %1 字</source>
<translation>Text · %1 chars</translation>
</message>
<message>
<location filename="../src/ui/aboutdialog.cpp" line="16"/>
<source>关于小埃同学</source>
<translation>About AI See</translation>
</message>
<message>
<location filename="../src/ui/aboutdialog.cpp" line="18"/>
<source>GXDE 小埃同学</source>
<translation>GXDE AI See</translation>
</message>
</context>
<context>
<name>SegmentDialog</name>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="21"/>
<source>分词</source>
<translation>Segment</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="34"/>
<source>按住并滑过词语可以连续选择,选中后复制。</source>
<translation>Press and slide across words to select several, then copy.</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="74"/>
<source>全选</source>
<translation>Select all</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="75"/>
<source>清空</source>
<translation>Clear</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="76"/>
<source>已选 0 个</source>
<translation>0 selected</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="78"/>
<source>复制选中</source>
<translation>Copy selected</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="95"/>
<source>关闭</source>
<translation>Close</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="116"/>
<source>已选 %1 个</source>
<translation>%1 selected</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="128"/>
<source>还没有选中词语</source>
<translation>No words selected</translation>
</message>
<message>
<location filename="../src/dialogs/segmentdialog.cpp" line="132"/>
<source>已复制 %1 个</source>
<translation>Copied %1</translation>
</message>
</context>
<context>
<name>SpeechDialog</name>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="19"/>
<source>朗读</source>
<translation>Read aloud</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="36"/>
<source>语速</source>
<translation>Speed</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="48"/>
<source>开始朗读</source>
<translation>Start</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="53"/>
<source>停止</source>
<translation>Stop</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="54"/>
<source>关闭</source>
<translation>Close</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="82"/>
<source>未找到 spd-say 或 espeak-ng</source>
<translation>spd-say or espeak-ng not found</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="92"/>
<source>朗读失败:%1</source>
<translation>Speech failed: %1</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="96"/>
<source>朗读结束</source>
<translation>Finished</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="98"/>
<source>正在朗读…</source>
<translation>Speaking…</translation>
</message>
<message>
<location filename="../src/dialogs/speechdialog.cpp" line="109"/>
<source>已停止</source>
<translation>Stopped</translation>
</message>
</context>
<context>
<name>StagingView</name>
<message>
<location filename="../src/ui/portalpanel.cpp" line="351"/>
<location filename="../src/ui/portalpanel.cpp" line="355"/>
<source>把文件、文字或图片拖到这里</source>
<translation>Drop files, text or images here</translation>
</message>
@@ -484,42 +691,48 @@ to choose a window</translation>
<context>
<name>TodoWindow</name>
<message>
<location filename="../src/todo/todowindow.cpp" line="145"/>
<location filename="../src/todo/todowindow.cpp" line="195"/>
<location filename="../src/todo/todowindow.cpp" line="151"/>
<location filename="../src/todo/todowindow.cpp" line="213"/>
<source>待办</source>
<translation>Todo</translation>
</message>
<message>
<location filename="../src/todo/todowindow.cpp" line="158"/>
<location filename="../src/todo/todowindow.cpp" line="203"/>
<location filename="../src/todo/todowindow.cpp" line="171"/>
<location filename="../src/todo/todowindow.cpp" line="221"/>
<source>%1 项</source>
<translation>%1 items</translation>
</message>
<message>
<location filename="../src/todo/todowindow.cpp" line="208"/>
<location filename="../src/todo/todowindow.cpp" line="226"/>
<source>+</source>
<translation>+</translation>
</message>
<message>
<location filename="../src/todo/todowindow.cpp" line="212"/>
<location filename="../src/todo/todowindow.cpp" line="280"/>
<location filename="../src/todo/todowindow.cpp" line="230"/>
<location filename="../src/todo/todowindow.cpp" line="298"/>
<source>新建待办</source>
<translation>New todo</translation>
</message>
<message>
<location filename="../src/todo/todowindow.cpp" line="220"/>
<location filename="../src/todo/todowindow.cpp" line="238"/>
<source>×</source>
<translation>×</translation>
</message>
<message>
<location filename="../src/todo/todowindow.cpp" line="224"/>
<location filename="../src/todo/todowindow.cpp" line="242"/>
<location filename="../src/todo/todowindow.cpp" line="343"/>
<source>隐藏</source>
<translation>Hide</translation>
</message>
<message>
<location filename="../src/todo/todowindow.cpp" line="285"/>
<location filename="../src/todo/todowindow.cpp" line="303"/>
<source>要做的事…</source>
<translation>Something to do…</translation>
</message>
<message>
<location filename="../src/todo/todowindow.cpp" line="340"/>
<source>置顶</source>
<translation>Always on top</translation>
</message>
</context>
</TS>