13 Commits

Author SHA1 Message Date
e8eeb4ac72 Point agents at GitHub issues and the domain glossary.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 16:15:54 +08:00
Tommy
629a36c0af Merge pull request #3 from TommyFang2077/TommyFang2077/plugin-agentrq
Fix Linux clipboard image paste ingest
2026-08-16 13:26:35 +08:00
0d762a1c5e revert: drop the AgentRQ plugin from the desktop shell
It is a remote human-in-the-loop task queue with per-workspace tokens, not a chat-native dsh plugin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 13:11:26 +08:00
d6a7a3ab08 fix: do not cancel HTML or text paste when no image is in the event
Empty text/plain used to preventDefault before native clipboard read, which discarded HTML-only paste if no image was recovered.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:46:10 +08:00
00209d55e6 fix: read Linux clipboard images natively and ingest them as paste
GTK/wl-paste/xclip fallbacks plus a paste-event path so ModLens can take over image paste instead of only synthesizing drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:39:08 +08:00
d6f5359873 fix: ship a loadable AgentRQ bundle that stays idle without a workspace URL
The first integration copied sources without lib/, skipped packaging paths, and could fail the default web profile when url was unset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 12:36:04 +08:00
73cb4cec60 feat: integrate AgentRQ task manager plugin
- Clone agentrq/agentrq plugin to plugins/agentrq/
- Update package.json name to 'agentrq'
- Add agentrq plugin to tauri.conf.json resources
- Add AGENTRQ_PACKAGE constant and bundled_agentrq_plugin() discovery
- Add install_agentrq_plugin() function to modlens.rs
- Update README with plugin listing
2026-08-16 12:13:11 +08:00
366bee00d6 Add Anchored Standard screenshot to README
Show the mode menu with the experimental preset selected so readers can recognize the default setup.
2026-08-15 23:11:42 +08:00
Tommy
079d5de378 Put a full-width product banner at the top of the README.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:54:09 +08:00
Tommy
27af857d4b Fix Flatpak CI: stub vendor dirs for tauri-build and move to GNOME 50.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:43:33 +08:00
Tommy
edab39cae2 Restructure the README around the two selling points.
Lead with the hero screenshot, "eyes for DeepSeek" and the ~8%
anchored lift, then a 30-second install table before the details.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:40:10 +08:00
Tommy
cb63aaa28e Tone down the intro: this is a personal shell that keeps getting updates.
Invite issues instead of claiming the bundled plugins need no setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:37:36 +08:00
Tommy
844c6338cf Pitch built-in vision and the ~8% Anchored Standard lift.
Lead with ModLens giving DeepSeek eyes, and cite Project2 Ability
91 to 98/99 as about +8% over official Standard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 22:36:36 +08:00
26 changed files with 1002 additions and 135 deletions

View File

@@ -150,7 +150,7 @@ jobs:
needs: [test, version, vendor]
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
options: --privileged
steps:
- uses: actions/checkout@v4
@@ -164,7 +164,7 @@ jobs:
with:
bundle: io.github.tommyfang.DshDesktop.flatpak
manifest-path: flatpak/io.github.tommyfang.DshDesktop.yml
cache-key: flatpak-gnome-47-${{ hashFiles('flatpak/io.github.tommyfang.DshDesktop.yml', 'Cargo.lock') }}
cache-key: flatpak-gnome-50-${{ hashFiles('flatpak/io.github.tommyfang.DshDesktop.yml', 'Cargo.lock') }}
upload-artifact: false
- name: Attach Flatpak to GitHub Release

3
.gitignore vendored
View File

@@ -22,6 +22,9 @@ src-tauri/gen/
.flatpak-builder/
.flatpak-repo/
# Local agent scratch (wayfinder snapshots, research notes)
.scratch/
# OS
.DS_Store
*~

15
AGENTS.md Normal file
View File

