mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
Compare commits
13 Commits
v0.1.0
...
e8eeb4ac72
| Author | SHA1 | Date | |
|---|---|---|---|
| e8eeb4ac72 | |||
|
|
629a36c0af | ||
| 0d762a1c5e | |||
| d6a7a3ab08 | |||
| 00209d55e6 | |||
| d6f5359873 | |||
| 73cb4cec60 | |||
| 366bee00d6 | |||
|
|
079d5de378 | ||
|
|
27af857d4b | ||
|
|
edab39cae2 | ||
|
|
cb63aaa28e | ||
|
|
844c6338cf |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
15
AGENTS.md
Normal 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
37
CONTEXT.md
Normal 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
161
Cargo.lock
generated
@@ -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
124
README.md
@@ -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> —— 粘贴图片直接识别 · ⚓ 锚定模式实测比官方 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 原生窗口 + 系统 WebView,Linux / Windows / macOS 通用。打开就是官方 WebUI——会话、工作区、插件、技能一个不少,`dsh` 升级后界面跟着升级,永远不用重打包前端。
|
||||
|
||||

|
||||
这是作者自用的第三方壳,**不包含** 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 就能跑。
|
||||
|
||||

|
||||
|
||||
### ⚓ 锚定模式:比官方 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%**。另附零工具锚定变体。新会话默认就是它,开箱即用。
|
||||
|
||||

|
||||
|
||||
*百分比按预设作者在 Project2 / DeepSeek V4 Pro 上的 Ability 计算:`(98−91)/91 ≈ 8%`。同配置可复现;社区实验预设,不是官方出品,不代表所有任务都涨。*
|
||||
|
||||
### 🪟 像个真正的 Mac / Linux / Windows 应用
|
||||
|
||||
36px 苹果风薄标题栏,不占一排后退/前进/刷新;左侧 `•••` 菜单可重启 dsh 或跳回浏览器。关窗口自动停掉 `dsh web`,崩溃一键拉起。凭据、权限、会话全部还在 `~/.dsh`,卸载壳不丢任何东西。
|
||||
|
||||

|
||||
|
||||
*上图为桌面壳嵌套官方 WebUI 的界面示意(自定义标题栏为实际注入样式)。凭据、会话和插件仍在 `~/.dsh`,截图未使用真实对话记录。*
|
||||
|
||||

|
||||
|
||||
## 三十秒上手
|
||||
|
||||
去 [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。
|
||||
|
||||

|
||||
|
||||
## 功能一览
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| 原生窗口 | 启动 `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 配置里。
|
||||
|
||||

|
||||
|
||||
### 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 的作者均无从属关系。
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
51
docs/agents/domain.md
Normal 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…_
|
||||
45
docs/agents/issue-tracker.md
Normal file
45
docs/agents/issue-tracker.md
Normal 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.
|
||||
15
docs/agents/triage-labels.md
Normal file
15
docs/agents/triage-labels.md
Normal 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.
|
||||
BIN
docs/screenshots/anchored.png
Normal file
BIN
docs/screenshots/anchored.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 253 KiB |
BIN
docs/screenshots/banner.png
Normal file
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 |
188
docs/screenshots/src/banner.html
Normal file
188
docs/screenshots/src/banner.html
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 智能体工作台
|
||||
|
||||
@@ -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]);
|
||||
.ok()?;
|
||||
file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_internal(url: &Url) -> bool {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user