20 Commits
Author SHA1 Message Date
shenmo 4291ee286c chore(release): 1.0.0~beta2
Building GXDE Arch Package / detect (push) Skipped
Building GXDE Arch Package / aarch64-arch (push) Skipped
Building GXDE Arch Package / x86_64-arch (push) Skipped
Building GXDE Testing Package / call-arch (push) Skipped
Building Program / call (push) Skipped
Building GXDE Testing Package / call-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-i386-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-amd64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-arm64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-riscv64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-loong64-zhuangzhuang (push) Skipped
Building GXDE Package / ${{ inputs.display-name || 'Build Job' }} (push) Skipped
- 会话列表拖拽排序/多选/批量删除
- 128k/1024k token 上下文上限
- 待办取消置顶;全屏隐藏联动
- 英文回退翻译补齐;新应用图标
- DTK 关于窗口、分词/朗读对话框、消息常驻操作
2026-10-30 22:37:05 +08:00
shenmo c8a0391abe chore(release): 1.0.0~beta
Building GXDE Arch Package / detect (push) Skipped
Building GXDE Arch Package / aarch64-arch (push) Skipped
Building GXDE Arch Package / x86_64-arch (push) Skipped
Building GXDE Testing Package / call-arch (push) Skipped
Building Program / call (push) Skipped
Building GXDE Testing Package / call-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-i386-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-amd64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-arm64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-riscv64-zhuangzhuang (push) Skipped
Building GXDE Testing Package (zhuangzhuang) / call-loong64-zhuangzhuang (push) Skipped
Building GXDE Package / ${{ inputs.display-name || 'Build Job' }} (push) Skipped
- 版本号 1.0.0~beta(CMake / 程序 / debian)
- 新增 GitHub Actions 构建(复用 GXDE-OS/GXDE building-deb)
- CHANGELOG 1.0.0~beta
- docs/TODO.md 记录后续待办
2026-10-30 22:27:58 +08:00
shenmo ca088fbaa6 feat(panel/chat): 显隐切换、暂存清空隐藏、菜单避让、待办联动、DTK 关于
- 传送门 desktop 入口,--show 再点一次回 daemon;右键菜单加隐藏
- 暂存区清空后整个面板隐藏
- 右键菜单弹到面板左侧,避免被自己遮挡
- 启动悬停 80ms
- 添加待办后立即打开待办窗口;待办独立图标
- 分词胶囊按浅/深色取色,修复透明不可见
- 关于改用 DAboutDialog
- 聊天消息下方常驻 复制/编辑/重试;助手消息可编辑、可从任意一条重试
2026-10-30 22:27:16 +08:00
shenmo 630ca6cadc fix(panel): 暂存优先模式;工厂自身接收拖拽;去掉 drop 提示
- 暂存区非空且未拖拽时只显示暂存列表,隐藏传送门与内容工厂并收窄窗口
- 内容工厂自身区域拖入也会展开动作
- 面板矩形改用屏幕可用区 + 边距计算(layer-shell 下 mapToGlobal 不可靠)
- 离开概览只复位传送门状态,不关闭面板;125ms
- 移除“在哪里 drop”提示
2026-10-30 22:00:39 +08:00
shenmo 3b92202f2d feat(panel): 内容工厂常驻、问问小埃大按钮、分词/朗读 Dialog
- 内容工厂固定高度常驻,面板尺寸/位置不再随拖拽变化
- 问问小埃改成与工厂项一致的大按钮,可点击/可 drop,drop 后新开对话并填入内容
- 工厂动作按内容过滤:无 CJK 不出分词,无链接不出提取,朗读仅文本
- 朗读打开语速 Dialog,分词改为可滑动选择的胶囊 + 复制
- BeginPortal 传面板矩形,供概览避让
2026-10-30 21:45:10 +08:00
shenmo 4032de318c feat(chat): DTK 主窗口 + 新模型;fix(panel): 菜单位置/工厂挤压与误隐藏
- ChatWindow 改为 DMainWindow + DTitlebar,图标按 DPR 取图
- DeepSeek 模型改为 deepseek-flash / deepseek-v4-pro,设置里可拉取 /models
- 内容工厂右侧常驻「问问小埃同学」大按钮,移除传送门上的常驻入口
- 工厂出现时面板整体上移预留空间,不再挤压传送门
- 工厂隐藏改为检查指针是否仍在面板内,拖进工厂不再消失
- 右键菜单超出屏幕时向上弹出
- 移除 512 图标(由 293px 源图放大,发糊)
2026-10-30 21:03:25 +08:00
shenmo7192 798f5aa7ce feat(chat): 重做聊天窗口界面
- 消息改成气泡列表:头像、左右分栏、markdown 正文、hover 显示复制/编辑/分支/重试
- 会话列表:应用图标 + 名称、每项显示标题/最后一条预览/时间、hover 与选中态
- 输入区:圆角输入框内嵌发送按钮、快捷键提示
- 空会话欢迎页;消息区域支持拖放
- 头部显示当前会话标题与模型徽章
- 设置/更多按钮移到侧栏底部
2026-10-30 20:40:45 +08:00
shenmo7192 5fbf7d7931 feat(todo): 待办窗口可拖动并记忆位置
- 拖动标题区移动,layer-shell margins 实时更新
- 位置持久化到 todo/rightMargin、todo/topMargin
2026-10-30 20:28:22 +08:00
shenmo7192 680cd3c7ab feat(i18n): 英文翻译(95 条)并验证加载
- translations/gxde-ai-see_en.ts 全部翻译完成
- selftest 支持 GXDE_AI_SEE_QM 环境变量,验证 3 个上下文的翻译生效
- CHANGELOG 更新
2026-10-30 20:27:06 +08:00
shenmo7192 d949d278cb feat(chat): 会话重命名与删除
- 侧栏右键菜单:重命名、删除
- 删除后自动选中相邻会话,流式输出中先取消请求
2026-10-30 20:26:13 +08:00
shenmo7192 46263e70d7 test(chat): MCP 工具循环端到端测试
- tests/api/fake_openai.py:假 OpenAI 流式接口,先返回 tool_call 再返回文本
- tests/chattest.cpp:真实 ChatClient + McpManager 走完 请求→工具→回填→回答
- fix(mcp): 回调应收到 JSON-RPC 的 result,而不是整个响应包(否则工具结果为空)
- 两个无头测试均通过
2026-10-30 20:25:37 +08:00
shenmo7192 ba7f57c7d5 feat(i18n): 翻译加载与 .qm 构建
- 启动时按 locale 加载 gxde-ai-see_<locale>.qm(用户数据目录 / /usr/share)
- translations/gxde-ai-see_en.ts 翻译模板,lupdate/lrelease 接入 CMake 并安装
- README 补充翻译流程
2026-10-30 20:23:15 +08:00
shenmo7192 77a4369cd6 feat(chat): MCP stdio 工具接入
- McpServer:启动进程、initialize、tools/list、tools/call
- McpManager:多服务器、工具名冲突处理、OpenAI function 定义
- ChatClient:请求携带 tools,流式累积 tool_calls,结束后交回上层
- ChatWindow:工具调用循环(最多 5 轮)、scaffold 消息、设置里的 MCP JSON 编辑
- 无头自测 gxde-ai-see-mcptest + tests/mcp/test_server.py 验证通过
2026-10-30 20:22:14 +08:00
shenmo7192 c7865fcbf0 feat(chat): Markdown 渲染与消息复制
- 助手消息支持标题、粗体、行内代码、代码块、链接、列表
- 每条助手消息新增「复制」链接
2026-10-30 20:20:18 +08:00
shenmo7192 1fa1ff45f6 feat(staging): 删除动画与拖动排序
- 删除:右移淡出 200ms(OutCubic)后移除
- 拖动排序:允许把条目拖回列表,按落点移动并持久化
- 自身拖拽不再被当作外部拖入
2026-10-30 20:19:36 +08:00
shenmo7192 d69326bc6f feat(app): 不支持传送门的环境只提示一次并保持待命
- --daemon 启动时检测 org.gxde.Portal,缺失则发一次桌面通知
- 记录 warned/unsupportedEnv,避免 gxde-k9 反复拉起时重复提示
2026-10-30 20:17:25 +08:00
shenmo7192 07f215bb34 feat(chat,panel): 对话增强与面板窗口管理;chore: debian 打包
Chat:
- 消息操作:编辑用户消息、重试、从任意回复分支
- 上下文:条数设置 + 超出自动压缩(摘要旧消息)
- 关于对话框;侧栏更多菜单