@@ -0,0 +1,15 @@
# DeepSeek Harness Desktop
## Agent skills
### Issue tracker
Issues live in GitHub Issues; use the `gh` CLI. See `docs/agents/issue-tracker.md`.
### Triage labels
Five canonical roles, each label string equal to its name: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain.md`.

37
CONTEXT.md Normal file
View File

@@ -0,0 +1,37 @@
# DeepSeek Harness Desktop
Official dsh WebUI in a native window. This glossary is the language for the shell, what it ships, and how users get more plugins.
## Language
**壳**:
The Tauri desktop application that launches `dsh web` and embeds the official WebUI.
_Avoid_: 应用, 客户端, wrapper, desktop shell对用户说话时
**捆绑件**:
A piece shipped inside the installer and synced into the user's dsh profile or preset directory on launch.
_Avoid_: 内置插件, 三件套
**市场**:
The in-WebUI storefront plugin `dshmarket`. It is itself a Bundled component.
_Avoid_: 插件商店, awesome-dsh-plugin, 目录
**目录**:
The curated awesome-dsh-plugin list that names installable Community plugins (`plugins.json`).
_Avoid_: 市场, registry, awesome 列表(对内请用「目录」)
**社区插件**:
A plugin acquired through the Market, not shipped as a Bundled component.
_Avoid_: 内置插件, 第三方插件(捆绑件也可以是上游项目)
**默认安装**:
A Community plugin the Shell installs through the Market on first launch. ModLens is one.
_Avoid_: 捆绑件(默认安装不进安装包)
**降级**:
Bundled UI stays available when a default-installed Community plugin is missing. Vision settings stay; image reading does not.
_Avoid_: fallback本项目里 fallback 指把引擎再打进安装包,已否决)
**在线更新**:
Fetching a newer Shell, dsh, Bundled component, Market, or default-installed Community plugin over the network.
_Avoid_: 升级(和 dsh 自己的版本号口语混淆)

161
Cargo.lock generated
View File

@@ -114,6 +114,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -904,13 +905,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser",
"foldhash",
"foldhash 0.2.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -943,6 +950,8 @@ dependencies = [
"arboard",
"dsh-core",
"env_logger",
"gdk",
"gtk",
"image",
"log",
"open",
@@ -1157,6 +1166,12 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1173,6 +1188,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1617,6 +1638,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -2329,6 +2359,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2601,6 +2640,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2661,6 +2710,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.14.0",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -4254,6 +4314,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -4519,6 +4590,76 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.1",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.1",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.13.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.104"
@@ -5101,6 +5242,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.20",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.4"

124
README.md
View File

@@ -1,12 +1,22 @@
# DeepSeek Harness Desktop
<p align="center">
<img src="docs/screenshots/banner.png" alt="DeepSeek Harness Desktop官方 dsh 原生壳,给 DeepSeek 带上眼睛" />
</p>
<h1 align="center">DeepSeek Harness Desktop</h1>
<p align="center">
<img src="docs/screenshots/icon.png" width="96" alt="DeepSeek Harness" />
<strong>官方 <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a>dsh的原生桌面壳</strong>
</p>
<p align="center">
<strong>把官方 <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a><code>dsh</code>WebUI 放进原生窗口。</strong><br />
Tauri 2 壳 · 系统 WebView · 苹果风薄标题栏 · Linux / Windows / macOS
👁️ 给 DeepSeek <strong>带上眼睛</strong> —— 粘贴图片直接识别&nbsp;&nbsp;·&nbsp;&nbsp;⚓ 锚定模式实测比官方 Standard <strong>约 +8%</strong>
</p>
<p align="center">
<a href="https://github.com/TommyFang2077/dsh-desktop/releases/latest"><b>⬇ 下载</b></a> ·
<a href="#三十秒上手">三十秒上手</a> ·
<a href="#内置插件与预设">内置插件</a> ·
<a href="#从源码运行">从源码运行</a>
</p>
<p align="center">
@@ -17,31 +27,69 @@
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/topic-dsh--plugin-1f6feb" alt="dsh-plugin" /></a>
</p>
本仓库是第三方桌面壳,**不包含** DeepSeek Harness 源码。官方 WebUI 由本机或 Flatpak 内的 `dsh web` 提供;升级 `dsh` 后界面跟着升级,不必重打包前端。仓库已按 [DeepSeek Harness 贡献指南](https://github.com/deepseek-ai/deepseek-harness/blob/master/CONTRIBUTING.md) 添加 GitHub topic [`dsh-plugin`](https://github.com/topics/dsh-plugin),方便在生态里被发现。
dsh 只能开在浏览器标签页里这个仓库把它变成一个真正的桌面应用Tauri 2 原生窗口 + 系统 WebViewLinux / Windows / macOS 通用。打开就是官方 WebUI——会话、工作区、插件、技能一个不少`dsh` 升级后界面跟着升级,永远不用重打包前端。
![启动页:正在启动官方 WebUI](docs/screenshots/splash.png)
这是作者自用的第三方壳,**不包含** DeepSeek Harness 源码,会持续跟着 dsh 和内置组件更新;踩到坑欢迎 [提 issue](https://github.com/TommyFang2077/dsh-desktop/issues)。仓库按官方 [贡献指南](https://github.com/deepseek-ai/deepseek-harness/blob/master/CONTRIBUTING.md) 挂了 [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic。
## 功能
## 为什么值得一试
| 能力 | 说明 |
| --- | --- |
| 原生窗口 | 启动 `dsh web --host 127.0.0.1 --port 0`,解析 stdout 里的随机端口,用系统 WebView 加载官方 WebUI |
| 薄标题栏 | 左侧 `•••` 菜单(重新启动 / 在浏览器中打开),右侧最小化 · 缩放 · 关闭;不再占用一排后退/前进/刷新 |
| 零重写 | 官方会话、工作区、插件、技能全部保留 |
| 内置 ModLens | 启动时写入 `~/.dsh/profiles/web`,纯文本模型自动套视觉桥 |
| 视觉设置页 | WebUI **设置 → 视觉模型** 配置引擎,写入 `~/.modlens/config.json` |
| 内置锚定预设 | **锚定式标准(实验)**、**零工具锚定式标准(实验)** 写入 `~/.dsh/.agent-presets/` |
| 生命周期 | 启动页显示状态;关窗口停掉 `dsh web`;崩溃可从标题栏重新启动 |
### 👁️ 给 DeepSeek 带上眼睛
DeepSeek 主力对话模型是纯文本的——你贴一张截图,它两眼一抹黑。本应用内置 [ModLens](https://github.com/liustack/modlens)(全网第一个 dsh 视觉插件):外挂视觉引擎后,**图片直接粘贴进对话框就能识别**,不用先存盘再填路径。配套的 **设置 → 视觉模型** 页面把 OpenAI / Gemini / Anthropic / 本机 CLI 引擎全配齐,免费的 Gemini key 就能跑。
![设置 → 视觉模型](docs/screenshots/vision.png)
### ⚓ 锚定模式:比官方 Standard 约 +8%
DeepSeek V4 Pro 会按「第一眼看到的工具表」选执行轨迹Project2 评测里官方 Minimal 拿 **99**Standard 只有 **91**——但常驻 Minimal 又缺工具。内置的 [锚定式标准](https://github.com/xiaobright/dsh-anchored-standard) 两头都要:**首轮用 Minimal 真工具对钉住高分轨迹,从第二轮起解锁完整 Standard 工具目录**,同配置实测 Ability **98 / 99**,相对 Standard 的 91 约 **+8% / +9%**。另附零工具锚定变体。新会话默认就是它,开箱即用。
![模式菜单:锚定式标准(实验)已选中](docs/screenshots/anchored.png)
*百分比按预设作者在 Project2 / DeepSeek V4 Pro 上的 Ability 计算:`(9891)/91 ≈ 8%`。同配置可复现;社区实验预设,不是官方出品,不代表所有任务都涨。*
### 🪟 像个真正的 Mac / Linux / Windows 应用
36px 苹果风薄标题栏,不占一排后退/前进/刷新;左侧 `•••` 菜单可重启 dsh 或跳回浏览器。关窗口自动停掉 `dsh web`,崩溃一键拉起。凭据、权限、会话全部还在 `~/.dsh`,卸载壳不丢任何东西。
![主窗口:官方 WebUI 嵌在原生壳里](docs/screenshots/session.png)
*上图为桌面壳嵌套官方 WebUI 的界面示意(自定义标题栏为实际注入样式)。凭据、会话和插件仍在 `~/.dsh`,截图未使用真实对话记录。*
![标题栏菜单:重新启动 / 在浏览器中打开](docs/screenshots/menu.png)
## 三十秒上手
去 [GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases/latest) 下载对应平台的安装包:
| 平台 | 产物 | 运行时要求 |
| --- | --- | --- |
| 🪟 Windows | NSIS `.exe` / `.msi` | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载)+ 本机 `dsh` |
| 🍎 macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「隐私与安全性」允许 + 本机 `dsh` |
| 🐧 Linux | `.deb` / `.rpm` | WebKitGTK 4.1 + 本机 `dsh` |
| 📦 Linux Flatpak | `.flatpak` | **零依赖**:自带 Node.js 24 与 `@deepseek-ai/dsh` |
除 Flatpak 外需要本机有 `dsh`
```bash
npm install -g @deepseek-ai/dsh
```
装好后启动,等启动页转完就是官方 WebUI。
![启动页:正在启动官方 WebUI](docs/screenshots/splash.png)
## 功能一览
| 能力 | 说明 |
| --- | --- |
| 原生窗口 | 启动 `dsh web --host 127.0.0.1 --port 0`,解析随机端口后用系统 WebView 加载 |
| 零重写 | 官方会话、工作区、插件、技能全部保留dsh 升级即界面升级 |
| 内置视图 | 👁️ 纯文本 DeepSeek 也能粘贴识图ModLens + 设置页) |
| 内置锚定 | ⚓ 相对官方 Standard 约 +8%Project2 Ability 91 → 98/99 |
| 薄标题栏 | `•••` 菜单(重新启动 / 在浏览器中打开)+ 右侧最小化 · 缩放 · 关闭 |
| 生命周期 | 关窗口停掉 `dsh web`;崩溃可从标题栏一键重启 |
## 内置插件与预设
应用启动时把下面这些东西同步到用户目录。版本钉死在 [Makefile](Makefile);第三方原文许可证见 [docs/licenses/](docs/licenses/) 与 [THIRD_PARTY.md](THIRD_PARTY.md)。
应用启动时把下面这些同步到用户目录。版本钉死在 [Makefile](Makefile);第三方原文许可证见 [docs/licenses/](docs/licenses/) 与 [THIRD_PARTY.md](THIRD_PARTY.md)。
### 1. DeepSeek Harness`dsh`
@@ -70,7 +118,7 @@
| 当前版本 | `3.16.6` |
| 作者 | Leon Liu / [liustack](https://github.com/liustack) |
| 许可证 | MIT · [docs/licenses/modlens.LICENSE](docs/licenses/modlens.LICENSE) |
| 作用 | 给纯文本对话模型补视觉能力(粘贴图片即可)。已声明视觉能力的模型(如 Qwen不会走这条桥 |
| 亮点 | 给 DeepSeek 带上眼睛:纯文本模型粘贴即可读图 |
| 安装位置 | 启动时复制到 `~/.dsh/profiles/web/node_modules/@liustack/modlens` |
官方安装方式(本应用已内置,一般不必再跑):
@@ -100,8 +148,6 @@ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.6
外链在系统浏览器中打开Tauri `on_navigation`),密钥只写在本机 ModLens 配置里。
![设置 → 视觉模型](docs/screenshots/vision.png)
### 4. Anchored Standard 预设
| | |
@@ -111,31 +157,11 @@ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.6
| 作者 | [xiaobright](https://github.com/xiaobright) |
| 许可证 | MIT含 DeepSeek 部分版权)· [LICENSE](docs/licenses/dsh-anchored-standard.LICENSE) · [NOTICE](docs/licenses/dsh-anchored-standard.NOTICE) |
| 本仓库中的名称 | **锚定式标准(实验)**、**零工具锚定式标准(实验)**`scripts/localize_preset.py` 本地化) |
| 亮点 | 相对官方 Standard 约 **+8%**Project2 Ability 91 → 98/99同时拿回完整工具目录 |
| 安装位置 | `~/.dsh/.agent-presets/anchored-standard``zero-anchored-standard` |
NOTICE 写明:预设改编自 DeepSeek Harness Standard agent preset[deepseek-harness@47f9438](https://github.com/deepseek-ai/deepseek-harness))。这是社区实验 preset**不是** DeepSeek 官方预设。若用户还没有默认 preset桌面会把默认设为锚定式标准。
## 安装包
`v*` 标签(例如 `git tag v0.1.0 && git push origin v0.1.0`)后,[Release 工作流](.github/workflows/release.yml)会测试、打包并发布:
| 平台 | 产物 | 运行时要求 |
| --- | --- | --- |
| Windows | NSIS `.exe`、MSI | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载);本机 `dsh` |
| macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「系统设置 → 隐私与安全性」允许;本机 `dsh` |
| Linux | `.deb``.rpm` | WebKitGTK 4.1;本机 `dsh` |
| Linux | `.flatpak` | **自带** Node.js 24 与 `@deepseek-ai/dsh`,不需要本机安装 dsh |
从 [GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases) 下载对应文件。
本机 `dsh`
```bash
npm install -g @deepseek-ai/dsh
# 或
npx --yes @deepseek-ai/dsh --version
```
## 从源码运行
开发依赖Rust stable、系统 WebView。
@@ -167,6 +193,8 @@ cargo tauri build --bundles nsis,msi # Windows
cargo tauri build --bundles app,dmg # macOS
```
发布:打 `v*` 标签(如 `git tag v0.1.0 && git push origin v0.1.0`[Release 工作流](.github/workflows/release.yml)自动测试、打包 Windows / macOS / deb / rpm / Flatpak 并挂到 GitHub Releases。
## Flatpak
Flatpak 是唯一把 `dsh` 打进包内的渠道。
@@ -174,9 +202,9 @@ Flatpak 是唯一把 `dsh` 打进包内的渠道。
```bash
flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub org.gnome.Sdk//47 org.flatpak.Builder \
org.freedesktop.Sdk.Extension.rust-stable//24.08 \
org.freedesktop.Sdk.Extension.node24//24.08
flatpak install --user -y flathub org.gnome.Sdk//50 org.flatpak.Builder \
org.freedesktop.Sdk.Extension.rust-stable//25.08 \
org.freedesktop.Sdk.Extension.node24//25.08
make vendor
make flatpak-build
@@ -205,6 +233,10 @@ dsh-desktop/
└── .github/workflows/ # 测试 + 多平台发布
```
## 反馈
自用项目会持续更新。bug、想法、打包问题都欢迎开 [issue](https://github.com/TommyFang2077/dsh-desktop/issues)。
## 图标与商标
应用图标使用 [Icons8 上的 DeepSeek 图标](https://icons8.com/icon/YWOidjGxCpFW/deepseek)。DeepSeek 名称与鲸鱼标志归 DeepSeek 所有。本项目是独立第三方桌面壳,与 DeepSeek、ModLens、Anchored Standard 的作者均无从属关系。

View File

@@ -170,6 +170,25 @@ pub fn file_from_bytes(name: String, mime: String, data: Vec<u8>) -> Option<Clip
})
}
pub fn detect_image_mime(data: &[u8]) -> Option<&'static str> {
if data.len() >= 8 && data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return Some("image/jpeg");
}
if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
return Some("image/webp");
}
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
return Some("image/gif");
}
if data.len() >= 2 && data.starts_with(b"BM") {
return Some("image/bmp");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -187,9 +206,22 @@ mod tests {
#[test]
fn ingest_js_defines_helper() {
assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles"));
assert!(INGEST_JS.contains("window.__dshDesktopIngestFiles"));
assert!(INGEST_JS.contains("/modlens/paste"));
assert!(INGEST_JS.contains("ClipboardEvent"));
assert!(INGEST_JS.contains("DragEvent"));
assert!(INGEST_JS.contains("new File"));
assert!(INGEST_JS.contains("text/uri-list"));
assert!(INGEST_JS.contains("__dshDesktopLooksLikeImagePath"));
}
#[test]
fn sniffs_image_magic() {
assert_eq!(
detect_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 1]),
Some("image/png")
);
assert_eq!(detect_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
assert_eq!(detect_image_mime(b"not-an-image"), None);
}
#[test]

View File

@@ -97,7 +97,9 @@ fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
if !up_to_date && copy_tree(&src, &dest, true).is_err() {
return false;
}
let fallback = dsh_home().join("profiles/node_modules").join(VISION_PACKAGE);
let fallback = dsh_home()
.join("profiles/node_modules")
.join(VISION_PACKAGE);
let _ = replace_symlink(&fallback, &dest);
true
}
@@ -431,7 +433,8 @@ mod tests {
#[test]
fn keeps_other_entries() {
let original = "- id: other\n config:\n x: 1\n- id: modlens\n config:\n autoRead: false\n";
let original =
"- id: other\n config:\n x: 1\n- id: modlens\n config:\n autoRead: false\n";
let text = ensure_modlens_overlay(original);
assert!(text.contains("id: other"));
assert!(text.contains("autoRead: true"));
@@ -461,7 +464,8 @@ ui-theme:
#[test]
fn already_wrapped_is_left_alone() {
let original = "agent-default-model:\n provider: deepseek-modlens\n model: deepseek-v4-pro\n";
let original =
"agent-default-model:\n provider: deepseek-modlens\n model: deepseek-v4-pro\n";
assert_eq!(remap_default_text_model(original), original);
}

View File

@@ -3,8 +3,8 @@ Name=DeepSeek Harness
Name[zh_CN]=DeepSeek Harness
GenericName=AI Agent Harness
GenericName[zh_CN]=AI 智能体工作台
Comment=Desktop shell for DeepSeek Harness
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness
Comment=Give DeepSeek eyes (ModLens) and ~+8% via Anchored Standard
Comment[zh_CN]=给 DeepSeek 带上眼睛;内置锚定模式约 +8%
Exec=dsh-desktop
Icon=io.github.tommyfang.DshDesktop
Terminal=false

View File

@@ -2,8 +2,8 @@
<component type="desktop-application">
<id>io.github.tommyfang.DshDesktop</id>
<name>DeepSeek Harness Desktop</name>
<summary>Desktop shell for DeepSeek Harness</summary>
<summary xml:lang="zh_CN">DeepSeek Harness 的原生桌面壳</summary>
<summary>Give DeepSeek eyes and ~+8% with Anchored Standard</summary>
<summary xml:lang="zh_CN">DeepSeek 带上眼睛;内置锚定模式约 +8%</summary>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
@@ -14,15 +14,12 @@
<description>
<p>
DeepSeek Harness Desktop runs the official DeepSeek Harness (dsh) WebUI
in a native Tauri window. It starts dsh web, waits for its local URL and
embeds the UI with the system WebView. Credentials, plugins and agent
permissions stay in ~/.dsh.
</p>
<p>
The app bundles ModLens for text-only vision, a settings page at
设置 → 视觉模型, and the community Anchored Standard presets.
The Flatpak also bundles Node.js and @deepseek-ai/dsh.
Official DeepSeek Harness (dsh) WebUI in a native window. Built-in
ModLens vision gives text-only DeepSeek eyes: paste an image and it
can read it. Built-in Anchored Standard raises DeepSeek V4 Pro about
8% over official Standard on Project2 (Ability 91 to 98/99) by
anchoring the first turn on Minimal tools, then unlocking the full
catalog. Credentials stay in ~/.dsh.
</p>
</description>

51
docs/agents/domain.md Normal file
View File

@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

View File

@@ -0,0 +1,45 @@
# Issue tracker: GitHub
Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
## Conventions
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
- **Comment on an issue**: `gh issue comment <number> --body "..."`
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
- **Close**: `gh issue close <number> --comment "..."`
Infer the repo from `git remote -v``gh` does this automatically when run inside a clone.
## Pull requests as a triage surface
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
- **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`.
## When a skill says "publish to the issue tracker"
Create a GitHub issue.
## When a skill says "fetch the relevant ticket"
Run `gh issue view <number> --comments`.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`.
- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>`, where `<blocker-db-id>` is the blocker's numeric **database id** (`gh api repos/<owner>/<repo>/issues/<n> --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker is closed.
- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins.
- **Claim**: `gh issue edit <n> --add-assignee @me` — the session's first write.
- **Resolve**: `gh issue comment <n> --body "<answer>"`, then `gh issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.

View File

@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

BIN
docs/screenshots/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,188 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Banner</title>
<link rel="stylesheet" href="chrome.css" />
<style>
:root {
--bg: #f6f6f8;
--sidebar: #efeff2;
--text: #1d1d1f;
--muted: #86868b;
--line: rgba(0,0,0,0.06);
--accent: #0071e3;
--card: #ffffff;
}
html, body {
margin: 0; width: 1920px; height: 960px; overflow: hidden;
background: #07080c; color: #f5f5f7;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
background:
radial-gradient(ellipse 70% 55% at 50% 18%, rgba(0, 113, 227, 0.28), transparent 62%),
radial-gradient(ellipse 50% 40% at 78% 88%, rgba(29, 78, 216, 0.16), transparent 70%),
#07080c;
}
.wordmark {
position: absolute; top: 32px; left: 0; right: 0;
display: flex; flex-direction: column; align-items: center; gap: 10px;
z-index: 2;
}
.wordmark .row {
display: flex; align-items: center; gap: 14px;
}
.wordmark img {
width: 44px; height: 44px; border-radius: 11px;
box-shadow: 0 0 0 1px rgba(255,255,255,0.08);
}
.wordmark h1 {
margin: 0; font-size: 34px; font-weight: 650; letter-spacing: -0.04em;
}
.wordmark p {
margin: 0; color: rgba(245,245,247,0.72); font-size: 16px; letter-spacing: -0.01em;
}
.stage {
position: absolute; left: 0; right: 0; top: 124px;
display: flex; justify-content: center;
}
.app {
width: 1280px; height: 800px;
transform: scale(1.06);
transform-origin: top center;
border-radius: 14px;
overflow: hidden;
background: var(--bg);
color: var(--text);
box-shadow:
0 0 0 1px rgba(255,255,255,0.08),
0 28px 80px rgba(0,0,0,0.55);
}
.app #dsh-desktop-titlebar { position: absolute; }
.shell {
display: grid;
grid-template-columns: 248px 1fr;
height: calc(800px - 36px);
margin-top: 36px;
}
.sidebar {
background: var(--sidebar);
border-right: 0.5px solid var(--line);
padding: 16px 12px 18px;
display: flex; flex-direction: column; gap: 14px;
}
.brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 8px 8px;
font-weight: 600; font-size: 13px;
}
.brand img { width: 22px; height: 22px; border-radius: 6px; }
.new-session {
height: 34px; border: 0; border-radius: 10px;
background: var(--card); color: var(--text);
font: 600 13px/1 inherit; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.section-label {
margin: 8px 8px 4px; font-size: 11px; color: var(--muted); letter-spacing: 0.04em;
}
.session {
padding: 8px 10px; border-radius: 8px; font-size: 13px;
background: rgba(255,255,255,0.7);
}
.session small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
.main {
display: flex; flex-direction: column; min-width: 0;
background: var(--bg);
}
.hero {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px;
padding-bottom: 40px;
}
.hero .mark {
width: 56px; height: 56px; border-radius: 14px; margin-bottom: 8px;
}
.hero h1 { margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.03em; color: var(--text); }
.badge {
font-size: 11px; color: var(--muted); background: rgba(0,0,0,0.04);
padding: 2px 8px; border-radius: 999px;
}
.hint { color: var(--muted); font-size: 13px; margin: 0; }
.composer {
margin: 0 28px 22px; background: var(--card);
border: 0.5px solid var(--line); border-radius: 16px;
min-height: 92px; padding: 14px 16px 12px;
display: flex; flex-direction: column; justify-content: space-between;
box-shadow: 0 8px 28px rgba(0,0,0,0.04);
}
.placeholder { color: #aeaeb2; font-size: 14px; }
.tools { display: flex; justify-content: space-between; align-items: center; }
.chips { display: flex; gap: 8px; color: var(--muted); font-size: 12px; }
.chip {
border: 0.5px solid var(--line); border-radius: 999px; padding: 4px 10px; background: #fff;
}
.send {
width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff;
display: grid; place-items: center; font-size: 14px;
}
</style>
</head>
<body>
<div class="wordmark">
<div class="row">
<img src="../../../ui/icon.png" alt="" />
<h1>DeepSeek Harness Desktop</h1>
</div>
<p>官方 dsh 原生壳 · 给 DeepSeek 带上眼睛 · 锚定模式约 +8%</p>
</div>
<div class="stage">
<div class="app">
<header id="dsh-desktop-titlebar">
<button class="more" type="button">•••</button>
<div class="menu"></div>
<div class="drag"></div>
<div class="traffic">
<button class="tl min"></button>
<button class="tl zoom"></button>
<button class="tl close"></button>
</div>
</header>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<img src="../../../ui/icon.png" alt="" />
DeepSeek Harness
</div>
<div class="new-session"> 新会话</div>
<div class="section-label">工作区</div>
<div class="session">
新会话
<small>锚定式标准(实验)</small>
</div>
</aside>
<section class="main">
<div class="hero">
<img class="mark" src="../../../ui/icon.png" alt="" />
<h1>探索未至之境</h1>
<span class="badge">预览版</span>
<p class="hint">官方 WebUI 运行在原生窗口中,凭据仍保存在 ~/.dsh</p>
</div>
<div class="composer">
<div class="placeholder">给 Harness 发送消息</div>
<div class="tools">
<div class="chips">
<span class="chip">锚定式标准(实验)</span>
<span class="chip">工作区</span>
</div>
<div class="send"></div>
</div>
</div>
</section>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,6 +1,6 @@
app-id: io.github.tommyfang.DshDesktop
runtime: org.gnome.Platform
runtime-version: '47'
runtime-version: '50'
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.node24
@@ -50,6 +50,7 @@ modules:
build-args:
- --share=network
build-commands:
- mkdir -p vendor/modlens vendor/anchored-standard vendor/zero-anchored-standard
- cargo build --release --locked --offline || cargo build --release
- install -Dm755 target/release/dsh-desktop ${FLATPAK_DEST}/bin/dsh-desktop
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/vision

View File

@@ -21,9 +21,24 @@ capture() {
echo "wrote $OUT/$name.png"
}
capture_banner() {
"$CHROME" \
--headless=new \
--disable-gpu \
--no-sandbox \
--hide-scrollbars \
--force-device-scale-factor=2 \
--window-size=1920,960 \
--default-background-color=00000000 \
--screenshot="$OUT/banner.png" \
"file://$SRC/banner.html"
echo "wrote $OUT/banner.png"
}
mkdir -p "$OUT"
capture splash "$SRC/splash.html"
capture session "$SRC/session.html"
capture vision "$SRC/vision.html"
capture menu "$SRC/menu.html"
capture_banner
cp -f "$ROOT/ui/icon.png" "$OUT/icon.png"

View File

@@ -24,9 +24,13 @@ tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
arboard = "3"
arboard = { version = "3", features = ["wayland-data-control"] }
image = { version = "0.25", default-features = false, features = ["png"] }
open = "5"
url = "2"
log = "0.4"
env_logger = "0.11"
[target.'cfg(target_os = "linux")'.dependencies]
gdk = "0.18"
gtk = "0.18"

View File

@@ -1,7 +1,7 @@
[Desktop Entry]
Categories={{categories}}
Comment={{comment}}
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness
Comment[zh_CN]=给 DeepSeek 带上眼睛;锚定模式约 +8%
Exec={{exec}}
GenericName=AI Agent Harness
GenericName[zh_CN]=AI 智能体工作台

View File

@@ -1,11 +1,13 @@
use std::io::Cursor;
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use dsh_core::clipboard::{
file_from_bytes, filename_for_mime, files_from_paths, parse_uri_list, ClipboardFile,
detect_image_mime, file_from_bytes, filename_for_mime, files_from_paths, parse_uri_list,
ClipboardFile,
};
use dsh_core::launcher::{DshLauncher, DshProcess, URL_TIMEOUT_SECONDS};
use dsh_core::modlens::ensure_modlens;
@@ -134,39 +136,93 @@ fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
}
#[tauri::command]
fn read_clipboard_images() -> Result<Vec<ClipboardFile>, String> {
read_images().map_err(|e| e.to_string())
fn read_clipboard_images(app: AppHandle) -> Result<Vec<ClipboardFile>, String> {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
app.run_on_main_thread(move || {
let _ = tx.send(read_images());
})
.map_err(|e| e.to_string())?;
rx.recv().map_err(|e| e.to_string())?
}
fn read_images() -> Result<Vec<ClipboardFile>, String> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
if let Ok(img) = clipboard.get_image() {
if let Some(file) = read_native_image() {
return Ok(vec![file]);
}
if let Some(file) = read_arboard_image() {
return Ok(vec![file]);
}
if let Some(file) = read_cli_image() {
return Ok(vec![file]);
}
if let Some(files) = read_arboard_image_paths() {
return Ok(files);
}
Ok(Vec::new())
}
#[cfg(target_os = "linux")]
fn read_native_image() -> Option<ClipboardFile> {
let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD);
let pixbuf = clipboard.wait_for_image()?;
let png = pixbuf.save_to_bufferv("png", &[]).ok()?;
file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png)
}
#[cfg(not(target_os = "linux"))]
fn read_native_image() -> Option<ClipboardFile> {
None
}
fn read_arboard_image() -> Option<ClipboardFile> {
let mut clipboard = arboard::Clipboard::new().ok()?;
let img = clipboard.get_image().ok()?;
let width = img.width as u32;
let height = img.height as u32;
let bytes = img.bytes.into_owned();
if let Some(buffer) = image::RgbaImage::from_raw(width, height, bytes) {
let buffer = image::RgbaImage::from_raw(width, height, bytes)?;
let mut png = Vec::new();
if buffer
buffer
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.is_ok()
{
if let Some(file) = file_from_bytes(
filename_for_mime("image/png", 0),
"image/png".into(),
png,
) {
return Ok(vec![file]);
}
}
}
}
if let Ok(text) = clipboard.get_text() {
.ok()?;
file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png)
}
fn read_arboard_image_paths() -> Option<Vec<ClipboardFile>> {
let mut clipboard = arboard::Clipboard::new().ok()?;
let text = clipboard.get_text().ok()?;
let loaded = files_from_paths(parse_uri_list(&text));
if !loaded.is_empty() {
return Ok(loaded);
if loaded.is_empty() {
None
} else {
Some(loaded)
}
}
fn read_cli_image() -> Option<ClipboardFile> {
const ATTEMPTS: &[(&str, &[&str])] = &[
("wl-paste", &["--type", "image/png"]),
("wl-paste", &["--type", "image/jpeg"]),
("wl-paste", &["--type", "image/webp"]),
("xclip", &["-selection", "clipboard", "-t", "image/png", "-o"]),
("xclip", &["-selection", "clipboard", "-t", "image/jpeg", "-o"]),
("xclip", &["-selection", "clipboard", "-t", "image/webp", "-o"]),
];
for (bin, args) in ATTEMPTS {
let output = match Command::new(bin).args(*args).output() {
Ok(output) => output,
Err(_) => continue,
};
if !output.status.success() || output.stdout.is_empty() {
continue;
}
let mime = detect_image_mime(&output.stdout).unwrap_or("image/png");
if let Some(file) = file_from_bytes(filename_for_mime(mime, 0), mime.into(), output.stdout)
{
return Some(file);
}
}
Ok(Vec::new())
None
}
fn is_internal(url: &Url) -> bool {

View File

@@ -22,8 +22,8 @@
"homepage": "https://github.com/TommyFang2077/dsh-desktop",
"copyright": "Copyright © 2026 TommyFang2077",
"category": "DeveloperTool",
"shortDescription": "Desktop shell for DeepSeek Harness",
"longDescription": "Runs the official DeepSeek Harness (dsh) WebUI in a native window. Starts dsh web, embeds the UI with the system WebView, and ships ModLens plus Anchored Standard presets. Credentials stay in ~/.dsh.",
"shortDescription": "Give DeepSeek eyes; Anchored Standard ~+8%",
"longDescription": "Official dsh WebUI in a native window. Built-in ModLens vision gives text-only DeepSeek eyes (paste to read images). Built-in Anchored Standard raises DeepSeek about 8% over official Standard on Project2 (Ability 91 to 98/99). Credentials stay in ~/.dsh.",
"licenseFile": "../LICENSE",
"icon": [
"icons/32x32.png",

View File

@@ -36,6 +36,25 @@ class VisionPluginFilesTests(unittest.TestCase):
self.assertIn("example: 'haiku'", host)
class ClipboardIngestTests(unittest.TestCase):
def test_inject_delivers_images_as_paste_not_only_drop(self):
ingest = (ROOT / "ui" / "inject" / "ingest.js").read_text(encoding="utf-8")
chrome = (ROOT / "ui" / "inject" / "chrome.js").read_text(encoding="utf-8")
self.assertIn("window.__dshDesktopIngestFiles", ingest)
self.assertIn("/modlens/paste", ingest)
self.assertIn("ClipboardEvent", ingest)
self.assertIn("navigator.clipboard.read", chrome)
self.assertIn("read_clipboard_images", chrome)
self.assertIn("__dshDesktopIngesting", chrome)
self.assertIn('clipboardText(e, "text/html")', chrome)
self.assertNotIn(
'item.kind === "file" && item.getAsFile()',
chrome,
)
self.assertNotIn("steal", chrome)
self.assertNotIn("|| !String(text).trim()", chrome)
class BundledAttributionTests(unittest.TestCase):
def test_readme_cites_upstream_plugins(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
@@ -49,6 +68,8 @@ class BundledAttributionTests(unittest.TestCase):
self.assertIn("ffb845c5480adc953392a6db6f8a98ede621174b", text)
self.assertIn("dsh-desktop-vision", text)
self.assertIn("dsh-plugin", readme)
self.assertIn("带上眼睛", readme)
self.assertIn("+8%", readme)
self.assertTrue((ROOT / "docs" / "licenses" / "modlens.LICENSE").is_file())
self.assertTrue(
(ROOT / "docs" / "licenses" / "dsh-anchored-standard.NOTICE").is_file()

View File

@@ -146,23 +146,81 @@
inject();
}
function clipboardText(e, type) {
try {
return e.clipboardData ? (e.clipboardData.getData(type) || "") : "";
} catch (err) {
return "";
}
}
async function readAsyncClipboardImages() {
if (!navigator.clipboard || !navigator.clipboard.read) return [];
try {
const items = await navigator.clipboard.read();
const files = [];
for (const item of items) {
for (const type of item.types) {
if (type.indexOf("image/") !== 0) continue;
const blob = await item.getType(type);
if (!blob || !blob.size) continue;
const ext = (type.split("/")[1] || "png").replace("jpeg", "jpg");
files.push(new File([blob], "clipboard." + ext, { type: blob.type || type }));
}
}
return files;
} catch (err) {
return [];
}
}
const harness = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname);
if (harness && location.protocol.indexOf("http") === 0) {
document.addEventListener("paste", async function (e) {
const t = api();
if (!t || !t.core) return;
if (window.__dshDesktopIngesting) return;
const ingest = window.__dshDesktopIngestFiles;
const fromEvent = window.__dshDesktopImageFilesFromEvent;
const pathLike = window.__dshDesktopLooksLikeImagePath;
if (!ingest) return;
try {
const items = e.clipboardData ? Array.from(e.clipboardData.items) : [];
if (items.some(function (item) { return item.kind === "file" && item.getAsFile(); })) {
return;
}
const files = await t.core.invoke("read_clipboard_images");
if (files && files.length) {
const local = fromEvent ? fromEvent(e) : [];
const text = clipboardText(e, "text/plain");
const uris = clipboardText(e, "text/uri-list");
const html = clipboardText(e, "text/html");
const imagePath = Boolean(pathLike && (pathLike(text) || pathLike(uris)));
const hasText = Boolean(String(text).trim() || String(html).trim());
// Image files in the event, or a pasted image path, are ours. Empty
// payload is the Linux case where the image is only on the native
// clipboard. Non-empty text/html must reach the page — do not cancel
// first and then fail to recover an image.
if (!local.length && !imagePath && hasText) return;
e.preventDefault();
e.stopImmediatePropagation();
if (window.__dshDesktopPasteFiles) window.__dshDesktopPasteFiles(files);
window.__dshDesktopIngesting = true;
try {
if (local.length) {
await ingest(local);
return;
}
let files = await readAsyncClipboardImages();
if (!files.length) {
const t = api();
if (t && t.core) {
const payload = await t.core.invoke("read_clipboard_images");
if (payload && payload.length && window.__dshDesktopPasteFiles) {
await window.__dshDesktopPasteFiles(payload);
return;
}
}
}
if (files.length) await ingest(files);
} finally {
window.__dshDesktopIngesting = false;
}
} catch (err) {
window.__dshDesktopIngesting = false;
}
} catch (err) {}
}, true);
}
})();

View File

@@ -1,45 +1,179 @@
(function () {
const IMAGE_PATH = /^(?:file:\/\/|(?:\/|\.{1,2}\/)).+\.(?:png|jpe?g|gif|webp|bmp|tiff?)$/i;
const IMAGE_MIME = /^image\/(png|jpe?g|gif|webp|bmp|tiff?)$/i;
function looksLikeImagePath(text) {
const first = (text || "").trim().split(/\s+/, 1)[0] || "";
return IMAGE_PATH.test(first);
}
document.addEventListener("paste", function (e) {
try {
const items = Array.from(e.clipboardData ? e.clipboardData.items : []);
const files = items.filter(function (item) { return item.kind === "file"; })
.map(function (item) { return item.getAsFile(); })
.filter(Boolean);
if (files.length > 0) return;
const text = e.clipboardData ? (e.clipboardData.getData("text/plain") || "") : "";
const uris = e.clipboardData ? (e.clipboardData.getData("text/uri-list") || "") : "";
if (looksLikeImagePath(text) || looksLikeImagePath(uris)) {
e.preventDefault();
e.stopImmediatePropagation();
function addImageFile(out, seen, file) {
if (!file || !file.size) return;
const type = file.type || "";
if (type && !IMAGE_MIME.test(type)) return;
if (!type && !IMAGE_PATH.test(file.name || "")) return;
const key = (file.name || "") + ":" + file.size + ":" + type;
if (seen.has(key)) return;
seen.add(key);
out.push(file);
}
function imageFilesFromEvent(e) {
const out = [];
const seen = new Set();
const data = e.clipboardData || e.dataTransfer;
if (!data) return out;
const items = data.items ? Array.from(data.items) : [];
for (const item of items) {
if (item.kind === "file" || (item.type && item.type.indexOf("image/") === 0)) {
try {
addImageFile(out, seen, item.getAsFile());
} catch (err) {}
}, true);
}
}
if (data.files) {
for (const file of data.files) addImageFile(out, seen, file);
}
return out;
}
function composer() {
const el = document.activeElement;
if (el && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable)) {
return el;
}
return document.querySelector("form textarea, textarea, [contenteditable='true']");
}
function insertText(target, text) {
const el = target && (target.tagName === "TEXTAREA" || target.tagName === "INPUT")
? target
: composer();
if (!el || (el.tagName !== "TEXTAREA" && el.tagName !== "INPUT")) return;
el.focus();
let inserted = false;
try {
inserted = document.execCommand("insertText", false, text);
} catch (err) {
inserted = false;
}
if (!inserted) {
const proto = el.tagName === "TEXTAREA"
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
const start = el.selectionStart || el.value.length;
const end = el.selectionEnd || start;
setter.call(el, el.value.slice(0, start) + text + el.value.slice(end));
el.dispatchEvent(new Event("input", { bubbles: true }));
}
}
function dataTransferOf(files) {
const dt = new DataTransfer();
for (const file of files) dt.items.add(file);
return dt;
}
function dispatchWithData(type, target, dt, key) {
let ev;
try {
if (type === "paste") {
ev = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: dt });
} else {
ev = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt });
}
} catch (err) {
ev = new Event(type, { bubbles: true, cancelable: true });
}
try {
Object.defineProperty(ev, key, { value: dt, configurable: true });
} catch (err) {}
target.dispatchEvent(ev);
return ev;
}
function currentModelLabel() {
const buttons = document.querySelectorAll("button[aria-label]");
for (const button of buttons) {
const label = button.getAttribute("aria-label") || "";
if (/选择模型|select model|current model/i.test(label)) return label;
}
return "";
}
function shouldTakeoverPaste() {
const label = currentModelLabel();
return fetch("/modlens/paste?model=" + encodeURIComponent(label))
.then(function (res) {
if (!res.ok) return false;
return res.json().then(function (body) {
return body && body.takeover === true;
});
})
.catch(function () {
return false;
});
}
function uploadModlens(files) {
return Promise.all(
files.map(function (file) {
return file.arrayBuffer().then(function (buffer) {
return fetch("/modlens/paste", { method: "POST", body: buffer }).then(function (res) {
if (!res.ok) throw new Error("modlens paste failed " + res.status);
return res.json();
});
});
})
).then(function (results) {
const text = results.map(function (r) { return r.path; }).filter(Boolean).join(" ");
if (text) insertText(composer(), text + " ");
});
}
function deliverNative(files) {
const prev = window.__dshDesktopIngesting;
window.__dshDesktopIngesting = true;
try {
const dt = dataTransferOf(files);
const target = composer() || document;
const paste = dispatchWithData("paste", target, dt, "clipboardData");
if (paste.defaultPrevented) return;
dispatchWithData("drop", document, dt, "dataTransfer");
} finally {
window.__dshDesktopIngesting = prev;
}
}
window.__dshDesktopLooksLikeImagePath = looksLikeImagePath;
window.__dshDesktopImageFilesFromEvent = imageFilesFromEvent;
window.__dshDesktopIngestFiles = function (files) {
if (!files || !files.length) return Promise.resolve();
return shouldTakeoverPaste()
.then(function (takeover) {
if (takeover) return uploadModlens(files);
deliverNative(files);
})
.catch(function (err) {
console.error("dsh-desktop paste image failed", err);
});
};
window.__dshDesktopPasteFiles = function (items) {
try {
const dt = new DataTransfer();
const files = [];
for (const item of items) {
const bin = atob(item.b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
dt.items.add(new File([bytes], item.name, { type: item.type }));
files.push(new File([bytes], item.name, { type: item.type }));
}
const drop = new DragEvent("drop", {
bubbles: true,
cancelable: true,
dataTransfer: dt
});
try {
Object.defineProperty(drop, "dataTransfer", { value: dt, configurable: true });
} catch (e) {}
document.dispatchEvent(drop);
return window.__dshDesktopIngestFiles(files);
} catch (err) {
console.error("dsh-desktop paste image failed", err);
return Promise.resolve();
}
};
})();