Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4291ee286c | ||
|
|
c8a0391abe | ||
|
|
ca088fbaa6 | ||
|
|
630ca6cadc | ||
|
|
3b92202f2d | ||
|
|
4032de318c | ||
|
|
798f5aa7ce | ||
|
|
5fbf7d7931 | ||
|
|
680cd3c7ab | ||
|
|
d949d278cb | ||
|
|
46263e70d7 | ||
|
|
ba7f57c7d5 | ||
|
|
77a4369cd6 | ||
|
|
c7865fcbf0 | ||
|
|
1fa1ff45f6 | ||
|
|
d69326bc6f | ||
|
|
07f215bb34 | ||
|
|
e3c8da1a3f | ||
|
|
150a49af2f | ||
|
|
3a73a8c5d1 |
@@ -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
|
||||
@@ -23,3 +23,16 @@ ui_*.h
|
||||
*.so
|
||||
*.a
|
||||
.DS_Store
|
||||
|
||||
# test helpers
|
||||
tests/portal/pointer
|
||||
|
||||
# debian build
|
||||
obj-*/
|
||||
debian/.debhelper/
|
||||
debian/files
|
||||
debian/debhelper-build-stamp
|
||||
debian/gxde-ai-see/
|
||||
|
||||
# translations build output
|
||||
*.qm
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 更新日志
|
||||
|
||||
## 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
|
||||
|
||||
### 新增
|
||||
|
||||
- 传送门面板:DBlur 双栏、layer-shell 定位、拖拽自动弹出、位置/高度可调并持久化
|
||||
- 暂存区:文件/文字/图片、去重、上限、文件消失清理、固定/消耗语义、全选/反选/删除选中
|
||||
- 暂存区动画:hover 高亮、删除右移淡出、拖动排序
|
||||
- 内容工厂:按类型给出动作,动作按钮可直接接收 drop
|
||||
- http(s) 链接按扩展名分类,支持下载/另存为/复制/Markdown/待办/问小埃
|
||||
- thunder:// 解析真实地址,magnet 解析名称,非标准 scheme 尝试打开
|
||||
- 文字:ICU 分词、提取链接、朗读
|
||||
- 文件/图片:打开、复制路径、保存图片、占位 OCR/描述
|
||||
- 待办窗口:layer-shell、不抢焦点、复选框/删除/新建、持久化
|
||||
- 小埃同学对话:气泡消息/头像/会话列表(标题+预览+时间)/输入区重做;
|
||||
DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、
|
||||
编辑/重试/分支、上下文条数与自动压缩、Markdown、关于
|
||||
- MCP:stdio JSON-RPC,自动 initialize / tools-list,工具以 function calling 暴露给模型并在对话中执行
|
||||
- 合成器 portal(flake-wm / gxde-wlcom):org.gxde.Portal、缩略图坐标映射、
|
||||
原生 drop 到目标窗口
|
||||
- 对话窗口改用 DMainWindow/DTitlebar(DTK),消息气泡、会话列表重做
|
||||
- DeepSeek 模型更新为 deepseek-flash / deepseek-v4-pro,设置里可自动获取 /models
|
||||
- 内容工厂右侧常驻「问问小埃同学」大按钮;面板为工厂预留空间,不再上挤
|
||||
- 英文翻译(95 条)与翻译加载;gxde-k9 开机自启与保活开关;不支持环境只提示一次并保持待命
|
||||
|
||||
-- shenmo <shenmo@spark-app.store>
|
||||
@@ -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
|
||||
)
|
||||
@@ -15,6 +15,7 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network DBus Svg)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(DTK2WIDGET REQUIRED dtk2widget)
|
||||
pkg_check_modules(ICU REQUIRED icu-i18n icu-uc)
|
||||
find_package(LayerShellQt QUIET)
|
||||
|
||||
set(GXDE_AI_SEE_SOURCES
|
||||
@@ -23,7 +24,24 @@ set(GXDE_AI_SEE_SOURCES
|
||||
src/config.cpp
|
||||
src/staging/stagingmodel.cpp
|
||||
src/portal/portalclient.cpp
|
||||
src/factory/linktools.cpp
|
||||
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
|
||||
src/todo/todowindow.cpp
|
||||
src/chat/chatclient.cpp
|
||||
src/chat/chatwindow.cpp
|
||||
src/chat/mcpclient.cpp
|
||||
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})
|
||||
@@ -31,6 +49,7 @@ add_executable(gxde-ai-see ${GXDE_AI_SEE_SOURCES})
|
||||
target_include_directories(gxde-ai-see PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${DTK2WIDGET_INCLUDE_DIRS}
|
||||
${ICU_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(gxde-ai-see PRIVATE
|
||||
@@ -41,6 +60,7 @@ target_link_libraries(gxde-ai-see PRIVATE
|
||||
Qt6::DBus
|
||||
Qt6::Svg
|
||||
${DTK2WIDGET_LIBRARIES}
|
||||
${ICU_LIBRARIES}
|
||||
)
|
||||
|
||||
target_link_directories(gxde-ai-see PRIVATE ${DTK2WIDGET_LIBRARY_DIRS})
|
||||
@@ -50,8 +70,58 @@ if (LayerShellQt_FOUND)
|
||||
target_compile_definitions(gxde-ai-see PRIVATE GXDE_HAVE_LAYERSHELLQT=1)
|
||||
endif ()
|
||||
|
||||
add_executable(gxde-ai-see-selftest
|
||||
tests/selftest.cpp
|
||||
src/factory/linktools.cpp
|
||||
src/factory/contentclassifier.cpp
|
||||
src/factory/factoryactions.cpp
|
||||
src/actions/texttools.cpp
|
||||
)
|
||||
target_include_directories(gxde-ai-see-selftest PRIVATE src ${ICU_INCLUDE_DIRS})
|
||||
target_link_libraries(gxde-ai-see-selftest PRIVATE Qt6::Core Qt6::Gui ${ICU_LIBRARIES})
|
||||
|
||||
add_executable(gxde-ai-see-chattest
|
||||
tests/chattest.cpp
|
||||
src/chat/chatclient.cpp
|
||||
src/chat/mcpclient.cpp
|
||||
src/config.cpp
|
||||
)
|
||||
target_include_directories(gxde-ai-see-chattest PRIVATE src)
|
||||
target_link_libraries(gxde-ai-see-chattest PRIVATE Qt6::Core Qt6::Network)
|
||||
target_compile_definitions(gxde-ai-see-chattest PRIVATE
|
||||
TESTS_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests")
|
||||
|
||||
add_executable(gxde-ai-see-mcptest
|
||||
tests/mcptest.cpp
|
||||
src/chat/mcpclient.cpp
|
||||
src/config.cpp
|
||||
)
|
||||
target_include_directories(gxde-ai-see-mcptest PRIVATE src)
|
||||
target_link_libraries(gxde-ai-see-mcptest PRIVATE Qt6::Core)
|
||||
|
||||
# translations
|
||||
find_program(LRELEASE_EXECUTABLE NAMES lrelease lrelease6)
|
||||
file(GLOB TS_FILES ${CMAKE_CURRENT_SOURCE_DIR}/translations/*.ts)
|
||||
if (LRELEASE_EXECUTABLE AND TS_FILES)
|
||||
set(QM_FILES)
|
||||
foreach(ts ${TS_FILES})
|
||||
get_filename_component(ts_name ${ts} NAME_WE)
|
||||
set(qm ${CMAKE_CURRENT_BINARY_DIR}/translations/${ts_name}.qm)
|
||||
add_custom_command(OUTPUT ${qm}
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/translations
|
||||
COMMAND ${LRELEASE_EXECUTABLE} ${ts} -qm ${qm}
|
||||
DEPENDS ${ts}
|
||||
VERBATIM)
|
||||
list(APPEND QM_FILES ${qm})
|
||||
endforeach()
|
||||
add_custom_target(gxde-ai-see-translations ALL DEPENDS ${QM_FILES})
|
||||
add_dependencies(gxde-ai-see gxde-ai-see-translations)
|
||||
install(FILES ${QM_FILES} DESTINATION share/gxde-ai-see/translations)
|
||||
endif()
|
||||
|
||||
install(TARGETS gxde-ai-see RUNTIME DESTINATION bin)
|
||||
install(FILES data/gxde-ai-see.desktop DESTINATION share/applications)
|
||||
install(DIRECTORY data/icons/ DESTINATION share/icons/hicolor)
|
||||
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)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
它把「拖拽」变成贯穿整个桌面的动作:拖到 **传送门**,选一个窗口,内容就落进那个窗口的输入框;
|
||||
拖到 **内容工厂**,按类型分词、提链接、翻译、OCR、加待办、问小埃;拖进 **暂存区**,东西先放着,随时再拖出去。
|
||||
|
||||
> 当前状态:**方案评审中(v0.2)**,代码尚未开始。设计文档见 [docs/PLAN.md](docs/PLAN.md)。
|
||||
> 当前状态:**0.1.0 初版**,可安装试用。设计文档见 [docs/PLAN.md](docs/PLAN.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- 多对话管理、流式输出、Markdown、代码块。
|
||||
- 编辑消息、重试、从任意消息 fork。
|
||||
- 上下文长度设置 + 手动/自动压缩。
|
||||
- MCP(stdio)工具接入。
|
||||
- MCP(stdio)工具接入:设置里填服务器 JSON,工具自动以 function calling 暴露给模型。
|
||||
- 独立桌面入口;后续作为 GXDE 各项能力的 AI 入口与 skill 平台。
|
||||
|
||||
---
|
||||
@@ -106,20 +106,40 @@
|
||||
|
||||
## 🔨 构建
|
||||
|
||||
尚未开始编码;构建说明会在 M1 完成后补充:
|
||||
依赖:Qt 6.8、dtk2widget 6、LayerShellQt 6、ICU、Wayland。
|
||||
|
||||
```bash
|
||||
cmake -B build -G Ninja
|
||||
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build
|
||||
sudo cmake --install build # 安装程序、desktop 与图标
|
||||
```
|
||||
|
||||
依赖(计划):Qt 6.8、dtk2widget 6、LayerShellQt 6、Wayland、ICU、SQLite。
|
||||
打 deb:
|
||||
|
||||
```bash
|
||||
dpkg-buildpackage -b -us -uc
|
||||
```
|
||||
|
||||
## 🚀 使用
|
||||
|
||||
```bash
|
||||
gxde-ai-see --show # 打开传送门面板
|
||||
gxde-ai-see --chat # 打开小埃同学对话
|
||||
gxde-ai-see --todo # 打开待办
|
||||
gxde-ai-see --daemon # 后台常驻(由 gxde-k9 拉起)
|
||||
```
|
||||
|
||||
- 首次对话:`--chat` → 左下「设置」→ 填 DeepSeek API Key。
|
||||
- 传送门需要 GXDE Wayland 合成器(flake-wm / gxde-wlcom)配合,见
|
||||
[方案 §4](docs/PLAN.md);其他环境自动降级。
|
||||
- 面板右键菜单:固定位置/大小、恢复默认位置、开机自启与保活、清空、关于。
|
||||
|
||||
---
|
||||
|
||||
## 📄 文档
|
||||
|
||||
- [设计方案(v0.2)](docs/PLAN.md)
|
||||
- 翻译:`translations/gxde-ai-see_en.ts`(`lupdate src -ts translations/gxde-ai-see_en.ts` 更新,`lrelease` 生成 .qm)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.0
|
||||
Name=GXDE Todo
|
||||
Name[zh_CN]=GXDE 待办
|
||||
Comment=Floating todo list of GXDE AI See
|
||||
Comment[zh_CN]=小埃同学的待办
|
||||
Exec=gxde-ai-see --todo
|
||||
Icon=gxde-ai-see-todo
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupNotify=false
|
||||
@@ -7,7 +7,7 @@ GenericName=AI Assistant
|
||||
GenericName[zh_CN]=AI 助手
|
||||
Comment=Drag to a window and it lands in the input box
|
||||
Comment[zh_CN]=拖一下,世界就到你手边
|
||||
Exec=gxde-ai-see --show
|
||||
Exec=gxde-ai-see --chat
|
||||
Icon=gxde-ai-see
|
||||
Terminal=false
|
||||
Categories=Utility;Office;
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 560 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 855 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 955 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,27 @@
|
||||
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: 传送门面板、暂存区、内容工厂、待办与小埃同学对话。
|
||||
|
||||
-- shenmo <shenmo@spark-app.store> Thu, 30 Oct 2026 20:20:00 +0800
|
||||
@@ -0,0 +1,26 @@
|
||||
Source: gxde-ai-see
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: shenmo <shenmo@spark-app.store>
|
||||
Build-Depends: debhelper-compat (= 13),
|
||||
cmake,
|
||||
ninja-build,
|
||||
pkg-config,
|
||||
qt6-base-dev,
|
||||
qt6-base-dev-tools,
|
||||
qt6-svg-dev,
|
||||
libdtk6core-dev,
|
||||
libdtk2widget6-dev,
|
||||
liblayershellqtinterface-dev,
|
||||
libicu-dev
|
||||
Standards-Version: 4.6.2
|
||||
Rules-Requires-Root: no
|
||||
Homepage: https://gitee.com/GXDE-OS/gxde-ai-see
|
||||
|
||||
Package: gxde-ai-see
|
||||
Architecture: any
|
||||
Depends: ${shlibs:Depends}, ${misc:Depends}
|
||||
Recommends: gxde-k9
|
||||
Description: GXDE AI See - 小埃同学
|
||||
GXDE 桌面的 AI 助手:传送门、内容工厂、待办与小埃同学对话。
|
||||
支持拖拽暂存、原生 drop 到目标窗口,以及 DeepSeek / 自定义 API 的流式对话。
|
||||
@@ -0,0 +1,9 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: gxde-ai-see
|
||||
Source: https://gitee.com/GXDE-OS/gxde-ai-see
|
||||
|
||||
Files: *
|
||||
Copyright: 2026 GXDE OS Team
|
||||
License: GPL-3.0-or-later
|
||||
On Debian systems, the complete text of the GNU General Public License
|
||||
version 3 can be found in "/usr/share/common-licenses/GPL-3".
|
||||
@@ -0,0 +1 @@
|
||||
dh_auto_configure
|
||||
@@ -0,0 +1,3 @@
|
||||
shlibs:Depends=libc6 (>= 2.34), libdtk2widget6 (>= 6:6.3.8), libdtk6core (>= 6.0.48), libgcc-s1 (>= 3.0), libicu76 (>= 76.1-1~), liblayershellqtinterface6 (>= 6.1.0), libqt6core6t64 (>= 6.8.2), libqt6dbus6 (>= 6.1.2), libqt6gui6 (>= 6.8.2), libqt6network6 (>= 6.1.2), libqt6widgets6 (>= 6.3.0), libstdc++6 (>= 5)
|
||||
misc:Depends=
|
||||
misc:Pre-Depends=
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_configure:
|
||||
dh_auto_configure -- -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
@@ -0,0 +1 @@
|
||||
3.0 (native)
|
||||
@@ -282,11 +282,19 @@ interface org.gxde.Portal
|
||||
- **分词**:ICU `BreakIterator`(zh)先做;效果不够再换内置词典。
|
||||
- **提取链接**:正则抽 `http(s)://`、`www.`、邮箱;弹框列出、单条/全部复制。
|
||||
- **打开**:`QDesktopServices::openUrl`。
|
||||
- **链接分类**(`factory/linktools.*`):
|
||||
- http(s) 按扩展名判定 图片 / 音频 / 视频 / 文档 / 压缩包 / 代码 / 网页;
|
||||
- 动作:浏览器打开、下载、另存为、复制、复制为 Markdown、复制文件名、待办、问小埃;
|
||||
图片额外「预览」,音视频额外「在线播放」;
|
||||
- 非标准 scheme(thunder / spk / magnet / ed2k / 其它):
|
||||
尝试打开、复制、待办、问小埃;thunder 额外解析 base64 得到真实地址(查看/复制),
|
||||
magnet 解析 `dn` 作为显示名。
|
||||
- **翻译 / 生成描述**:走小埃同学的模型(设置里选模型)。
|
||||
- **朗读**:优先 GXDE Daemon TTS,回退 `spd-say`。
|
||||
- **OCR**:优先 GXDE Daemon,未安装则禁用并提示。
|
||||
- **待办**:§8。
|
||||
- **问问小埃**:把内容作为附件塞进当前(或新建)对话的输入框。
|
||||
- **问问小埃**:不直接粘贴,而是**激活小埃同学窗口**,让用户把内容拖过去:
|
||||
左侧列表空白 = 新建对话;左侧某个对话 = 该对话;右侧对话区 = 当前对话。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 需要随库发布后,应用改为链接该控件
|
||||
@@ -0,0 +1,116 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "downloader.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QStandardPaths>
|
||||
|
||||
#include "factory/linktools.h"
|
||||
|
||||
Downloader *Downloader::instance()
|
||||
{
|
||||
static Downloader *self = new Downloader;
|
||||
return self;
|
||||
}
|
||||
|
||||
Downloader::Downloader(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
}
|
||||
|
||||
QString Downloader::suggestedFileName(const QUrl &url)
|
||||
{
|
||||
QString name = LinkTools::displayNameFor(url);
|
||||
name = QFileInfo(name).fileName();
|
||||
if (name.isEmpty()) {
|
||||
name = QStringLiteral("download");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
QString Downloader::uniquePath(const QString &directory, const QString &fileName)
|
||||
{
|
||||
QDir dir(directory);
|
||||
QString path = dir.filePath(fileName);
|
||||
if (!QFileInfo::exists(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const QFileInfo info(path);
|
||||
const QString base = info.completeBaseName();
|
||||
const QString suffix = info.suffix();
|
||||
for (int index = 1; index < 1000; ++index) {
|
||||
const QString candidate =
|
||||
suffix.isEmpty()
|
||||
? dir.filePath(QStringLiteral("%1 (%2)").arg(base).arg(index))
|
||||
: dir.filePath(QStringLiteral("%1 (%2).%3").arg(base).arg(index).arg(suffix));
|
||||
if (!QFileInfo::exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
void Downloader::download(const QUrl &url)
|
||||
{
|
||||
QString directory = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
|
||||
if (directory.isEmpty()) {
|
||||
directory = QDir::homePath();
|
||||
}
|
||||
QDir().mkpath(directory);
|
||||
start(url, uniquePath(directory, suggestedFileName(url)));
|
||||
}
|
||||
|
||||
void Downloader::saveAs(const QUrl &url, QWidget *parent)
|
||||
{
|
||||
QString directory = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
|
||||
if (directory.isEmpty()) {
|
||||
directory = QDir::homePath();
|
||||
}
|
||||
const QString suggested = QDir(directory).filePath(suggestedFileName(url));
|
||||
const QString path = QFileDialog::getSaveFileName(parent, tr("另存为"), suggested);
|
||||
if (path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
start(url, path);
|
||||
}
|
||||
|
||||
void Downloader::start(const QUrl &url, const QString &path)
|
||||
{
|
||||
auto *file = new QFile(path, this);
|
||||
if (!file->open(QIODevice::WriteOnly)) {
|
||||
Q_EMIT message(tr("无法写入:%1").arg(path));
|
||||
file->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkRequest request(url);
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
QNetworkReply *reply = m_manager.get(request);
|
||||
|
||||
Q_EMIT message(tr("开始下载:%1").arg(QFileInfo(path).fileName()));
|
||||
|
||||
connect(reply, &QNetworkReply::readyRead, file, [reply, file]() {
|
||||
file->write(reply->readAll());
|
||||
});
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, file, path]() {
|
||||
file->write(reply->readAll());
|
||||
file->close();
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
Q_EMIT message(tr("下载失败:%1").arg(reply->errorString()));
|
||||
QFile::remove(path);
|
||||
} else {
|
||||
Q_EMIT message(tr("下载完成:%1").arg(path));
|
||||
}
|
||||
file->deleteLater();
|
||||
reply->deleteLater();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QUrl>
|
||||
|
||||
class QWidget;
|
||||
|
||||
/**
|
||||
* Small async downloader for links dropped on the content factory.
|
||||
*
|
||||
* Emits \a message with a human readable status; the panel shows it as a
|
||||
* toast. Downloads go to the XDG download directory unless "save as" is used.
|
||||
*/
|
||||
class Downloader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static Downloader *instance();
|
||||
|
||||
void download(const QUrl &url);
|
||||
void saveAs(const QUrl &url, QWidget *parent);
|
||||
|
||||
signals:
|
||||
void message(const QString &text);
|
||||
|
||||
private:
|
||||
explicit Downloader(QObject *parent = nullptr);
|
||||
|
||||
void start(const QUrl &url, const QString &path);
|
||||
static QString suggestedFileName(const QUrl &url);
|
||||
static QString uniquePath(const QString &directory, const QString &fileName);
|
||||
|
||||
QNetworkAccessManager m_manager;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "texttools.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
|
||||
#include <unicode/ubrk.h>
|
||||
#include <unicode/utypes.h>
|
||||
|
||||
namespace TextTools {
|
||||
|
||||
QStringList segment(const QString &text)
|
||||
{
|
||||
QStringList words;
|
||||
if (text.isEmpty()) {
|
||||
return words;
|
||||
}
|
||||
|
||||
UErrorCode status = U_ZERO_ERROR;
|
||||
UBreakIterator *iterator =
|
||||
ubrk_open(UBRK_WORD, "zh", reinterpret_cast<const UChar *>(text.utf16()), text.size(),
|
||||
&status);
|
||||
if (U_FAILURE(status) || !iterator) {
|
||||
return words;
|
||||
}
|
||||
|
||||
int32_t start = ubrk_first(iterator);
|
||||
for (int32_t end = ubrk_next(iterator); end != UBRK_DONE; start = end, end = ubrk_next(iterator)) {
|
||||
if (ubrk_getRuleStatus(iterator) == UBRK_WORD_NONE) {
|
||||
continue;
|
||||
}
|
||||
const QString word = text.mid(start, end - start).trimmed();
|
||||
if (!word.isEmpty()) {
|
||||
words.append(word);
|
||||
}
|
||||
}
|
||||
|
||||
ubrk_close(iterator);
|
||||
return words;
|
||||
}
|
||||
|
||||
QStringList extractLinks(const QString &text)
|
||||
{
|
||||
static const QRegularExpression pattern(
|
||||
QStringLiteral("(https?://[^\\s<>\"'\\x{3000}\\x{FF08}\\x{FF09}\\x{3010}\\x{3011}]+"
|
||||
"|www\\.[^\\s<>\"'\\x{3000}\\x{FF08}\\x{FF09}\\x{3010}\\x{3011}]+"
|
||||
"|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
|
||||
QStringList links;
|
||||
QSet<QString> seen;
|
||||
auto it = pattern.globalMatch(text);
|
||||
while (it.hasNext()) {
|
||||
QString link = it.next().captured(1);
|
||||
while (link.endsWith(QLatin1Char('.')) || link.endsWith(QLatin1Char(',')) ||
|
||||
link.endsWith(QLatin1Char(')'))) {
|
||||
link.chop(1);
|
||||
}
|
||||
if (!link.isEmpty() && !seen.contains(link)) {
|
||||
seen.insert(link);
|
||||
links.append(link);
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
QString readTextFile(const QString &path, qint64 maxBytes)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return QString();
|
||||
}
|
||||
const QByteArray data = file.read(maxBytes);
|
||||
return QString::fromUtf8(data);
|
||||
}
|
||||
|
||||
} // namespace TextTools
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace TextTools {
|
||||
|
||||
/** Chinese word segmentation (ICU word break, zh locale). */
|
||||
QStringList segment(const QString &text);
|
||||
|
||||
/** Extract http(s) links, www links and e-mail addresses. */
|
||||
QStringList extractLinks(const QString &text);
|
||||
|
||||
/** Read a text file with a size cap; returns an empty string on failure. */
|
||||
QString readTextFile(const QString &path, qint64 maxBytes = 4 * 1024 * 1024);
|
||||
|
||||
} // namespace TextTools
|
||||
@@ -3,11 +3,16 @@
|
||||
|
||||
#include "app.h"
|
||||
|
||||
#include "chat/chatwindow.h"
|
||||
#include "config.h"
|
||||
#include "portal/portalclient.h"
|
||||
#include "staging/stagingmodel.h"
|
||||
#include "todo/todomodel.h"
|
||||
#include "todo/todowindow.h"
|
||||
#include "ui/portalpanel.h"
|
||||
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusMessage>
|
||||
#include <QLocalServer>
|
||||
#include <QTimer>
|
||||
#include <QLocalSocket>
|
||||
@@ -19,7 +24,7 @@ App::App(QObject *parent)
|
||||
{
|
||||
}
|
||||
|
||||
bool App::init(bool daemon, bool forceShow)
|
||||
bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
|
||||
{
|
||||
Config::instance()->init();
|
||||
m_daemon = daemon;
|
||||
@@ -29,7 +34,7 @@ bool App::init(bool daemon, bool forceShow)
|
||||
QLocalSocket probe;
|
||||
probe.connectToServer(serverName);
|
||||
if (probe.waitForConnected(200)) {
|
||||
probe.write("show");
|
||||
probe.write(showChat ? "chat" : (showTodo ? "todo" : "show"));
|
||||
probe.waitForBytesWritten(200);
|
||||
probe.disconnectFromServer();
|
||||
return false;
|
||||
@@ -41,8 +46,13 @@ bool App::init(bool daemon, bool forceShow)
|
||||
connect(m_server, &QLocalServer::newConnection, this, [this]() {
|
||||
while (QLocalSocket *socket = m_server->nextPendingConnection()) {
|
||||
connect(socket, &QLocalSocket::readyRead, this, [this, socket]() {
|
||||
if (socket->readAll().contains("show")) {
|
||||
showPanel();
|
||||
const QByteArray data = socket->readAll();
|
||||
if (data.contains("chat")) {
|
||||
showChatWindow();
|
||||
} else if (data.contains("todo")) {
|
||||
showTodoWindow();
|
||||
} else if (data.contains("show")) {
|
||||
togglePanel();
|
||||
}
|
||||
});
|
||||
connect(socket, &QLocalSocket::disconnected, socket,
|
||||
@@ -52,36 +62,95 @@ bool App::init(bool daemon, bool forceShow)
|
||||
|
||||
m_model = new StagingModel(this);
|
||||
|
||||
/* gxde-k9 keeps restarting us; when the compositor has no portal support we
|
||||
* warn once and then stay idle instead of exiting (which would make k9
|
||||
* relaunch us every few seconds). */
|
||||
if (daemon && !PortalClient::instance()->available() &&
|
||||
!Config::instance()->getBool(QStringLiteral("warned/unsupportedEnv"), false)) {
|
||||
QDBusMessage notification = QDBusMessage::createMethodCall(
|
||||
QStringLiteral("org.freedesktop.Notifications"),
|
||||
QStringLiteral("/org/freedesktop/Notifications"),
|
||||
QStringLiteral("org.freedesktop.Notifications"), QStringLiteral("Notify"));
|
||||
notification << QStringLiteral("gxde-ai-see") << 0u
|
||||
<< QStringLiteral("gxde-ai-see")
|
||||
<< tr("小埃同学")
|
||||
<< tr("当前桌面不支持传送门(需要 GXDE Wayland 合成器)。"
|
||||
"小埃同学会继续在后台待命,暂存区、内容工厂和对话仍然可用。")
|
||||
<< QStringList() << QVariantMap() << 6000;
|
||||
QDBusConnection::sessionBus().send(notification);
|
||||
Config::instance()->set(QStringLiteral("warned/unsupportedEnv"), true);
|
||||
Config::instance()->sync();
|
||||
}
|
||||
|
||||
PortalClient *portal = PortalClient::instance();
|
||||
connect(portal, &PortalClient::dragStarted, this, [this](const QString &mimeTypes) {
|
||||
if (!PortalClient::mimeInteresting(mimeTypes)) {
|
||||
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();
|
||||
}
|
||||
if (showTodo) {
|
||||
showTodoWindow();
|
||||
}
|
||||
if (!daemon || forceShow || !m_model->isEmpty()) {
|
||||
showPanel();
|
||||
m_panelPinned = forceShow;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -94,9 +163,19 @@ void App::ensurePanel()
|
||||
}
|
||||
|
||||
m_panel = new PortalPanel(m_model);
|
||||
connect(m_panel, &PortalPanel::openChatRequested, this, []() {
|
||||
// TODO(M4): open the AI See chat window.
|
||||
connect(m_panel, &PortalPanel::openChatRequested, this, &App::showChatWindow);
|
||||
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()
|
||||
@@ -104,3 +183,39 @@ void App::showPanel()
|
||||
ensurePanel();
|
||||
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) {
|
||||
m_chatWindow = new ChatWindow;
|
||||
}
|
||||
m_chatWindow->activateForAsk();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
|
||||
#include <QObject>
|
||||
|
||||
class ChatWindow;
|
||||
class PortalPanel;
|
||||
class QLocalServer;
|
||||
class StagingModel;
|
||||
class TodoWindow;
|
||||
|
||||
/**
|
||||
* Application controller: owns the staging model, the panel and the single
|
||||
@@ -25,15 +27,21 @@ public:
|
||||
* @param forceShow always show the panel on startup
|
||||
* @return false when another instance is already running
|
||||
*/
|
||||
bool init(bool daemon, bool forceShow);
|
||||
bool init(bool daemon, bool forceShow, bool showTodo = false, bool showChat = false);
|
||||
|
||||
private:
|
||||
void ensurePanel();
|
||||
void showPanel();
|
||||
void togglePanel();
|
||||
void showTodoWindow();
|
||||
void showChatWindow();
|
||||
|
||||
StagingModel *m_model = nullptr;
|
||||
PortalPanel *m_panel = nullptr;
|
||||
TodoWindow *m_todoWindow = nullptr;
|
||||
ChatWindow *m_chatWindow = nullptr;
|
||||
QLocalServer *m_server = nullptr;
|
||||
bool m_daemon = false;
|
||||
bool m_dragging = false;
|
||||
bool m_panelPinned = false;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "chatclient.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QUrl>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool prepareRequest(const QVector<ChatMessage> &messages, bool stream,
|
||||
const QJsonArray &tools, const QJsonArray &extraMessages,
|
||||
QNetworkRequest *request, QByteArray *body, QString *error)
|
||||
{
|
||||
const QString apiKey =
|
||||
Config::instance()->get(QStringLiteral("chat/apiKey")).toString().trimmed();
|
||||
if (apiKey.isEmpty()) {
|
||||
*error = QObject::tr("还没有配置 API Key。请先点右上角设置,填入 DeepSeek API Key。");
|
||||
return false;
|
||||
}
|
||||
|
||||
QString baseUrl = Config::instance()
|
||||
->get(QStringLiteral("chat/baseUrl"),
|
||||
QStringLiteral("https://api.deepseek.com"))
|
||||
.toString()
|
||||
.trimmed();
|
||||
while (baseUrl.endsWith(QLatin1Char('/'))) {
|
||||
baseUrl.chop(1);
|
||||
}
|
||||
if (!baseUrl.endsWith(QStringLiteral("/chat/completions"))) {
|
||||
baseUrl += QStringLiteral("/chat/completions");
|
||||
}
|
||||
|
||||
QJsonArray jsonMessages;
|
||||
for (const ChatMessage &message : messages) {
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("role"), message.role);
|
||||
object.insert(QStringLiteral("content"), message.content);
|
||||
jsonMessages.append(object);
|
||||
}
|
||||
|
||||
QJsonObject payload;
|
||||
payload.insert(QStringLiteral("model"),
|
||||
Config::instance()
|
||||
->get(QStringLiteral("chat/model"), QStringLiteral("deepseek-flash"))
|
||||
.toString());
|
||||
QJsonArray allMessages = jsonMessages;
|
||||
for (const QJsonValue &extra : extraMessages) {
|
||||
allMessages.append(extra);
|
||||
}
|
||||
payload.insert(QStringLiteral("messages"), allMessages);
|
||||
payload.insert(QStringLiteral("stream"), stream);
|
||||
if (!tools.isEmpty()) {
|
||||
payload.insert(QStringLiteral("tools"), tools);
|
||||
payload.insert(QStringLiteral("tool_choice"), QStringLiteral("auto"));
|
||||
}
|
||||
payload.insert(QStringLiteral("temperature"), 0.7);
|
||||
|
||||
*request = QNetworkRequest{QUrl(baseUrl)};
|
||||
request->setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
request->setRawHeader("Authorization", "Bearer " + apiKey.toUtf8());
|
||||
request->setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
*body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
|
||||
return true;
|
||||
}
|
||||
|
||||
QString errorFromBody(const QByteArray &body, const QString &fallback)
|
||||
{
|
||||
const QJsonDocument document = QJsonDocument::fromJson(body);
|
||||
if (document.isObject()) {
|
||||
const QString detail = document.object()
|
||||
.value(QStringLiteral("error"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("message"))
|
||||
.toString();
|
||||
if (!detail.isEmpty()) {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatClient::ChatClient(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
}
|
||||
|
||||
void ChatClient::setTools(const QJsonArray &tools)
|
||||
{
|
||||
m_tools = tools;
|
||||
}
|
||||
|
||||
void ChatClient::send(const QVector<ChatMessage> &messages, const QJsonArray &extraMessages)
|
||||
{
|
||||
cancel();
|
||||
m_buffer.clear();
|
||||
m_fullText.clear();
|
||||
m_toolCalls.clear();
|
||||
m_done = false;
|
||||
|
||||
QNetworkRequest request;
|
||||
QByteArray body;
|
||||
QString error;
|
||||
if (!prepareRequest(messages, true, m_tools, extraMessages, &request, &body, &error)) {
|
||||
Q_EMIT failed(error);
|
||||
return;
|
||||
}
|
||||
|
||||
m_reply = m_manager.post(request, body);
|
||||
connect(m_reply, &QNetworkReply::readyRead, this, &ChatClient::handleReadyRead);
|
||||
connect(m_reply, &QNetworkReply::finished, this, &ChatClient::handleFinished);
|
||||
}
|
||||
|
||||
void ChatClient::sendOnce(const QVector<ChatMessage> &messages)
|
||||
{
|
||||
if (m_onceReply) {
|
||||
m_onceReply->abort();
|
||||
m_onceReply->deleteLater();
|
||||
m_onceReply = nullptr;
|
||||
}
|
||||
|
||||
QNetworkRequest request;
|
||||
QByteArray body;
|
||||
QString error;
|
||||
if (!prepareRequest(messages, false, QJsonArray(), QJsonArray(), &request, &body, &error)) {
|
||||
Q_EMIT onceFailed(error);
|
||||
return;
|
||||
}
|
||||
|
||||
m_onceReply = m_manager.post(request, body);
|
||||
connect(m_onceReply, &QNetworkReply::finished, this, [this]() {
|
||||
if (!m_onceReply) {
|
||||
return;
|
||||
}
|
||||
QNetworkReply *reply = m_onceReply;
|
||||
m_onceReply = nullptr;
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
Q_EMIT onceFailed(errorFromBody(reply->readAll(), reply->errorString()));
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonDocument document = QJsonDocument::fromJson(reply->readAll());
|
||||
const QJsonArray choices =
|
||||
document.object().value(QStringLiteral("choices")).toArray();
|
||||
if (choices.isEmpty()) {
|
||||
Q_EMIT onceFailed(tr("模型没有返回内容"));
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
const QString content = choices.first()
|
||||
.toObject()
|
||||
.value(QStringLiteral("message"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("content"))
|
||||
.toString();
|
||||
Q_EMIT onceFinished(content);
|
||||
reply->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void ChatClient::cancel()
|
||||
{
|
||||
if (m_reply) {
|
||||
QNetworkReply *reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
reply->disconnect(this);
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
}
|
||||
if (m_onceReply) {
|
||||
QNetworkReply *reply = m_onceReply;
|
||||
m_onceReply = nullptr;
|
||||
reply->disconnect(this);
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
}
|
||||
}
|
||||
|
||||
void ChatClient::handleReadyRead()
|
||||
{
|
||||
if (!m_reply) {
|
||||
return;
|
||||
}
|
||||
m_buffer.append(m_reply->readAll());
|
||||
|
||||
int newline;
|
||||
while ((newline = m_buffer.indexOf('\n')) >= 0) {
|
||||
QByteArray line = m_buffer.left(newline);
|
||||
m_buffer.remove(0, newline + 1);
|
||||
if (line.endsWith('\r')) {
|
||||
line.chop(1);
|
||||
}
|
||||
if (!line.startsWith("data:")) {
|
||||
continue;
|
||||
}
|
||||
QByteArray payload = line.mid(5).trimmed();
|
||||
if (payload == "[DONE]") {
|
||||
m_done = true;
|
||||
continue;
|
||||
}
|
||||
handlePayload(payload);
|
||||
}
|
||||
}
|
||||
|
||||
void ChatClient::handlePayload(const QByteArray &payload)
|
||||
{
|
||||
const QJsonDocument document = QJsonDocument::fromJson(payload);
|
||||
if (!document.isObject()) {
|
||||
return;
|
||||
}
|
||||
const QJsonObject object = document.object();
|
||||
|
||||
if (object.contains(QStringLiteral("error"))) {
|
||||
const QJsonObject error = object.value(QStringLiteral("error")).toObject();
|
||||
Q_EMIT failed(error.value(QStringLiteral("message")).toString(tr("请求失败")));
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray choices = object.value(QStringLiteral("choices")).toArray();
|
||||
if (choices.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
const QJsonObject deltaObject =
|
||||
choices.first().toObject().value(QStringLiteral("delta")).toObject();
|
||||
const QString content = deltaObject.value(QStringLiteral("content")).toString();
|
||||
if (!content.isEmpty()) {
|
||||
m_fullText += content;
|
||||
Q_EMIT delta(content);
|
||||
}
|
||||
|
||||
const QJsonArray toolCalls = deltaObject.value(QStringLiteral("tool_calls")).toArray();
|
||||
for (const QJsonValue &value : toolCalls) {
|
||||
const QJsonObject object = value.toObject();
|
||||
const int index = object.value(QStringLiteral("index")).toInt();
|
||||
while (m_toolCalls.size() <= index) {
|
||||
m_toolCalls.append(ToolCall{});
|
||||
}
|
||||
ToolCall &call = m_toolCalls[index];
|
||||
if (object.contains(QStringLiteral("id"))) {
|
||||
call.id = object.value(QStringLiteral("id")).toString();
|
||||
}
|
||||
const QJsonObject function = object.value(QStringLiteral("function")).toObject();
|
||||
if (function.contains(QStringLiteral("name"))) {
|
||||
call.name += function.value(QStringLiteral("name")).toString();
|
||||
}
|
||||
if (function.contains(QStringLiteral("arguments"))) {
|
||||
call.arguments += function.value(QStringLiteral("arguments")).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChatClient::handleFinished()
|
||||
{
|
||||
if (!m_reply) {
|
||||
return;
|
||||
}
|
||||
QNetworkReply *reply = m_reply;
|
||||
m_reply = nullptr;
|
||||
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (reply->error() != QNetworkReply::NoError &&
|
||||
reply->error() != QNetworkReply::OperationCanceledError) {
|
||||
QString message = errorFromBody(reply->readAll(), reply->errorString());
|
||||
if (status == 401) {
|
||||
message = tr("API Key 无效或已过期");
|
||||
}
|
||||
Q_EMIT failed(message);
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_buffer.isEmpty()) {
|
||||
QByteArray line = m_buffer.trimmed();
|
||||
if (line.startsWith("data:")) {
|
||||
handlePayload(line.mid(5).trimmed());
|
||||
}
|
||||
m_buffer.clear();
|
||||
}
|
||||
|
||||
if (!m_toolCalls.isEmpty()) {
|
||||
const QVector<ToolCall> calls = m_toolCalls;
|
||||
m_toolCalls.clear();
|
||||
Q_EMIT toolCallsReady(calls);
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
Q_EMIT finished(m_fullText);
|
||||
reply->deleteLater();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
struct ChatMessage
|
||||
{
|
||||
QString role; // system / user / assistant
|
||||
QString content;
|
||||
};
|
||||
|
||||
struct ToolCall
|
||||
{
|
||||
QString id;
|
||||
QString name;
|
||||
QString arguments;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal client for the DeepSeek (OpenAI compatible) chat API.
|
||||
*
|
||||
* \a send streams (SSE), \a sendOnce performs a non streaming request used for
|
||||
* context compression and other housekeeping calls.
|
||||
*/
|
||||
class ChatClient : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatClient(QObject *parent = nullptr);
|
||||
|
||||
bool busy() const { return m_reply != nullptr; }
|
||||
void setTools(const QJsonArray &tools);
|
||||
void send(const QVector<ChatMessage> &messages, const QJsonArray &extraMessages = QJsonArray());
|
||||
void sendOnce(const QVector<ChatMessage> &messages);
|
||||
void cancel();
|
||||
|
||||
signals:
|
||||
void delta(const QString &text);
|
||||
void finished(const QString &text);
|
||||
void failed(const QString &error);
|
||||
void onceFinished(const QString &text);
|
||||
void onceFailed(const QString &error);
|
||||
void toolCallsReady(const QVector<ToolCall> &calls);
|
||||
|
||||
private:
|
||||
void handleReadyRead();
|
||||
void handleFinished();
|
||||
void handlePayload(const QByteArray &payload);
|
||||
|
||||
QNetworkAccessManager m_manager;
|
||||
class QNetworkReply *m_reply = nullptr;
|
||||
class QNetworkReply *m_onceReply = nullptr;
|
||||
QByteArray m_buffer;
|
||||
QJsonArray m_tools;
|
||||
QVector<ToolCall> m_toolCalls;
|
||||
QString m_fullText;
|
||||
bool m_done = false;
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "chatview.h"
|
||||
|
||||
#include <QMimeData>
|
||||
|
||||
#include "markdown.h"
|
||||
|
||||
#include <QDragEnterEvent>
|
||||
#include <QDropEvent>
|
||||
#include <QHBoxLayout>
|
||||
#include <QScrollBar>
|
||||
#include <QLabel>
|
||||
#include <QTextBrowser>
|
||||
#include <QTextDocument>
|
||||
#include <QAbstractTextDocumentLayout>
|
||||
#include <QMimeData>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kAvatarSize = 30;
|
||||
constexpr int kBubbleMaxWidth = 580;
|
||||
|
||||
QString avatarStyle(bool user)
|
||||
{
|
||||
return user ? QStringLiteral("QLabel { background: rgba(74,144,255,0.85); color: white;"
|
||||
" border-radius: %1px; font-size: 12px; }")
|
||||
.arg(kAvatarSize / 2)
|
||||
: QStringLiteral("QLabel { background: rgba(255,255,255,0.14); color: #eaeaea;"
|
||||
" border-radius: %1px; font-size: 12px; }")
|
||||
.arg(kAvatarSize / 2);
|
||||
}
|
||||
|
||||
QToolButton *actionButton(const QString &text, QWidget *parent)
|
||||
{
|
||||
auto *button = new QToolButton(parent);
|
||||
button->setText(text);
|
||||
button->setCursor(Qt::PointingHandCursor);
|
||||
button->setFocusPolicy(Qt::NoFocus);
|
||||
button->setAutoRaise(true);
|
||||
button->setStyleSheet(QStringLiteral(
|
||||
"QToolButton { color: rgba(160,200,240,0.75); background: transparent; border: none;"
|
||||
" font-size: 11px; padding: 2px 4px; }"
|
||||
"QToolButton:hover { color: #cfe5ff; }"));
|
||||
return button;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/** QTextBrowser that grows to fit its document. */
|
||||
class BubbleText : public QTextBrowser
|
||||
{
|
||||
public:
|
||||
explicit BubbleText(QWidget *parent = nullptr)
|
||||
: QTextBrowser(parent)
|
||||
{
|
||||
setFrameShape(QFrame::NoFrame);
|
||||
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
setOpenExternalLinks(false);
|
||||
setMinimumWidth(160);
|
||||
setMaximumWidth(kBubbleMaxWidth);
|
||||
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
|
||||
setStyleSheet(QStringLiteral("QTextBrowser { background: transparent; border: none; }"));
|
||||
document()->setDocumentMargin(0);
|
||||
document()->setDefaultStyleSheet(QStringLiteral(
|
||||
"body { color: #ececec; }"
|
||||
"pre { background: #2b2b31; }"
|
||||
"code { background: #2b2b31; }"
|
||||
"a { color: #7aa7d9; }"
|
||||
"h1, h2, h3 { margin: 4px 0; }"
|
||||
".li { margin-left: 10px; }"
|
||||
".gap { height: 6px; }"));
|
||||
connect(document()->documentLayout(), &QAbstractTextDocumentLayout::documentSizeChanged,
|
||||
this, [this]() { fitHeight(); });
|
||||
}
|
||||
|
||||
void setContent(const QString &html)
|
||||
{
|
||||
setHtml(html);
|
||||
fitHeight();
|
||||
}
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent *event) override
|
||||
{
|
||||
QTextBrowser::resizeEvent(event);
|
||||
fitHeight();
|
||||
}
|
||||
|
||||
private:
|
||||
void fitHeight()
|
||||
{
|
||||
const int wanted = int(document()->size().height()) + 4;
|
||||
if (wanted > 0 && height() != wanted) {
|
||||
setFixedHeight(wanted);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class MessageRow : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MessageRow(const ChatMessage &message, int index, bool isLast, QWidget *parent = nullptr)
|
||||
: QWidget(parent)
|
||||
, m_message(message)
|
||||
, m_index(index)
|
||||
, m_isUser(message.role == QStringLiteral("user"))
|
||||
, m_isLast(isLast)
|
||||
{
|
||||
auto *row = new QHBoxLayout(this);
|
||||
row->setContentsMargins(4, 2, 4, 2);
|
||||
row->setSpacing(8);
|
||||
|
||||
auto *avatar = new QLabel(m_isUser ? tr("你") : tr("埃"), this);
|
||||
avatar->setFixedSize(kAvatarSize, kAvatarSize);
|
||||
avatar->setAlignment(Qt::AlignCenter);
|
||||
avatar->setStyleSheet(avatarStyle(m_isUser));
|
||||
|
||||
auto *column = new QVBoxLayout;
|
||||
column->setContentsMargins(0, 0, 0, 0);
|
||||
column->setSpacing(3);
|
||||
|
||||
auto *meta = new QLabel(m_isUser ? tr("你") : tr("小埃"), this);
|
||||
meta->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.4); font-size: 10px;"));
|
||||
meta->setAlignment(m_isUser ? Qt::AlignRight : Qt::AlignLeft);
|
||||
column->addWidget(meta);
|
||||
|
||||
auto *bubble = new QFrame(this);
|
||||
bubble->setMaximumWidth(kBubbleMaxWidth + 26);
|
||||
bubble->setStyleSheet(
|
||||
m_isUser ? QStringLiteral("QFrame { background: rgba(74,144,255,0.20);"
|
||||
" border-radius: 12px; }")
|
||||
: QStringLiteral("QFrame { background: rgba(255,255,255,0.065);"
|
||||
" border-radius: 12px; }"));
|
||||
auto *bubbleLayout = new QVBoxLayout(bubble);
|
||||
bubbleLayout->setContentsMargins(13, 9, 13, 9);
|
||||
bubbleLayout->setSpacing(0);
|
||||
|
||||
if (m_isUser) {
|
||||
auto *label = new QLabel(bubble);
|
||||
label->setTextFormat(Qt::RichText);
|
||||
label->setWordWrap(true);
|
||||
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
label->setMaximumWidth(kBubbleMaxWidth);
|
||||
label->setStyleSheet(QStringLiteral("QLabel { color: #f2f2f2; font-size: 13px; }"));
|
||||
label->setText(escapedHtml(message.content));
|
||||
m_label = label;
|
||||
bubbleLayout->addWidget(label);
|
||||
} else {
|
||||
m_body = new BubbleText(bubble);
|
||||
m_body->setContent(markdownToHtml(message.content));
|
||||
connect(m_body, &QTextBrowser::anchorClicked, this,
|
||||
[this](const QUrl &url) { Q_EMIT linkActivated(url); });
|
||||
bubbleLayout->addWidget(m_body);
|
||||
}
|
||||
column->addWidget(bubble);
|
||||
|
||||
m_actions = new QWidget(this);
|
||||
auto *actions = new QHBoxLayout(m_actions);
|
||||
actions->setContentsMargins(2, 0, 2, 0);
|
||||
actions->setSpacing(6);
|
||||
|
||||
auto *copy = actionButton(tr("复制"), m_actions);
|
||||
connect(copy, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT copyRequested(m_index); });
|
||||
actions->addWidget(copy);
|
||||
|
||||
if (m_isUser) {
|
||||
auto *edit = actionButton(tr("编辑"), m_actions);
|
||||
connect(edit, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT editRequested(m_index); });
|
||||
actions->addWidget(edit);
|
||||
} else {
|
||||
auto *fork = actionButton(tr("分支"), m_actions);
|
||||
connect(fork, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT forkRequested(m_index); });
|
||||
actions->addWidget(fork);
|
||||
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();
|
||||
column->addWidget(m_actions);
|
||||
|
||||
if (m_isUser) {
|
||||
row->addStretch(1);
|
||||
row->addLayout(column);
|
||||
row->addWidget(avatar, 0, Qt::AlignTop);
|
||||
} else {
|
||||
row->addWidget(avatar, 0, Qt::AlignTop);
|
||||
row->addLayout(column);
|
||||
row->addStretch(1);
|
||||
}
|
||||
}
|
||||
|
||||
int rowIndex() const { return m_index; }
|
||||
|
||||
void setContent(const QString &text)
|
||||
{
|
||||
m_message.content = text;
|
||||
if (m_body) {
|
||||
m_body->setContent(markdownToHtml(text));
|
||||
} else if (m_label) {
|
||||
m_label->setText(escapedHtml(text));
|
||||
}
|
||||
}
|
||||
|
||||
signals:
|
||||
void copyRequested(int index);
|
||||
void editRequested(int index);
|
||||
void forkRequested(int index);
|
||||
void retryRequested(int index);
|
||||
void linkActivated(const QUrl &url);
|
||||
|
||||
protected:
|
||||
|
||||
private:
|
||||
ChatMessage m_message;
|
||||
int m_index = -1;
|
||||
bool m_isUser = false;
|
||||
bool m_isLast = false;
|
||||
QWidget *m_actions = nullptr;
|
||||
BubbleText *m_body = nullptr;
|
||||
QLabel *m_label = nullptr;
|
||||
};
|
||||
|
||||
ChatMessageList::ChatMessageList(QWidget *parent)
|
||||
: QScrollArea(parent)
|
||||
{
|
||||
setAcceptDrops(true);
|
||||
setWidgetResizable(true);
|
||||
setFrameShape(QFrame::NoFrame);
|
||||
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
setStyleSheet(QStringLiteral("QScrollArea { background: transparent; border: none; }"));
|
||||
viewport()->setStyleSheet(QStringLiteral("background: transparent;"));
|
||||
|
||||
m_container = new QWidget;
|
||||
m_container->setStyleSheet(QStringLiteral("background: transparent;"));
|
||||
m_layout = new QVBoxLayout(m_container);
|
||||
m_layout->setContentsMargins(8, 10, 8, 10);
|
||||
m_layout->setSpacing(6);
|
||||
m_layout->addStretch(1);
|
||||
setWidget(m_container);
|
||||
|
||||
m_welcome = new QWidget(m_container);
|
||||
auto *welcomeLayout = new QVBoxLayout(m_welcome);
|
||||
welcomeLayout->setContentsMargins(24, 40, 24, 24);
|
||||
welcomeLayout->setSpacing(10);
|
||||
welcomeLayout->setAlignment(Qt::AlignHCenter | Qt::AlignTop);
|
||||
|
||||
auto *bigAvatar = new QLabel(tr("埃"), m_welcome);
|
||||
bigAvatar->setFixedSize(64, 64);
|
||||
bigAvatar->setAlignment(Qt::AlignCenter);
|
||||
bigAvatar->setStyleSheet(QStringLiteral(
|
||||
"QLabel { background: rgba(74,144,255,0.22); color: #cfe5ff; border-radius: 32px;"
|
||||
" font-size: 26px; }"));
|
||||
welcomeLayout->addWidget(bigAvatar, 0, Qt::AlignHCenter);
|
||||
|
||||
auto *title = new QLabel(tr("你好,我是小埃同学"), m_welcome);
|
||||
QFont titleFont = title->font();
|
||||
titleFont.setPixelSize(20);
|
||||
titleFont.setWeight(QFont::DemiBold);
|
||||
title->setFont(titleFont);
|
||||
title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.9);"));
|
||||
welcomeLayout->addWidget(title, 0, Qt::AlignHCenter);
|
||||
|
||||
auto *hint = new QLabel(
|
||||
tr("把文件、链接或文字拖进窗口,或者直接输入问题。\n"
|
||||
"第一次使用请在左下角「设置」里填入 DeepSeek API Key。"),
|
||||
m_welcome);
|
||||
hint->setAlignment(Qt::AlignCenter);
|
||||
hint->setWordWrap(true);
|
||||
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.5); font-size: 12px;"));
|
||||
welcomeLayout->addWidget(hint, 0, Qt::AlignHCenter);
|
||||
|
||||
m_layout->insertWidget(0, m_welcome);
|
||||
}
|
||||
|
||||
void ChatMessageList::setMessages(const QVector<ChatMessage> &messages)
|
||||
{
|
||||
m_messages = messages;
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void ChatMessageList::rebuild()
|
||||
{
|
||||
for (MessageRow *row : m_rows) {
|
||||
m_layout->removeWidget(row);
|
||||
row->deleteLater();
|
||||
}
|
||||
m_rows.clear();
|
||||
|
||||
bool hasConversation = false;
|
||||
for (const ChatMessage &message : m_messages) {
|
||||
if (message.role == QStringLiteral("system")) {
|
||||
continue;
|
||||
}
|
||||
hasConversation = true;
|
||||
}
|
||||
m_welcome->setVisible(!hasConversation);
|
||||
|
||||
const int lastAssistant = [this]() {
|
||||
int last = -1;
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
if (m_messages.at(i).role == QStringLiteral("assistant")) {
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}();
|
||||
|
||||
for (int i = 0; i < m_messages.size(); ++i) {
|
||||
const ChatMessage &message = m_messages.at(i);
|
||||
if (message.role == QStringLiteral("system")) {
|
||||
if (!message.content.isEmpty()) {
|
||||
auto *note = new QLabel(message.content, m_container);
|
||||
note->setWordWrap(true);
|
||||
note->setAlignment(Qt::AlignCenter);
|
||||
note->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: rgba(255,255,255,0.42); font-size: 11px;"
|
||||
" background: rgba(255,255,255,0.04); border-radius: 8px;"
|
||||
" padding: 6px 10px; }"));
|
||||
m_layout->insertWidget(m_layout->count() - 1, note);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *row = new MessageRow(message, i, i == lastAssistant, m_container);
|
||||
connect(row, &MessageRow::copyRequested, this, &ChatMessageList::copyRequested);
|
||||
connect(row, &MessageRow::editRequested, this, &ChatMessageList::editRequested);
|
||||
connect(row, &MessageRow::forkRequested, this, &ChatMessageList::forkRequested);
|
||||
connect(row, &MessageRow::retryRequested, this, &ChatMessageList::retryRequested);
|
||||
connect(row, &MessageRow::linkActivated, this, &ChatMessageList::linkActivated);
|
||||
m_layout->insertWidget(m_layout->count() - 1, row);
|
||||
m_rows.append(row);
|
||||
}
|
||||
|
||||
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
|
||||
}
|
||||
|
||||
void ChatMessageList::appendMessage(const ChatMessage &message)
|
||||
{
|
||||
m_messages.append(message);
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void ChatMessageList::updateMessage(int index, const QString &content)
|
||||
{
|
||||
if (index < 0 || index >= m_messages.size()) {
|
||||
return;
|
||||
}
|
||||
m_messages[index].content = content;
|
||||
|
||||
for (MessageRow *row : m_rows) {
|
||||
if (row->rowIndex() == index) {
|
||||
row->setContent(content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
|
||||
}
|
||||
|
||||
void ChatMessageList::appendNote(const QString &text)
|
||||
{
|
||||
auto *note = new QLabel(text, m_container);
|
||||
note->setWordWrap(true);
|
||||
note->setAlignment(Qt::AlignCenter);
|
||||
note->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: rgba(255,255,255,0.45); font-size: 11px;"
|
||||
" background: rgba(255,255,255,0.04); border-radius: 8px; padding: 5px 10px; }"));
|
||||
m_layout->insertWidget(m_layout->count() - 1, note);
|
||||
QTimer::singleShot(0, this, &ChatMessageList::scrollToBottom);
|
||||
}
|
||||
|
||||
void ChatMessageList::dragEnterEvent(QDragEnterEvent *event)
|
||||
{
|
||||
if (event->mimeData() &&
|
||||
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
|
||||
event->mimeData()->hasImage())) {
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
QScrollArea::dragEnterEvent(event);
|
||||
}
|
||||
|
||||
void ChatMessageList::dragMoveEvent(QDragMoveEvent *event)
|
||||
{
|
||||
if (event->mimeData() &&
|
||||
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
|
||||
event->mimeData()->hasImage())) {
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
QScrollArea::dragMoveEvent(event);
|
||||
}
|
||||
|
||||
void ChatMessageList::dropEvent(QDropEvent *event)
|
||||
{
|
||||
if (event->mimeData() &&
|
||||
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
|
||||
event->mimeData()->hasImage())) {
|
||||
Q_EMIT dropped(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
QScrollArea::dropEvent(event);
|
||||
}
|
||||
|
||||
void ChatMessageList::clear()
|
||||
{
|
||||
m_messages.clear();
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void ChatMessageList::scrollToBottom()
|
||||
{
|
||||
verticalScrollBar()->setValue(verticalScrollBar()->maximum());
|
||||
}
|
||||
|
||||
#include "chatview.moc"
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QScrollArea>
|
||||
#include <QVector>
|
||||
|
||||
#include "chatclient.h"
|
||||
|
||||
class QLabel;
|
||||
class QMimeData;
|
||||
class QUrl;
|
||||
class QVBoxLayout;
|
||||
|
||||
/**
|
||||
* Scrollable message list with chat bubbles, avatars and inline actions.
|
||||
*/
|
||||
class ChatMessageList : public QScrollArea
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatMessageList(QWidget *parent = nullptr);
|
||||
|
||||
void setMessages(const QVector<ChatMessage> &messages);
|
||||
void appendMessage(const ChatMessage &message);
|
||||
void updateMessage(int index, const QString &content);
|
||||
void appendNote(const QString &text);
|
||||
void clear();
|
||||
|
||||
void scrollToBottom();
|
||||
|
||||
signals:
|
||||
void dropped(const QMimeData *mime);
|
||||
void copyRequested(int index);
|
||||
void editRequested(int index);
|
||||
void forkRequested(int index);
|
||||
void retryRequested(int index);
|
||||
void linkActivated(const QUrl &url);
|
||||
|
||||
protected:
|
||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||
void dragMoveEvent(QDragMoveEvent *event) override;
|
||||
void dropEvent(QDropEvent *event) override;
|
||||
|
||||
private:
|
||||
void rebuild();
|
||||
|
||||
QWidget *m_container = nullptr;
|
||||
QVBoxLayout *m_layout = nullptr;
|
||||
QWidget *m_welcome = nullptr;
|
||||
QVector<class MessageRow *> m_rows;
|
||||
QVector<ChatMessage> m_messages;
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QVector>
|
||||
#include <DMainWindow>
|
||||
#include <QWidget>
|
||||
|
||||
#include "chatclient.h"
|
||||
|
||||
class QLabel;
|
||||
class QListWidget;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class ChatMessageList;
|
||||
class QMimeData;
|
||||
class QUrl;
|
||||
|
||||
struct ChatConversation
|
||||
{
|
||||
QString id;
|
||||
QString title;
|
||||
qint64 updatedAt = 0;
|
||||
QVector<ChatMessage> messages;
|
||||
};
|
||||
|
||||
/**
|
||||
* GXDE AI See chat window.
|
||||
*
|
||||
* Left: conversation list (also a drop target: empty area creates a new
|
||||
* conversation, an item targets that conversation).
|
||||
* Right: conversation view (drop targets the current conversation) and input.
|
||||
*/
|
||||
class ChatWindow : public Dtk::Widget::DMainWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ChatWindow(QWidget *parent = nullptr);
|
||||
|
||||
/** 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);
|
||||
|
||||
protected:
|
||||
void showEvent(QShowEvent *event) override;
|
||||
|
||||
private:
|
||||
void setupUi();
|
||||
void load();
|
||||
void save() const;
|
||||
void newConversation();
|
||||
void selectConversation(int index);
|
||||
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);
|
||||
void showAbout();
|
||||
bool needsCompression() const;
|
||||
void openSettings();
|
||||
void appendError(const QString &message);
|
||||
int currentIndex() const { return m_current; }
|
||||
|
||||
QListWidget *m_sidebar = nullptr;
|
||||
ChatMessageList *m_messages = nullptr;
|
||||
QLabel *m_titleLabel = nullptr;
|
||||
QLabel *m_modelLabel = nullptr;
|
||||
QPlainTextEdit *m_input = nullptr;
|
||||
QPushButton *m_sendButton = nullptr;
|
||||
QLabel *m_status = nullptr;
|
||||
ChatClient *m_client = nullptr;
|
||||
|
||||
QVector<ChatConversation> m_conversations;
|
||||
int m_current = -1;
|
||||
int m_streamingIndex = -1;
|
||||
bool m_compressing = false;
|
||||
bool m_afterCompressSend = false;
|
||||
bool m_skipCompress = false;
|
||||
QJsonArray m_toolScaffold;
|
||||
int m_toolRounds = 0;
|
||||
QString m_storePath;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "markdown.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
|
||||
QString escapedHtml(const QString &text)
|
||||
{
|
||||
QString html = text.toHtmlEscaped();
|
||||
html.replace(QLatin1Char('\n'), QStringLiteral("<br/>"));
|
||||
return html;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
QString renderInline(QString text)
|
||||
{
|
||||
text = text.toHtmlEscaped();
|
||||
text.replace(QRegularExpression(QStringLiteral("`([^`]+)`")),
|
||||
QStringLiteral("<code>\\1</code>"));
|
||||
text.replace(QRegularExpression(QStringLiteral("\\*\\*([^*]+)\\*\\*")),
|
||||
QStringLiteral("<b>\\1</b>"));
|
||||
text.replace(
|
||||
QRegularExpression(QStringLiteral("\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)")),
|
||||
QStringLiteral("<a href=\"\\2\">\\1</a>"));
|
||||
text.replace(QRegularExpression(QStringLiteral("(^|[\\s(])(https?://[^\\s<]+)")),
|
||||
QStringLiteral("\\1<a href=\"\\2\">\\2</a>"));
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString markdownToHtml(const QString &markdown)
|
||||
{
|
||||
QString html;
|
||||
QString code;
|
||||
bool inCode = false;
|
||||
|
||||
const QStringList lines = markdown.split(QLatin1Char('\n'));
|
||||
for (const QString &line : lines) {
|
||||
if (line.trimmed().startsWith(QStringLiteral("```"))) {
|
||||
if (inCode) {
|
||||
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
|
||||
code.clear();
|
||||
}
|
||||
inCode = !inCode;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
code += line;
|
||||
code += QLatin1Char('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(QStringLiteral("### "))) {
|
||||
html += QStringLiteral("<h3>%1</h3>").arg(renderInline(line.mid(4)));
|
||||
} else if (line.startsWith(QStringLiteral("## "))) {
|
||||
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(3)));
|
||||
} else if (line.startsWith(QStringLiteral("# "))) {
|
||||
html += QStringLiteral("<h2>%1</h2>").arg(renderInline(line.mid(2)));
|
||||
} else if (line.startsWith(QStringLiteral("- ")) ||
|
||||
line.startsWith(QStringLiteral("* "))) {
|
||||
html += QStringLiteral("<div class='li'>• %1</div>").arg(renderInline(line.mid(2)));
|
||||
} else if (line.trimmed().isEmpty()) {
|
||||
html += QStringLiteral("<div class='gap'></div>");
|
||||
} else {
|
||||
html += QStringLiteral("<div>%1</div>").arg(renderInline(line));
|
||||
}
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
html += QStringLiteral("<pre><code>%1</code></pre>").arg(code.toHtmlEscaped());
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
/** Lightweight Markdown to HTML used by assistant bubbles. */
|
||||
QString markdownToHtml(const QString &markdown);
|
||||
|
||||
/** Escape plain text and keep line breaks. */
|
||||
QString escapedHtml(const QString &text);
|
||||
@@ -0,0 +1,289 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "mcpclient.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonDocument>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
McpServer::McpServer(const QString &name, const QString &command, const QStringList &args,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_name(name)
|
||||
, m_command(command)
|
||||
, m_args(args)
|
||||
{
|
||||
m_process = new QProcess(this);
|
||||
m_process->setProcessChannelMode(QProcess::SeparateChannels);
|
||||
|
||||
connect(m_process, &QProcess::readyReadStandardOutput, this, [this]() {
|
||||
m_buffer.append(m_process->readAllStandardOutput());
|
||||
int newline;
|
||||
while ((newline = m_buffer.indexOf('\n')) >= 0) {
|
||||
QByteArray line = m_buffer.left(newline);
|
||||
m_buffer.remove(0, newline + 1);
|
||||
line = line.trimmed();
|
||||
if (!line.isEmpty()) {
|
||||
handleLine(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
connect(m_process, &QProcess::readyReadStandardError, this, [this]() {
|
||||
const QByteArray error = m_process->readAllStandardError();
|
||||
if (!error.isEmpty()) {
|
||||
qWarning().noquote() << QStringLiteral("[mcp:%1]").arg(m_name)
|
||||
<< QString::fromUtf8(error).trimmed();
|
||||
}
|
||||
});
|
||||
connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
|
||||
qWarning().noquote() << QStringLiteral("[mcp:%1] process error %2").arg(m_name).arg(error);
|
||||
});
|
||||
}
|
||||
|
||||
McpServer::~McpServer()
|
||||
{
|
||||
if (m_process && m_process->state() != QProcess::NotRunning) {
|
||||
m_process->terminate();
|
||||
if (!m_process->waitForFinished(1000)) {
|
||||
m_process->kill();
|
||||
m_process->waitForFinished(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void McpServer::start()
|
||||
{
|
||||
m_process->start(m_command, m_args);
|
||||
if (!m_process->waitForStarted(3000)) {
|
||||
qWarning().noquote() << QStringLiteral("[mcp:%1] failed to start %2").arg(m_name, m_command);
|
||||
return;
|
||||
}
|
||||
|
||||
const int initId = m_nextId++;
|
||||
m_callbacks.insert(initId, [this](const QJsonObject &) {
|
||||
send(QJsonObject{ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
|
||||
{ QStringLiteral("method"),
|
||||
QStringLiteral("notifications/initialized") } });
|
||||
|
||||
const int toolsId = m_nextId++;
|
||||
m_callbacks.insert(toolsId, [this](const QJsonObject &response) {
|
||||
m_tools = response.value(QStringLiteral("tools")).toArray();
|
||||
m_ready = true;
|
||||
Q_EMIT readyChanged();
|
||||
});
|
||||
send(QJsonObject{ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
|
||||
{ QStringLiteral("id"), toolsId },
|
||||
{ QStringLiteral("method"), QStringLiteral("tools/list") },
|
||||
{ QStringLiteral("params"), QJsonObject{} } });
|
||||
});
|
||||
|
||||
QJsonObject params{
|
||||
{ QStringLiteral("protocolVersion"), QStringLiteral("2024-11-05") },
|
||||
{ QStringLiteral("capabilities"), QJsonObject{} },
|
||||
{ QStringLiteral("clientInfo"),
|
||||
QJsonObject{ { QStringLiteral("name"), QStringLiteral("gxde-ai-see") },
|
||||
{ QStringLiteral("version"), QStringLiteral("0.1.0") } } },
|
||||
};
|
||||
send(QJsonObject{ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
|
||||
{ QStringLiteral("id"), initId },
|
||||
{ QStringLiteral("method"), QStringLiteral("initialize") },
|
||||
{ QStringLiteral("params"), params } });
|
||||
}
|
||||
|
||||
void McpServer::send(const QJsonObject &message)
|
||||
{
|
||||
if (!m_process || m_process->state() != QProcess::Running) {
|
||||
return;
|
||||
}
|
||||
QByteArray data = QJsonDocument(message).toJson(QJsonDocument::Compact);
|
||||
data.append('\n');
|
||||
m_process->write(data);
|
||||
}
|
||||
|
||||
void McpServer::handleLine(const QByteArray &line)
|
||||
{
|
||||
const QJsonDocument document = QJsonDocument::fromJson(line);
|
||||
if (!document.isObject()) {
|
||||
return;
|
||||
}
|
||||
const QJsonObject object = document.object();
|
||||
|
||||
if (object.contains(QStringLiteral("id"))) {
|
||||
const int id = object.value(QStringLiteral("id")).toInt(-1);
|
||||
auto callback = m_callbacks.take(id);
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
if (object.contains(QStringLiteral("error"))) {
|
||||
const QJsonObject error = object.value(QStringLiteral("error")).toObject();
|
||||
callback(QJsonObject{ { QStringLiteral("error"),
|
||||
error.value(QStringLiteral("message"))
|
||||
.toString(QStringLiteral("MCP 调用失败")) } });
|
||||
} else {
|
||||
callback(object.value(QStringLiteral("result")).toObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void McpServer::callTool(const QString &tool, const QJsonObject &arguments,
|
||||
std::function<void(const QJsonObject &)> callback)
|
||||
{
|
||||
if (!m_ready) {
|
||||
QJsonObject error{ { QStringLiteral("error"),
|
||||
QStringLiteral("MCP 服务器尚未就绪") } };
|
||||
if (callback) {
|
||||
callback(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const int id = m_nextId++;
|
||||
m_callbacks.insert(id, std::move(callback));
|
||||
QJsonObject params{ { QStringLiteral("name"), tool },
|
||||
{ QStringLiteral("arguments"), arguments } };
|
||||
send(QJsonObject{ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
|
||||
{ QStringLiteral("id"), id },
|
||||
{ QStringLiteral("method"), QStringLiteral("tools/call") },
|
||||
{ QStringLiteral("params"), params } });
|
||||
}
|
||||
|
||||
McpManager *McpManager::instance()
|
||||
{
|
||||
static McpManager *self = new McpManager;
|
||||
return self;
|
||||
}
|
||||
|
||||
McpManager::McpManager(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void McpManager::clearServers()
|
||||
{
|
||||
for (McpServer *server : m_servers) {
|
||||
server->deleteLater();
|
||||
}
|
||||
m_servers.clear();
|
||||
}
|
||||
|
||||
void McpManager::reload()
|
||||
{
|
||||
clearServers();
|
||||
m_routes.clear();
|
||||
m_definitions = QJsonArray();
|
||||
m_status.clear();
|
||||
|
||||
const QString json =
|
||||
Config::instance()->get(QStringLiteral("chat/mcpServers")).toString().trimmed();
|
||||
if (!json.isEmpty()) {
|
||||
const QJsonDocument document = QJsonDocument::fromJson(json.toUtf8());
|
||||
const QJsonArray servers = document.isArray() ? document.array() : QJsonArray();
|
||||
for (const QJsonValue &value : servers) {
|
||||
const QJsonObject object = value.toObject();
|
||||
const QString command = object.value(QStringLiteral("command")).toString();
|
||||
if (command.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
QString name = object.value(QStringLiteral("name")).toString();
|
||||
if (name.isEmpty()) {
|
||||
name = QStringLiteral("mcp");
|
||||
}
|
||||
QStringList args;
|
||||
for (const QJsonValue &arg : object.value(QStringLiteral("args")).toArray()) {
|
||||
args.append(arg.toString());
|
||||
}
|
||||
auto *server = new McpServer(name, command, args, this);
|
||||
m_servers.append(server);
|
||||
connect(server, &McpServer::readyChanged, this, [this]() { rebuild(); });
|
||||
server->start();
|
||||
}
|
||||
}
|
||||
|
||||
rebuild();
|
||||
}
|
||||
|
||||
void McpManager::rebuild()
|
||||
{
|
||||
m_definitions = QJsonArray();
|
||||
m_routes.clear();
|
||||
m_status.clear();
|
||||
|
||||
QSet<QString> used;
|
||||
for (McpServer *server : m_servers) {
|
||||
const QJsonArray tools = server->tools();
|
||||
m_status.append(QStringLiteral("%1: %2 个工具%3")
|
||||
.arg(server->name())
|
||||
.arg(tools.size())
|
||||
.arg(server->ready() ? QString() : QStringLiteral("(连接中)")));
|
||||
for (const QJsonValue &value : tools) {
|
||||
const QJsonObject tool = value.toObject();
|
||||
QString exposed = tool.value(QStringLiteral("name")).toString();
|
||||
exposed.replace(QRegularExpression(QStringLiteral("[^A-Za-z0-9_-]")),
|
||||
QStringLiteral("_"));
|
||||
if (exposed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (used.contains(exposed)) {
|
||||
exposed = server->name() + QLatin1Char('_') + exposed;
|
||||
exposed.replace(QRegularExpression(QStringLiteral("[^A-Za-z0-9_-]")),
|
||||
QStringLiteral("_"));
|
||||
}
|
||||
while (used.contains(exposed)) {
|
||||
exposed += QLatin1Char('_');
|
||||
}
|
||||
used.insert(exposed);
|
||||
|
||||
m_routes.insert(exposed, ToolRoute{ server, tool.value(QStringLiteral("name")).toString() });
|
||||
|
||||
QJsonObject function;
|
||||
function.insert(QStringLiteral("name"), exposed);
|
||||
function.insert(QStringLiteral("description"),
|
||||
tool.value(QStringLiteral("description")).toString());
|
||||
QJsonObject parameters = tool.value(QStringLiteral("inputSchema")).toObject();
|
||||
if (parameters.isEmpty()) {
|
||||
parameters.insert(QStringLiteral("type"), QStringLiteral("object"));
|
||||
parameters.insert(QStringLiteral("properties"), QJsonObject{});
|
||||
}
|
||||
function.insert(QStringLiteral("parameters"), parameters);
|
||||
m_definitions.append(
|
||||
QJsonObject{ { QStringLiteral("type"), QStringLiteral("function") },
|
||||
{ QStringLiteral("function"), function } });
|
||||
}
|
||||
}
|
||||
|
||||
Q_EMIT toolsChanged();
|
||||
}
|
||||
|
||||
bool McpManager::hasTools() const
|
||||
{
|
||||
return !m_definitions.isEmpty();
|
||||
}
|
||||
|
||||
QJsonArray McpManager::toolDefinitions() const
|
||||
{
|
||||
return m_definitions;
|
||||
}
|
||||
|
||||
QStringList McpManager::statusLines() const
|
||||
{
|
||||
return m_status;
|
||||
}
|
||||
|
||||
void McpManager::callTool(const QString &exposedName, const QJsonObject &arguments,
|
||||
std::function<void(const QJsonObject &)> callback)
|
||||
{
|
||||
const auto it = m_routes.constFind(exposedName);
|
||||
if (it == m_routes.constEnd()) {
|
||||
if (callback) {
|
||||
callback(QJsonObject{ { QStringLiteral("error"),
|
||||
QStringLiteral("未找到工具 %1").arg(exposedName) } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
it->server->callTool(it->originalName, arguments, std::move(callback));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QProcess;
|
||||
|
||||
/**
|
||||
* One MCP server speaking JSON-RPC 2.0 over stdio.
|
||||
*/
|
||||
class McpServer : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
McpServer(const QString &name, const QString &command, const QStringList &args,
|
||||
QObject *parent = nullptr);
|
||||
~McpServer() override;
|
||||
|
||||
void start();
|
||||
|
||||
QString name() const { return m_name; }
|
||||
bool ready() const { return m_ready; }
|
||||
QJsonArray tools() const { return m_tools; }
|
||||
|
||||
void callTool(const QString &tool, const QJsonObject &arguments,
|
||||
std::function<void(const QJsonObject &)> callback);
|
||||
|
||||
signals:
|
||||
void readyChanged();
|
||||
|
||||
private:
|
||||
void send(const QJsonObject &message);
|
||||
void handleLine(const QByteArray &line);
|
||||
void finishToolList();
|
||||
|
||||
QString m_name;
|
||||
QString m_command;
|
||||
QStringList m_args;
|
||||
QProcess *m_process = nullptr;
|
||||
QByteArray m_buffer;
|
||||
int m_nextId = 1;
|
||||
QHash<int, std::function<void(const QJsonObject &)>> m_callbacks;
|
||||
QJsonArray m_tools;
|
||||
bool m_ready = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregates the configured MCP servers and exposes their tools in the
|
||||
* OpenAI function-calling format.
|
||||
*/
|
||||
class McpManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static McpManager *instance();
|
||||
|
||||
void reload();
|
||||
|
||||
bool hasTools() const;
|
||||
QJsonArray toolDefinitions() const;
|
||||
QStringList statusLines() const;
|
||||
|
||||
void callTool(const QString &exposedName, const QJsonObject &arguments,
|
||||
std::function<void(const QJsonObject &)> callback);
|
||||
|
||||
signals:
|
||||
void toolsChanged();
|
||||
|
||||
private:
|
||||
explicit McpManager(QObject *parent = nullptr);
|
||||
void clearServers();
|
||||
void rebuild();
|
||||
|
||||
QList<McpServer *> m_servers;
|
||||
struct ToolRoute
|
||||
{
|
||||
McpServer *server = nullptr;
|
||||
QString originalName;
|
||||
};
|
||||
QHash<QString, ToolRoute> m_routes;
|
||||
QJsonArray m_definitions;
|
||||
QStringList m_status;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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("已停止"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "contentclassifier.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QImageReader>
|
||||
#include <QMimeData>
|
||||
#include <QUrl>
|
||||
|
||||
namespace {
|
||||
|
||||
QString extensionOfFile(const QString &path)
|
||||
{
|
||||
return QFileInfo(path).suffix().toLower();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString DropContent::summary() const
|
||||
{
|
||||
switch (kind) {
|
||||
case Files: {
|
||||
if (filePaths.size() == 1) {
|
||||
const QFileInfo info(filePaths.first());
|
||||
return QStringLiteral("文件 · %1").arg(info.fileName());
|
||||
}
|
||||
return QStringLiteral("%1 个文件").arg(filePaths.size());
|
||||
}
|
||||
case Image:
|
||||
return QStringLiteral("图片");
|
||||
case Text:
|
||||
return QStringLiteral("文字 · %1 字").arg(text.size());
|
||||
case Link:
|
||||
case CustomLink: {
|
||||
const QString label = link.fileType != QStringLiteral("page") && !link.fileType.isEmpty()
|
||||
? link.fileType
|
||||
: link.kindLabel();
|
||||
return link.displayName.isEmpty() ? label
|
||||
: QStringLiteral("%1 · %2").arg(label, link.displayName);
|
||||
}
|
||||
default:
|
||||
return QString();
|
||||
}
|
||||
}
|
||||
|
||||
namespace ContentClassifier {
|
||||
|
||||
DropContent classifyText(const QString &text)
|
||||
{
|
||||
DropContent content;
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty()) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const LinkInfo info = LinkTools::parse(trimmed);
|
||||
if (info.isValid()) {
|
||||
content.kind = info.kind == LinkInfo::Http ? DropContent::Link : DropContent::CustomLink;
|
||||
content.link = info;
|
||||
content.text = trimmed;
|
||||
return content;
|
||||
}
|
||||
|
||||
content.kind = DropContent::Text;
|
||||
content.text = text;
|
||||
return content;
|
||||
}
|
||||
|
||||
DropContent classify(const QMimeData *mime)
|
||||
{
|
||||
DropContent content;
|
||||
if (!mime) {
|
||||
return content;
|
||||
}
|
||||
|
||||
content.mimes = mime->formats();
|
||||
|
||||
if (mime->hasUrls()) {
|
||||
QStringList localFiles;
|
||||
LinkInfo link;
|
||||
for (const QUrl &url : mime->urls()) {
|
||||
if (url.isLocalFile()) {
|
||||
localFiles.append(url.toLocalFile());
|
||||
} else if (!link.isValid()) {
|
||||
link = LinkTools::parseUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localFiles.isEmpty()) {
|
||||
content.kind = DropContent::Files;
|
||||
content.filePaths = localFiles;
|
||||
if (localFiles.size() == 1) {
|
||||
const QString suffix = extensionOfFile(localFiles.first());
|
||||
if (LinkTools::fileTypeForExtension(suffix) == QStringLiteral("image") &&
|
||||
QImageReader(localFiles.first()).canRead()) {
|
||||
content.kind = DropContent::Image;
|
||||
content.image = QImage(localFiles.first());
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
if (link.isValid()) {
|
||||
content.kind =
|
||||
link.kind == LinkInfo::Http ? DropContent::Link : DropContent::CustomLink;
|
||||
content.link = link;
|
||||
content.text = link.url.toString();
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
if (mime->hasImage()) {
|
||||
content.image = qvariant_cast<QImage>(mime->imageData());
|
||||
if (!content.image.isNull()) {
|
||||
content.kind = DropContent::Image;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
if (mime->hasText()) {
|
||||
return classifyText(mime->text());
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
} // namespace ContentClassifier
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QStringList>
|
||||
|
||||
#include "linktools.h"
|
||||
|
||||
class QMimeData;
|
||||
|
||||
/**
|
||||
* The payload dropped onto the content factory, already classified.
|
||||
*/
|
||||
struct DropContent
|
||||
{
|
||||
enum Kind {
|
||||
None,
|
||||
Files,
|
||||
Image,
|
||||
Text,
|
||||
Link, // http(s)
|
||||
CustomLink, // thunder://, spk://, magnet:, ...
|
||||
};
|
||||
|
||||
Kind kind = None;
|
||||
QStringList filePaths;
|
||||
QString text;
|
||||
QStringList mimes;
|
||||
LinkInfo link;
|
||||
QImage image;
|
||||
|
||||
bool valid() const { return kind != None; }
|
||||
QString summary() const;
|
||||
};
|
||||
|
||||
namespace ContentClassifier {
|
||||
DropContent classify(const QMimeData *mime);
|
||||
DropContent classifyText(const QString &text);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "contentfactory.h"
|
||||
|
||||
#include "factoryactions.h"
|
||||
|
||||
#include <QDragEnterEvent>
|
||||
#include <QDragLeaveEvent>
|
||||
#include <QDragMoveEvent>
|
||||
#include <QDropEvent>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMimeData>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPushButton>
|
||||
#include <QStyle>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace {
|
||||
|
||||
bool mimeSupported(const QMimeData *mime)
|
||||
{
|
||||
return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class ActionButton : public QToolButton
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ActionButton(const FactoryAction &action, QWidget *parent)
|
||||
: QToolButton(parent)
|
||||
, m_id(action.id)
|
||||
{
|
||||
setText(action.label);
|
||||
setToolTip(action.hint);
|
||||
setEnabled(action.enabled);
|
||||
setCursor(Qt::PointingHandCursor);
|
||||
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||
setFixedHeight(32);
|
||||
setAutoRaise(true);
|
||||
setFocusPolicy(Qt::NoFocus);
|
||||
setAcceptDrops(true);
|
||||
if (!action.icon.isEmpty()) {
|
||||
const QIcon icon = QIcon::fromTheme(action.icon);
|
||||
if (!icon.isNull()) {
|
||||
setIcon(icon);
|
||||
setIconSize(QSize(16, 16));
|
||||
setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||
}
|
||||
}
|
||||
setStyleSheet(QStringLiteral(
|
||||
"QToolButton { color: rgba(255,255,255,0.86); background: rgba(255,255,255,0.07);"
|
||||
" border: none; border-radius: 7px; padding: 0 8px; font-size: 12px; }"
|
||||
"QToolButton:hover { background: rgba(120,180,255,0.30); color: white; }"
|
||||
"QToolButton[dragover=\"true\"] { background: rgba(120,180,255,0.45); color: white;"
|
||||
" border: 1px solid rgba(160,205,255,0.9); }"
|
||||
"QToolButton:disabled { color: rgba(255,255,255,0.32); background: rgba(255,255,255,0.03); }"));
|
||||
}
|
||||
|
||||
signals:
|
||||
void dropped(const QString &id, const QMimeData *mime);
|
||||
void clickedAction(const QString &id);
|
||||
|
||||
protected:
|
||||
void dragEnterEvent(QDragEnterEvent *event) override
|
||||
{
|
||||
if (!mimeSupported(event->mimeData())) {
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
setProperty("dragover", true);
|
||||
repolish();
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void dragMoveEvent(QDragMoveEvent *event) override
|
||||
{
|
||||
if (mimeSupported(event->mimeData())) {
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
}
|
||||
|
||||
void dragLeaveEvent(QDragLeaveEvent *event) override
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
setProperty("dragover", false);
|
||||
repolish();
|
||||
}
|
||||
|
||||
void dropEvent(QDropEvent *event) override
|
||||
{
|
||||
setProperty("dragover", false);
|
||||
repolish();
|
||||
if (!mimeSupported(event->mimeData())) {
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
event->acceptProposedAction();
|
||||
Q_EMIT dropped(m_id, event->mimeData());
|
||||
}
|
||||
|
||||
void mouseReleaseEvent(QMouseEvent *event) override
|
||||
{
|
||||
QToolButton::mouseReleaseEvent(event);
|
||||
if (isEnabled() && rect().contains(event->position().toPoint())) {
|
||||
Q_EMIT clickedAction(m_id);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
void repolish()
|
||||
{
|
||||
style()->unpolish(this);
|
||||
style()->polish(this);
|
||||
update();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
setAcceptDrops(true);
|
||||
setMinimumHeight(44);
|
||||
|
||||
auto *outer = new QHBoxLayout(this);
|
||||
outer->setContentsMargins(12, 7, 12, 7);
|
||||
outer->setSpacing(10);
|
||||
|
||||
auto *left = new QVBoxLayout;
|
||||
left->setContentsMargins(0, 0, 0, 0);
|
||||
left->setSpacing(6);
|
||||
|
||||
auto *header = new QHBoxLayout;
|
||||
header->setContentsMargins(0, 0, 0, 0);
|
||||
header->setSpacing(8);
|
||||
|
||||
auto *title = new QLabel(QStringLiteral("🔧 内容工厂"), this);
|
||||
QFont titleFont = title->font();
|
||||
titleFont.setPixelSize(12);
|
||||
titleFont.setWeight(QFont::DemiBold);
|
||||
title->setFont(titleFont);
|
||||
title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.88);"));
|
||||
header->addWidget(title);
|
||||
|
||||
m_summary = new QLabel(this);
|
||||
m_summary->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.45); font-size: 11px;"));
|
||||
m_summary->setText(tr("拖到这里,按类型处理"));
|
||||
header->addWidget(m_summary, 1);
|
||||
|
||||
auto *hint = new QLabel(tr("拖到动作上松手"), this);
|
||||
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.32); font-size: 11px;"));
|
||||
header->addWidget(hint);
|
||||
|
||||
left->addLayout(header);
|
||||
|
||||
m_actions = new QWidget(this);
|
||||
m_grid = new QGridLayout(m_actions);
|
||||
m_grid->setContentsMargins(0, 0, 0, 0);
|
||||
m_grid->setHorizontalSpacing(6);
|
||||
m_grid->setVerticalSpacing(6);
|
||||
m_actions->hide();
|
||||
left->addWidget(m_actions);
|
||||
|
||||
outer->addLayout(left, 1);
|
||||
|
||||
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)
|
||||
{
|
||||
m_content = content;
|
||||
m_summary->setText(content.summary());
|
||||
rebuild();
|
||||
m_actions->show();
|
||||
Q_EMIT heightChanged();
|
||||
}
|
||||
|
||||
void ContentFactory::clearContent()
|
||||
{
|
||||
m_content = DropContent();
|
||||
m_summary->setText(tr("拖到这里,按类型处理"));
|
||||
while (QLayoutItem *item = m_grid->takeAt(0)) {
|
||||
if (QWidget *widget = item->widget()) {
|
||||
widget->deleteLater();
|
||||
}
|
||||
delete item;
|
||||
}
|
||||
m_actions->hide();
|
||||
Q_EMIT heightChanged();
|
||||
}
|
||||
|
||||
void ContentFactory::rebuild()
|
||||
{
|
||||
while (QLayoutItem *item = m_grid->takeAt(0)) {
|
||||
if (QWidget *widget = item->widget()) {
|
||||
widget->deleteLater();
|
||||
}
|
||||
delete item;
|
||||
}
|
||||
|
||||
const QVector<FactoryAction> actions = actionsFor(m_content);
|
||||
int column = 0;
|
||||
int row = 0;
|
||||
for (const FactoryAction &action : actions) {
|
||||
auto *button = new ActionButton(action, m_actions);
|
||||
installEventFilter(button);
|
||||
connect(button, &ActionButton::clickedAction, this, [this](const QString &id) {
|
||||
if (m_content.valid()) {
|
||||
Q_EMIT actionRequested(id, m_content);
|
||||
}
|
||||
});
|
||||
connect(button, &ActionButton::dropped, this, [this](const QString &id,
|
||||
const QMimeData *mime) {
|
||||
const DropContent content = ContentClassifier::classify(mime);
|
||||
if (content.valid()) {
|
||||
Q_EMIT actionRequested(id, content);
|
||||
}
|
||||
});
|
||||
m_grid->addWidget(button, row, column);
|
||||
if (++column == 3) {
|
||||
column = 0;
|
||||
++row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ContentFactory::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
const QRectF box = QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0);
|
||||
QPainterPath path;
|
||||
path.addRoundedRect(box, 12, 12);
|
||||
painter.fillPath(path, QColor(255, 255, 255, m_content.valid() ? 14 : 8));
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 42), 1.0));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
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) {
|
||||
Q_EMIT dragActivity();
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
#include "contentfactory.moc"
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include "contentclassifier.h"
|
||||
|
||||
class QGridLayout;
|
||||
class QLabel;
|
||||
class QMimeData;
|
||||
|
||||
/**
|
||||
* Third box of the panel: shows type aware actions for the dragged payload.
|
||||
*
|
||||
* Every action button is itself a drop target, so the user can drag the
|
||||
* payload straight onto the action and release.
|
||||
*/
|
||||
class ContentFactory : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ContentFactory(QWidget *parent = nullptr);
|
||||
|
||||
void setContent(const DropContent &content);
|
||||
void clearContent();
|
||||
bool hasContent() const { return m_content.valid(); }
|
||||
const DropContent &content() const { return m_content; }
|
||||
|
||||
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 AskTile *m_askTile = nullptr;
|
||||
QWidget *m_actions = nullptr;
|
||||
QGridLayout *m_grid = nullptr;
|
||||
DropContent m_content;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "factoryactions.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include "../actions/texttools.h"
|
||||
|
||||
namespace {
|
||||
|
||||
FactoryAction action(const QString &id, const QString &label, const QString &icon = QString(),
|
||||
const QString &hint = QString(), bool enabled = true)
|
||||
{
|
||||
return FactoryAction{ id, label, icon, hint, enabled };
|
||||
}
|
||||
|
||||
QVector<FactoryAction> linkActions(const DropContent &content)
|
||||
{
|
||||
QVector<FactoryAction> actions;
|
||||
const LinkInfo &link = content.link;
|
||||
|
||||
if (link.kind == LinkInfo::Http) {
|
||||
actions.append(action(QStringLiteral("open"), QStringLiteral("浏览器打开"),
|
||||
QStringLiteral("web-browser")));
|
||||
if (link.fileType == QStringLiteral("image")) {
|
||||
actions.append(action(QStringLiteral("preview"), QStringLiteral("预览图片"),
|
||||
QStringLiteral("image-x-generic")));
|
||||
} else if (link.fileType == QStringLiteral("audio") ||
|
||||
link.fileType == QStringLiteral("video")) {
|
||||
actions.append(action(QStringLiteral("play"), QStringLiteral("在线播放"),
|
||||
QStringLiteral("media-playback-start")));
|
||||
}
|
||||
actions.append(action(QStringLiteral("download"), QStringLiteral("下载"),
|
||||
QStringLiteral("download")));
|
||||
actions.append(action(QStringLiteral("save_as"), QStringLiteral("另存为…"),
|
||||
QStringLiteral("document-save-as")));
|
||||
actions.append(action(QStringLiteral("copy"), QStringLiteral("复制链接"),
|
||||
QStringLiteral("edit-copy")));
|
||||
actions.append(action(QStringLiteral("copy_markdown"), QStringLiteral("复制为 Markdown"),
|
||||
QStringLiteral("edit-copy")));
|
||||
actions.append(action(QStringLiteral("copy_name"), QStringLiteral("复制文件名"),
|
||||
QStringLiteral("edit-copy")));
|
||||
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")));
|
||||
return actions;
|
||||
}
|
||||
|
||||
actions.append(action(QStringLiteral("try_open"), QStringLiteral("尝试打开"),
|
||||
QStringLiteral("application-x-executable")));
|
||||
if (!link.decodedUrl.isEmpty()) {
|
||||
actions.append(action(QStringLiteral("decode"), QStringLiteral("查看真实地址"),
|
||||
QStringLiteral("edit-find")));
|
||||
actions.append(action(QStringLiteral("copy_decoded"), QStringLiteral("复制真实地址"),
|
||||
QStringLiteral("edit-copy")));
|
||||
}
|
||||
actions.append(action(QStringLiteral("copy"), QStringLiteral("复制链接"),
|
||||
QStringLiteral("edit-copy")));
|
||||
if (!link.displayName.isEmpty()) {
|
||||
actions.append(action(QStringLiteral("copy_name"), QStringLiteral("复制名称"),
|
||||
QStringLiteral("edit-copy")));
|
||||
}
|
||||
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")));
|
||||
actions.append(action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
|
||||
QStringLiteral("dialog-question")));
|
||||
return actions;
|
||||
}
|
||||
|
||||
QVector<FactoryAction> fileActions(const DropContent &content)
|
||||
{
|
||||
QVector<FactoryAction> actions;
|
||||
const QString path = content.filePaths.value(0);
|
||||
const QString type = LinkTools::fileTypeForExtension(QFileInfo(path).suffix().toLower());
|
||||
|
||||
actions.append(action(QStringLiteral("open"), QStringLiteral("打开"),
|
||||
QStringLiteral("document-open")));
|
||||
if (type == QStringLiteral("image")) {
|
||||
actions.append(action(QStringLiteral("ocr"), QStringLiteral("提取文字"),
|
||||
QStringLiteral("insert-text")));
|
||||
actions.append(action(QStringLiteral("describe"), QStringLiteral("生成描述"),
|
||||
QStringLiteral("dialog-question")));
|
||||
} else if (type == QStringLiteral("audio") || type == QStringLiteral("video")) {
|
||||
actions.append(action(QStringLiteral("transcribe"), QStringLiteral("转文字"),
|
||||
QStringLiteral("insert-text"), QStringLiteral("尚未接入"), false));
|
||||
} else if (type == QStringLiteral("document") || type == QStringLiteral("code")) {
|
||||
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("复制路径"),
|
||||
QStringLiteral("edit-copy")));
|
||||
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")));
|
||||
actions.append(action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
|
||||
QStringLiteral("dialog-question")));
|
||||
return actions;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QVector<FactoryAction> actionsFor(const DropContent &content)
|
||||
{
|
||||
switch (content.kind) {
|
||||
case DropContent::Files:
|
||||
return fileActions(content);
|
||||
case DropContent::Image:
|
||||
return {
|
||||
action(QStringLiteral("preview"), QStringLiteral("预览图片"),
|
||||
QStringLiteral("image-x-generic")),
|
||||
action(QStringLiteral("save_image"), QStringLiteral("保存图片…"),
|
||||
QStringLiteral("document-save-as")),
|
||||
action(QStringLiteral("ocr"), QStringLiteral("提取文字"),
|
||||
QStringLiteral("insert-text")),
|
||||
action(QStringLiteral("describe"), QStringLiteral("生成描述"),
|
||||
QStringLiteral("dialog-question")),
|
||||
action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")),
|
||||
};
|
||||
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);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
#include "contentclassifier.h"
|
||||
|
||||
struct FactoryAction
|
||||
{
|
||||
QString id;
|
||||
QString label;
|
||||
QString icon; // theme icon name, optional
|
||||
QString hint;
|
||||
bool enabled = true;
|
||||
};
|
||||
|
||||
/** Actions offered by the content factory for a given payload. */
|
||||
QVector<FactoryAction> actionsFor(const DropContent &content);
|
||||
@@ -0,0 +1,241 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "linktools.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QUrlQuery>
|
||||
|
||||
namespace {
|
||||
|
||||
const QStringList kImageExtensions = {
|
||||
QStringLiteral("jpg"), QStringLiteral("jpeg"), QStringLiteral("png"),
|
||||
QStringLiteral("gif"), QStringLiteral("webp"), QStringLiteral("svg"),
|
||||
QStringLiteral("bmp"), QStringLiteral("avif"), QStringLiteral("heic"),
|
||||
QStringLiteral("ico"),
|
||||
};
|
||||
|
||||
const QStringList kAudioExtensions = {
|
||||
QStringLiteral("mp3"), QStringLiteral("flac"), QStringLiteral("wav"),
|
||||
QStringLiteral("ogg"), QStringLiteral("oga"), QStringLiteral("m4a"),
|
||||
QStringLiteral("aac"), QStringLiteral("opus"), QStringLiteral("wma"),
|
||||
};
|
||||
|
||||
const QStringList kVideoExtensions = {
|
||||
QStringLiteral("mp4"), QStringLiteral("mkv"), QStringLiteral("webm"),
|
||||
QStringLiteral("avi"), QStringLiteral("mov"), QStringLiteral("flv"),
|
||||
QStringLiteral("m4v"), QStringLiteral("ts"),
|
||||
};
|
||||
|
||||
const QStringList kDocumentExtensions = {
|
||||
QStringLiteral("pdf"), QStringLiteral("doc"), QStringLiteral("docx"),
|
||||
QStringLiteral("xls"), QStringLiteral("xlsx"), QStringLiteral("ppt"),
|
||||
QStringLiteral("pptx"), QStringLiteral("odt"), QStringLiteral("ods"),
|
||||
QStringLiteral("txt"), QStringLiteral("md"), QStringLiteral("rtf"),
|
||||
QStringLiteral("epub"),
|
||||
};
|
||||
|
||||
const QStringList kArchiveExtensions = {
|
||||
QStringLiteral("zip"), QStringLiteral("7z"), QStringLiteral("rar"),
|
||||
QStringLiteral("tar"), QStringLiteral("gz"), QStringLiteral("bz2"),
|
||||
QStringLiteral("xz"), QStringLiteral("zst"), QStringLiteral("deb"),
|
||||
QStringLiteral("rpm"), QStringLiteral("apk"), QStringLiteral("iso"),
|
||||
};
|
||||
|
||||
const QStringList kCodeExtensions = {
|
||||
QStringLiteral("json"), QStringLiteral("xml"), QStringLiteral("html"),
|
||||
QStringLiteral("htm"), QStringLiteral("js"), QStringLiteral("css"),
|
||||
QStringLiteral("py"), QStringLiteral("c"), QStringLiteral("cpp"),
|
||||
QStringLiteral("h"), QStringLiteral("sh"), QStringLiteral("go"),
|
||||
QStringLiteral("rs"), QStringLiteral("java"),
|
||||
};
|
||||
|
||||
QString extensionOfPath(const QString &path)
|
||||
{
|
||||
const QString suffix = QFileInfo(path).suffix().toLower();
|
||||
return suffix;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString LinkInfo::kindLabel() const
|
||||
{
|
||||
switch (kind) {
|
||||
case Http:
|
||||
return fileType == QStringLiteral("page") ? QStringLiteral("网页链接")
|
||||
: QStringLiteral("文件链接");
|
||||
case Standard:
|
||||
return QStringLiteral("链接");
|
||||
case Custom:
|
||||
return QStringLiteral("应用链接");
|
||||
default:
|
||||
return QString();
|
||||
}
|
||||
}
|
||||
|
||||
namespace LinkTools {
|
||||
|
||||
QString fileTypeForExtension(const QString &extension)
|
||||
{
|
||||
if (extension.isEmpty()) {
|
||||
return QStringLiteral("page");
|
||||
}
|
||||
if (kImageExtensions.contains(extension)) {
|
||||
return QStringLiteral("image");
|
||||
}
|
||||
if (kAudioExtensions.contains(extension)) {
|
||||
return QStringLiteral("audio");
|
||||
}
|
||||
if (kVideoExtensions.contains(extension)) {
|
||||
return QStringLiteral("video");
|
||||
}
|
||||
if (kDocumentExtensions.contains(extension)) {
|
||||
return QStringLiteral("document");
|
||||
}
|
||||
if (kArchiveExtensions.contains(extension)) {
|
||||
return QStringLiteral("archive");
|
||||
}
|
||||
if (kCodeExtensions.contains(extension)) {
|
||||
return QStringLiteral("code");
|
||||
}
|
||||
return QStringLiteral("unknown");
|
||||
}
|
||||
|
||||
QString decodeThunder(const QString &text)
|
||||
{
|
||||
static const QString prefix = QStringLiteral("thunder://");
|
||||
if (!text.startsWith(prefix, Qt::CaseInsensitive)) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString payload = text.mid(prefix.size()).trimmed();
|
||||
payload.replace(QLatin1Char('-'), QLatin1Char('+'));
|
||||
payload.replace(QLatin1Char('_'), QLatin1Char('/'));
|
||||
|
||||
QByteArray encoded = payload.toLatin1();
|
||||
while (encoded.size() % 4 != 0) {
|
||||
encoded.append('=');
|
||||
}
|
||||
|
||||
const QByteArray decoded = QByteArray::fromBase64(encoded);
|
||||
QString value = QString::fromUtf8(decoded);
|
||||
if (value.startsWith(QStringLiteral("AA"))) {
|
||||
value.remove(0, 2);
|
||||
}
|
||||
if (value.endsWith(QStringLiteral("ZZ"))) {
|
||||
value.chop(2);
|
||||
}
|
||||
return value.trimmed();
|
||||
}
|
||||
|
||||
QString displayNameFor(const QUrl &url)
|
||||
{
|
||||
if (url.scheme().compare(QStringLiteral("magnet"), Qt::CaseInsensitive) == 0) {
|
||||
const QUrlQuery query(url);
|
||||
const QString name = query.queryItemValue(QStringLiteral("dn"));
|
||||
if (!name.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
QString path = url.path();
|
||||
const QString name = path.section(QLatin1Char('/'), -1, -1, QString::SectionSkipEmpty);
|
||||
if (!name.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
return url.host();
|
||||
}
|
||||
|
||||
LinkInfo parseUrl(const QUrl &url)
|
||||
{
|
||||
LinkInfo info;
|
||||
if (!url.isValid() || url.scheme().isEmpty()) {
|
||||
return info;
|
||||
}
|
||||
|
||||
info.url = url;
|
||||
info.scheme = url.scheme().toLower();
|
||||
info.displayName = displayNameFor(url);
|
||||
|
||||
if (info.scheme == QStringLiteral("http") || info.scheme == QStringLiteral("https")) {
|
||||
info.kind = LinkInfo::Http;
|
||||
info.extension = extensionOfPath(url.path());
|
||||
info.fileType = fileTypeForExtension(info.extension);
|
||||
info.looksLikeFile = info.fileType != QStringLiteral("page") &&
|
||||
info.fileType != QStringLiteral("unknown");
|
||||
} else if (info.scheme == QStringLiteral("thunder")) {
|
||||
info.kind = LinkInfo::Custom;
|
||||
info.decodedUrl = decodeThunder(url.toString());
|
||||
if (!info.decodedUrl.isEmpty()) {
|
||||
const QUrl real(info.decodedUrl);
|
||||
info.extension = extensionOfPath(real.path());
|
||||
info.fileType = fileTypeForExtension(info.extension);
|
||||
info.displayName = displayNameFor(real);
|
||||
}
|
||||
} else if (info.scheme == QStringLiteral("magnet") || info.scheme == QStringLiteral("ed2k") ||
|
||||
info.scheme == QStringLiteral("spk") ||
|
||||
info.scheme == QStringLiteral("x-scheme-handler")) {
|
||||
info.kind = LinkInfo::Custom;
|
||||
} else if (info.scheme == QStringLiteral("ftp") || info.scheme == QStringLiteral("mailto") ||
|
||||
info.scheme == QStringLiteral("tel") || info.scheme == QStringLiteral("file")) {
|
||||
info.kind = LinkInfo::Standard;
|
||||
} else {
|
||||
info.kind = LinkInfo::Custom;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
LinkInfo parse(const QString &text)
|
||||
{
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty() || trimmed.contains(QLatin1Char('\n'))) {
|
||||
return LinkInfo();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith(QStringLiteral("www."), Qt::CaseInsensitive)) {
|
||||
return parseUrl(QUrl(QStringLiteral("https://") + trimmed));
|
||||
}
|
||||
|
||||
/* QUrl lowercases the host part, which destroys base64 payloads such as
|
||||
* thunder://. Handle these schemes on the raw string. */
|
||||
if (trimmed.startsWith(QStringLiteral("thunder://"), Qt::CaseInsensitive)) {
|
||||
LinkInfo info;
|
||||
info.kind = LinkInfo::Custom;
|
||||
info.raw = trimmed;
|
||||
info.scheme = QStringLiteral("thunder");
|
||||
info.url = QUrl(trimmed);
|
||||
info.decodedUrl = decodeThunder(trimmed);
|
||||
if (!info.decodedUrl.isEmpty()) {
|
||||
const QUrl real(info.decodedUrl);
|
||||
info.extension = extensionOfPath(real.path());
|
||||
info.fileType = fileTypeForExtension(info.extension);
|
||||
info.displayName = displayNameFor(real);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
static const QRegularExpression schemePattern(
|
||||
QStringLiteral("^[A-Za-z][A-Za-z0-9+.-]*://?"), QRegularExpression::CaseInsensitiveOption);
|
||||
if (!schemePattern.match(trimmed).hasMatch() &&
|
||||
!trimmed.startsWith(QStringLiteral("magnet:"), Qt::CaseInsensitive) &&
|
||||
!trimmed.startsWith(QStringLiteral("mailto:"), Qt::CaseInsensitive) &&
|
||||
!trimmed.startsWith(QStringLiteral("tel:"), Qt::CaseInsensitive)) {
|
||||
return LinkInfo();
|
||||
}
|
||||
|
||||
LinkInfo info = parseUrl(QUrl(trimmed));
|
||||
if (info.isValid() && info.raw.isEmpty()) {
|
||||
info.raw = trimmed;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
bool isHttpLink(const QString &text)
|
||||
{
|
||||
const LinkInfo info = parse(text);
|
||||
return info.kind == LinkInfo::Http;
|
||||
}
|
||||
|
||||
} // namespace LinkTools
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
/**
|
||||
* Classification of a link dropped onto the content factory.
|
||||
*/
|
||||
struct LinkInfo
|
||||
{
|
||||
enum Kind {
|
||||
None,
|
||||
Http, // http(s) URL
|
||||
Standard, // other standard URL (ftp, mailto, tel, file)
|
||||
Custom, // app specific scheme (thunder, spk, magnet, ed2k, ...)
|
||||
Other,
|
||||
};
|
||||
|
||||
Kind kind = None;
|
||||
QUrl url;
|
||||
QString scheme;
|
||||
QString extension; // lower case, without dot
|
||||
QString fileType; // image / audio / video / document / archive / code / page / unknown
|
||||
QString raw; // original text, preserved for custom schemes
|
||||
QString decodedUrl; // thunder:// and friends
|
||||
QString displayName; // magnet dn / last path segment
|
||||
bool looksLikeFile = false;
|
||||
|
||||
bool isValid() const { return kind != None; }
|
||||
QString kindLabel() const;
|
||||
};
|
||||
|
||||
namespace LinkTools {
|
||||
|
||||
LinkInfo parse(const QString &text);
|
||||
LinkInfo parseUrl(const QUrl &url);
|
||||
|
||||
/** Map a file extension to a coarse content type. */
|
||||
QString fileTypeForExtension(const QString &extension);
|
||||
|
||||
/** Decode thunder:// (base64 of "AA" + url + "ZZ") and magnet display name. */
|
||||
QString decodeThunder(const QString &text);
|
||||
|
||||
QString displayNameFor(const QUrl &url);
|
||||
|
||||
bool isHttpLink(const QString &text);
|
||||
|
||||
} // namespace LinkTools
|
||||
@@ -4,6 +4,10 @@
|
||||
#include <DApplication>
|
||||
#include <QCommandLineOption>
|
||||
#include <QCommandLineParser>
|
||||
#include <QIcon>
|
||||
#include <QLocale>
|
||||
#include <QStandardPaths>
|
||||
#include <QTranslator>
|
||||
|
||||
#include "app.h"
|
||||
|
||||
@@ -15,9 +19,42 @@ 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"));
|
||||
}
|
||||
app.setWindowIcon(appIcon);
|
||||
app.setQuitOnLastWindowClosed(false);
|
||||
|
||||
const QString locale = QLocale::system().name();
|
||||
auto *translator = new QTranslator(&app);
|
||||
const QStringList translationDirs = {
|
||||
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
|
||||
+ QStringLiteral("/translations"),
|
||||
QStringLiteral("/usr/share/gxde-ai-see/translations"),
|
||||
QStringLiteral("/usr/local/share/gxde-ai-see/translations"),
|
||||
};
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(QStringLiteral("GXDE 小埃同学"));
|
||||
parser.addHelpOption();
|
||||
@@ -27,12 +64,19 @@ int main(int argc, char *argv[])
|
||||
QStringLiteral("后台常驻,需要时再显示面板"));
|
||||
QCommandLineOption showOption(QStringLiteral("show"),
|
||||
QStringLiteral("启动后立即显示传送门面板"));
|
||||
QCommandLineOption todoOption(QStringLiteral("todo"),
|
||||
QStringLiteral("打开待办窗口"));
|
||||
QCommandLineOption chatOption(QStringLiteral("chat"),
|
||||
QStringLiteral("打开小埃同学对话窗口"));
|
||||
parser.addOption(daemonOption);
|
||||
parser.addOption(showOption);
|
||||
parser.addOption(todoOption);
|
||||
parser.addOption(chatOption);
|
||||
parser.process(app);
|
||||
|
||||
App gxde;
|
||||
if (!gxde.init(parser.isSet(daemonOption), parser.isSet(showOption))) {
|
||||
if (!gxde.init(parser.isSet(daemonOption), parser.isSet(showOption),
|
||||
parser.isSet(todoOption), parser.isSet(chatOption))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -306,6 +306,23 @@ void StagingModel::setChecked(int row, bool checked)
|
||||
save();
|
||||
}
|
||||
|
||||
void StagingModel::moveItem(int from, int to)
|
||||
{
|
||||
if (from < 0 || from >= m_items.size() || to < 0 || to >= m_items.size() || from == to) {
|
||||
return;
|
||||
}
|
||||
if (to > from) {
|
||||
beginMoveRows(QModelIndex(), from, from, QModelIndex(), to + 1);
|
||||
m_items.move(from, to);
|
||||
endMoveRows();
|
||||
} else {
|
||||
beginMoveRows(QModelIndex(), from, from, QModelIndex(), to);
|
||||
m_items.move(from, to);
|
||||
endMoveRows();
|
||||
}
|
||||
save();
|
||||
}
|
||||
|
||||
void StagingModel::setPinned(int row, bool pinned)
|
||||
{
|
||||
if (row < 0 || row >= m_items.size()) {
|
||||
|
||||
@@ -51,6 +51,7 @@ public:
|
||||
|
||||
void setChecked(int row, bool checked);
|
||||
void setPinned(int row, bool pinned);
|
||||
void moveItem(int from, int to);
|
||||
void clearAll();
|
||||
|
||||
const StagingItem *itemAt(int row) const;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "todomodel.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSaveFile>
|
||||
#include <QUuid>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
TodoModel *TodoModel::instance()
|
||||
{
|
||||
static TodoModel *self = new TodoModel;
|
||||
return self;
|
||||
}
|
||||
|
||||
TodoModel::TodoModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
m_path = Config::instance()->dataDir() + QStringLiteral("/todo.json");
|
||||
load();
|
||||
}
|
||||
|
||||
int TodoModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
return parent.isValid() ? 0 : m_items.size();
|
||||
}
|
||||
|
||||
QVariant TodoModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() >= m_items.size()) {
|
||||
return QVariant();
|
||||
}
|
||||
const TodoItem &item = m_items.at(index.row());
|
||||
switch (role) {
|
||||
case Qt::DisplayRole:
|
||||
case TextRole:
|
||||
return item.text;
|
||||
case DoneRole:
|
||||
return item.done;
|
||||
case IdRole:
|
||||
return item.id;
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
|
||||
bool TodoModel::removeRows(int row, int count, const QModelIndex &parent)
|
||||
{
|
||||
if (parent.isValid() || row < 0 || count <= 0 || row + count > m_items.size()) {
|
||||
return false;
|
||||
}
|
||||
beginRemoveRows(QModelIndex(), row, row + count - 1);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
m_items.removeAt(row);
|
||||
}
|
||||
endRemoveRows();
|
||||
save();
|
||||
Q_EMIT countChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
Qt::ItemFlags TodoModel::flags(const QModelIndex &index) const
|
||||
{
|
||||
return index.isValid() ? (Qt::ItemIsEnabled | Qt::ItemIsSelectable) : Qt::NoItemFlags;
|
||||
}
|
||||
|
||||
void TodoModel::add(const QString &text)
|
||||
{
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
TodoItem item;
|
||||
item.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
item.text = trimmed;
|
||||
item.createdAt = QDateTime::currentMSecsSinceEpoch();
|
||||
|
||||
beginInsertRows(QModelIndex(), 0, 0);
|
||||
m_items.prepend(item);
|
||||
endInsertRows();
|
||||
save();
|
||||
Q_EMIT countChanged();
|
||||
}
|
||||
|
||||
void TodoModel::setDone(int row, bool done)
|
||||
{
|
||||
if (row < 0 || row >= m_items.size() || m_items[row].done == done) {
|
||||
return;
|
||||
}
|
||||
m_items[row].done = done;
|
||||
const QModelIndex idx = index(row);
|
||||
Q_EMIT dataChanged(idx, idx, { DoneRole });
|
||||
save();
|
||||
}
|
||||
|
||||
const TodoItem *TodoModel::itemAt(int row) const
|
||||
{
|
||||
return (row >= 0 && row < m_items.size()) ? &m_items.at(row) : nullptr;
|
||||
}
|
||||
|
||||
void TodoModel::load()
|
||||
{
|
||||
QFile file(m_path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return;
|
||||
}
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
if (!doc.isObject()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QList<TodoItem> items;
|
||||
const QJsonArray array = doc.object().value(QStringLiteral("items")).toArray();
|
||||
for (const QJsonValue &value : array) {
|
||||
const QJsonObject object = value.toObject();
|
||||
TodoItem item;
|
||||
item.id = object.value(QStringLiteral("id")).toString();
|
||||
item.text = object.value(QStringLiteral("text")).toString();
|
||||
item.done = object.value(QStringLiteral("done")).toBool();
|
||||
item.createdAt =
|
||||
static_cast<qint64>(object.value(QStringLiteral("createdAt")).toDouble());
|
||||
if (!item.text.isEmpty()) {
|
||||
items.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
beginResetModel();
|
||||
m_items = items;
|
||||
endResetModel();
|
||||
Q_EMIT countChanged();
|
||||
}
|
||||
|
||||
void TodoModel::save() const
|
||||
{
|
||||
QJsonArray array;
|
||||
for (const TodoItem &item : m_items) {
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("id"), item.id);
|
||||
object.insert(QStringLiteral("text"), item.text);
|
||||
object.insert(QStringLiteral("done"), item.done);
|
||||
object.insert(QStringLiteral("createdAt"), static_cast<double>(item.createdAt));
|
||||
array.append(object);
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("version"), 1);
|
||||
root.insert(QStringLiteral("items"), array);
|
||||
|
||||
QSaveFile file(m_path);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
return;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
file.commit();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
struct TodoItem
|
||||
{
|
||||
QString id;
|
||||
QString text;
|
||||
bool done = false;
|
||||
qint64 createdAt = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persisted todo list backing the todo window and the "add to todo" action.
|
||||
*/
|
||||
class TodoModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
TextRole = Qt::UserRole + 1,
|
||||
DoneRole,
|
||||
IdRole,
|
||||
};
|
||||
|
||||
static TodoModel *instance();
|
||||
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
bool removeRows(int row, int count, const QModelIndex &parent = QModelIndex()) override;
|
||||
Qt::ItemFlags flags(const QModelIndex &index) const override;
|
||||
|
||||
void add(const QString &text);
|
||||
void setDone(int row, bool done);
|
||||
const TodoItem *itemAt(int row) const;
|
||||
int count() const { return m_items.size(); }
|
||||
|
||||
void load();
|
||||
void save() const;
|
||||
|
||||
signals:
|
||||
void countChanged();
|
||||
|
||||
private:
|
||||
explicit TodoModel(QObject *parent = nullptr);
|
||||
|
||||
QList<TodoItem> m_items;
|
||||
QString m_path;
|
||||
};
|
||||
@@ -0,0 +1,413 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "todowindow.h"
|
||||
|
||||
#include "todomodel.h"
|
||||
|
||||
#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>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPushButton>
|
||||
#include <QScreen>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#ifdef GXDE_HAVE_LAYERSHELLQT
|
||||
#include <LayerShellQt/Window>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kWidth = 320;
|
||||
constexpr int kRowHeight = 40;
|
||||
|
||||
QRect checkRect(const QRect &row)
|
||||
{
|
||||
return QRect(row.left() + 12, row.center().y() - 9, 18, 18);
|
||||
}
|
||||
|
||||
QRect deleteRect(const QRect &row)
|
||||
{
|
||||
return QRect(row.right() - 34, row.center().y() - 13, 26, 26);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TodoDelegate : public QStyledItemDelegate
|
||||
{
|
||||
public:
|
||||
explicit TodoDelegate(QObject *parent = nullptr)
|
||||
: QStyledItemDelegate(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void setHoverRow(int row) { m_hoverRow = row; }
|
||||
|
||||
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override
|
||||
{
|
||||
Q_UNUSED(option)
|
||||
Q_UNUSED(index)
|
||||
return QSize(0, kRowHeight);
|
||||
}
|
||||
|
||||
void paint(QPainter *painter, const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index) const override
|
||||
{
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
const QRect row = option.rect.adjusted(4, 2, -4, -2);
|
||||
const bool hovered = index.row() == m_hoverRow;
|
||||
const bool done = index.data(TodoModel::DoneRole).toBool();
|
||||
|
||||
if (hovered) {
|
||||
painter->setPen(Qt::NoPen);
|
||||
painter->setBrush(QColor(255, 255, 255, 24));
|
||||
painter->drawRoundedRect(row, 8, 8);
|
||||
}
|
||||
|
||||
const QRect check = checkRect(option.rect);
|
||||
painter->setPen(QPen(done ? QColor(74, 144, 255) : QColor(255, 255, 255, 90), 1.4));
|
||||
painter->setBrush(done ? QColor(74, 144, 255) : QColor(255, 255, 255, 12));
|
||||
painter->drawRoundedRect(check, 5, 5);
|
||||
if (done) {
|
||||
QPainterPath mark;
|
||||
mark.moveTo(check.left() + 4.5, check.center().y());
|
||||
mark.lineTo(check.left() + 7.5, check.bottom() - 5);
|
||||
mark.lineTo(check.right() - 4.0, check.top() + 5.0);
|
||||
painter->setPen(QPen(Qt::white, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
|
||||
painter->setBrush(Qt::NoBrush);
|
||||
painter->drawPath(mark);
|
||||
}
|
||||
|
||||
QFont font = option.font;
|
||||
font.setPixelSize(13);
|
||||
font.setStrikeOut(done);
|
||||
painter->setFont(font);
|
||||
painter->setPen(done ? QColor(255, 255, 255, 100) : QColor(255, 255, 255, 232));
|
||||
|
||||
const QRect textRect(check.right() + 12, option.rect.top(),
|
||||
deleteRect(option.rect).left() - check.right() - 22,
|
||||
option.rect.height());
|
||||
const QString text = index.data(TodoModel::TextRole).toString();
|
||||
const QFontMetrics metrics(font);
|
||||
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter,
|
||||
metrics.elidedText(text, Qt::ElideRight, textRect.width()));
|
||||
|
||||
if (hovered) {
|
||||
const QRect del = deleteRect(option.rect).adjusted(8, 8, -8, -8);
|
||||
painter->setPen(QPen(QColor(255, 255, 255, 165), 1.6, Qt::SolidLine, Qt::RoundCap));
|
||||
painter->drawLine(del.topLeft(), del.bottomRight());
|
||||
painter->drawLine(del.topRight(), del.bottomLeft());
|
||||
}
|
||||
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
bool editorEvent(QEvent *event, QAbstractItemModel *model,
|
||||
const QStyleOptionViewItem &option, const QModelIndex &index) override
|
||||
{
|
||||
if (event->type() != QEvent::MouseButtonRelease) {
|
||||
return false;
|
||||
}
|
||||
auto *mouse = static_cast<QMouseEvent *>(event);
|
||||
const QPoint pos = mouse->position().toPoint();
|
||||
auto *todo = static_cast<TodoModel *>(model);
|
||||
|
||||
if (checkRect(option.rect).contains(pos)) {
|
||||
todo->setDone(index.row(), !index.data(TodoModel::DoneRole).toBool());
|
||||
return true;
|
||||
}
|
||||
if (deleteRect(option.rect).contains(pos)) {
|
||||
todo->removeRow(index.row());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
int m_hoverRow = -1;
|
||||
};
|
||||
|
||||
TodoWindow::TodoWindow(TodoModel *model, QWidget *parent)
|
||||
: Dtk::Widget::DBlurEffectWidget(parent)
|
||||
, m_model(model)
|
||||
{
|
||||
setWindowTitle(tr("待办"));
|
||||
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);
|
||||
setMaskColor(QColor(24, 24, 28, 190));
|
||||
setBlurRectXRadius(16);
|
||||
setBlurRectYRadius(16);
|
||||
setFixedWidth(kWidth);
|
||||
|
||||
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();
|
||||
|
||||
connect(m_model, &TodoModel::countChanged, this, [this]() {
|
||||
m_countLabel->setText(tr("%1 项").arg(m_model->count()));
|
||||
refreshLayout();
|
||||
m_view->viewport()->update();
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
if (auto *layer = LayerShellQt::Window::get(windowHandle())) {
|
||||
LayerShellQt::Window::Anchors anchors;
|
||||
anchors |= LayerShellQt::Window::AnchorRight;
|
||||
anchors |= LayerShellQt::Window::AnchorTop;
|
||||
layer->setAnchors(anchors);
|
||||
layer->setMargins(QMargins(0, m_topMargin, m_rightMargin, 0));
|
||||
layer->setLayer(LayerShellQt::Window::LayerOverlay);
|
||||
layer->setKeyboardInteractivity(LayerShellQt::Window::KeyboardInteractivityNone);
|
||||
layer->setExclusiveZone(0);
|
||||
layer->setScope(QStringLiteral("gxde-ai-see-todo"));
|
||||
layer->setScreenConfiguration(LayerShellQt::Window::ScreenFromQWindow);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void TodoWindow::setupUi()
|
||||
{
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->setContentsMargins(12, 10, 12, 10);
|
||||
outer->setSpacing(8);
|
||||
|
||||
auto *header = new QHBoxLayout;
|
||||
header->setContentsMargins(4, 0, 4, 0);
|
||||
header->setSpacing(8);
|
||||
|
||||
auto *title = new QLabel(tr("待办"), this);
|
||||
QFont titleFont = title->font();
|
||||
titleFont.setPixelSize(13);
|
||||
titleFont.setWeight(QFont::DemiBold);
|
||||
title->setFont(titleFont);
|
||||
title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.9);"));
|
||||
header->addWidget(title);
|
||||
|
||||
m_countLabel = new QLabel(tr("%1 项").arg(m_model->count()), this);
|
||||
m_countLabel->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.45); font-size: 11px;"));
|
||||
header->addWidget(m_countLabel);
|
||||
header->addStretch();
|
||||
|
||||
auto *addButton = new QPushButton(tr("+"), this);
|
||||
addButton->setFixedSize(26, 26);
|
||||
addButton->setCursor(Qt::PointingHandCursor);
|
||||
addButton->setFocusPolicy(Qt::NoFocus);
|
||||
addButton->setToolTip(tr("新建待办"));
|
||||
addButton->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.08);"
|
||||
" border: none; border-radius: 6px; font-size: 14px; }"
|
||||
"QPushButton:hover { background: rgba(120,180,255,0.3); color: white; }"));
|
||||
connect(addButton, &QPushButton::clicked, this, &TodoWindow::addEntry);
|
||||
header->addWidget(addButton);
|
||||
|
||||
auto *closeButton = new QPushButton(tr("×"), this);
|
||||
closeButton->setFixedSize(26, 26);
|
||||
closeButton->setCursor(Qt::PointingHandCursor);
|
||||
closeButton->setFocusPolicy(Qt::NoFocus);
|
||||
closeButton->setToolTip(tr("隐藏"));
|
||||
closeButton->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { color: rgba(255,255,255,0.7); background: transparent;"
|
||||
" border: none; font-size: 16px; }"
|
||||
"QPushButton:hover { color: white; }"));
|
||||
connect(closeButton, &QPushButton::clicked, this, &QWidget::hide);
|
||||
header->addWidget(closeButton);
|
||||
|
||||
outer->addLayout(header);
|
||||
|
||||
m_view = new QListView(this);
|
||||
m_view->setFrameShape(QFrame::NoFrame);
|
||||
m_view->setSelectionMode(QAbstractItemView::NoSelection);
|
||||
m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
||||
m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
m_view->setMouseTracking(true);
|
||||
m_view->viewport()->setMouseTracking(true);
|
||||
m_view->setStyleSheet(QStringLiteral("QListView { background: transparent; }"));
|
||||
|
||||
auto *delegate = new TodoDelegate(m_view);
|
||||
m_view->setModel(m_model);
|
||||
m_view->setItemDelegate(delegate);
|
||||
m_view->setUniformItemSizes(true);
|
||||
|
||||
connect(m_view, &QListView::entered, this, [delegate](const QModelIndex &index) {
|
||||
delegate->setHoverRow(index.row());
|
||||
});
|
||||
|
||||
outer->addWidget(m_view, 1);
|
||||
}
|
||||
|
||||
void TodoWindow::popup()
|
||||
{
|
||||
refreshLayout();
|
||||
show();
|
||||
raise();
|
||||
}
|
||||
|
||||
void TodoWindow::refreshLayout()
|
||||
{
|
||||
QScreen *screen = this->screen();
|
||||
if (!screen) {
|
||||
screen = QGuiApplication::primaryScreen();
|
||||
}
|
||||
const int screenHeight = screen ? screen->availableGeometry().height() : 900;
|
||||
const int content = m_model->count() * kRowHeight + 8;
|
||||
const int listHeight = qBound(80, content, qMax(160, screenHeight / 2));
|
||||
if (m_view) {
|
||||
m_view->setFixedHeight(listHeight);
|
||||
}
|
||||
resize(kWidth, listHeight + 56);
|
||||
}
|
||||
|
||||
void TodoWindow::addEntry()
|
||||
{
|
||||
QDialog dialog(nullptr);
|
||||
dialog.setWindowTitle(tr("新建待办"));
|
||||
dialog.setMinimumWidth(320);
|
||||
|
||||
auto *layout = new QVBoxLayout(&dialog);
|
||||
auto *edit = new QLineEdit(&dialog);
|
||||
edit->setPlaceholderText(tr("要做的事…"));
|
||||
layout->addWidget(edit);
|
||||
|
||||
auto *buttons =
|
||||
new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog);
|
||||
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
|
||||
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
if (dialog.exec() == QDialog::Accepted) {
|
||||
m_model->add(edit->text());
|
||||
refreshLayout();
|
||||
}
|
||||
}
|
||||
|
||||
void TodoWindow::applyMargins()
|
||||
{
|
||||
if (!m_keepAbove) {
|
||||
return;
|
||||
}
|
||||
#ifdef GXDE_HAVE_LAYERSHELLQT
|
||||
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
|
||||
if (auto *layer = LayerShellQt::Window::get(windowHandle())) {
|
||||
layer->setMargins(QMargins(0, m_topMargin, m_rightMargin, 0));
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (QScreen *scr = screen() ? screen() : QGuiApplication::primaryScreen()) {
|
||||
const QRect available = scr->availableGeometry();
|
||||
move(available.right() - width() - m_rightMargin, available.top() + m_topMargin);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
Config::instance()->set(QStringLiteral("todo/topMargin"), m_topMargin);
|
||||
Config::instance()->sync();
|
||||
}
|
||||
|
||||
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;
|
||||
m_dragStartTop = m_topMargin;
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
Dtk::Widget::DBlurEffectWidget::mousePressEvent(event);
|
||||
}
|
||||
|
||||
void TodoWindow::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
if (m_dragging) {
|
||||
const QPoint delta = event->position().toPoint() - m_dragStart;
|
||||
m_rightMargin = qMax(0, m_dragStartRight - delta.x());
|
||||
m_topMargin = qMax(0, m_dragStartTop + delta.y());
|
||||
applyMargins();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
Dtk::Widget::DBlurEffectWidget::mouseMoveEvent(event);
|
||||
}
|
||||
|
||||
void TodoWindow::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
if (m_dragging) {
|
||||
m_dragging = false;
|
||||
saveMargins();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
Dtk::Widget::DBlurEffectWidget::mouseReleaseEvent(event);
|
||||
}
|
||||
|
||||
void TodoWindow::showEvent(QShowEvent *event)
|
||||
{
|
||||
Dtk::Widget::DBlurEffectWidget::showEvent(event);
|
||||
refreshLayout();
|
||||
applyMargins();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <DBlurEffectWidget>
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QListView;
|
||||
class TodoModel;
|
||||
|
||||
/**
|
||||
* Floating todo window (optional keep-above, draggable, hidden from the dock).
|
||||
*
|
||||
* On Wayland it is a layer-shell overlay surface (keyboard interactivity is
|
||||
* none so it never steals focus); new entries are added from the panel or the
|
||||
* "+" button which opens a normal dialog.
|
||||
*/
|
||||
class TodoWindow : public Dtk::Widget::DBlurEffectWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit TodoWindow(TodoModel *model, QWidget *parent = nullptr);
|
||||
|
||||
void popup();
|
||||
void refreshLayout();
|
||||
|
||||
protected:
|
||||
void 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();
|
||||
void addEntry();
|
||||
void applyMargins();
|
||||
void saveMargins();
|
||||
|
||||
TodoModel *m_model = nullptr;
|
||||
QListView *m_view = nullptr;
|
||||
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;
|
||||
int m_dragStartTop = 0;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -5,6 +5,30 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "portal/portalclient.h"
|
||||
|
||||
#include "actions/downloader.h"
|
||||
#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>
|
||||
#include <QClipboard>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFileDialog>
|
||||
#include <QMessageBox>
|
||||
#include <QMenu>
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
#include <QFile>
|
||||
#include <QMouseEvent>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include "staging/stagingitem.h"
|
||||
#include "staging/stagingmodel.h"
|
||||
|
||||
@@ -28,6 +52,8 @@
|
||||
#include <QScreen>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QTimer>
|
||||
#include <QVariantAnimation>
|
||||
#include <QEasingCurve>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
@@ -112,17 +138,28 @@ QString typeLabel(const QModelIndex &index)
|
||||
|
||||
class StagingDelegate : public QStyledItemDelegate
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit StagingDelegate(QObject *parent = nullptr)
|
||||
: QStyledItemDelegate(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void setHoverRow(int row)
|
||||
void setHoverRow(int row) { m_hoverRow = row; }
|
||||
|
||||
void setRemoveState(int row, qreal progress)
|
||||
{
|
||||
m_hoverRow = row;
|
||||
m_removingRow = row;
|
||||
m_removeProgress = progress;
|
||||
}
|
||||
|
||||
signals:
|
||||
void removeRequested(int row);
|
||||
|
||||
public:
|
||||
|
||||
|
||||
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override
|
||||
{
|
||||
Q_UNUSED(option)
|
||||
@@ -136,6 +173,11 @@ public:
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
if (index.row() == m_removingRow && m_removeProgress > 0.0) {
|
||||
painter->setOpacity(1.0 - m_removeProgress);
|
||||
painter->translate(m_removeProgress * 48.0, 0.0);
|
||||
}
|
||||
|
||||
const QRect row = option.rect.adjusted(4, 2, -4, -2);
|
||||
const bool hovered = index.row() == m_hoverRow;
|
||||
|
||||
@@ -227,7 +269,7 @@ public:
|
||||
}
|
||||
|
||||
if (closeRect(option.rect).contains(pos)) {
|
||||
model->removeRow(index.row());
|
||||
Q_EMIT removeRequested(index.row());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -236,6 +278,8 @@ public:
|
||||
|
||||
private:
|
||||
int m_hoverRow = -1;
|
||||
int m_removingRow = -1;
|
||||
qreal m_removeProgress = 0.0;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
@@ -265,8 +309,36 @@ public:
|
||||
viewport()->setAutoFillBackground(false);
|
||||
setStyleSheet(QStringLiteral("QListView { background: transparent; }"));
|
||||
setAcceptDrops(true);
|
||||
|
||||
m_removeAnim = new QVariantAnimation(this);
|
||||
m_removeAnim->setDuration(200);
|
||||
m_removeAnim->setEasingCurve(QEasingCurve::OutCubic);
|
||||
connect(m_removeAnim, &QVariantAnimation::valueChanged, this, [this](const QVariant &value) {
|
||||
if (m_removingRow >= 0) {
|
||||
m_delegate->setRemoveState(m_removingRow, value.toReal());
|
||||
viewport()->update();
|
||||
}
|
||||
});
|
||||
connect(m_removeAnim, &QVariantAnimation::finished, this, [this]() {
|
||||
if (m_removingRow < 0) {
|
||||
return;
|
||||
}
|
||||
auto *staging = qobject_cast<StagingModel *>(this->model());
|
||||
const int row = m_removingRow;
|
||||
m_delegate->setRemoveState(-1, 0.0);
|
||||
m_removingRow = -1;
|
||||
if (staging) {
|
||||
staging->removeRow(row);
|
||||
}
|
||||
viewport()->update();
|
||||
});
|
||||
connect(m_delegate, &StagingDelegate::removeRequested, this, &StagingView::startRemove);
|
||||
}
|
||||
|
||||
signals:
|
||||
void dragContent(const QMimeData *mime);
|
||||
void dragFinished();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override
|
||||
{
|
||||
@@ -322,10 +394,11 @@ protected:
|
||||
void dragEnterEvent(QDragEnterEvent *event) override
|
||||
{
|
||||
if (isOwnDrag(event->mimeData())) {
|
||||
event->ignore();
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
if (mimeSupported(event->mimeData())) {
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
@@ -335,10 +408,11 @@ protected:
|
||||
void dragMoveEvent(QDragMoveEvent *event) override
|
||||
{
|
||||
if (isOwnDrag(event->mimeData())) {
|
||||
event->ignore();
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
if (mimeSupported(event->mimeData())) {
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
updateHover(event->position().toPoint());
|
||||
return;
|
||||
@@ -348,18 +422,48 @@ protected:
|
||||
|
||||
void dropEvent(QDropEvent *event) override
|
||||
{
|
||||
if (isOwnDrag(event->mimeData()) || !mimeSupported(event->mimeData())) {
|
||||
if (isOwnDrag(event->mimeData())) {
|
||||
auto *staging = qobject_cast<StagingModel *>(this->model());
|
||||
const QString id = QString::fromUtf8(
|
||||
event->mimeData()->data(QStringLiteral("application/x-gxde-ai-see-staging")));
|
||||
int from = -1;
|
||||
if (staging) {
|
||||
for (int row = 0; row < staging->count(); ++row) {
|
||||
const StagingItem *item = staging->itemAt(row);
|
||||
if (item && item->id == id) {
|
||||
from = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const QModelIndex index = indexAt(event->position().toPoint());
|
||||
int to = index.isValid() ? index.row() : (staging ? staging->count() - 1 : -1);
|
||||
if (staging && from >= 0 && to >= 0 && from != to) {
|
||||
staging->moveItem(from, to);
|
||||
}
|
||||
m_internalMove = true;
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
if (!mimeSupported(event->mimeData())) {
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
|
||||
auto *staging = qobject_cast<StagingModel *>(model());
|
||||
auto *staging = qobject_cast<StagingModel *>(this->model());
|
||||
if (staging) {
|
||||
staging->addMimeData(event->mimeData());
|
||||
}
|
||||
Q_EMIT dragFinished();
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void dragLeaveEvent(QDragLeaveEvent *event) override
|
||||
{
|
||||
Q_EMIT dragFinished();
|
||||
QListView::dragLeaveEvent(event);
|
||||
}
|
||||
|
||||
private:
|
||||
void updateHover(const QPoint &pos)
|
||||
{
|
||||
@@ -377,6 +481,21 @@ private:
|
||||
return mime && mime->hasFormat(QStringLiteral("application/x-gxde-ai-see-staging"));
|
||||
}
|
||||
|
||||
void startRemove(int row)
|
||||
{
|
||||
if (m_removeAnim->state() == QAbstractAnimation::Running) {
|
||||
return;
|
||||
}
|
||||
if (row < 0 || !model() || row >= model()->rowCount()) {
|
||||
return;
|
||||
}
|
||||
m_removingRow = row;
|
||||
m_delegate->setRemoveState(row, 0.0);
|
||||
m_removeAnim->setStartValue(0.0);
|
||||
m_removeAnim->setEndValue(1.0);
|
||||
m_removeAnim->start();
|
||||
}
|
||||
|
||||
void startDragForIndex(const QModelIndex &index)
|
||||
{
|
||||
if (!index.isValid()) {
|
||||
@@ -416,10 +535,15 @@ private:
|
||||
const Qt::DropAction action =
|
||||
drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::CopyAction);
|
||||
|
||||
if (m_internalMove) {
|
||||
m_internalMove = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Dragging an item out of the staging area consumes it unless it is
|
||||
* pinned. A cancelled drag (Esc / dropped on nothing) keeps it. */
|
||||
if (action != Qt::IgnoreAction && !pinned) {
|
||||
auto *staging = qobject_cast<StagingModel *>(model());
|
||||
auto *staging = qobject_cast<StagingModel *>(this->model());
|
||||
if (staging) {
|
||||
for (int row = 0; row < staging->count(); ++row) {
|
||||
const StagingItem *item = staging->itemAt(row);
|
||||
@@ -433,9 +557,12 @@ private:
|
||||
}
|
||||
|
||||
StagingDelegate *m_delegate = nullptr;
|
||||
QVariantAnimation *m_removeAnim = nullptr;
|
||||
QPoint m_pressPos;
|
||||
QModelIndex m_pressIndex;
|
||||
int m_hoverRow = -1;
|
||||
int m_removingRow = -1;
|
||||
bool m_internalMove = false;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
@@ -454,23 +581,13 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
m_chatButton = new QPushButton(tr("问问小埃 →"), this);
|
||||
m_chatButton->setCursor(Qt::PointingHandCursor);
|
||||
m_chatButton->setFlat(true);
|
||||
m_chatButton->setFocusPolicy(Qt::NoFocus);
|
||||
m_chatButton->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { color: #9ecbff; background: transparent; border: none;"
|
||||
" font-size: 13px; }"
|
||||
"QPushButton:hover { color: #cfe5ff; }"));
|
||||
connect(m_chatButton, &QPushButton::clicked, this, &PortalZone::chatRequested);
|
||||
|
||||
m_hintLabel = new QLabel(this);
|
||||
m_hintLabel->setAlignment(Qt::AlignCenter);
|
||||
m_hintLabel->setWordWrap(true);
|
||||
@@ -485,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;
|
||||
@@ -497,12 +626,13 @@ signals:
|
||||
void chatRequested();
|
||||
void portalRequested();
|
||||
void portalCancelled();
|
||||
void dragContent(const QMimeData *mime);
|
||||
void dragFinished();
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent *event) override
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
m_chatButton->setGeometry(12, height() - 42, width() - 24, 30);
|
||||
m_hintLabel->setGeometry(12, height() - 84, width() - 24, 36);
|
||||
}
|
||||
|
||||
@@ -570,15 +700,17 @@ protected:
|
||||
}
|
||||
m_active = true;
|
||||
update();
|
||||
if (!m_portalActive) {
|
||||
if (!m_portalActive && !m_suppressed) {
|
||||
m_portalTimer.start();
|
||||
}
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void dragMoveEvent(QDragMoveEvent *event) override
|
||||
{
|
||||
if (mimeSupported(event->mimeData())) {
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
}
|
||||
@@ -589,6 +721,7 @@ protected:
|
||||
m_active = false;
|
||||
update();
|
||||
m_portalTimer.stop();
|
||||
Q_EMIT dragFinished();
|
||||
}
|
||||
|
||||
void dropEvent(QDropEvent *event) override
|
||||
@@ -596,6 +729,7 @@ protected:
|
||||
m_active = false;
|
||||
m_portalTimer.stop();
|
||||
update();
|
||||
Q_EMIT dragFinished();
|
||||
if (!m_portalActive) {
|
||||
showHint(tr("悬停在「传送门」上,再移动到目标窗口"));
|
||||
event->ignore();
|
||||
@@ -612,13 +746,13 @@ private:
|
||||
m_hintTimer.start();
|
||||
}
|
||||
|
||||
QPushButton *m_chatButton = nullptr;
|
||||
QLabel *m_hintLabel = nullptr;
|
||||
QTimer m_hintTimer;
|
||||
QTimer m_portalTimer;
|
||||
bool m_active = false;
|
||||
bool m_hover = false;
|
||||
bool m_portalActive = false;
|
||||
bool m_suppressed = false;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
@@ -641,10 +775,17 @@ PortalPanel::PortalPanel(StagingModel *model, QWidget *parent)
|
||||
setupWindow();
|
||||
setupUi();
|
||||
|
||||
m_rightMargin = Config::instance()->getInt(QStringLiteral("panel/rightMargin"), 16);
|
||||
m_bottomMargin = Config::instance()->getInt(QStringLiteral("panel/bottomMargin"), 16);
|
||||
m_manualListHeight = Config::instance()->getInt(QStringLiteral("panel/listHeight"), 0);
|
||||
m_fixedPosition = Config::instance()->getBool(QStringLiteral("panel/fixedPosition"), true);
|
||||
m_fixedSize = Config::instance()->getBool(QStringLiteral("panel/fixedSize"), false);
|
||||
|
||||
connect(m_model, &StagingModel::countChanged, this, [this]() {
|
||||
if (m_countLabel) {
|
||||
m_countLabel->setText(tr("%1 项").arg(m_model->count()));
|
||||
}
|
||||
updateMode();
|
||||
refreshLayout();
|
||||
});
|
||||
connect(m_model, &StagingModel::countChanged, m_view->viewport(),
|
||||
@@ -691,7 +832,8 @@ void PortalPanel::setupWindow()
|
||||
anchors |= LayerShellQt::Window::AnchorRight;
|
||||
anchors |= LayerShellQt::Window::AnchorBottom;
|
||||
layer->setAnchors(anchors);
|
||||
layer->setMargins(QMargins(0, 0, 16, 16));
|
||||
layer->setMargins(
|
||||
QMargins(0, 0, m_rightMargin, m_bottomMargin));
|
||||
layer->setLayer(LayerShellQt::Window::LayerOverlay);
|
||||
layer->setKeyboardInteractivity(
|
||||
LayerShellQt::Window::KeyboardInteractivityNone);
|
||||
@@ -705,17 +847,43 @@ void PortalPanel::setupWindow()
|
||||
|
||||
void PortalPanel::setupUi()
|
||||
{
|
||||
auto *outer = new QHBoxLayout(this);
|
||||
outer->setContentsMargins(kMargin, kMargin, kMargin, kMargin);
|
||||
auto *root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(kMargin, kMargin, kMargin, kMargin);
|
||||
root->setSpacing(8);
|
||||
|
||||
auto *outer = new QHBoxLayout;
|
||||
outer->setContentsMargins(0, 0, 0, 0);
|
||||
outer->setSpacing(10);
|
||||
|
||||
m_zone = new PortalZone(this);
|
||||
m_zone->setFixedWidth(kZoneWidth);
|
||||
outer->addWidget(m_zone);
|
||||
connect(m_zone, &PortalZone::chatRequested, this, &PortalPanel::openChatRequested);
|
||||
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 {
|
||||
@@ -728,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);
|
||||
@@ -782,9 +952,62 @@ void PortalPanel::setupUi()
|
||||
right->addWidget(m_view, 1);
|
||||
|
||||
outer->addLayout(right, 1);
|
||||
root->addLayout(outer);
|
||||
|
||||
m_factory = new QWidget(this);
|
||||
m_factory->hide();
|
||||
m_factory = new ContentFactory(this);
|
||||
m_factory->show();
|
||||
root->addWidget(m_factory);
|
||||
|
||||
m_toast = new QLabel(this);
|
||||
m_toast->hide();
|
||||
m_toast->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
m_toast->setStyleSheet(QStringLiteral(
|
||||
"QLabel { background: rgba(0,0,0,0.72); color: white; border-radius: 8px;"
|
||||
" padding: 6px 12px; font-size: 12px; }"));
|
||||
|
||||
m_factoryTimer = new QTimer(this);
|
||||
m_factoryTimer->setSingleShot(true);
|
||||
m_factoryTimer->setInterval(220);
|
||||
connect(m_factoryTimer, &QTimer::timeout, this, [this]() {
|
||||
if (!m_factory || !m_factory->isVisible()) {
|
||||
return;
|
||||
}
|
||||
/* Dragging from the portal zone into the factory must not hide it:
|
||||
* only hide once the pointer is really outside the panel. */
|
||||
if (rect().contains(mapFromGlobal(QCursor::pos()))) {
|
||||
m_factoryTimer->start();
|
||||
return;
|
||||
}
|
||||
m_factory->clearContent();
|
||||
refreshLayout();
|
||||
});
|
||||
|
||||
connect(m_zone, &PortalZone::dragContent, this, &PortalPanel::noteDragContent);
|
||||
connect(m_zone, &PortalZone::dragFinished, this, &PortalPanel::noteDragFinished);
|
||||
connect(m_view, &StagingView::dragContent, this, &PortalPanel::noteDragContent);
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void PortalPanel::refreshLayout()
|
||||
@@ -792,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();
|
||||
@@ -802,13 +1051,190 @@ void PortalPanel::updateGeometryForContent()
|
||||
const int maxListHeight = qMax(120, screenHeight / 3);
|
||||
|
||||
const int contentHeight = m_model->count() * kRowHeight + 8;
|
||||
const int listHeight = qBound(140, contentHeight, maxListHeight);
|
||||
const int wanted = m_manualListHeight > 0 ? m_manualListHeight : contentHeight;
|
||||
const int listHeight = qBound(140, wanted, maxListHeight);
|
||||
m_view->setFixedHeight(listHeight);
|
||||
|
||||
const int headerHeight = 26;
|
||||
const int selectionHeight = (m_selectionBar && m_selectionBar->isVisible()) ? 32 : 0;
|
||||
const int totalHeight = kMargin * 2 + headerHeight + 6 + selectionHeight + listHeight;
|
||||
resize(kPanelWidth, totalHeight);
|
||||
/* 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_stagingOnly && m_factory) ? m_factory->sizeHint().height() + 6 : 0;
|
||||
const int totalHeight =
|
||||
kMargin * 2 + headerHeight + 6 + selectionHeight + listHeight + factoryHeight;
|
||||
resize(m_stagingOnly ? 360 : kPanelWidth, totalHeight);
|
||||
}
|
||||
|
||||
void PortalPanel::noteDragContent(const QMimeData *mime)
|
||||
{
|
||||
if (!mime) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_factoryTimer->stop();
|
||||
|
||||
const DropContent content = ContentClassifier::classify(mime);
|
||||
if (!content.valid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_factory->isVisible() && m_factory->hasContent() &&
|
||||
m_factory->content().summary() == content.summary()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_factory->setContent(content);
|
||||
}
|
||||
|
||||
void PortalPanel::noteDragFinished()
|
||||
{
|
||||
if (m_factoryTimer) {
|
||||
m_factoryTimer->start();
|
||||
}
|
||||
}
|
||||
|
||||
void PortalPanel::toast(const QString &text)
|
||||
{
|
||||
if (!m_toast) {
|
||||
return;
|
||||
}
|
||||
m_toast->setText(text);
|
||||
m_toast->adjustSize();
|
||||
m_toast->move((width() - m_toast->width()) / 2, height() - m_toast->height() - 14);
|
||||
m_toast->show();
|
||||
m_toast->raise();
|
||||
QTimer::singleShot(2600, m_toast, &QLabel::hide);
|
||||
}
|
||||
|
||||
void PortalPanel::showTextDialog(const QString &title, const QString &text)
|
||||
{
|
||||
QMessageBox box(QMessageBox::Information, title, text.left(4000), QMessageBox::Ok, this);
|
||||
QPushButton *copyButton = box.addButton(tr("复制"), QMessageBox::ActionRole);
|
||||
box.exec();
|
||||
if (box.clickedButton() == copyButton) {
|
||||
QApplication::clipboard()->setText(text);
|
||||
toast(tr("已复制"));
|
||||
}
|
||||
}
|
||||
|
||||
void PortalPanel::runFactoryAction(const QString &id, const DropContent &content)
|
||||
{
|
||||
auto copyText = [this](const QString &text) {
|
||||
QApplication::clipboard()->setText(text);
|
||||
toast(tr("已复制"));
|
||||
};
|
||||
|
||||
const QString link = content.link.url.toString();
|
||||
const QString sourceText = content.kind == DropContent::Text
|
||||
? content.text
|
||||
: (content.kind == DropContent::Files
|
||||
? TextTools::readTextFile(content.filePaths.value(0),
|
||||
512 * 1024)
|
||||
: content.text);
|
||||
|
||||
if (id == QLatin1String("open")) {
|
||||
if (content.kind == DropContent::Files) {
|
||||
QDesktopServices::openUrl(QUrl::fromLocalFile(content.filePaths.value(0)));
|
||||
} else if (!link.isEmpty()) {
|
||||
QDesktopServices::openUrl(content.link.url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("open_browser") || id == QLatin1String("try_open") ||
|
||||
id == QLatin1String("preview") || id == QLatin1String("play")) {
|
||||
QDesktopServices::openUrl(content.link.url);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("download")) {
|
||||
Downloader::instance()->download(content.link.url);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("save_as")) {
|
||||
Downloader::instance()->saveAs(content.link.url, this);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy")) {
|
||||
copyText(!content.link.raw.isEmpty() ? content.link.raw
|
||||
: (link.isEmpty() ? content.text : link));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy_markdown")) {
|
||||
copyText(QStringLiteral("[%1](%2)").arg(content.link.displayName, link));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy_name")) {
|
||||
copyText(content.link.displayName);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy_path")) {
|
||||
copyText(content.filePaths.value(0));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy_decoded")) {
|
||||
copyText(content.link.decodedUrl);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("decode")) {
|
||||
showTextDialog(tr("真实地址"), content.link.decodedUrl);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("segment")) {
|
||||
const QStringList words = TextTools::segment(sourceText.left(20000));
|
||||
auto *dialog = new SegmentDialog(words);
|
||||
dialog->show();
|
||||
dialog->raise();
|
||||
dialog->activateWindow();
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("extract_links")) {
|
||||
const QStringList links = TextTools::extractLinks(sourceText);
|
||||
showTextDialog(tr("提取链接(%1)").arg(links.size()), links.join(QLatin1Char('\n')));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("speak")) {
|
||||
auto *dialog = new SpeechDialog(sourceText.left(20000));
|
||||
dialog->show();
|
||||
dialog->raise();
|
||||
dialog->activateWindow();
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("todo")) {
|
||||
const QString text = content.kind == DropContent::Text ? content.text.left(500)
|
||||
: content.summary();
|
||||
TodoModel::instance()->add(text);
|
||||
toast(tr("已添加到待办"));
|
||||
Q_EMIT todoAdded();
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("save_image")) {
|
||||
if (content.image.isNull()) {
|
||||
toast(tr("没有可保存的图片"));
|
||||
return;
|
||||
}
|
||||
const QString path = QFileDialog::getSaveFileName(
|
||||
this, tr("保存图片"), QStringLiteral("image.png"), tr("图片 (*.png *.jpg)"));
|
||||
if (!path.isEmpty()) {
|
||||
content.image.save(path);
|
||||
toast(tr("已保存:%1").arg(path));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("ask")) {
|
||||
Q_EMIT openChatRequested();
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("translate")) {
|
||||
toast(tr("翻译接口将在小埃同学接入后可用"));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("ocr") || id == QLatin1String("describe") ||
|
||||
id == QLatin1String("transcribe")) {
|
||||
toast(tr("该能力尚未接入 GXDE Daemon"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast(tr("未知操作:%1").arg(id));
|
||||
}
|
||||
|
||||
void PortalPanel::updateSelectionBar()
|
||||
@@ -846,10 +1272,252 @@ void PortalPanel::dismiss()
|
||||
hide();
|
||||
}
|
||||
|
||||
void PortalPanel::applyMargins()
|
||||
{
|
||||
#ifdef GXDE_HAVE_LAYERSHELLQT
|
||||
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
|
||||
if (auto *layer = LayerShellQt::Window::get(windowHandle())) {
|
||||
layer->setMargins(
|
||||
QMargins(0, 0, m_rightMargin, m_bottomMargin));
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (QScreen *scr = screen() ? screen() : QGuiApplication::primaryScreen()) {
|
||||
const QRect available = scr->availableGeometry();
|
||||
move(available.right() - width() - m_rightMargin,
|
||||
available.bottom() - height() - m_bottomMargin);
|
||||
}
|
||||
}
|
||||
|
||||
void PortalPanel::saveWindowState()
|
||||
{
|
||||
Config *config = Config::instance();
|
||||
config->set(QStringLiteral("panel/rightMargin"), m_rightMargin);
|
||||
config->set(QStringLiteral("panel/bottomMargin"), m_bottomMargin);
|
||||
config->set(QStringLiteral("panel/listHeight"), m_manualListHeight);
|
||||
config->set(QStringLiteral("panel/fixedPosition"), m_fixedPosition);
|
||||
config->set(QStringLiteral("panel/fixedSize"), m_fixedSize);
|
||||
config->sync();
|
||||
}
|
||||
|
||||
void PortalPanel::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() != Qt::LeftButton) {
|
||||
Dtk::Widget::DBlurEffectWidget::mousePressEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const QPoint pos = event->position().toPoint();
|
||||
if (!m_fixedSize && pos.y() <= 10) {
|
||||
m_dragMode = 2;
|
||||
m_dragStart = pos;
|
||||
m_resizeStartHeight = m_view->height();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
if (!m_fixedPosition) {
|
||||
m_dragMode = 1;
|
||||
m_dragStart = pos;
|
||||
m_dragStartRight = m_rightMargin;
|
||||
m_dragStartBottom = m_bottomMargin;
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
Dtk::Widget::DBlurEffectWidget::mousePressEvent(event);
|
||||
}
|
||||
|
||||
void PortalPanel::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
const QPoint pos = event->position().toPoint();
|
||||
|
||||
if (m_dragMode == 1) {
|
||||
const QPoint delta = pos - m_dragStart;
|
||||
m_rightMargin = qMax(0, m_dragStartRight - delta.x());
|
||||
m_bottomMargin = qMax(0, m_dragStartBottom - delta.y());
|
||||
applyMargins();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
if (m_dragMode == 2) {
|
||||
const int delta = m_dragStart.y() - pos.y();
|
||||
m_manualListHeight = qMax(120, m_resizeStartHeight + delta);
|
||||
refreshLayout();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_fixedSize && pos.y() <= 10) {
|
||||
setCursor(Qt::SizeVerCursor);
|
||||
} else if (!m_fixedPosition && pos.x() <= 10) {
|
||||
setCursor(Qt::SizeAllCursor);
|
||||
} else {
|
||||
unsetCursor();
|
||||
}
|
||||
Dtk::Widget::DBlurEffectWidget::mouseMoveEvent(event);
|
||||
}
|
||||
|
||||
void PortalPanel::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
if (m_dragMode != 0) {
|
||||
m_dragMode = 0;
|
||||
saveWindowState();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
Dtk::Widget::DBlurEffectWidget::mouseReleaseEvent(event);
|
||||
}
|
||||
|
||||
void PortalPanel::contextMenuEvent(QContextMenuEvent *event)
|
||||
{
|
||||
showContextMenu(event->globalPos());
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void PortalPanel::showContextMenu(const QPoint &globalPos)
|
||||
{
|
||||
QMenu menu(this);
|
||||
|
||||
QAction *fixedPosition = menu.addAction(tr("固定位置"));
|
||||
fixedPosition->setCheckable(true);
|
||||
fixedPosition->setChecked(m_fixedPosition);
|
||||
|
||||
QAction *resetPosition = menu.addAction(tr("恢复默认位置"));
|
||||
|
||||
QAction *fixedSize = menu.addAction(tr("固定大小"));
|
||||
fixedSize->setCheckable(true);
|
||||
fixedSize->setChecked(m_fixedSize);
|
||||
|
||||
QAction *autoHideFullscreen = menu.addAction(tr("全屏时隐藏"));
|
||||
autoHideFullscreen->setCheckable(true);
|
||||
autoHideFullscreen->setChecked(
|
||||
Config::instance()->getBool(QStringLiteral("panel/hideOnFullscreen"), false));
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
const QString slimy = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) +
|
||||
QStringLiteral("/GXDE/gxde-k9/slimy/gxde-ai-see.slimy");
|
||||
QAction *autostart = menu.addAction(tr("开机自启与后台保活"));
|
||||
autostart->setCheckable(true);
|
||||
autostart->setChecked(QFile::exists(slimy));
|
||||
|
||||
menu.addSeparator();
|
||||
QAction *clear = menu.addAction(tr("清空暂存区"));
|
||||
QAction *hide = menu.addAction(tr("隐藏"));
|
||||
QAction *about = menu.addAction(tr("关于"));
|
||||
QAction *quit = menu.addAction(tr("退出"));
|
||||
|
||||
menu.ensurePolished();
|
||||
const QSize menuSize = menu.sizeHint();
|
||||
QPoint menuPos = globalPos;
|
||||
QScreen *scr = screen() ? screen() : QGuiApplication::primaryScreen();
|
||||
if (scr) {
|
||||
const QRect available = scr->availableGeometry();
|
||||
if (menuPos.y() + menuSize.height() > available.bottom()) {
|
||||
menuPos.setY(globalPos.y() - menuSize.height());
|
||||
}
|
||||
if (menuPos.y() < available.top()) {
|
||||
menuPos.setY(available.top());
|
||||
}
|
||||
if (menuPos.x() + menuSize.width() > available.right()) {
|
||||
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();
|
||||
} else if (chosen == resetPosition) {
|
||||
m_rightMargin = 16;
|
||||
m_bottomMargin = 16;
|
||||
applyMargins();
|
||||
saveWindowState();
|
||||
} else if (chosen == fixedSize) {
|
||||
m_fixedSize = fixedSize->isChecked();
|
||||
saveWindowState();
|
||||
} else if (chosen == autoHideFullscreen) {
|
||||
Config::instance()->set(QStringLiteral("panel/hideOnFullscreen"),
|
||||
autoHideFullscreen->isChecked());
|
||||
Config::instance()->sync();
|
||||
} else if (chosen == autostart) {
|
||||
const bool enable = autostart->isChecked();
|
||||
QDir().mkpath(QFileInfo(slimy).absolutePath());
|
||||
if (enable) {
|
||||
QFile file(slimy);
|
||||
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
QTextStream out(&file);
|
||||
out << "#!/bin/bash\n"
|
||||
"PID_DIR=\"${PID_DIR:-/tmp/GXDE/gxde-k9/}\"\n"
|
||||
"mkdir -p \"$PID_DIR\"\n"
|
||||
"PID_FILE=\"$PID_DIR/gxde-ai-see.pid\"\n"
|
||||
"if [ -f \"$PID_FILE\" ]; then\n"
|
||||
" PID=$(cat \"$PID_FILE\" 2>/dev/null)\n"
|
||||
" [ -n \"$PID\" ] && [ -e \"/proc/$PID\" ] && exit 0\n"
|
||||
" rm -f \"$PID_FILE\"\n"
|
||||
"fi\n"
|
||||
"/usr/bin/gxde-ai-see --daemon >/dev/null 2>&1 &\n"
|
||||
"echo $! > \"$PID_FILE\"\n";
|
||||
file.close();
|
||||
file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
|
||||
QFileDevice::ExeOwner);
|
||||
}
|
||||
} else {
|
||||
QFile::remove(slimy);
|
||||
}
|
||||
toast(enable ? tr("已开启开机自启与保活") : tr("已关闭开机自启与保活"));
|
||||
} else if (chosen == clear) {
|
||||
m_model->clearAll();
|
||||
} else if (chosen == about) {
|
||||
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);
|
||||
refreshLayout();
|
||||
applyMargins();
|
||||
|
||||
if (!QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
|
||||
QScreen *scr = screen();
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
#include <DBlurEffectWidget>
|
||||
#include <QWidget>
|
||||
|
||||
class ContentFactory;
|
||||
class QLabel;
|
||||
class QMimeData;
|
||||
class QPushButton;
|
||||
class QTimer;
|
||||
struct DropContent;
|
||||
class PortalZone;
|
||||
class StagingModel;
|
||||
class StagingView;
|
||||
@@ -29,28 +33,63 @@ 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;
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void contextMenuEvent(QContextMenuEvent *event) override;
|
||||
|
||||
private:
|
||||
void setupUi();
|
||||
void setupWindow();
|
||||
void updateGeometryForContent();
|
||||
void updateMode();
|
||||
void updateSelectionBar();
|
||||
void noteDragContent(const QMimeData *mime);
|
||||
void noteDragFinished();
|
||||
void runFactoryAction(const QString &id, const DropContent &content);
|
||||
void showTextDialog(const QString &title, const QString &text);
|
||||
void toast(const QString &text);
|
||||
void applyMargins();
|
||||
void saveWindowState();
|
||||
void showContextMenu(const QPoint &globalPos);
|
||||
|
||||
StagingModel *m_model = nullptr;
|
||||
StagingView *m_view = nullptr;
|
||||
PortalZone *m_zone = nullptr;
|
||||
QWidget *m_header = nullptr;
|
||||
QWidget *m_selectionBar = nullptr;
|
||||
QWidget *m_factory = nullptr;
|
||||
ContentFactory *m_factory = nullptr;
|
||||
QLabel *m_toast = nullptr;
|
||||
QTimer *m_factoryTimer = nullptr;
|
||||
QLabel *m_countLabel = nullptr;
|
||||
QPushButton *m_selectAllButton = nullptr;
|
||||
QPushButton *m_invertButton = nullptr;
|
||||
QPushButton *m_deleteButton = nullptr;
|
||||
|
||||
int m_rightMargin = 16;
|
||||
int m_bottomMargin = 16;
|
||||
int m_manualListHeight = 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
|
||||
QPoint m_dragStart;
|
||||
int m_dragStartRight = 0;
|
||||
int m_dragStartBottom = 0;
|
||||
int m_resizeStartHeight = 0;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Fake OpenAI compatible streaming endpoint used by tests/chattest.cpp.
|
||||
|
||||
First request streams a tool call for the ``echo`` MCP tool; once the request
|
||||
contains a ``tool`` message it streams the final answer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
messages = body.get("messages", [])
|
||||
has_tool_result = any(message.get("role") == "tool" for message in messages)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
|
||||
if has_tool_result:
|
||||
delta = {"choices": [{"index": 0, "delta": {"content": "工具返回:echo: hello"},
|
||||
"finish_reason": None}]}
|
||||
else:
|
||||
delta = {"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "echo",
|
||||
"arguments": json.dumps({"text": "hello"})},
|
||||
}],
|
||||
},
|
||||
"finish_reason": None,
|
||||
}]}
|
||||
|
||||
self.wfile.write(("data: " + json.dumps(delta) + "\n\n").encode())
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
print("PORT", server.server_port, flush=True)
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// End-to-end chat + MCP test against a fake OpenAI compatible server:
|
||||
// request -> streaming tool call -> MCP tools/call -> follow-up request -> text.
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QProcess>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTextStream>
|
||||
#include <QTimer>
|
||||
|
||||
#include "chat/chatclient.h"
|
||||
#include "chat/mcpclient.h"
|
||||
#include "config.h"
|
||||
|
||||
static QTextStream out(stdout);
|
||||
static int exitCode = 1;
|
||||
|
||||
static void finish(QCoreApplication *app, bool ok, const QString &message)
|
||||
{
|
||||
out << (ok ? "PASS " : "FAIL ") << message << "\n";
|
||||
out.flush();
|
||||
exitCode = ok ? 0 : 1;
|
||||
app->quit();
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
app.setOrganizationName(QStringLiteral("GXDE"));
|
||||
app.setApplicationName(QStringLiteral("gxde-ai-see"));
|
||||
|
||||
QTemporaryDir configDir;
|
||||
if (!configDir.isValid()) {
|
||||
finish(&app, false, QStringLiteral("cannot create temp config dir"));
|
||||
return exitCode;
|
||||
}
|
||||
qputenv("XDG_CONFIG_HOME", configDir.path().toUtf8());
|
||||
qputenv("XDG_DATA_HOME", configDir.path().toUtf8());
|
||||
qputenv("XDG_CACHE_HOME", configDir.path().toUtf8());
|
||||
|
||||
const QString testsDir = QStringLiteral(TESTS_DIR);
|
||||
|
||||
QProcess server;
|
||||
server.setProcessChannelMode(QProcess::MergedChannels);
|
||||
server.start(QStringLiteral("python3"),
|
||||
{ testsDir + QStringLiteral("/api/fake_openai.py") });
|
||||
if (!server.waitForStarted(5000)) {
|
||||
finish(&app, false, QStringLiteral("fake API server did not start"));
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
QByteArray output;
|
||||
int port = 0;
|
||||
while (port == 0) {
|
||||
if (!server.waitForReadyRead(5000)) {
|
||||
break;
|
||||
}
|
||||
output += server.readAll();
|
||||
const int index = output.indexOf("PORT ");
|
||||
if (index >= 0) {
|
||||
const int end = output.indexOf('\n', index);
|
||||
if (end > index) {
|
||||
port = output.mid(index + 5, end - index - 5).trimmed().toInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (port == 0) {
|
||||
finish(&app, false, QStringLiteral("cannot read fake API port"));
|
||||
return exitCode;
|
||||
}
|
||||
out << "fake API port " << port << "\n";
|
||||
|
||||
Config::instance()->init();
|
||||
Config::instance()->set(QStringLiteral("chat/apiKey"), QStringLiteral("test"));
|
||||
Config::instance()->set(QStringLiteral("chat/baseUrl"),
|
||||
QStringLiteral("http://127.0.0.1:%1").arg(port));
|
||||
Config::instance()->set(QStringLiteral("chat/model"), QStringLiteral("test-model"));
|
||||
Config::instance()->set(
|
||||
QStringLiteral("chat/mcpServers"),
|
||||
QStringLiteral("[{\"name\":\"test\",\"command\":\"python3\",\"args\":[\"%1/mcp/test_server.py\"]}]")
|
||||
.arg(testsDir));
|
||||
Config::instance()->sync();
|
||||
|
||||
McpManager::instance()->reload();
|
||||
ChatClient client;
|
||||
|
||||
QObject::connect(&client, &ChatClient::failed, [&](const QString &error) {
|
||||
finish(&app, false, QStringLiteral("chat failed: %1").arg(error));
|
||||
});
|
||||
|
||||
QObject::connect(&client, &ChatClient::toolCallsReady,
|
||||
[&](const QVector<ToolCall> &calls) {
|
||||
if (calls.isEmpty()) {
|
||||
finish(&app, false, QStringLiteral("empty tool call"));
|
||||
return;
|
||||
}
|
||||
out << "tool call " << calls.first().name << " "
|
||||
<< calls.first().arguments << "\n";
|
||||
const ToolCall call = calls.first();
|
||||
McpManager::instance()->callTool(
|
||||
call.name,
|
||||
QJsonObject{ { QStringLiteral("text"), QStringLiteral("hello") } },
|
||||
[&, call](const QJsonObject &result) {
|
||||
QString text;
|
||||
for (const QJsonValue &value :
|
||||
result.value(QStringLiteral("content")).toArray()) {
|
||||
text += value.toObject()
|
||||
.value(QStringLiteral("text"))
|
||||
.toString();
|
||||
}
|
||||
out << "tool result " << text << "\n";
|
||||
if (!text.contains(QStringLiteral("echo: hello"))) {
|
||||
finish(&app, false, QStringLiteral("tool result mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonObject assistant;
|
||||
assistant.insert(QStringLiteral("role"),
|
||||
QStringLiteral("assistant"));
|
||||
assistant.insert(QStringLiteral("content"), QJsonValue::Null);
|
||||
QJsonObject function;
|
||||
function.insert(QStringLiteral("name"), call.name);
|
||||
function.insert(QStringLiteral("arguments"), call.arguments);
|
||||
assistant.insert(
|
||||
QStringLiteral("tool_calls"),
|
||||
QJsonArray{ QJsonObject{
|
||||
{ QStringLiteral("id"), call.id },
|
||||
{ QStringLiteral("type"), QStringLiteral("function") },
|
||||
{ QStringLiteral("function"), function } } });
|
||||
QJsonObject toolMessage{
|
||||
{ QStringLiteral("role"), QStringLiteral("tool") },
|
||||
{ QStringLiteral("tool_call_id"), call.id },
|
||||
{ QStringLiteral("content"), text },
|
||||
};
|
||||
client.send(
|
||||
{ ChatMessage{ QStringLiteral("user"),
|
||||
QStringLiteral("hi") } },
|
||||
QJsonArray{ assistant, toolMessage });
|
||||
});
|
||||
});
|
||||
|
||||
QObject::connect(&client, &ChatClient::finished, [&](const QString &text) {
|
||||
out << "answer " << text << "\n";
|
||||
finish(&app, text.contains(QStringLiteral("echo: hello")),
|
||||
QStringLiteral("tool loop end to end"));
|
||||
});
|
||||
|
||||
QTimer::singleShot(20000, &app, [&]() {
|
||||
finish(&app, false, QStringLiteral("timeout"));
|
||||
});
|
||||
|
||||
auto *poller = new QTimer(&app);
|
||||
poller->setInterval(100);
|
||||
QObject::connect(poller, &QTimer::timeout, [&]() {
|
||||
if (!McpManager::instance()->hasTools()) {
|
||||
return;
|
||||
}
|
||||
poller->stop();
|
||||
out << "mcp tools ready\n";
|
||||
client.setTools(McpManager::instance()->toolDefinitions());
|
||||
client.send({ ChatMessage{ QStringLiteral("user"), QStringLiteral("hi") } });
|
||||
});
|
||||
poller->start();
|
||||
|
||||
const int rc = app.exec();
|
||||
if (server.state() != QProcess::NotRunning) {
|
||||
server.kill();
|
||||
server.waitForFinished(1000);
|
||||
}
|
||||
return exitCode != 0 ? exitCode : rc;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tiny stdio MCP server used by tests/mcptest.cpp."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def reply(message, result):
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": result}), flush=True)
|
||||
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
message = json.loads(line)
|
||||
method = message.get("method")
|
||||
|
||||
if method == "initialize":
|
||||
reply(message, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "test-server", "version": "0.1"},
|
||||
})
|
||||
elif method == "tools/list":
|
||||
reply(message, {
|
||||
"tools": [{
|
||||
"name": "echo",
|
||||
"description": "Echo the given text back",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
}]
|
||||
})
|
||||
elif method == "tools/call":
|
||||
arguments = message.get("params", {}).get("arguments", {})
|
||||
reply(message, {
|
||||
"content": [{"type": "text", "text": "echo: " + str(arguments.get("text", ""))}]
|
||||
})
|
||||
elif "id" in message:
|
||||
reply(message, {})
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Headless MCP smoke test: starts the given command as an MCP server, waits for
|
||||
// tools/list and calls the first tool once.
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QJsonDocument>
|
||||
#include <QTextStream>
|
||||
#include <QTimer>
|
||||
|
||||
#include "chat/mcpclient.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
QTextStream out(stdout);
|
||||
|
||||
if (argc < 3) {
|
||||
out << "usage: " << argv[0] << " <command> <script> [args...]\n";
|
||||
return 2;
|
||||
}
|
||||
|
||||
QStringList args;
|
||||
for (int i = 2; i < argc; ++i) {
|
||||
args.append(QString::fromLocal8Bit(argv[i]));
|
||||
}
|
||||
|
||||
McpServer server(QStringLiteral("test"), QString::fromLocal8Bit(argv[1]), args);
|
||||
|
||||
QObject::connect(&server, &McpServer::readyChanged, [&]() {
|
||||
out << "READY tools=" << server.tools().size() << "\n";
|
||||
if (server.tools().isEmpty()) {
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
const QString name =
|
||||
server.tools().first().toObject().value(QStringLiteral("name")).toString();
|
||||
out << "TOOL " << name << "\n";
|
||||
server.callTool(name, QJsonObject{ { QStringLiteral("text"),
|
||||
QStringLiteral("hello") } },
|
||||
[&](const QJsonObject &result) {
|
||||
out << "RESULT "
|
||||
<< QString::fromUtf8(
|
||||
QJsonDocument(result).toJson(QJsonDocument::Compact))
|
||||
<< "\n";
|
||||
out.flush();
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
server.start();
|
||||
QTimer::singleShot(8000, &app, [&]() {
|
||||
out << "TIMEOUT\n";
|
||||
out.flush();
|
||||
app.exit(1);
|
||||
});
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Build the wlr-virtual-pointer test driver.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
cc -O2 -Wall pointer.c virtual-pointer-protocol.c -o pointer $(pkg-config --cflags --libs wayland-client)
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""GTK3 drag source used by the portal integration test."""
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
from gi.repository import Gdk, Gtk # noqa: E402
|
||||
|
||||
|
||||
class Source(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="PortalDragSource")
|
||||
self.set_default_size(420, 280)
|
||||
self.set_position(Gtk.WindowPosition.CENTER)
|
||||
|
||||
label = Gtk.Label()
|
||||
label.set_markup("<span size='x-large'>DRAG ME</span>\n\nportal test payload")
|
||||
label.set_hexpand(True)
|
||||
label.set_vexpand(True)
|
||||
|
||||
box = Gtk.EventBox()
|
||||
box.add(label)
|
||||
self.add(box)
|
||||
box.add_events(
|
||||
Gdk.EventMask.BUTTON_PRESS_MASK
|
||||
| Gdk.EventMask.BUTTON_RELEASE_MASK
|
||||
| Gdk.EventMask.POINTER_MOTION_MASK
|
||||
)
|
||||
box.connect("button-press-event", lambda *args: print("PRESS", flush=True))
|
||||
box.connect("button-release-event", lambda *args: print("RELEASE", flush=True))
|
||||
box.connect("motion-notify-event", lambda *args: print("MOTION", flush=True))
|
||||
|
||||
box.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, [], Gdk.DragAction.COPY)
|
||||
box.drag_source_add_text_targets()
|
||||
box.connect("drag-data-get", self.on_drag_data_get)
|
||||
box.connect("drag-begin", lambda *args: print("DRAG-BEGIN", flush=True))
|
||||
box.connect("drag-end", lambda *args: print("DRAG-END", flush=True))
|
||||
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
self.show_all()
|
||||
|
||||
def on_drag_data_get(self, widget, context, data, info, time):
|
||||
data.set_text("portal-payload-42", -1)
|
||||
|
||||
|
||||
Source()
|
||||
Gtk.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""GTK3 drop target used by the portal integration test.
|
||||
|
||||
GtkTextView accepts text drops out of the box; every insertion is printed as
|
||||
``DROP <text> at <offset>`` so the orchestrator can assert that the *native*
|
||||
drop landed in this window.
|
||||
"""
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
from gi.repository import Gtk # noqa: E402
|
||||
|
||||
|
||||
class Target(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="PortalDropTarget")
|
||||
self.set_default_size(420, 280)
|
||||
self.set_position(Gtk.WindowPosition.CENTER)
|
||||
|
||||
view = Gtk.TextView()
|
||||
view.set_wrap_mode(Gtk.WrapMode.WORD)
|
||||
self.buffer = view.get_buffer()
|
||||
self.buffer.connect("insert-text", self.on_insert)
|
||||
|
||||
scroller = Gtk.ScrolledWindow()
|
||||
scroller.add(view)
|
||||
self.add(scroller)
|
||||
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
self.show_all()
|
||||
|
||||
def on_insert(self, buffer, location, text, length):
|
||||
print("DROP", repr(text), "at", location.get_offset(), flush=True)
|
||||
|
||||
|
||||
Target()
|
||||
Gtk.main()
|
||||
@@ -0,0 +1,234 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Minimal wlr-virtual-pointer driver used by the portal integration test.
|
||||
// Reads commands from stdin, one per line:
|
||||
// move <x> <y> absolute motion in output coordinates
|
||||
// press left button down
|
||||
// release left button up
|
||||
// sleep <ms>
|
||||
// size print "size <w> <h>"
|
||||
// quit
|
||||
// After each command it prints "ok <line>" so the orchestrator can sync.
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <linux/input-event-codes.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <wayland-client.h>
|
||||
|
||||
#include "virtual-pointer-client.h"
|
||||
|
||||
struct state {
|
||||
struct wl_display *display;
|
||||
struct wl_seat *seat;
|
||||
struct zwlr_virtual_pointer_manager_v1 *manager;
|
||||
struct zwlr_virtual_pointer_v1 *pointer;
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
};
|
||||
|
||||
static uint32_t now_ms(void)
|
||||
{
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (uint32_t)(ts.tv_sec * 1000 + ts.tv_nsec / 1000000);
|
||||
}
|
||||
|
||||
static void output_geometry(void *data, struct wl_output *output, int32_t x, int32_t y,
|
||||
int32_t physical_w, int32_t physical_h, int32_t subpixel,
|
||||
const char *make, const char *model, int32_t transform)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)physical_w;
|
||||
(void)physical_h;
|
||||
(void)subpixel;
|
||||
(void)make;
|
||||
(void)model;
|
||||
(void)transform;
|
||||
}
|
||||
|
||||
static void output_mode(void *data, struct wl_output *output, uint32_t flags, int32_t width,
|
||||
int32_t height, int32_t refresh)
|
||||
{
|
||||
(void)output;
|
||||
(void)flags;
|
||||
(void)refresh;
|
||||
struct state *state = data;
|
||||
if (width > 0 && height > 0) {
|
||||
state->width = (uint32_t)width;
|
||||
state->height = (uint32_t)height;
|
||||
}
|
||||
}
|
||||
|
||||
static void output_done(void *data, struct wl_output *output)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
}
|
||||
|
||||
static void output_scale(void *data, struct wl_output *output, int32_t factor)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
(void)factor;
|
||||
}
|
||||
|
||||
static void output_name(void *data, struct wl_output *output, const char *name)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
(void)name;
|
||||
}
|
||||
|
||||
static void output_description(void *data, struct wl_output *output, const char *description)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
(void)description;
|
||||
}
|
||||
|
||||
static const struct wl_output_listener output_listener = {
|
||||
.geometry = output_geometry,
|
||||
.mode = output_mode,
|
||||
.done = output_done,
|
||||
.scale = output_scale,
|
||||
.name = output_name,
|
||||
.description = output_description,
|
||||
};
|
||||
|
||||
static void seat_capabilities(void *data, struct wl_seat *seat, uint32_t caps)
|
||||
{
|
||||
(void)data;
|
||||
(void)seat;
|
||||
(void)caps;
|
||||
}
|
||||
|
||||
static void seat_name(void *data, struct wl_seat *seat, const char *name)
|
||||
{
|
||||
(void)data;
|
||||
(void)seat;
|
||||
(void)name;
|
||||
}
|
||||
|
||||
static const struct wl_seat_listener seat_listener = {
|
||||
.capabilities = seat_capabilities,
|
||||
.name = seat_name,
|
||||
};
|
||||
|
||||
static void registry_global(void *data, struct wl_registry *registry, uint32_t name,
|
||||
const char *interface, uint32_t version)
|
||||
{
|
||||
struct state *state = data;
|
||||
|
||||
if (strcmp(interface, wl_output_interface.name) == 0) {
|
||||
struct wl_output *output =
|
||||
wl_registry_bind(registry, name, &wl_output_interface, version < 2 ? version : 2);
|
||||
wl_output_add_listener(output, &output_listener, state);
|
||||
} else if (strcmp(interface, wl_seat_interface.name) == 0) {
|
||||
state->seat = wl_registry_bind(registry, name, &wl_seat_interface, version < 5 ? version : 5);
|
||||
wl_seat_add_listener(state->seat, &seat_listener, state);
|
||||
} else if (strcmp(interface, zwlr_virtual_pointer_manager_v1_interface.name) == 0) {
|
||||
state->manager = wl_registry_bind(registry, name,
|
||||
&zwlr_virtual_pointer_manager_v1_interface,
|
||||
version < 2 ? version : 2);
|
||||
}
|
||||
}
|
||||
|
||||
static void registry_global_remove(void *data, struct wl_registry *registry, uint32_t name)
|
||||
{
|
||||
(void)data;
|
||||
(void)registry;
|
||||
(void)name;
|
||||
}
|
||||
|
||||
static const struct wl_registry_listener registry_listener = {
|
||||
.global = registry_global,
|
||||
.global_remove = registry_global_remove,
|
||||
};
|
||||
|
||||
static void send_motion(struct state *state, double x, double y)
|
||||
{
|
||||
zwlr_virtual_pointer_v1_motion_absolute(state->pointer, now_ms(), wl_fixed_from_double(x),
|
||||
wl_fixed_from_double(y), state->width,
|
||||
state->height);
|
||||
zwlr_virtual_pointer_v1_frame(state->pointer);
|
||||
wl_display_roundtrip(state->display);
|
||||
}
|
||||
|
||||
static void send_button(struct state *state, uint32_t button, uint32_t button_state)
|
||||
{
|
||||
zwlr_virtual_pointer_v1_button(state->pointer, now_ms(), button, button_state);
|
||||
zwlr_virtual_pointer_v1_frame(state->pointer);
|
||||
wl_display_roundtrip(state->display);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
struct state state = { 0 };
|
||||
state.display = wl_display_connect(NULL);
|
||||
if (!state.display) {
|
||||
fprintf(stderr, "cannot connect to wayland display\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
struct wl_registry *registry = wl_display_get_registry(state.display);
|
||||
wl_registry_add_listener(registry, ®istry_listener, &state);
|
||||
wl_display_roundtrip(state.display);
|
||||
wl_display_roundtrip(state.display);
|
||||
|
||||
if (!state.seat || !state.manager) {
|
||||
fprintf(stderr, "missing seat or virtual pointer manager\n");
|
||||
return 1;
|
||||
}
|
||||
if (state.width == 0 || state.height == 0) {
|
||||
state.width = 1280;
|
||||
state.height = 720;
|
||||
}
|
||||
|
||||
state.pointer = zwlr_virtual_pointer_manager_v1_create_virtual_pointer(state.manager, state.seat);
|
||||
if (!state.pointer) {
|
||||
fprintf(stderr, "cannot create virtual pointer\n");
|
||||
return 1;
|
||||
}
|
||||
wl_display_roundtrip(state.display);
|
||||
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), stdin)) {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
int ms = 0;
|
||||
|
||||
if (sscanf(line, "move %lf %lf", &x, &y) == 2) {
|
||||
send_motion(&state, x, y);
|
||||
} else if (strncmp(line, "press", 5) == 0) {
|
||||
send_button(&state, BTN_LEFT, WL_POINTER_BUTTON_STATE_PRESSED);
|
||||
} else if (strncmp(line, "release", 7) == 0) {
|
||||
send_button(&state, BTN_LEFT, WL_POINTER_BUTTON_STATE_RELEASED);
|
||||
} else if (sscanf(line, "sleep %d", &ms) == 1) {
|
||||
struct timespec delay = { ms / 1000, (long)(ms % 1000) * 1000000L };
|
||||
nanosleep(&delay, NULL);
|
||||
} else if (strncmp(line, "size", 4) == 0) {
|
||||
printf("size %u %u\n", state.width, state.height);
|
||||
} else if (strncmp(line, "quit", 4) == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
printf("ok %s", line);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
zwlr_virtual_pointer_v1_destroy(state.pointer);
|
||||
zwlr_virtual_pointer_manager_v1_destroy(state.manager);
|
||||
wl_display_disconnect(state.display);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""End-to-end test for the GXDE portal (native drop into a window).
|
||||
|
||||
Run inside a private session bus:
|
||||
|
||||
dbus-run-session -- python3 tests/portal/run.py [compositor]
|
||||
|
||||
It starts a nested GXDE compositor, a GTK drag source and a GTK drop target,
|
||||
drives the pointer through wlr-virtual-pointer, asks the compositor to open the
|
||||
portal overview and asserts that the drop lands in the target window.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RUNTIME = f"/run/user/{os.getuid()}"
|
||||
PORTAL = ["org.gxde.Portal", "/org/gxde/Portal", "org.gxde.Portal"]
|
||||
|
||||
|
||||
def log(message):
|
||||
print(f"[test] {message}", flush=True)
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=20.0, interval=0.1):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(interval)
|
||||
return None
|
||||
|
||||
|
||||
def portal_call(method, *args):
|
||||
address = os.environ.get("DBUS_SESSION_BUS_ADDRESS", "")
|
||||
cmd = ["busctl", f"--address={address}", "--json=short", "call", *PORTAL, method, *args]
|
||||
out = subprocess.check_output(cmd, text=True)
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def list_windows():
|
||||
data = portal_call("ListWindows")["data"]
|
||||
rows = data[0] if data else []
|
||||
return [
|
||||
{
|
||||
"uuid": row[0],
|
||||
"title": row[1],
|
||||
"app": row[2],
|
||||
"x": row[3],
|
||||
"y": row[4],
|
||||
"w": row[5],
|
||||
"h": row[6],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def layout():
|
||||
data = portal_call("GetLayout")["data"]
|
||||
rows = data[0] if data else []
|
||||
return [
|
||||
{
|
||||
"uuid": row[0],
|
||||
"title": row[1],
|
||||
"x": row[3],
|
||||
"y": row[4],
|
||||
"w": row[5],
|
||||
"h": row[6],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def find_by_title(windows, title):
|
||||
for window in windows:
|
||||
if window["title"] == title:
|
||||
return window
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
compositor = (
|
||||
sys.argv[1]
|
||||
if len(sys.argv) > 1
|
||||
else os.path.expanduser("~/playground/flake-wm/build-portal/flakewm")
|
||||
)
|
||||
parent_display = os.environ.get("WAYLAND_DISPLAY", "wayland-0")
|
||||
|
||||
before = set(glob.glob(f"{RUNTIME}/wayland-*"))
|
||||
env = os.environ.copy()
|
||||
env["WAYLAND_DISPLAY"] = parent_display
|
||||
|
||||
compositor_log = open("/tmp/pt-comp.log", "wb")
|
||||
compositor_proc = subprocess.Popen(
|
||||
[compositor], env=env, stdout=compositor_log, stderr=subprocess.STDOUT
|
||||
)
|
||||
|
||||
procs = [compositor_proc]
|
||||
pointer = None
|
||||
try:
|
||||
def nested_display():
|
||||
for path in set(glob.glob(f"{RUNTIME}/wayland-*")) - before:
|
||||
if not path.endswith(".lock"):
|
||||
return os.path.basename(path)
|
||||
return None
|
||||
|
||||
display = wait_for(nested_display, 25.0)
|
||||
assert display, "nested compositor did not create a Wayland socket"
|
||||
log(f"nested display: {display}")
|
||||
env["WAYLAND_DISPLAY"] = display
|
||||
|
||||
target_log = open("/tmp/pt-target.log", "wb")
|
||||
source_log = open("/tmp/pt-source.log", "wb")
|
||||
target = subprocess.Popen(
|
||||
[sys.executable, f"{HERE}/drop_target.py"],
|
||||
env=env, stdout=target_log, stderr=subprocess.STDOUT,
|
||||
)
|
||||
source = subprocess.Popen(
|
||||
[sys.executable, f"{HERE}/drag_source.py"],
|
||||
env=env, stdout=source_log, stderr=subprocess.STDOUT,
|
||||
)
|
||||
procs += [target, source]
|
||||
|
||||
def portal_ready():
|
||||
try:
|
||||
portal_call("ListWindows")
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
assert wait_for(portal_ready, 20.0, 0.3), "org.gxde.Portal never appeared"
|
||||
log("portal D-Bus ready")
|
||||
|
||||
windows = wait_for(
|
||||
lambda: list_windows()
|
||||
if find_by_title(list_windows(), "PortalDragSource")
|
||||
and find_by_title(list_windows(), "PortalDropTarget")
|
||||
else None,
|
||||
20.0,
|
||||
0.2,
|
||||
)
|
||||
assert windows, "windows did not appear"
|
||||
log(f"windows: {json.dumps(windows)}")
|
||||
|
||||
src = find_by_title(windows, "PortalDragSource")
|
||||
dst = find_by_title(windows, "PortalDropTarget")
|
||||
|
||||
pointer = subprocess.Popen(
|
||||
[f"{HERE}/pointer"], env=env, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, text=True, bufsize=1,
|
||||
)
|
||||
procs.append(pointer)
|
||||
|
||||
def pcmd(line):
|
||||
pointer.stdin.write(line + "\n")
|
||||
pointer.stdin.flush()
|
||||
response = pointer.stdout.readline().strip()
|
||||
if not (response.startswith("ok") or response.startswith("size")):
|
||||
raise RuntimeError(f"pointer failed: {response!r}")
|
||||
return response
|
||||
|
||||
log(f"pointer {pcmd('size')}")
|
||||
|
||||
sx = src["x"] + src["w"] / 2
|
||||
sy = src["y"] + src["h"] / 2
|
||||
pcmd(f"move {sx:.0f} {sy:.0f}")
|
||||
time.sleep(0.2)
|
||||
pcmd("press")
|
||||
time.sleep(0.2)
|
||||
pcmd(f"move {sx + 60:.0f} {sy:.0f}")
|
||||
time.sleep(0.8)
|
||||
|
||||
if "DRAG-BEGIN" not in open("/tmp/pt-source.log").read():
|
||||
raise RuntimeError("GTK drag did not start; see /tmp/pt-source.log")
|
||||
log("drag started")
|
||||
|
||||
started = portal_call("BeginPortal")["data"][0]
|
||||
assert started, "BeginPortal returned false"
|
||||
log("portal opened")
|
||||
|
||||
time.sleep(0.7) # let the overview animation settle
|
||||
tiles = wait_for(lambda: layout() or None, 5.0, 0.2)
|
||||
assert tiles, "portal layout is empty"
|
||||
log(f"layout: {json.dumps(tiles)}")
|
||||
|
||||
tile = find_by_title(tiles, "PortalDropTarget") or find_by_title(tiles, "PortalDragSource")
|
||||
assert tile, "target window not in the portal layout"
|
||||
|
||||
# If we only see the source tile, the drop still validates retargeting.
|
||||
tx = tile["x"] + tile["w"] / 2
|
||||
ty = tile["y"] + tile["h"] / 2
|
||||
pcmd(f"move {tx:.0f} {ty:.0f}")
|
||||
time.sleep(0.6)
|
||||
|
||||
subprocess.run(["grim", "/tmp/pt-before-drop.png"], env=env, check=False)
|
||||
pcmd("release")
|
||||
time.sleep(1.0)
|
||||
subprocess.run(["grim", "/tmp/pt-after-drop.png"], env=env, check=False)
|
||||
|
||||
target_text = open("/tmp/pt-target.log").read()
|
||||
log(f"target log: {target_text.strip()!r}")
|
||||
assert "DROP" in target_text, "the drop did not reach the target window"
|
||||
assert "portal-payload-42" in target_text, "wrong payload"
|
||||
|
||||
log("PASS: native drop landed in the target window")
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"FAIL: {exc}")
|
||||
for path in ("/tmp/pt-comp.log", "/tmp/pt-source.log", "/tmp/pt-target.log"):
|
||||
try:
|
||||
content = open(path).read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if content:
|
||||
log(f"--- {path} ---\n{content[-2000:]}")
|
||||
return 1
|
||||
finally:
|
||||
if pointer is not None:
|
||||
try:
|
||||
pointer.stdin.write("quit\n")
|
||||
pointer.stdin.flush()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
for proc in reversed(procs):
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
for proc in reversed(procs):
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception: # noqa: BLE001
|
||||
proc.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,370 @@
|
||||
/* Generated by wayland-scanner 1.23.1 */
|
||||
|
||||
#ifndef WLR_VIRTUAL_POINTER_UNSTABLE_V1_CLIENT_PROTOCOL_H
|
||||
#define WLR_VIRTUAL_POINTER_UNSTABLE_V1_CLIENT_PROTOCOL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "wayland-client.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @page page_wlr_virtual_pointer_unstable_v1 The wlr_virtual_pointer_unstable_v1 protocol
|
||||
* @section page_ifaces_wlr_virtual_pointer_unstable_v1 Interfaces
|
||||
* - @subpage page_iface_zwlr_virtual_pointer_v1 - virtual pointer
|
||||
* - @subpage page_iface_zwlr_virtual_pointer_manager_v1 - virtual pointer manager
|
||||
* @section page_copyright_wlr_virtual_pointer_unstable_v1 Copyright
|
||||
* <pre>
|
||||
*
|
||||
* Copyright © 2019 Josef Gajdusek
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
* </pre>
|
||||
*/
|
||||
struct wl_output;
|
||||
struct wl_seat;
|
||||
struct zwlr_virtual_pointer_manager_v1;
|
||||
struct zwlr_virtual_pointer_v1;
|
||||
|
||||
#ifndef ZWLR_VIRTUAL_POINTER_V1_INTERFACE
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_INTERFACE
|
||||
/**
|
||||
* @page page_iface_zwlr_virtual_pointer_v1 zwlr_virtual_pointer_v1
|
||||
* @section page_iface_zwlr_virtual_pointer_v1_desc Description
|
||||
*
|
||||
* This protocol allows clients to emulate a physical pointer device. The
|
||||
* requests are mostly mirror opposites of those specified in wl_pointer.
|
||||
* @section page_iface_zwlr_virtual_pointer_v1_api API
|
||||
* See @ref iface_zwlr_virtual_pointer_v1.
|
||||
*/
|
||||
/**
|
||||
* @defgroup iface_zwlr_virtual_pointer_v1 The zwlr_virtual_pointer_v1 interface
|
||||
*
|
||||
* This protocol allows clients to emulate a physical pointer device. The
|
||||
* requests are mostly mirror opposites of those specified in wl_pointer.
|
||||
*/
|
||||
extern const struct wl_interface zwlr_virtual_pointer_v1_interface;
|
||||
#endif
|
||||
#ifndef ZWLR_VIRTUAL_POINTER_MANAGER_V1_INTERFACE
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_INTERFACE
|
||||
/**
|
||||
* @page page_iface_zwlr_virtual_pointer_manager_v1 zwlr_virtual_pointer_manager_v1
|
||||
* @section page_iface_zwlr_virtual_pointer_manager_v1_desc Description
|
||||
*
|
||||
* This object allows clients to create individual virtual pointer objects.
|
||||
* @section page_iface_zwlr_virtual_pointer_manager_v1_api API
|
||||
* See @ref iface_zwlr_virtual_pointer_manager_v1.
|
||||
*/
|
||||
/**
|
||||
* @defgroup iface_zwlr_virtual_pointer_manager_v1 The zwlr_virtual_pointer_manager_v1 interface
|
||||
*
|
||||
* This object allows clients to create individual virtual pointer objects.
|
||||
*/
|
||||
extern const struct wl_interface zwlr_virtual_pointer_manager_v1_interface;
|
||||
#endif
|
||||
|
||||
#ifndef ZWLR_VIRTUAL_POINTER_V1_ERROR_ENUM
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_ERROR_ENUM
|
||||
enum zwlr_virtual_pointer_v1_error {
|
||||
/**
|
||||
* client sent invalid axis enumeration value
|
||||
*/
|
||||
ZWLR_VIRTUAL_POINTER_V1_ERROR_INVALID_AXIS = 0,
|
||||
/**
|
||||
* client sent invalid axis source enumeration value
|
||||
*/
|
||||
ZWLR_VIRTUAL_POINTER_V1_ERROR_INVALID_AXIS_SOURCE = 1,
|
||||
};
|
||||
#endif /* ZWLR_VIRTUAL_POINTER_V1_ERROR_ENUM */
|
||||
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_MOTION 0
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_MOTION_ABSOLUTE 1
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_BUTTON 2
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS 3
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_FRAME 4
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_SOURCE 5
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_STOP 6
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_DISCRETE 7
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_DESTROY 8
|
||||
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_MOTION_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_MOTION_ABSOLUTE_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_BUTTON_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_FRAME_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_SOURCE_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_STOP_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_DISCRETE_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_DESTROY_SINCE_VERSION 1
|
||||
|
||||
/** @ingroup iface_zwlr_virtual_pointer_v1 */
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_set_user_data(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, void *user_data)
|
||||
{
|
||||
wl_proxy_set_user_data((struct wl_proxy *) zwlr_virtual_pointer_v1, user_data);
|
||||
}
|
||||
|
||||
/** @ingroup iface_zwlr_virtual_pointer_v1 */
|
||||
static inline void *
|
||||
zwlr_virtual_pointer_v1_get_user_data(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1)
|
||||
{
|
||||
return wl_proxy_get_user_data((struct wl_proxy *) zwlr_virtual_pointer_v1);
|
||||
}
|
||||
|
||||
static inline uint32_t
|
||||
zwlr_virtual_pointer_v1_get_version(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1)
|
||||
{
|
||||
return wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* The pointer has moved by a relative amount to the previous request.
|
||||
*
|
||||
* Values are in the global compositor space.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_motion(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, wl_fixed_t dx, wl_fixed_t dy)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_MOTION, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, dx, dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* The pointer has moved in an absolute coordinate frame.
|
||||
*
|
||||
* Value of x can range from 0 to x_extent, value of y can range from 0
|
||||
* to y_extent.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_motion_absolute(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t x, uint32_t y, uint32_t x_extent, uint32_t y_extent)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_MOTION_ABSOLUTE, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, x, y, x_extent, y_extent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* A button was pressed or released.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_button(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t button, uint32_t state)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_BUTTON, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, button, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Scroll and other axis requests.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_axis(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t axis, wl_fixed_t value)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_AXIS, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, axis, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Indicates the set of events that logically belong together.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_frame(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_FRAME, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Source information for scroll and other axis.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_axis_source(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t axis_source)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_AXIS_SOURCE, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, axis_source);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Stop notification for scroll and other axes.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_axis_stop(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t axis)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_AXIS_STOP, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, axis);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Discrete step information for scroll and other axes.
|
||||
*
|
||||
* This event allows the client to extend data normally sent using the axis
|
||||
* event with discrete value.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_axis_discrete(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t axis, wl_fixed_t value, int32_t discrete)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_AXIS_DISCRETE, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, axis, value, discrete);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_destroy(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), WL_MARSHAL_FLAG_DESTROY);
|
||||
}
|
||||
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER 0
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_DESTROY 1
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER_WITH_OUTPUT 2
|
||||
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_DESTROY_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER_WITH_OUTPUT_SINCE_VERSION 2
|
||||
|
||||
/** @ingroup iface_zwlr_virtual_pointer_manager_v1 */
|
||||
static inline void
|
||||
zwlr_virtual_pointer_manager_v1_set_user_data(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1, void *user_data)
|
||||
{
|
||||
wl_proxy_set_user_data((struct wl_proxy *) zwlr_virtual_pointer_manager_v1, user_data);
|
||||
}
|
||||
|
||||
/** @ingroup iface_zwlr_virtual_pointer_manager_v1 */
|
||||
static inline void *
|
||||
zwlr_virtual_pointer_manager_v1_get_user_data(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1)
|
||||
{
|
||||
return wl_proxy_get_user_data((struct wl_proxy *) zwlr_virtual_pointer_manager_v1);
|
||||
}
|
||||
|
||||
static inline uint32_t
|
||||
zwlr_virtual_pointer_manager_v1_get_version(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1)
|
||||
{
|
||||
return wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_manager_v1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*
|
||||
* Creates a new virtual pointer. The optional seat is a suggestion to the
|
||||
* compositor.
|
||||
*/
|
||||
static inline struct zwlr_virtual_pointer_v1 *
|
||||
zwlr_virtual_pointer_manager_v1_create_virtual_pointer(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1, struct wl_seat *seat)
|
||||
{
|
||||
struct wl_proxy *id;
|
||||
|
||||
id = wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_manager_v1,
|
||||
ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER, &zwlr_virtual_pointer_v1_interface, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_manager_v1), 0, seat, NULL);
|
||||
|
||||
return (struct zwlr_virtual_pointer_v1 *) id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_manager_v1_destroy(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_manager_v1,
|
||||
ZWLR_VIRTUAL_POINTER_MANAGER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_manager_v1), WL_MARSHAL_FLAG_DESTROY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*
|
||||
* Creates a new virtual pointer. The seat and the output arguments are
|
||||
* optional. If the seat argument is set, the compositor should assign the
|
||||
* input device to the requested seat. If the output argument is set, the
|
||||
* compositor should map the input device to the requested output.
|
||||
*/
|
||||
static inline struct zwlr_virtual_pointer_v1 *
|
||||
zwlr_virtual_pointer_manager_v1_create_virtual_pointer_with_output(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1, struct wl_seat *seat, struct wl_output *output)
|
||||
{
|
||||
struct wl_proxy *id;
|
||||
|
||||
id = wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_manager_v1,
|
||||
ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER_WITH_OUTPUT, &zwlr_virtual_pointer_v1_interface, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_manager_v1), 0, seat, output, NULL);
|
||||
|
||||
return (struct zwlr_virtual_pointer_v1 *) id;
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Generated by wayland-scanner 1.23.1 */
|
||||
|
||||
/*
|
||||
* Copyright © 2019 Josef Gajdusek
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include "wayland-util.h"
|
||||
|
||||
#ifndef __has_attribute
|
||||
# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */
|
||||
#endif
|
||||
|
||||
#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
|
||||
#define WL_PRIVATE __attribute__ ((visibility("hidden")))
|
||||
#else
|
||||
#define WL_PRIVATE
|
||||
#endif
|
||||
|
||||
extern const struct wl_interface wl_output_interface;
|
||||
extern const struct wl_interface wl_seat_interface;
|
||||
extern const struct wl_interface zwlr_virtual_pointer_v1_interface;
|
||||
|
||||
static const struct wl_interface *wlr_virtual_pointer_unstable_v1_types[] = {
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
&wl_seat_interface,
|
||||
&zwlr_virtual_pointer_v1_interface,
|
||||
&wl_seat_interface,
|
||||
&wl_output_interface,
|
||||
&zwlr_virtual_pointer_v1_interface,
|
||||
};
|
||||
|
||||
static const struct wl_message zwlr_virtual_pointer_v1_requests[] = {
|
||||
{ "motion", "uff", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "motion_absolute", "uuuuu", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "button", "uuu", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "axis", "uuf", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "frame", "", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "axis_source", "u", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "axis_stop", "uu", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "axis_discrete", "uufi", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "destroy", "", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
};
|
||||
|
||||
WL_PRIVATE const struct wl_interface zwlr_virtual_pointer_v1_interface = {
|
||||
"zwlr_virtual_pointer_v1", 2,
|
||||
9, zwlr_virtual_pointer_v1_requests,
|
||||
0, NULL,
|
||||
};
|
||||
|
||||
static const struct wl_message zwlr_virtual_pointer_manager_v1_requests[] = {
|
||||
{ "create_virtual_pointer", "?on", wlr_virtual_pointer_unstable_v1_types + 5 },
|
||||
{ "destroy", "", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "create_virtual_pointer_with_output", "2?o?on", wlr_virtual_pointer_unstable_v1_types + 7 },
|
||||
};
|
||||
|
||||
WL_PRIVATE const struct wl_interface zwlr_virtual_pointer_manager_v1_interface = {
|
||||
"zwlr_virtual_pointer_manager_v1", 2,
|
||||
3, zwlr_virtual_pointer_manager_v1_requests,
|
||||
0, NULL,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Headless checks for the pure (non widget) parts of the content factory.
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QTextStream>
|
||||
#include <QTranslator>
|
||||
|
||||
#include "actions/texttools.h"
|
||||
#include "factory/contentclassifier.h"
|
||||
#include "factory/factoryactions.h"
|
||||
#include "factory/linktools.h"
|
||||
|
||||
static int failures = 0;
|
||||
|
||||
static void check(bool condition, const QString &what)
|
||||
{
|
||||
QTextStream out(stdout);
|
||||
if (condition) {
|
||||
out << "PASS " << what << "\n";
|
||||
} else {
|
||||
out << "FAIL " << what << "\n";
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
static bool hasAction(const QVector<FactoryAction> &actions, const QString &id)
|
||||
{
|
||||
for (const FactoryAction &action : actions) {
|
||||
if (action.id == id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
|
||||
if (qEnvironmentVariableIsSet("GXDE_AI_SEE_QM")) {
|
||||
auto *translator = new QTranslator(&app);
|
||||
if (translator->load(QString::fromLocal8Bit(qgetenv("GXDE_AI_SEE_QM")))) {
|
||||
app.installTranslator(translator);
|
||||
check(QCoreApplication::translate("PortalPanel", "暂存区")
|
||||
== QStringLiteral("Staging"),
|
||||
"english translation: PortalPanel/暂存区");
|
||||
check(QCoreApplication::translate("ChatWindow", "发送") == QStringLiteral("Send"),
|
||||
"english translation: ChatWindow/发送");
|
||||
check(QCoreApplication::translate("TodoWindow", "待办") == QStringLiteral("Todo"),
|
||||
"english translation: TodoWindow/待办");
|
||||
} else {
|
||||
check(false, "load english .qm");
|
||||
}
|
||||
}
|
||||
|
||||
// http link with a video extension
|
||||
const LinkInfo video = LinkTools::parse(QStringLiteral("https://example.com/media/clip.mp4"));
|
||||
check(video.kind == LinkInfo::Http, "https link recognised");
|
||||
check(video.extension == QStringLiteral("mp4"), "mp4 extension parsed");
|
||||
check(video.fileType == QStringLiteral("video"), "mp4 classified as video");
|
||||
check(hasAction(actionsFor(ContentClassifier::classifyText(QStringLiteral(
|
||||
"https://example.com/media/clip.mp4"))),
|
||||
QStringLiteral("download")),
|
||||
"video link offers download");
|
||||
check(hasAction(actionsFor(ContentClassifier::classifyText(QStringLiteral(
|
||||
"https://example.com/media/clip.mp4"))),
|
||||
QStringLiteral("play")),
|
||||
"video link offers play");
|
||||
|
||||
const LinkInfo image = LinkTools::parse(QStringLiteral("https://example.com/a.png?x=1"));
|
||||
check(image.fileType == QStringLiteral("image"), "png classified as image");
|
||||
|
||||
const LinkInfo page = LinkTools::parse(QStringLiteral("https://example.com/article"));
|
||||
check(page.fileType == QStringLiteral("page"), "extensionless url is a page");
|
||||
|
||||
// thunder:// -> base64("AA" + url + "ZZ")
|
||||
const QByteArray thunderPayload =
|
||||
QByteArray("AA") + "http://example.com/file.zip" + QByteArray("ZZ");
|
||||
const QString thunder =
|
||||
QStringLiteral("thunder://") + QString::fromLatin1(thunderPayload.toBase64());
|
||||
const LinkInfo thunderInfo = LinkTools::parse(thunder);
|
||||
check(thunderInfo.kind == LinkInfo::Custom, "thunder link classified as custom");
|
||||
check(thunderInfo.decodedUrl == QStringLiteral("http://example.com/file.zip"),
|
||||
"thunder payload decoded");
|
||||
check(hasAction(actionsFor(ContentClassifier::classifyText(thunder)),
|
||||
QStringLiteral("decode")),
|
||||
"thunder offers decode action");
|
||||
|
||||
// magnet
|
||||
const LinkInfo magnet =
|
||||
LinkTools::parse(QStringLiteral("magnet:?xt=urn:btih:abc&dn=Some%20File"));
|
||||
check(magnet.kind == LinkInfo::Custom, "magnet classified as custom");
|
||||
check(magnet.displayName == QStringLiteral("Some File"), "magnet display name parsed");
|
||||
|
||||
// spk://
|
||||
const LinkInfo spk = LinkTools::parse(QStringLiteral("spk://store/app/com.example"));
|
||||
check(spk.kind == LinkInfo::Custom, "spk link classified as custom");
|
||||
|
||||
// plain text is not a link
|
||||
const LinkInfo text = LinkTools::parse(QStringLiteral("hello world"));
|
||||
check(!text.isValid(), "plain text is not a link");
|
||||
|
||||
// segmentation
|
||||
const QStringList words = TextTools::segment(QStringLiteral("我爱北京天安门"));
|
||||
check(words.size() >= 3, "segmentation produced words");
|
||||
check(words.contains(QStringLiteral("北京")), "segmentation keeps 北京 together");
|
||||
|
||||
// link extraction
|
||||
const QStringList links = TextTools::extractLinks(
|
||||
QStringLiteral("看 https://a.example/x?y=1 和 test@example.com,还有 www.b.example"));
|
||||
check(links.size() == 3, QStringLiteral("extractLinks found 3 links, got %1").arg(links.size()));
|
||||
|
||||
// classifier
|
||||
const DropContent textContent = ContentClassifier::classifyText(QStringLiteral("just words"));
|
||||
check(textContent.kind == DropContent::Text, "text classified as text");
|
||||
const DropContent linkContent =
|
||||
ContentClassifier::classifyText(QStringLiteral("https://example.com/a.zip"));
|
||||
check(linkContent.kind == DropContent::Link, "link classified as link");
|
||||
check(hasAction(actionsFor(linkContent), QStringLiteral("save_as")), "link offers save as");
|
||||
|
||||
QTextStream(stdout) << (failures == 0 ? "ALL PASS\n" : "FAILURES\n");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<TS version="2.1" language="en">
|
||||
<context>
|
||||
<name>App</name>
|
||||
<message>
|
||||
<location filename="../src/app.cpp" line="76"/>
|
||||
<source>小埃同学</source>
|
||||
<translation>AI See</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/app.cpp" line="77"/>
|
||||
<source>当前桌面不支持传送门(需要 GXDE Wayland 合成器)。小埃同学会继续在后台待命,暂存区、内容工厂和对话仍然可用。</source>
|
||||
<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>
|
||||
<location filename="../src/chat/chatclient.cpp" line="158"/>
|
||||
<source>模型没有返回内容</source>
|
||||
<translation>The model returned nothing</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatclient.cpp" line="227"/>
|
||||
<source>请求失败</source>
|
||||
<translation>Request failed</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatclient.cpp" line="277"/>
|
||||
<source>API Key 无效或已过期</source>
|
||||
<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'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="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="346"/>
|
||||
<source>工具调用次数过多,已停止</source>
|
||||
<translation>Too many tool calls, stopped</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="405"/>
|
||||
<source>⚠️ %1</source>
|
||||
<translation>⚠️ %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="460"/>
|
||||
<source>+ 新建对话</source>
|
||||
<translation>+ New conversation</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="624"/>
|
||||
<source>压缩上下文</source>
|
||||
<translation>Compress context</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="625"/>
|
||||
<source>重试上一条</source>
|
||||
<translation>Retry last message</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="626"/>
|
||||
<source>重新加载 MCP</source>
|
||||
<translation>Reload MCP</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="634"/>
|
||||
<source>关于</source>
|
||||
<translation>About</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="569"/>
|
||||
<source>发送</source>
|
||||
<translation>Send</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="1066"/>
|
||||
<source>正在压缩上下文…</source>
|
||||
<translation>Compressing context…</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="1082"/>
|
||||
<source>编辑消息</source>
|
||||
<translation>Edit message</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="1164"/>
|
||||
<source>%1 · 分支</source>
|
||||
<translation>%1 · branch</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="490"/>
|
||||
<source>⚙ 设置</source>
|
||||
<translation>⚙ Settings</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="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="1225"/>
|
||||
<source>DeepSeek API Key</source>
|
||||
<translation>DeepSeek API key</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="1232"/>
|
||||
<source>API 地址</source>
|
||||
<translation>API endpoint</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="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="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="1328"/>
|
||||
<source>MCP 服务器(JSON)</source>
|
||||
<translation>MCP servers (JSON)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="1335"/>
|
||||
<source>系统提示</source>
|
||||
<translation>System prompt</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatwindow.cpp" line="1340"/>
|
||||
<source>还没有账号?前往 <a href='https://platform.deepseek.com'>platform.deepseek.com</a> 开通并创建 API Key。Key 只保存在本机配置文件中。</source>
|
||||
<translation>No account yet? Sign up and create an API key at <a href='https://platform.deepseek.com'>platform.deepseek.com</a>. The key is stored only in the local configuration file.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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>
|
||||
</context>
|
||||
<context>
|
||||
<name>ContentFactory</name>
|
||||
<message>
|
||||
<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="220"/>
|
||||
<source>拖到动作上松手</source>
|
||||
<translation>Drop onto an action</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>Downloader</name>
|
||||
<message>
|
||||
<location filename="../src/actions/downloader.cpp" line="78"/>
|
||||
<source>另存为</source>
|
||||
<translation>Save as</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/actions/downloader.cpp" line="89"/>
|
||||
<source>无法写入:%1</source>
|
||||
<translation>Cannot write: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/actions/downloader.cpp" line="99"/>
|
||||
<source>开始下载:%1</source>
|
||||
<translation>Downloading: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/actions/downloader.cpp" line="108"/>
|
||||
<source>下载失败:%1</source>
|
||||
<translation>Download failed: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/actions/downloader.cpp" line="111"/>
|
||||
<source>下载完成:%1</source>
|
||||
<translation>Downloaded: %1</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>MessageRow</name>
|
||||
<message>
|
||||
<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/chat/chatview.cpp" line="121"/>
|
||||
<source>埃</source>
|
||||
<translation>AI</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatview.cpp" line="130"/>
|
||||
<source>小埃</source>
|
||||
<translation>AI See</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/chat/chatview.cpp" line="170"/>
|
||||
<source>复制</source>
|
||||
<translation>Copy</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="1179"/>
|
||||
<source>真实地址</source>
|
||||
<translation>Real address</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/ui/portalpanel.cpp" line="1192"/>
|
||||
<source>提取链接(%1)</source>
|
||||
<translation>Links (%1)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/ui/portalpanel.cpp" line="1206"/>
|
||||
<source>已添加到待办</source>
|
||||
<translation>Added to todo</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="1216"/>
|
||||
<source>保存图片</source>
|
||||
<translation>Save image</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="1219"/>
|
||||
<source>已保存:%1</source>
|
||||
<translation>Saved: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="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="1237"/>
|
||||
<source>未知操作:%1</source>
|
||||
<translation>Unknown action: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/ui/portalpanel.cpp" line="1381"/>
|
||||
<source>固定位置</source>
|
||||
<translation>Pin position</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/ui/portalpanel.cpp" line="1385"/>
|
||||
<source>恢复默认位置</source>
|
||||
<translation>Restore default position</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/ui/portalpanel.cpp" line="1387"/>
|
||||
<source>固定大小</source>
|
||||
<translation>Pin size</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/ui/portalpanel.cpp" line="1391"/>
|
||||
<source>全屏时隐藏</source>
|
||||
<translation>Hide on fullscreen</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="1405"/>
|
||||
<source>清空暂存区</source>
|
||||
<translation>Clear staging area</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="1408"/>
|
||||
<source>退出</source>
|
||||
<translation>Quit</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="1482"/>
|
||||
<source>已关闭开机自启与保活</source>
|
||||
<translation>Startup and keep-alive disabled</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="669"/>
|
||||
<source>传送门</source>
|
||||
<translation>Portal</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="734"/>
|
||||
<source>悬停在「传送门」上,再移动到目标窗口</source>
|
||||
<translation>Hover the portal, then move onto a target window</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>QObject</name>
|
||||
<message>
|
||||
<location filename="../src/chat/chatclient.cpp" line="24"/>
|
||||
<source>还没有配置 API Key。请先点右上角设置,填入 DeepSeek API Key。</source>
|
||||
<translation>No API key configured yet. Open Settings and enter your DeepSeek API key.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/ui/portalpanel.cpp" line="122"/>
|
||||
<source>文件</source>
|
||||
<translation>File</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../src/ui/portalpanel.cpp" line="124"/>
|
||||
<source>图片</source>
|
||||
<translation>Image</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="355"/>
|
||||
<source>把文件、文字或图片拖到这里</source>
|
||||
<translation>Drop files, text or images here</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>TodoWindow</name>
|
||||
<message>
|
||||
<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="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="226"/>
|
||||
<source>+</source>
|
||||
<translation>+</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="238"/>
|
||||
<source>×</source>
|
||||
<translation>×</translation>
|
||||
</message>
|
||||
<message>
|
||||
<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="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>
|
||||