18 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
70 changed files with 6135 additions and 100 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
+10
View File
@@ -26,3 +26,13 @@ ui_*.h
# 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>
+53 -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
)
@@ -28,10 +28,20 @@ set(GXDE_AI_SEE_SOURCES
src/factory/contentclassifier.cpp
src/factory/factoryactions.cpp
src/factory/contentfactory.cpp
src/dialogs/flowlayout.cpp
src/dialogs/segmentdialog.cpp
src/dialogs/speechdialog.cpp
src/actions/downloader.cpp
src/actions/texttools.cpp
src/todo/todomodel.cpp
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})
@@ -70,8 +80,48 @@ add_executable(gxde-ai-see-selftest
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)
+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 需要随库发布后,应用改为链接该控件
+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;
};
+121 -4
View File
@@ -15,6 +15,7 @@
#include <QMimeData>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QStyle>
#include <QToolButton>
#include <QVBoxLayout>
@@ -124,15 +125,80 @@ private:
QString m_id;
};
class AskTile : public QToolButton
{
Q_OBJECT
public:
explicit AskTile(QWidget *parent = nullptr)
: QToolButton(parent)
{
setText(tr("问问\n小埃同学"));
setToolTip(tr("把内容放到这里:新开一个对话并填入内容"));
setCursor(Qt::PointingHandCursor);
setFocusPolicy(Qt::NoFocus);
setAcceptDrops(true);
setFixedWidth(96);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
setStyleSheet(QStringLiteral(
"QToolButton { color: rgba(255,255,255,0.9); background: rgba(74,144,255,0.16);"
" border: 1px solid rgba(120,180,255,0.28); border-radius: 9px;"
" font-size: 12px; padding: 4px; }"
"QToolButton:hover { background: rgba(74,144,255,0.32); color: white;"
" border-color: rgba(160,205,255,0.7); }"
"QToolButton[dragover=\"true\"] { background: rgba(74,144,255,0.45);"
" border: 1px solid rgba(180,215,255,0.95); }"));
}
signals:
void dropped(const QMimeData *mime);
protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (!event->mimeData() ||
!(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
event->ignore();
return;
}
setProperty("dragover", true);
style()->unpolish(this);
style()->polish(this);
event->acceptProposedAction();
}
void dragLeaveEvent(QDragLeaveEvent *event) override
{
Q_UNUSED(event)
setProperty("dragover", false);
style()->unpolish(this);
style()->polish(this);
}
void dropEvent(QDropEvent *event) override
{
setProperty("dragover", false);
style()->unpolish(this);
style()->polish(this);
event->acceptProposedAction();
Q_EMIT dropped(event->mimeData());
}
};
ContentFactory::ContentFactory(QWidget *parent)
: QWidget(parent)
{
setAcceptDrops(true);
setMinimumHeight(44);
auto *outer = new QVBoxLayout(this);
auto *outer = new QHBoxLayout(this);
outer->setContentsMargins(12, 7, 12, 7);
outer->setSpacing(6);
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);
@@ -155,7 +221,7 @@ ContentFactory::ContentFactory(QWidget *parent)
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.32); font-size: 11px;"));
header->addWidget(hint);
outer->addLayout(header);
left->addLayout(header);
m_actions = new QWidget(this);
m_grid = new QGridLayout(m_actions);
@@ -163,7 +229,19 @@ ContentFactory::ContentFactory(QWidget *parent)
m_grid->setHorizontalSpacing(6);
m_grid->setVerticalSpacing(6);
m_actions->hide();
outer->addWidget(m_actions);
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)
@@ -239,6 +317,45 @@ void ContentFactory::paintEvent(QPaintEvent *event)
painter.drawPath(path);
}
void ContentFactory::dragEnterEvent(QDragEnterEvent *event)
{
if (!event->mimeData() ||
!(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
event->ignore();
return;
}
Q_EMIT dragContent(event->mimeData());
Q_EMIT dragActivity();
event->acceptProposedAction();
}
void ContentFactory::dragMoveEvent(QDragMoveEvent *event)
{
if (event->mimeData() &&
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
Q_EMIT dragContent(event->mimeData());
Q_EMIT dragActivity();
event->acceptProposedAction();
return;
}
event->ignore();
}
void ContentFactory::dragLeaveEvent(QDragLeaveEvent *event)
{
Q_UNUSED(event)
Q_EMIT dragActivity();
}
void ContentFactory::dropEvent(QDropEvent *event)
{
if (event->mimeData()) {
event->acceptProposedAction();
}
}
bool ContentFactory::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() == QEvent::DragEnter || event->type() == QEvent::DragMove) {
+11
View File
@@ -33,15 +33,26 @@ signals:
void actionRequested(const QString &id, const DropContent &content);
void heightChanged();
void dragActivity();
void dragContent(const QMimeData *mime);
void askRequested();
void askDropped(const QMimeData *mime);
public:
QSize sizeHint() const override;
protected:
void paintEvent(QPaintEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dragLeaveEvent(QDragLeaveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private:
void rebuild();
QLabel *m_summary = nullptr;
class AskTile *m_askTile = nullptr;
QWidget *m_actions = nullptr;
QGridLayout *m_grid = nullptr;
DropContent m_content;
+38 -26
View File
@@ -4,6 +4,9 @@
#include "factoryactions.h"
#include <QFileInfo>
#include <QRegularExpression>
#include "../actions/texttools.h"
namespace {
@@ -41,8 +44,6 @@ QVector<FactoryAction> linkActions(const DropContent &content)
QStringLiteral("edit-copy")));
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")));
actions.append(action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
QStringLiteral("dialog-question")));
return actions;
}
@@ -84,12 +85,20 @@ QVector<FactoryAction> fileActions(const DropContent &content)
actions.append(action(QStringLiteral("transcribe"), QStringLiteral("转文字"),
QStringLiteral("insert-text"), QStringLiteral("尚未接入"), false));
} else if (type == QStringLiteral("document") || type == QStringLiteral("code")) {
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")));
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")));
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")));
const QString fileText = TextTools::readTextFile(path);
if (!fileText.isEmpty() &&
fileText.contains(QRegularExpression(QStringLiteral("[\\x{4e00}-\\x{9fff}]")))) {
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")));
}
if (!fileText.isEmpty() && !TextTools::extractLinks(fileText).isEmpty()) {
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")));
}
if (!fileText.isEmpty()) {
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")));
}
}
actions.append(action(QStringLiteral("copy_path"), QStringLiteral("复制路径"),
@@ -120,25 +129,28 @@ QVector<FactoryAction> actionsFor(const DropContent &content)
QStringLiteral("dialog-question")),
action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")),
action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
QStringLiteral("dialog-question")),
};
case DropContent::Text:
return {
action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")),
action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")),
action(QStringLiteral("translate"), QStringLiteral("翻译"),
QStringLiteral("accessories-dictionary")),
action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")),
action(QStringLiteral("copy"), QStringLiteral("复制"), QStringLiteral("edit-copy")),
action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")),
action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
QStringLiteral("dialog-question")),
};
case DropContent::Text: {
QVector<FactoryAction> actions;
const QString &text = content.text;
if (text.contains(QRegularExpression(QStringLiteral("[\\x{4e00}-\\x{9fff}]")))) {
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")));
}
if (!TextTools::extractLinks(text).isEmpty()) {
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")));
}
actions.append(action(QStringLiteral("translate"), QStringLiteral("翻译"),
QStringLiteral("accessories-dictionary")));
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")));
actions.append(action(QStringLiteral("copy"), QStringLiteral("复制"),
QStringLiteral("edit-copy")));
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")));
return actions;
}
case DropContent::Link:
case DropContent::CustomLink:
return linkActions(content);
+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;
+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);
+474 -46
View File
@@ -10,16 +10,26 @@
#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 <QTemporaryDir>#include "staging/stagingitem.h"
#include <QFile>
#include <QMouseEvent>
#include <QTemporaryDir>
#include "staging/stagingitem.h"
#include "staging/stagingmodel.h"
#include <QApplication>
@@ -42,6 +52,8 @@
#include <QScreen>
#include <QStyledItemDelegate>
#include <QTimer>
#include <QVariantAnimation>
#include <QEasingCurve>
#include <QUrl>
#include <QVBoxLayout>
@@ -126,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)
@@ -150,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;
@@ -241,7 +269,7 @@ public:
}
if (closeRect(option.rect).contains(pos)) {
model->removeRow(index.row());
Q_EMIT removeRequested(index.row());
return true;
}
@@ -250,6 +278,8 @@ public:
private:
int m_hoverRow = -1;
int m_removingRow = -1;
qreal m_removeProgress = 0.0;
};
/* ------------------------------------------------------------------------- */
@@ -279,6 +309,30 @@ 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:
@@ -340,7 +394,7 @@ protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (isOwnDrag(event->mimeData())) {
event->ignore();
event->acceptProposedAction();
return;
}
if (mimeSupported(event->mimeData())) {
@@ -354,7 +408,7 @@ protected:
void dragMoveEvent(QDragMoveEvent *event) override
{
if (isOwnDrag(event->mimeData())) {
event->ignore();
event->acceptProposedAction();
return;
}
if (mimeSupported(event->mimeData())) {
@@ -368,12 +422,35 @@ 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());
}
@@ -404,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()) {
@@ -443,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);
@@ -460,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;
};
/* ------------------------------------------------------------------------- */
@@ -481,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);
@@ -512,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;
@@ -531,7 +633,6 @@ 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);
}
@@ -599,7 +700,7 @@ protected:
}
m_active = true;
update();
if (!m_portalActive) {
if (!m_portalActive && !m_suppressed) {
m_portalTimer.start();
}
Q_EMIT dragContent(event->mimeData());
@@ -645,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;
};
/* ------------------------------------------------------------------------- */
@@ -674,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(),
@@ -724,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);
@@ -749,10 +858,32 @@ void PortalPanel::setupUi()
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 {
@@ -765,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);
@@ -822,7 +955,7 @@ void PortalPanel::setupUi()
root->addLayout(outer);
m_factory = new ContentFactory(this);
m_factory->hide();
m_factory->show();
root->addWidget(m_factory);
m_toast = new QLabel(this);
@@ -834,12 +967,18 @@ void PortalPanel::setupUi()
m_factoryTimer = new QTimer(this);
m_factoryTimer->setSingleShot(true);
m_factoryTimer->setInterval(280);
m_factoryTimer->setInterval(220);
connect(m_factoryTimer, &QTimer::timeout, this, [this]() {
if (m_factory) {
m_factory->clearContent();
m_factory->hide();
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();
});
@@ -848,8 +987,27 @@ void PortalPanel::setupUi()
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::dragActivity, this, [this]() { m_factoryTimer->stop(); });
connect(m_factory, &ContentFactory::dragContent, this, &PortalPanel::noteDragContent);
connect(m_factory, &ContentFactory::dragActivity, this, [this]() {
if (m_factoryTimer) {
m_factoryTimer->start();
}
});
}
void PortalPanel::refreshLayout()
@@ -857,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();
@@ -867,16 +1051,19 @@ 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;
/* The factory always occupies the same strip, so neither the panel size nor
* its position changes when a drag starts or ends. */
const int factoryHeight =
(m_factory && m_factory->isVisible()) ? m_factory->sizeHint().height() + 8 : 0;
(!m_stagingOnly && m_factory) ? m_factory->sizeHint().height() + 6 : 0;
const int totalHeight =
kMargin * 2 + headerHeight + 6 + selectionHeight + listHeight + factoryHeight;
resize(kPanelWidth, totalHeight);
resize(m_stagingOnly ? 360 : kPanelWidth, totalHeight);
}
void PortalPanel::noteDragContent(const QMimeData *mime)
@@ -898,8 +1085,6 @@ void PortalPanel::noteDragContent(const QMimeData *mime)
}
m_factory->setContent(content);
m_factory->show();
refreshLayout();
}
void PortalPanel::noteDragFinished()
@@ -996,7 +1181,10 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
}
if (id == QLatin1String("segment")) {
const QStringList words = TextTools::segment(sourceText.left(20000));
showTextDialog(tr("分词(%1)").arg(words.size()), words.join(QStringLiteral(" / ")));
auto *dialog = new SegmentDialog(words);
dialog->show();
dialog->raise();
dialog->activateWindow();
return;
}
if (id == QLatin1String("extract_links")) {
@@ -1005,13 +1193,10 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
return;
}
if (id == QLatin1String("speak")) {
const QString program = QStandardPaths::findExecutable(QStringLiteral("spd-say"));
if (program.isEmpty()) {
toast(tr("未找到语音朗读程序"));
return;
}
QProcess::startDetached(program, { sourceText.left(4000) });
toast(tr("开始朗读"));
auto *dialog = new SpeechDialog(sourceText.left(20000));
dialog->show();
dialog->raise();
dialog->activateWindow();
return;
}
if (id == QLatin1String("todo")) {
@@ -1019,6 +1204,7 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
: content.summary();
TodoModel::instance()->add(text);
toast(tr("已添加到待办"));
Q_EMIT todoAdded();
return;
}
if (id == QLatin1String("save_image")) {
@@ -1086,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();
+28
View File
@@ -33,24 +33,38 @@ 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;
@@ -64,4 +78,18 @@ private:
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();
}
+17
View File
@@ -6,6 +6,7 @@
#include <QCoreApplication>
#include <QDebug>
#include <QTextStream>
#include <QTranslator>
#include "actions/texttools.h"
#include "factory/contentclassifier.h"
@@ -39,6 +40,22 @@ 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");
+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>