Panel:
- 拖动边框移动、拖顶部调整高度,位置/高度持久化
- 右键菜单:固定位置、恢复默认位置、固定大小、全屏时隐藏(预留)、
  开机自启与保活(写入 gxde-k9 .slimy)、清空暂存区、关于、退出

Packaging:
- debian/(control/rules/changelog/copyright/source format)
- README 构建与使用说明、CHANGELOG.md
2026-10-30 20:16:09 +08:00
shenmo7192 e3c8da1a3f feat(chat,todo): 小埃同学对话窗口与待办窗口
Chat:
- DTK2 窗口:左侧会话列表 + 右侧对话区 + 输入框
- DeepSeek / 自定义 API,SSE 流式输出,首字即显示
- 会话持久化到 chat.json,新建/切换/标题自动生成
- 设置对话框:API Key、API 地址、模型、系统提示,含开通指引
- 拖放:左侧空白=新建对话,左侧会话=指定会话,右侧对话区=当前会话
- Ctrl+Enter 发送,错误以消息形式回显

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

其他:
- desktop 入口改为打开对话窗口,--chat 参数
- 单实例消息支持 show/todo/chat
2026-10-30 20:12:31 +08:00
shenmo7192 150a49af2f docs(plan): 内容工厂链接分类与问问小埃的拖放交互 2026-10-30 20:09:14 +08:00
shenmo7192 3a73a8c5d1 feat(factory): 内容工厂与链接处理
- 第三栏内容工厂:按拖入类型展示动作,动作按钮本身可接收 drop
- 链接分类:http(s) 按扩展名判定图片/音视频/文档/压缩包/网页
- http 链接动作:打开、下载、另存为、复制、Markdown、复制文件名、待办、问小埃
- 非标准 scheme:尝试打开、复制;thunder 解析 base64 真实地址,magnet 解析 dn
- 文字动作:ICU 分词、正则提取链接、朗读(spd-say)、复制、待办
- 文件/图片动作:打开、提取文字(占位)、生成描述、保存图片、复制路径、待办
- Downloader:异步下载到 XDG 下载目录 / 另存为,面板 toast 提示
- TodoModel:持久化到 todo.json,供「添加到待办」使用
- 无头自测 gxde-ai-see-selftest:链接分类/分词/提取链接/动作匹配
- 合成器 portal 自测脚本 tests/portal(虚拟指针 + GTK 源/目标)
2026-10-30 20:09:01 +08:00
89 changed files with 8918 additions and 59 deletions
+13
View File
@@ -0,0 +1,13 @@
name: Building Program
run-name: Building ${{ GITHUB.REPOSITORY }}
on:
workflow_dispatch:
push:
tags:
- "*"
jobs:
call:
uses: GXDE-OS/GXDE/.github/workflows/building-deb.yml@master
secrets: inherit
+13
View File
@@ -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
+51
View File
@@ -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>
+73 -3
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.20)
project(gxde-ai-see
VERSION 0.1.0
VERSION 1.0.0
DESCRIPTION "GXDE AI See - 小埃同学"
LANGUAGES CXX
)
@@ -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)
+25 -5
View File
@@ -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)
---
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=GXDE Portal
Name[zh_CN]=传送门
Comment=Show or hide the GXDE AI See portal panel
Comment[zh_CN]=显示或隐藏小埃同学的传送门面板
Exec=gxde-ai-see --show
Icon=gxde-ai-see
Terminal=false
Categories=Utility;
StartupNotify=false
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=GXDE Todo
Name[zh_CN]=GXDE 待办
Comment=Floating todo list of GXDE AI See
Comment[zh_CN]=小埃同学的待办
Exec=gxde-ai-see --todo
Icon=gxde-ai-see-todo
Terminal=false
Categories=Utility;
StartupNotify=false
+1 -1
View File
@@ -7,7 +7,7 @@ GenericName=AI Assistant
GenericName[zh_CN]=AI 助手
Comment=Drag to a window and it lands in the input box
Comment[zh_CN]=拖一下,世界就到你手边
Exec=gxde-ai-see --show
Exec=gxde-ai-see --chat
Icon=gxde-ai-see
Terminal=false
Categories=Utility;Office;
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4a90ff"/>
<stop offset="1" stop-color="#2f6bd8"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="240" height="240" rx="54" fill="url(#g)"/>
<rect x="64" y="56" width="128" height="152" rx="16" fill="#ffffff" opacity="0.96"/>
<rect x="96" y="40" width="64" height="30" rx="10" fill="#dbe8ff"/>
<g stroke="#3d7fe8" stroke-width="9" stroke-linecap="round" fill="none">
<path d="M84 114 l14 14 26 -28"/>
<path d="M84 158 l14 14 26 -28"/>
<path d="M146 118 h30"/>
<path d="M146 162 h30"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+27
View File
@@ -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
+26
View File
@@ -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 的流式对话。
+9
View File
@@ -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".
+1
View File
@@ -0,0 +1 @@
dh_auto_configure
+3
View File
@@ -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=
Vendored Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_configure:
dh_auto_configure -- -DCMAKE_BUILD_TYPE=RelWithDebInfo
+1
View File
@@ -0,0 +1 @@
3.0 (native)
+9 -1
View File
@@ -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。
- **问问小埃**:把内容作为附件塞进当前(或新建)对话的输入框。
- **问问小埃**:不直接粘贴,而是**激活小埃同学窗口**,让用户把内容拖过去:
左侧列表空白 = 新建对话;左侧某个对话 = 该对话;右侧对话区 = 当前对话。
---
+14
View File
@@ -0,0 +1,14 @@
# 1.0.0~beta2 之后待办
已完成:会话列表拖拽排序/多选/批量删除(可复用控件已加入 dtk2widget-qt6)、
token 压缩上限、待办取消置顶、全屏隐藏联动、英文回退翻译、新图标、DTK 关于窗口。
## 仍需单独排期
- [ ] 对话浅色/深色主题跟随 DTK(当前仍是深色固定配色)
- [ ] 消息版本树:重试/编辑保留历史并可左右切换
- [ ] OCR / TTS / 翻译对接 GXDE Daemon(当前仓库未找到对应 D-Bus 接口,需先定义)
- [ ] X11 / 非 GXDE 合成器窗口图标网格降级方案
- [ ] DTK Wayland 文本拖拽预览(合成器侧渲染,工作量大)
- [ ] 更多窗口改用 DTK 控件(聊天设置、待办窗口等)
- [ ] dtk2widget-qt6 的 DReorderListWidget 需要随库发布后,应用改为链接该控件
+116
View File
@@ -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();
});
}
+39
View File
@@ -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;
};
+80
View File
@@ -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
+20
View File
@@ -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
+125 -10
View File
@@ -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();
}
+9 -1
View File
@@ -5,9 +5,11 @@
#include <QObject>
class ChatWindow;
class PortalPanel;
class QLocalServer;
class StagingModel;
class TodoWindow;
/**
* Application controller: owns the staging model, the panel and the single
@@ -25,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;
};
+302
View File
@@ -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();
}
+65
View File
@@ -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;
};
+432
View File
@@ -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"
+55
View File
@@ -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;
};
File diff suppressed because it is too large Load Diff
+102
View File
@@ -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;
};
+76
View File
@@ -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;
}
+12
View File
@@ -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);
+289
View File
@@ -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));
}
+93
View File
@@ -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;
};
+87
View File
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "flowlayout.h"
#include <QStyle>
#include <QWidget>
FlowLayout::FlowLayout(QWidget *parent, int margin, int spacing)
: QLayout(parent)
, m_spacing(spacing)
{
setContentsMargins(margin, margin, margin, margin);
}
FlowLayout::~FlowLayout()
{
while (QLayoutItem *item = takeAt(0)) {
delete item;
}
}
void FlowLayout::addItem(QLayoutItem *item) { m_items.append(item); }
int FlowLayout::count() const { return m_items.size(); }
QLayoutItem *FlowLayout::itemAt(int index) const { return m_items.value(index); }
QLayoutItem *FlowLayout::takeAt(int index)
{
return index >= 0 && index < m_items.size() ? m_items.takeAt(index) : nullptr;
}
Qt::Orientations FlowLayout::expandingDirections() const { return {}; }
bool FlowLayout::hasHeightForWidth() const { return true; }
int FlowLayout::heightForWidth(int width) const { return doLayout(QRect(0, 0, width, 0), true); }
void FlowLayout::setGeometry(const QRect &rect)
{
QLayout::setGeometry(rect);
doLayout(rect, false);
}
QSize FlowLayout::sizeHint() const { return minimumSize(); }
QSize FlowLayout::minimumSize() const
{
QSize size;
for (QLayoutItem *item : m_items) {
size = size.expandedTo(item->minimumSize());
}
const QMargins margins = contentsMargins();
return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom());
}
int FlowLayout::doLayout(const QRect &rect, bool testOnly) const
{
const QMargins margins = contentsMargins();
const QRect effective = rect.adjusted(margins.left(), margins.top(), -margins.right(),
-margins.bottom());
int x = effective.x();
int y = effective.y();
int lineHeight = 0;
for (QLayoutItem *item : m_items) {
QWidget *widget = item->widget();
int spaceX = m_spacing;
int spaceY = m_spacing;
if (widget) {
spaceX = widget->style()->layoutSpacing(QSizePolicy::PushButton,
QSizePolicy::PushButton, Qt::Horizontal);
spaceY = widget->style()->layoutSpacing(QSizePolicy::PushButton,
QSizePolicy::PushButton, Qt::Vertical);
}
int nextX = x + item->sizeHint().width() + spaceX;
if (nextX - spaceX > effective.right() && lineHeight > 0) {
x = effective.x();
y = y + lineHeight + spaceY;
nextX = x + item->sizeHint().width() + spaceX;
lineHeight = 0;
}
if (!testOnly) {
item->setGeometry(QRect(QPoint(x, y), item->sizeHint()));
}
x = nextX;
lineHeight = qMax(lineHeight, item->sizeHint().height());
}
return y + lineHeight - rect.y() + margins.bottom();
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QLayout>
#include <QList>
/** Simple wrapping layout (Qt flow layout example). */
class FlowLayout : public QLayout
{
public:
explicit FlowLayout(QWidget *parent = nullptr, int margin = 0, int spacing = 6);
~FlowLayout() override;
void addItem(QLayoutItem *item) override;
int count() const override;
QLayoutItem *itemAt(int index) const override;
QLayoutItem *takeAt(int index) override;
Qt::Orientations expandingDirections() const override;
bool hasHeightForWidth() const override;
int heightForWidth(int width) const override;
QSize minimumSize() const override;
void setGeometry(const QRect &rect) override;
QSize sizeHint() const override;
private:
int doLayout(const QRect &rect, bool testOnly) const;
QList<QLayoutItem *> m_items;
int m_spacing;
};
+164
View File
@@ -0,0 +1,164 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "segmentdialog.h"
#include "flowlayout.h"
#include <QApplication>
#include <QPalette>
#include <QClipboard>
#include <QEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
SegmentDialog::SegmentDialog(const QStringList &words, QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("分词"));
setAttribute(Qt::WA_DeleteOnClose);
resize(560, 420);
const bool dark = palette().color(QPalette::Window).lightness() < 128;
const QColor capsuleBg = dark ? QColor(255, 255, 255, 20) : QColor(0, 0, 0, 18);
const QColor capsuleBorder = dark ? QColor(255, 255, 255, 30) : QColor(0, 0, 0, 28);
const QColor textColor = palette().color(QPalette::WindowText);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 14, 16, 14);
layout->setSpacing(10);
auto *hint = new QLabel(tr("按住并滑过词语可以连续选择,选中后复制。"), this);
hint->setStyleSheet(QStringLiteral("font-size: 12px;"));
layout->addWidget(hint);
auto *scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto *container = new QWidget(scroll);
auto *flow = new FlowLayout(container, 6, 6);
container->setLayout(flow);
scroll->setWidget(container);
layout->addWidget(scroll, 1);
for (const QString &word : words) {
auto *capsule = new QPushButton(word, container);
capsule->setCheckable(true);
capsule->setCursor(Qt::PointingHandCursor);
capsule->setFocusPolicy(Qt::NoFocus);
capsule->setStyleSheet(QStringLiteral(
"QPushButton { color: %1; background: %2; border: 1px solid %3;"
" border-radius: 11px; padding: 3px 10px; font-size: 13px; }"
"QPushButton:hover { background: %4; }"
"QPushButton:checked { background: rgba(74,144,255,0.85);"
" border-color: rgba(74,144,255,1.0); color: white; }")
.arg(textColor.name(),
QStringLiteral("rgba(%1,%2,%3,%4)")
.arg(capsuleBg.red())
.arg(capsuleBg.green())
.arg(capsuleBg.blue())
.arg(capsuleBg.alpha()),
capsuleBorder.name(),
dark ? QStringLiteral("rgba(255,255,255,30)")
: QStringLiteral("rgba(0,0,0,28)")));
capsule->installEventFilter(this);
connect(capsule, &QPushButton::released, this, [this]() { m_selecting = false; });
flow->addWidget(capsule);
m_capsules.append(capsule);
}
auto *bottom = new QHBoxLayout;
auto *all = new QPushButton(tr("全选"), this);
auto *none = new QPushButton(tr("清空"), this);
m_count = new QLabel(tr("已选 0 个"), this);
m_count->setStyleSheet(QStringLiteral("font-size: 12px;"));
auto *copy = new QPushButton(tr("复制选中"), this);
copy->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 8px; padding: 6px 14px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
bottom->addWidget(all);
bottom->addWidget(none);
bottom->addStretch();
bottom->addWidget(m_count);
bottom->addSpacing(10);
bottom->addWidget(copy);
layout->addLayout(bottom);
connect(all, &QPushButton::clicked, this, [this]() { setAll(true); });
connect(none, &QPushButton::clicked, this, [this]() { setAll(false); });
connect(copy, &QPushButton::clicked, this, &SegmentDialog::copySelected);
auto *close = new QPushButton(tr("关闭"), this);
layout->addWidget(close, 0, Qt::AlignRight);
connect(close, &QPushButton::clicked, this, &QDialog::close);
}
void SegmentDialog::setAll(bool checked)
{
for (QPushButton *capsule : m_capsules) {
capsule->setChecked(checked);
}
updateCount();
}
void SegmentDialog::updateCount()
{
int count = 0;
for (QPushButton *capsule : m_capsules) {
if (capsule->isChecked()) {
++count;
}
}
m_count->setText(tr("已选 %1 个").arg(count));
}
void SegmentDialog::copySelected()
{
QStringList selected;
for (QPushButton *capsule : m_capsules) {
if (capsule->isChecked()) {
selected.append(capsule->text());
}
}
if (selected.isEmpty()) {
m_count->setText(tr("还没有选中词语"));
return;
}
QApplication::clipboard()->setText(selected.join(QLatin1Char(' ')));
m_count->setText(tr("已复制 %1 个").arg(selected.size()));
}
bool SegmentDialog::eventFilter(QObject *watched, QEvent *event)
{
auto *capsule = qobject_cast<QPushButton *>(watched);
if (!capsule) {
return QDialog::eventFilter(watched, event);
}
if (event->type() == QEvent::MouseButtonPress) {
auto *mouse = static_cast<QMouseEvent *>(event);
if (mouse->button() == Qt::LeftButton) {
m_selecting = true;
m_selectValue = !capsule->isChecked();
capsule->setChecked(m_selectValue);
updateCount();
}
} else if (event->type() == QEvent::Enter && m_selecting &&
(QApplication::mouseButtons() & Qt::LeftButton)) {
if (capsule->isChecked() != m_selectValue) {
capsule->setChecked(m_selectValue);
updateCount();
}
}
return QDialog::eventFilter(watched, event);
}
void SegmentDialog::mouseReleaseEvent(QMouseEvent *event)
{
m_selecting = false;
QDialog::mouseReleaseEvent(event);
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QStringList>
#include <QVector>
class QLabel;
class QPushButton;
/** Word segmentation result as selectable capsules. */
class SegmentDialog : public QDialog
{
Q_OBJECT
public:
explicit SegmentDialog(const QStringList &words, QWidget *parent = nullptr);
private:
void updateCount();
void copySelected();
void setAll(bool checked);
QVector<QPushButton *> m_capsules;
QLabel *m_count = nullptr;
bool m_selecting = false;
bool m_selectValue = true;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
};
+111
View File
@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "speechdialog.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPlainTextEdit>
#include <QProcess>
#include <QPushButton>
#include <QSlider>
#include <QStandardPaths>
#include <QVBoxLayout>
SpeechDialog::SpeechDialog(const QString &text, QWidget *parent)
: QDialog(parent)
, m_text(text)
{
setWindowTitle(tr("朗读"));
setAttribute(Qt::WA_DeleteOnClose);
resize(480, 380);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 14, 16, 14);
layout->setSpacing(10);
m_preview = new QPlainTextEdit(this);
m_preview->setReadOnly(true);
m_preview->setPlainText(text.left(4000));
m_preview->setStyleSheet(QStringLiteral(
"QPlainTextEdit { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.9);"
" border: none; border-radius: 8px; padding: 8px; }"));
layout->addWidget(m_preview, 1);
auto *rateRow = new QHBoxLayout;
rateRow->addWidget(new QLabel(tr("语速"), this));
m_rate = new QSlider(Qt::Horizontal, this);
m_rate->setRange(50, 200);
m_rate->setValue(100);
rateRow->addWidget(m_rate, 1);
layout->addLayout(rateRow);
m_status = new QLabel(this);
m_status->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.55); font-size: 12px;"));
layout->addWidget(m_status);
auto *buttons = new QHBoxLayout;
auto *play = new QPushButton(tr("开始朗读"), this);
play->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 8px; padding: 7px 16px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
auto *stopButton = new QPushButton(tr("停止"), this);
auto *close = new QPushButton(tr("关闭"), this);
buttons->addWidget(play);
buttons->addWidget(stopButton);
buttons->addStretch();
buttons->addWidget(close);
layout->addLayout(buttons);
connect(play, &QPushButton::clicked, this, &SpeechDialog::start);
connect(stopButton, &QPushButton::clicked, this, &SpeechDialog::stop);
connect(close, &QPushButton::clicked, this, &QDialog::close);
}
SpeechDialog::~SpeechDialog()
{
stop();
}
void SpeechDialog::start()
{
stop();
QString program = QStandardPaths::findExecutable(QStringLiteral("spd-say"));
QStringList args;
if (!program.isEmpty()) {
args = { QStringLiteral("-r"), QString::number(m_rate->value() - 100),
QStringLiteral("-w"), m_text.left(4000) };
} else {
program = QStandardPaths::findExecutable(QStringLiteral("espeak-ng"));
if (program.isEmpty()) {
m_status->setText(tr("未找到 spd-say 或 espeak-ng"));
return;
}
args = { QStringLiteral("-s"), QString::number(m_rate->value()),
m_text.left(4000) };
}
m_process = new QProcess(this);
m_process->setProcessChannelMode(QProcess::MergedChannels);
connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
m_status->setText(tr("朗读失败:%1").arg(m_process->errorString()));
});
connect(m_process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
[this](int, QProcess::ExitStatus) {
m_status->setText(tr("朗读结束"));
});
m_status->setText(tr("正在朗读…"));
m_process->start(program, args);
}
void SpeechDialog::stop()
{
if (m_process) {
m_process->kill();
m_process->waitForFinished(300);
m_process->deleteLater();
m_process = nullptr;
m_status->setText(tr("已停止"));
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QString>
class QLabel;
class QPlainTextEdit;
class QProcess;
class QSlider;
/** Text-to-speech dialog with a rate control. */
class SpeechDialog : public QDialog
{
Q_OBJECT
public:
explicit SpeechDialog(const QString &text, QWidget *parent = nullptr);
~SpeechDialog() override;
private:
void start();
void stop();
QString m_text;
QPlainTextEdit *m_preview = nullptr;
QSlider *m_rate = nullptr;
QLabel *m_status = nullptr;
QProcess *m_process = nullptr;
};
+128
View File
@@ -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
+41
View File
@@ -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);
}
+367
View File
@@ -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"
+59
View File
@@ -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;
};
+160
View File
@@ -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 {};
}
}
+21
View File
@@ -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);
+241
View File
@@ -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
+52
View File
@@ -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
+46 -2
View File
@@ -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;
}
+18 -1
View File
@@ -50,15 +50,22 @@ PortalClient::PortalClient(QObject *parent)
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
QString::fromLatin1(kInterface), QStringLiteral("PortalDropped"), this,
SLOT(onDropped(QString, bool)));
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
QString::fromLatin1(kInterface), QStringLiteral("PortalClosed"), this,
SLOT(onPortalClosed()));
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
QString::fromLatin1(kInterface), QStringLiteral("FullscreenChanged"), this,
SLOT(onFullscreenChanged(bool)));
}
bool PortalClient::beginPortal()
bool PortalClient::beginPortal(const QRect &panelRect)
{
QDBusMessage message =
QDBusMessage::createMethodCall(QString::fromLatin1(kService),
QString::fromLatin1(kPath),
QString::fromLatin1(kInterface),
QStringLiteral("BeginPortal"));
message << panelRect.x() << panelRect.y() << panelRect.width() << panelRect.height();
const QDBusMessage reply = QDBusConnection::sessionBus().call(message, QDBus::Block, 500);
if (reply.type() != QDBusMessage::ReplyMessage || reply.arguments().isEmpty()) {
return false;
@@ -92,6 +99,16 @@ void PortalClient::onDragStarted(const QString &mimeTypes)
Q_EMIT dragStarted(mimeTypes);
}
void PortalClient::onFullscreenChanged(bool fullscreen)
{
Q_EMIT fullscreenChanged(fullscreen);
}
void PortalClient::onPortalClosed()
{
Q_EMIT portalClosed();
}
void PortalClient::onDragEnded()
{
Q_EMIT dragEnded();
+6 -1
View File
@@ -4,6 +4,7 @@
#pragma once
#include <QObject>
#include <QRect>
/**
* Client for the compositor side "portal" (org.gxde.Portal).
@@ -23,7 +24,7 @@ public:
bool available() const { return m_available; }
/** @return true when the compositor opened the overview. */
bool beginPortal();
bool beginPortal(const QRect &panelRect = QRect());
void cancelPortal();
/** Filter the drag mime types we want to react to. */
@@ -32,11 +33,15 @@ public:
signals:
void dragStarted(const QString &mimeTypes);
void dragEnded();
void portalClosed();
void fullscreenChanged(bool fullscreen);
void dropped(const QString &uuid, bool accepted);
private slots:
void onDragStarted(const QString &mimeTypes);
void onDragEnded();
void onPortalClosed();
void onFullscreenChanged(bool fullscreen);
void onDropped(const QString &uuid, bool accepted);
private:
+17
View File
@@ -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()) {
+1
View File
@@ -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;
+165
View File
@@ -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();
}
+55
View File
@@ -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;
};
+413
View File
@@ -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();
}
+57
View File
@@ -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;
};
+25
View File
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "aboutdialog.h"
#include <QApplication>
#include <QCoreApplication>
#include <QIcon>
#include <DAboutDialog>
void showAboutDialog(QWidget *parent, const QString &description)
{
auto *dialog = new Dtk::Widget::DAboutDialog(parent);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->setWindowTitle(QObject::tr("关于小埃同学"));
dialog->setProductIcon(QApplication::windowIcon());
dialog->setProductName(QObject::tr("GXDE 小埃同学"));
dialog->setVersion(QCoreApplication::applicationVersion());
dialog->setDescription(description);
dialog->setLicense(QStringLiteral("GPL-3.0-or-later"));
dialog->setWebsiteName(QStringLiteral("gitee.com/GXDE-OS/gxde-ai-see"));
dialog->setWebsiteLink(QStringLiteral("https://gitee.com/GXDE-OS/gxde-ai-see"));
dialog->show();
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
class QWidget;
/** DTK native about dialog shared by the panel and the chat window. */
void showAboutDialog(QWidget *parent, const QString &description);
+701 -33
View File
@@ -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();
+40 -1
View File
@@ -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;
};
+55
View File
@@ -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()
+177
View File
@@ -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;
}
+45
View File
@@ -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, {})
+60
View File
@@ -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();
}
+5
View File
@@ -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)
+50
View File
@@ -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()
+41
View File
@@ -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()
+234
View File
@@ -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, &registry_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;
}
+242
View File
@@ -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())
+370
View File
@@ -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
+87
View File
@@ -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,
};
+126
View File
@@ -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;
}
+738
View File
@@ -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&apos;m AI See</translation>
</message>
<message>
<location filename="../src/chat/chatview.cpp" line="280"/>
<source>把文件、链接或文字拖进窗口,或者直接输入问题。
第一次使用请在左下角「设置」里填入 DeepSeek API Key。</source>
<translation>Drop files, links or text here, or just type a question.
On first use open Settings at the bottom left and enter your DeepSeek API key.</translation>
</message>
</context>
<context>
<name>ChatSidebar</name>
<message>
<location filename="../src/chat/chatwindow.cpp" line="147"/>
<source>重命名对话</source>
<translation>Rename conversation</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="149"/>
<source>删除选中</source>
<translation>Delete selected</translation>
</message>
</context>
<context>
<name>ChatWindow</name>
<message>
<location filename="../src/chat/chatwindow.cpp" line="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>还没有账号?前往 &lt;a href=&apos;https://platform.deepseek.com&apos;&gt;platform.deepseek.com&lt;/a&gt; 开通并创建 API Key。Key 只保存在本机配置文件中。</source>
<translation>No account yet? Sign up and create an API key at &lt;a href=&apos;https://platform.deepseek.com&apos;&gt;platform.deepseek.com&lt;/a&gt;. The key is stored only in the local configuration file.</translation>
</message>
<message>
<location filename="../src/chat/chatwindow.cpp" line="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>