20 Commits

Author SHA1 Message Date
f1b41e989a fix: retry Gitea publish through slow mainland link 2026-08-17 00:00:01 +08:00
33186b2ef9 fix: embed updater pubkey in tauri config 2026-08-16 23:43:30 +08:00
6a3cd55f88 docs: replace README banner with real-product showcase 2026-08-16 23:38:10 +08:00
862382daf6 release: v0.1.1 2026-08-16 23:25:03 +08:00
16c80dfa45 chore: rename repository to dsh-easy-desktop 2026-08-16 23:03:52 +08:00
a24fbe0037 fix: tolerate stale proxy metadata during setup 2026-08-16 23:01:52 +08:00
a3e1f11e85 feat: add mainland-reachable desktop distribution 2026-08-16 23:00:54 +08:00
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
59 changed files with 4644 additions and 280 deletions

View File

@@ -84,7 +84,7 @@ jobs:
- name: linux
platform: ubuntu-22.04
rust_targets: ""
args: --bundles deb,rpm
args: --bundles deb,rpm,appimage
- name: windows
platform: windows-latest
rust_targets: ""
@@ -128,6 +128,8 @@ jobs:
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: .
tagName: v__VERSION__
@@ -139,18 +141,38 @@ jobs:
- Windows / macOS / deb / rpm 需要本机已安装 `dsh``npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`。
- Flatpak 自带 Node.js 与 `dsh`。
- macOS 包未公证,需在「系统设置 → 隐私与安全性」中允许打开。
- 中国大陆用户可从 Gitea 镜像下载安装包;壳更新使用同一镜像并在安装前校验 Tauri 签名。
releaseDraft: false
prerelease: false
uploadUpdaterJson: false
uploadUpdaterSignatures: false
uploadUpdaterSignatures: true
args: ${{ matrix.args }}
- name: Collect Gitea release artifacts
uses: actions/upload-artifact@v4
with:
name: gitea-${{ matrix.name }}
path: |
target/**/release/bundle/**/*.dmg
target/**/release/bundle/**/*.app.tar.gz
target/**/release/bundle/**/*.app.tar.gz.sig
target/**/release/bundle/**/*.deb
target/**/release/bundle/**/*.rpm
target/**/release/bundle/**/*.AppImage
target/**/release/bundle/**/*.AppImage.sig
target/**/release/bundle/**/*.exe
target/**/release/bundle/**/*.exe.sig
target/**/release/bundle/**/*.msi
target/**/release/bundle/**/*.msi.sig
if-no-files-found: error
retention-days: 1
flatpak:
name: Flatpak
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 +186,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
@@ -176,3 +198,44 @@ jobs:
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Collect Gitea Flatpak artifact
uses: actions/upload-artifact@v4
with:
name: gitea-flatpak
path: io.github.tommyfang.DshDesktop.flatpak
if-no-files-found: error
retention-days: 1
publish-gitea:
name: Publish Gitea mirror
needs: [version, package, flatpak]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: gitea-*
path: artifacts
- name: Normalize artifacts and build latest.json
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
python3 scripts/build-gitea-update.py \
--artifacts artifacts \
--output staged \
--version "$VERSION" \
--pub-date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--package-base-url "https://git.fangsiyuan.top/api/packages/TomHanck4/generic/dsh-easy-desktop-updater"
- name: Publish Gitea release and updater feed
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_BASE_URL: https://git.fangsiyuan.top
GITEA_OWNER: TomHanck4
GITEA_REPO: dsh-easy-desktop
RELEASE_TAG: ${{ needs.version.outputs.tag }}
RELEASE_VERSION: ${{ needs.version.outputs.version }}
run: bash scripts/publish-gitea-release.sh staged

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 自己的版本号口语混淆)

544
Cargo.lock generated
View File

@@ -97,6 +97,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -114,6 +123,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -798,6 +808,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -904,13 +925,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"
@@ -922,12 +949,13 @@ dependencies = [
[[package]]
name = "dsh-core"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"base64 0.22.1",
"dirs",
"libc",
"regex",
"semver",
"serde",
"serde_json",
"shlex 1.3.0",
@@ -938,11 +966,13 @@ dependencies = [
[[package]]
name = "dsh-desktop"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"arboard",
"dsh-core",
"env_logger",
"gdk",
"gtk",
"image",
"log",
"open",
@@ -952,7 +982,9 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"url",
"webkit2gtk",
]
[[package]]
@@ -1151,12 +1183,28 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
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 +1221,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 +1671,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"
@@ -1716,6 +1779,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2058,6 +2136,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -2247,6 +2355,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2329,6 +2443,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"
@@ -2490,6 +2613,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2505,6 +2629,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2585,6 +2721,12 @@ dependencies = [
"libc",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2601,6 +2743,30 @@ 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.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.20",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2661,6 +2827,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"
@@ -3019,15 +3196,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3039,6 +3221,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3067,6 +3263,79 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -3082,6 +3351,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3139,6 +3417,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@@ -3366,6 +3667,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3478,6 +3795,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3572,7 +3895,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3605,6 +3928,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -3628,7 +3962,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3778,6 +4112,39 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.20",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3788,7 +4155,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3811,7 +4178,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -4023,6 +4390,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -4254,6 +4631,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"
@@ -4336,6 +4724,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4519,6 +4913,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"
@@ -4585,6 +5049,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4830,6 +5303,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5101,6 +5583,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"
@@ -5125,7 +5625,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -5189,6 +5689,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5323,6 +5833,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
@@ -5356,6 +5872,18 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.23"

View File

@@ -3,7 +3,7 @@ members = ["crates/dsh-core", "src-tauri"]
resolver = "2"
[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2021"
license = "MIT"
authors = ["TommyFang2077"]

View File

@@ -4,10 +4,12 @@ PREFIX ?= $(HOME)/.local
APP_ID := io.github.tommyfang.DshDesktop
DSH_VERSION := 0.1.0-rc.6
MODLENS_VERSION := 3.16.6
MARKET_VERSION := 1.9.0
ANCHORED_COMMIT := ffb845c5480adc953392a6db6f8a98ede621174b
ANCHORED_REPO := https://github.com/xiaobright/dsh-anchored-standard.git
VENDOR_DIR := vendor/dsh-prefix
MODLENS_DIR := vendor/modlens
MARKET_DIR := vendor/dshmarket
ANCHORED_DIR := vendor/anchored-standard
ZERO_DIR := vendor/zero-anchored-standard
FLATPAK ?= flatpak
@@ -35,16 +37,19 @@ build:
test:
$(CARGO) test -p dsh-core
$(PYTHON) -m unittest discover -s tests -v
node --test tests/*.test.mjs
vendor:
mkdir -p vendor
rm -rf $(VENDOR_DIR) $(MODLENS_DIR)
rm -rf $(VENDOR_DIR) $(MODLENS_DIR) $(MARKET_DIR)
npm install --prefix=$(CURDIR)/$(VENDOR_DIR) --global --prefer-offline --no-audit --no-fund @deepseek-ai/dsh@$(DSH_VERSION)
npm install --prefix=$(CURDIR)/$(MODLENS_DIR) --prefer-offline --no-audit --no-fund @liustack/modlens@$(MODLENS_VERSION)
npm install --prefix=$(CURDIR)/$(MARKET_DIR) --prefer-offline --no-audit --no-fund dshmarket@$(MARKET_VERSION)
$(PYTHON) scripts/patch-dshmarket-mainland.py
$(MAKE) vendor-anchored
vendor-native:
MODLENS_VERSION=$(MODLENS_VERSION) ANCHORED_COMMIT=$(ANCHORED_COMMIT) ANCHORED_REPO=$(ANCHORED_REPO) bash scripts/vendor-native.sh
MODLENS_VERSION=$(MODLENS_VERSION) MARKET_VERSION=$(MARKET_VERSION) ANCHORED_COMMIT=$(ANCHORED_COMMIT) ANCHORED_REPO=$(ANCHORED_REPO) bash scripts/vendor-native.sh
vendor-anchored:
rm -rf vendor/.anchored-src $(ANCHORED_DIR) $(ZERO_DIR)
@@ -79,11 +84,21 @@ install: build
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R $(MODLENS_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \
fi
if [ -d $(MARKET_DIR)/node_modules/dshmarket ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R $(MARKET_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \
fi
if [ -f plugins/dsh-desktop-vision/package.json ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R plugins/dsh-desktop-vision $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
fi
if [ -f plugins/dsh-desktop-voice/package.json ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/voice; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R plugins/dsh-desktop-voice $(DESTDIR)$(PREFIX)/share/dsh-desktop/voice; \
fi
if [ -f $(ANCHORED_DIR)/preset.yml ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
@@ -105,7 +120,7 @@ uninstall:
rm -f "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png"; \
done
flatpak-build: $(VENDOR_DIR)/bin/dsh $(MODLENS_DIR)/node_modules/@liustack/modlens $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml
flatpak-build: $(VENDOR_DIR)/bin/dsh $(MODLENS_DIR)/node_modules/@liustack/modlens $(MARKET_DIR)/node_modules/dshmarket $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml
$(BUILDER) --user --force-clean --install-deps-from=flathub $(BUILD_DIR) $(MANIFEST)
$(VENDOR_DIR)/bin/dsh:
@@ -114,6 +129,9 @@ $(VENDOR_DIR)/bin/dsh:
$(MODLENS_DIR)/node_modules/@liustack/modlens:
$(MAKE) vendor
$(MARKET_DIR)/node_modules/dshmarket:
$(MAKE) vendor
$(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml:
$(MAKE) vendor-anchored

206
README.md
View File

@@ -1,141 +1,119 @@
# DeepSeek Harness Desktop
<p align="center">
<img src="docs/screenshots/banner.png" alt="DeepSeek Harness Desktop内置离线语音、插件市场与视觉模型配置" />
</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
<strong>离线语音输入</strong>&nbsp;&nbsp;·&nbsp;&nbsp;<strong>内置插件市场</strong>&nbsp;&nbsp;·&nbsp;&nbsp;<strong>可视化配置视觉模型</strong>
</p>
<p align="center">
<a href="https://github.com/TommyFang2077/dsh-desktop/actions/workflows/ci.yml"><img src="https://github.com/TommyFang2077/dsh-desktop/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/TommyFang2077/dsh-desktop/actions/workflows/release.yml"><img src="https://github.com/TommyFang2077/dsh-desktop/actions/workflows/release.yml/badge.svg" alt="Release" /></a>
<a href="https://github.com/TommyFang2077/dsh-easy-desktop/releases/latest"><b>下载</b></a> ·
<a href="https://git.fangsiyuan.top/TomHanck4/dsh-easy-desktop/releases/latest"><b>大陆镜像</b></a> ·
<a href="#核心体验">核心体验</a> ·
<a href="#三十秒上手">三十秒上手</a> ·
<a href="#从源码运行">从源码运行</a>
</p>
<p align="center">
<a href="https://github.com/TommyFang2077/dsh-easy-desktop/actions/workflows/ci.yml"><img src="https://github.com/TommyFang2077/dsh-easy-desktop/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/TommyFang2077/dsh-easy-desktop/actions/workflows/release.yml"><img src="https://github.com/TommyFang2077/dsh-easy-desktop/actions/workflows/release.yml/badge.svg" alt="Release" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT" /></a>
<a href="https://github.com/TommyFang2077/dsh-desktop/releases/latest"><img src="https://img.shields.io/github/v/release/TommyFang2077/dsh-desktop" alt="release" /></a>
<a href="https://github.com/TommyFang2077/dsh-easy-desktop/releases/latest"><img src="https://img.shields.io/github/v/release/TommyFang2077/dsh-easy-desktop" alt="release" /></a>
<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 把官方 WebUI 变成原生桌面窗口。会话、工作区、插件和技能全部保留,`dsh` 更新后界面也会随之更新,不需要重新打包前端
![启动页:正在启动官方 WebUI](docs/screenshots/splash.png)
这是作者维护的第三方壳,**不包含** DeepSeek Harness 源码。重点补齐官方 WebUI 在桌面端缺少的输入和扩展体验:直接说话、直接安装插件、直接配置视觉模型。
## 功能
## 核心体验
| 能力 | 说明 |
| --- | --- |
| 原生窗口 | 启动 `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`;崩溃可从标题栏重新启动 |
### 内置语音SenseVoice 本机离线听写
![主窗口:官方 WebUI 嵌在原生壳里](docs/screenshots/session.png)
对话框旁直接提供麦克风按钮;按 `Ctrl+E`macOS 为 `⌘E`)即可开始或结束听写,也可切换为按住说话。默认引擎是本机离线 **SenseVoiceSmall**,支持中文、粤语、英语、日语和韩语,识别结果直接写入当前输入框。
*上图为桌面壳嵌套官方 WebUI 的界面示意(自定义标题栏为实际注入样式)。凭据、会话和插件仍在 `~/.dsh`,截图未使用真实对话记录。*
模型和 sherpa-onnx WASM 运行时**不塞进安装包**。首次点击麦克风时会明确提示下载约 245 MB底部状态条持续显示下载与校验进度安装完成后保存在系统缓存目录录音不离开本机。需要云端识别时也可切换到 OpenAI 兼容的 `/v1/audio/transcriptions` 接口。
![标题栏菜单:重新启动 / 在浏览器中打开](docs/screenshots/menu.png)
![设置 → 语音输入SenseVoice 本机离线听写](docs/screenshots/voice.webp)
## 内置插件与预设
### 内置插件市场:发现、安装和更新社区插件
应用启动时会把下面这些东西同步到用户目录。版本钉死在 [Makefile](Makefile);第三方原文许可证见 [docs/licenses/](docs/licenses/) 与 [THIRD_PARTY.md](THIRD_PARTY.md)
无需记包名或离开应用。在 **设置 → Plugin Market** 中可以浏览目录、搜索分类、查看已安装插件并直接安装、更新、备份或恢复社区插件。ModLens 等默认组件也能从这里正常更新;桌面启动不会再把用户更新的版本降回内置基线
### 1. DeepSeek Harness`dsh`
![设置 → Plugin Market浏览并安装社区插件](docs/screenshots/market.webp)
| | |
| --- | --- |
| 上游 | [@deepseek-ai/dsh](https://www.npmjs.com/package/@deepseek-ai/dsh) · [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
| 当前版本 | `0.1.0-rc.6` |
| 许可证 | MITCopyright (c) 2026 DeepSeek · [docs/licenses/deepseek-harness.LICENSE](docs/licenses/deepseek-harness.LICENSE) |
| 本仓库 | **不 vendoring 源码**。Flatpak 构建时 `make vendor` 打进 Node 24 + npm 包Windows / macOS / deb / rpm 运行时调用本机 `dsh` |
### 视觉模型配置:给 DeepSeek 带上眼睛
解析顺序:
纯文本 DeepSeek 配合视觉桥后,可以直接粘贴截图识别内容。内置的 **设置 → 视觉模型** 页面集中配置 OpenAI 兼容接口、Gemini API、Anthropic API、Antigravity CLI 和 Claude Code 登录;只展示当前引擎需要的字段,密钥保存在本机配置中,相关外链由系统浏览器打开。
1. 环境变量 `DSH_DESKTOP_DSH_BIN`
2. 命令行 `--dsh`
3. 应用自己的更新目录(`$XDG_DATA_HOME/dsh-desktop/dsh-prefix/bin/dsh`
4. **Flatpak**:内置 `/app/bin/dsh`
5. **宿主机**`~/.local/bin/dsh``~/.npm/_npx` 缓存 → `PATH``npx --yes @deepseek-ai/dsh`
![设置 → 视觉模型:配置 OpenAI 兼容视觉引擎](docs/screenshots/vision.webp)
`DSH_DESKTOP_NO_UPDATE=1` 或传 `--no-update` 可关掉启动时的 npm 更新检查。
### 锚定模式与原生窗口
### 2. ModLens`@liustack/modlens`
内置的锚定式标准预设首轮使用 Minimal 工具表固定执行轨迹,从第二轮起恢复完整 Standard 工具目录。Project2 / DeepSeek V4 Pro 同配置 Ability 为 **98 / 99**,相对官方 Standard 的 91 约 **+8% / +9%**。这是社区实验预设,不代表所有任务都会提升。
| | |
| --- | --- |
| 上游 | [liustack/modlens](https://github.com/liustack/modlens) · [npm @liustack/modlens](https://www.npmjs.com/package/@liustack/modlens) |
| 当前版本 | `3.16.6` |
| 作者 | Leon Liu / [liustack](https://github.com/liustack) |
| 许可证 | MIT · [docs/licenses/modlens.LICENSE](docs/licenses/modlens.LICENSE) |
| 作用 | 给纯文本对话模型补视觉能力(粘贴图片即可)。已声明视觉能力的模型(如 Qwen不会走这条桥 |
| 安装位置 | 启动时复制到 `~/.dsh/profiles/web/node_modules/@liustack/modlens` |
36px 薄标题栏保留更多对话空间;左侧 `•••` 菜单可重新启动 dsh 或在浏览器中打开。关闭窗口会停止对应的 `dsh web` 进程,凭据、权限和会话仍保存在 `~/.dsh`
官方安装方式(本应用已内置,一般不必再跑):
![主窗口:官方 WebUI 嵌在原生壳中](docs/screenshots/session.png)
```bash
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.6
```
## 三十秒上手
### 3. `dsh-desktop-vision`(本仓库)
| | |
| --- | --- |
| 路径 | [`plugins/dsh-desktop-vision/`](plugins/dsh-desktop-vision/) |
| 版本 | `0.1.4` |
| 许可证 | 与本仓库相同MIT |
| 作用 | 在官方 WebUI **设置 → 视觉模型** 增加表单,读写 `~/.modlens/config.json` |
支持的引擎:
| 引擎 | 默认接口 | 获取密钥 |
| --- | --- | --- |
| OpenAI 兼容 | `https://api.openai.com/v1` | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) |
| Gemini API | `https://generativelanguage.googleapis.com` | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) |
| Anthropic | `https://api.anthropic.com` | [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) |
| Antigravity CLI | 本机 CLI无需填 URL | [antigravity.google](https://antigravity.google/) |
| Claude CLI | 本机 CLI无需填 URL | [code.claude.com](https://code.claude.com) |
外链在系统浏览器中打开Tauri `on_navigation`),密钥只写在本机 ModLens 配置里。
![设置 → 视觉模型](docs/screenshots/vision.png)
### 4. Anchored Standard 预设
| | |
| --- | --- |
| 上游 | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) |
| 钉选提交 | [`ffb845c5480adc953392a6db6f8a98ede621174b`](https://github.com/xiaobright/dsh-anchored-standard/commit/ffb845c5480adc953392a6db6f8a98ede621174b) |
| 作者 | [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` 本地化) |
| 安装位置 | `~/.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)会测试、打包并发布:
从 [GitHub Releases](https://github.com/TommyFang2077/dsh-easy-desktop/releases/latest) 下载对应平台的安装包;中国大陆网络可改用 [Gitea 发行版镜像](https://git.fangsiyuan.top/TomHanck4/dsh-easy-desktop/releases/latest)。壳会在启动时从该镜像检查自身更新,下载完成后先校验 Tauri 签名再安装。
| 平台 | 产物 | 运行时要求 |
| --- | --- | --- |
| 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 |
| 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` |
从 [GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases) 下载对应文件。
本机 `dsh`
除 Flatpak 外,需要先安装 `dsh`
```bash
npm install -g @deepseek-ai/dsh
# 或
npx --yes @deepseek-ai/dsh --version
```
安装后直接启动,等待启动页完成即可进入官方 WebUI。
## 功能一览
| 能力 | 说明 |
| --- | --- |
| 离线语音 | 对话框麦克风、`Ctrl+E` / `⌘E` 快捷键、SenseVoice 模型按需下载、本机识别 |
| 插件市场 | 在设置内浏览、搜索、安装、更新、备份和恢复社区插件 |
| 视觉模型 | 粘贴图片直接识别;用表单配置五类视觉引擎,不必手改 JSON |
| 官方 WebUI | 会话、工作区、插件和技能原样保留dsh 更新后界面同步更新 |
| 锚定预设 | Project2 / DeepSeek V4 Pro 相对官方 Standard 约 +8% |
| 原生生命周期 | 随机本地端口启动 `dsh web`;关闭窗口停止服务;崩溃可一键重启 |
## 内置能力与数据位置
| 组件 | 当前基线 | 用途 | 本地位置 |
| --- | --- | --- | --- |
| DeepSeek Harness | `0.1.0-rc.6` | 官方 WebUIFlatpak 内置,其他安装包调用本机 `dsh` | `~/.dsh` |
| 离线语音 | `dsh-desktop-voice 0.4.0` | 麦克风、快捷键、SenseVoice / OpenAI 兼容听写 | 配置 `~/.config/dsh-desktop/voice.json`;模型在系统缓存目录 |
| 插件市场 | `dshmarket 1.9.0` | 社区插件的发现、安装和更新 | `~/.dsh/profiles/web` |
| 视觉配置 | `dsh-desktop-vision 0.1.4` | 配置视觉桥所使用的引擎、接口和模型 | `~/.modlens/config.json` |
| 视觉桥 | `ModLens 3.16.6` | 让纯文本模型读取粘贴的图片;可从市场更新 | `~/.dsh/profiles/web` |
| 锚定预设 | `ffb845c5480a` | 锚定式标准与零工具锚定式标准 | `~/.dsh/.agent-presets/` |
应用启动时会同步桌面自带组件,但会保留用户从市场更新到更新版本的 ModLens。捆绑版本固定在 [Makefile](Makefile) 中。
`dsh` 的查找顺序:`DSH_DESKTOP_DSH_BIN``--dsh` → 桌面更新目录 → Flatpak 内置路径 → 宿主机常见路径 → `PATH``npx`。壳启动的 dsh、市场和语音运行时默认通过 `https://registry.npmmirror.com` 获取 npm 包;已有 `npm_config_registry` / `NPM_CONFIG_REGISTRY` 会保留,也可用 `DSH_DESKTOP_NPM_REGISTRY` 显式覆盖。设置 `DSH_DESKTOP_NO_UPDATE=1` 或传入 `--no-update` 可关闭启动时的 dsh 更新检查。
## 安全与边界
- WebUI 只监听随机的 `127.0.0.1` 端口。
- 语音模型按需下载SenseVoice 识别在本机执行。
- API 密钥只写入本机配置,不进入仓库或远端服务。
- 卸载桌面壳不会删除 `~/.dsh` 中的会话、权限和工作区设置。
## 从源码运行
开发依赖Rust stable、系统 WebView。
@@ -145,8 +123,8 @@ npx --yes @deepseek-ai/dsh --version
- WindowsWebView2
```bash
git clone https://github.com/TommyFang2077/dsh-desktop.git
cd dsh-desktop
git clone https://github.com/TommyFang2077/dsh-easy-desktop.git
cd dsh-easy-desktop
make vendor-native # ModLens + 锚定预设Tauri 打包资源)
make run # 普通模式(跳过更新,便于开发)
make dev # 开 WebView 检查器和调试日志
@@ -167,6 +145,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 +154,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
@@ -195,6 +175,7 @@ dsh-desktop/
├── src-tauri/ # Tauri 窗口、命令、deb/rpm/nsis/dmg
├── crates/dsh-core/ # 启动 / 更新 / ModLens / 预设 / 剪贴板
├── plugins/dsh-desktop-vision/ # 设置 → 视觉模型
├── plugins/dsh-desktop-voice/ # 设置 → 语音输入 + 对话框麦克风
├── data/ # .desktop、图标、AppStream
├── flatpak/
├── docs/screenshots/ # README 截图
@@ -205,6 +186,23 @@ dsh-desktop/
└── .github/workflows/ # 测试 + 多平台发布
```
## 反馈
自用项目会持续更新。bug、想法、打包问题都欢迎开 [issue](https://github.com/TommyFang2077/dsh-easy-desktop/issues)。
## 上游项目与许可证
引用与许可证集中列在这里,正文只介绍用户能直接使用的能力。完整版权说明见 [THIRD_PARTY.md](THIRD_PARTY.md),许可证副本见 [docs/licenses/](docs/licenses/)。
| 组件 | 上游 / 固定版本 | 许可证 |
| --- | --- | --- |
| DeepSeek Harness | [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) · `0.1.0-rc.6` | [MIT](docs/licenses/deepseek-harness.LICENSE) |
| ModLens | [liustack/modlens](https://github.com/liustack/modlens) · `3.16.6` | [MIT](docs/licenses/modlens.LICENSE) |
| dshmarket | [dsh-market/dsh-market](https://github.com/dsh-market/dsh-market) · `1.9.0` | [MIT](docs/licenses/dshmarket.LICENSE) |
| SenseVoiceSmall ONNX | [FunAudioLLM/SenseVoice](https://github.com/FunAudioLLM/SenseVoice) · 按需下载 | [MIT](docs/licenses/sensevoice.LICENSE) |
| sherpa-onnx WASM | [k2-fsa/sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) · `1.13.5` · 按需下载 | Apache-2.0(许可证随运行时包提供) |
| Anchored Standard | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) · [`ffb845c5480a`](https://github.com/xiaobright/dsh-anchored-standard/commit/ffb845c5480adc953392a6db6f8a98ede621174b) | [MIT](docs/licenses/dsh-anchored-standard.LICENSE) · [NOTICE](docs/licenses/dsh-anchored-standard.NOTICE) |
## 图标与商标
应用图标使用 [Icons8 上的 DeepSeek 图标](https://icons8.com/icon/YWOidjGxCpFW/deepseek)。DeepSeek 名称与鲸鱼标志归 DeepSeek 所有。本项目是独立第三方桌面壳,与 DeepSeek、ModLens、Anchored Standard 的作者均无从属关系。

View File

@@ -15,6 +15,10 @@ DeepSeek, liustack, or xiaobright.
| ModLens (`@liustack/modlens`) | [liustack/modlens](https://github.com/liustack/modlens) | `3.16.6` | MIT, © 2026 Leon Liu (liustack) | Copied into `~/.dsh/profiles/web` so text-only models can read images. See `docs/licenses/modlens.LICENSE`. |
| Anchored Standard | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) | commit `ffb845c5480adc953392a6db6f8a98ede621174b` | MIT, © 2026 xiaobright; portions © 2026 DeepSeek | Localized as **锚定式标准(实验)** and **零工具锚定式标准(实验)**, written to `~/.dsh/.agent-presets/`. See `docs/licenses/dsh-anchored-standard.LICENSE` and `.NOTICE`. |
| `dsh-desktop-vision` | this repo `plugins/dsh-desktop-vision/` | `0.1.4` | MIT, © 2026 TommyFang2077 | Settings page **设置 → 视觉模型**; writes `~/.modlens/config.json`. |
| `dsh-desktop-voice` | this repo `plugins/dsh-desktop-voice/` | `0.4.0` | MIT, © 2026 TommyFang2077 | Settings page **设置 → 语音输入** and composer mic; writes `~/.config/dsh-desktop/voice.json`. |
| dshmarket | [dsh-market/dsh-market](https://github.com/dsh-market/dsh-market) | `1.9.0` | MIT, © 2026 fkysly and dsh-market contributors | Bundled WebUI market for browsing and installing Community plugins. See `docs/licenses/dshmarket.LICENSE`. |
| SenseVoiceSmall ONNX | [FunAudioLLM/SenseVoice](https://github.com/FunAudioLLM/SenseVoice) via [k2-fsa conversion](https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17) | commit `2365baeacb507f821a0c8120fcee3d484dba7a07` | MIT, © 2025 FunASR | Not bundled. Downloaded after the user confirms, then stored in the platform cache. See `docs/licenses/sensevoice.LICENSE`. |
| sherpa-onnx WASM | [k2-fsa/sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) | `1.13.5` | Apache-2.0 | Not bundled. Installed with the model to run SenseVoice locally; the npm package carries its upstream license. |
The Anchored Standard NOTICE records that the presets adapt the DeepSeek
Harness Standard agent preset from

View File

@@ -11,6 +11,7 @@ base64 = "0.22"
dirs = "6"
regex = "1"
serde = { version = "1", features = ["derive"] }
semver = "1"
serde_json = "1"
shlex = "1"
thiserror = "2"

View File

@@ -24,7 +24,12 @@ pub struct ClipboardFile {
}
pub fn is_image_mime(mime: &str) -> bool {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
let mime = mime
.split(';')
.next()
.unwrap_or(mime)
.trim()
.to_ascii_lowercase();
let mime = if mime == "image/jpg" {
"image/jpeg"
} else {
@@ -34,7 +39,12 @@ pub fn is_image_mime(mime: &str) -> bool {
}
pub fn filename_for_mime(mime: &str, index: usize) -> String {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
let mime = mime
.split(';')
.next()
.unwrap_or(mime)
.trim()
.to_ascii_lowercase();
let ext = match mime.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
@@ -84,9 +94,7 @@ fn url_parse_file(rest: &str) -> Result<String, ()> {
let decoded = percent_decode(&uri);
if let Some(idx) = decoded.find("://") {
let after = &decoded[idx + 3..];
let path = after
.strip_prefix("localhost")
.unwrap_or(after);
let path = after.strip_prefix("localhost").unwrap_or(after);
return Ok(path.to_string());
}
if let Some(path) = decoded.strip_prefix("file:") {
@@ -101,7 +109,8 @@ fn percent_decode(input: &str) -> String {
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(v) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
if let Ok(v) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
out.push(v);
i += 3;
@@ -170,6 +179,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 +215,25 @@ 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

@@ -10,7 +10,7 @@ use regex::Regex;
use thiserror::Error;
use crate::paths::{home_dir, is_flatpak};
use crate::updater::update_dsh_bin;
use crate::updater::{configure_npm_registry, update_dsh_bin};
use crate::{ENV_BIN_OVERRIDE, ENV_CWD_OVERRIDE};
pub const DSH_DEFAULT_HOST: &str = "127.0.0.1";
@@ -117,8 +117,8 @@ impl DshLauncher {
}
}
if let Some(cli) = &self.dsh_bin_override {
let parts = shlex::split(cli)
.ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?;
let parts =
shlex::split(cli).ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?;
candidates.push(parts);
}
@@ -197,6 +197,7 @@ impl DshLauncher {
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.stdin(Stdio::null());
configure_npm_registry(&mut cmd);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
@@ -208,9 +209,9 @@ impl DshLauncher {
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
let mut child = cmd.spawn().map_err(|exc| {
DshNotFound::new(format!("无法启动 dsh web: {exc}"))
})?;
let mut child = cmd
.spawn()
.map_err(|exc| DshNotFound::new(format!("无法启动 dsh web: {exc}")))?;
let stdout = child.stdout.take();
Ok(DshProcess::new(child, stdout, self.in_flatpak))
}

View File

@@ -19,3 +19,4 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const ENV_BIN_OVERRIDE: &str = "DSH_DESKTOP_DSH_BIN";
pub const ENV_CWD_OVERRIDE: &str = "DSH_DESKTOP_CWD";
pub const ENV_NO_UPDATE: &str = "DSH_DESKTOP_NO_UPDATE";
pub const ENV_NPM_REGISTRY: &str = "DSH_DESKTOP_NPM_REGISTRY";

View File

@@ -8,7 +8,13 @@ use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths};
pub const PACKAGE: &str = "@liustack/modlens";
pub const VISION_PACKAGE: &str = "dsh-desktop-vision";
pub const VOICE_PACKAGE: &str = "dsh-desktop-voice";
pub const MARKET_PACKAGE: &str = "dshmarket";
pub const MODLENS_VERSION: &str = "3.16.6";
const VISION_PLUGIN_VERSION: &str = "0.1.4";
const VOICE_PLUGIN_VERSION: &str = "0.4.0";
const MARKET_PLUGIN_VERSION: &str = "1.9.0";
const MANAGED_BUNDLES_DIR: &str = ".dsh-desktop/bundles";
pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js");
pub const MANAGED_OVERLAY: &str = "\
@@ -48,12 +54,42 @@ pub fn read_modlens_version(prefix: &Path) -> Option<String> {
.map(|s| s.to_string())
}
fn bundle_should_replace(installed: Option<&str>, bundled: &str) -> bool {
let Some(installed) = installed else {
return true;
};
if installed == bundled {
return false;
}
match (
semver::Version::parse(installed),
semver::Version::parse(bundled),
) {
(Ok(installed), Ok(bundled)) => installed < bundled,
_ => true,
}
}
fn bundled_plugin(paths: &BundledPaths, dirs: &[&str]) -> Option<PathBuf> {
dirs.iter().find_map(|rel| {
paths
.find_dir(rel, "package.json")
.filter(|p| p.join("client.js").is_file() && p.join("index.js").is_file())
})
}
pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("vision", "package.json")
.or_else(|| paths.find_dir("dsh-desktop-vision", "package.json"))
.or_else(|| paths.find_dir("plugins/dsh-desktop-vision", "package.json"))
.filter(|p| p.join("client.js").is_file())
bundled_plugin(
paths,
&["vision", "dsh-desktop-vision", "plugins/dsh-desktop-vision"],
)
}
fn bundled_voice_plugin(paths: &BundledPaths) -> Option<PathBuf> {
bundled_plugin(
paths,
&["voice", "dsh-desktop-voice", "plugins/dsh-desktop-voice"],
)
}
pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
@@ -62,6 +98,13 @@ pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
.or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens"))
}
pub fn bundled_market_prefix(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("market", "node_modules/dshmarket/package.json")
.or_else(|| paths.find_dir("dshmarket", "node_modules/dshmarket/package.json"))
.or_else(|| paths.find_dir("vendor/dshmarket", "node_modules/dshmarket/package.json"))
}
fn install_into_profile(src_prefix: &Path, profile: &Path) -> std::io::Result<()> {
let dest_pkg = package_dir(profile);
copy_tree(&package_dir(src_prefix), &dest_pkg, true)?;
@@ -86,22 +129,94 @@ fn read_pkg_version(dir: &Path) -> Option<String> {
.map(|s| s.to_string())
}
fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
let Some(src) = bundled_vision_plugin(paths) else {
fn local_plugin_spec(pkg: &str) -> String {
format!("file:{MANAGED_BUNDLES_DIR}/{pkg}")
}
fn install_profile_plugin(
profile: &Path,
pkg: &str,
expected_version: &str,
src: Option<PathBuf>,
) -> bool {
let Some(src) = src else {
return false;
};
let dest = profile.join("node_modules").join(VISION_PACKAGE);
let up_to_date = dest.join("client.js").is_file()
&& dest.join("index.js").is_file()
&& read_pkg_version(&dest) == read_pkg_version(&src);
if !up_to_date && copy_tree(&src, &dest, true).is_err() {
if read_pkg_version(&src).as_deref() != Some(expected_version) {
return false;
}
let fallback = dsh_home().join("profiles/node_modules").join(VISION_PACKAGE);
let managed = profile.join(MANAGED_BUNDLES_DIR).join(pkg);
let managed_current = managed.join("client.js").is_file()
&& managed.join("index.js").is_file()
&& read_pkg_version(&managed) == read_pkg_version(&src);
if !managed_current && copy_tree(&src, &managed, true).is_err() {
return false;
}
let dest = profile.join("node_modules").join(pkg);
let installed_current = dest.join("client.js").is_file()
&& dest.join("index.js").is_file()
&& read_pkg_version(&dest) == read_pkg_version(&managed);
if !installed_current && copy_tree(&managed, &dest, true).is_err() {
return false;
}
let fallback = dsh_home().join("profiles/node_modules").join(pkg);
let _ = replace_symlink(&fallback, &dest);
true
}
fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
install_profile_plugin(
profile,
VISION_PACKAGE,
VISION_PLUGIN_VERSION,
bundled_vision_plugin(paths),
)
}
fn install_voice_plugin(paths: &BundledPaths, profile: &Path) -> bool {
install_profile_plugin(
profile,
VOICE_PACKAGE,
VOICE_PLUGIN_VERSION,
bundled_voice_plugin(paths),
)
}
fn install_market_plugin(paths: &BundledPaths, profile: &Path) -> std::io::Result<Option<String>> {
let Some(prefix) = bundled_market_prefix(paths) else {
return Ok(None);
};
let src = prefix.join("node_modules").join(MARKET_PACKAGE);
let Some(version) = read_pkg_version(&src) else {
return Ok(None);
};
if version != MARKET_PLUGIN_VERSION {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("bundled dshmarket version {version} does not match {MARKET_PLUGIN_VERSION}"),
));
}
let dest = profile.join("node_modules").join(MARKET_PACKAGE);
if read_pkg_version(&dest).as_deref() != Some(version.as_str()) {
copy_tree(&src, &dest, true)?;
}
for dep in [
"@deepseek-ai/cordis",
"@deepseek-ai/cosmokit",
"@standard-schema/spec",
] {
let src_dep = prefix.join("node_modules").join(dep);
if src_dep.is_dir() {
copy_tree(&src_dep, &profile.join("node_modules").join(dep), true)?;
}
}
let fallback = dsh_home()
.join("profiles/node_modules")
.join(MARKET_PACKAGE);
let _ = replace_symlink(&fallback, &dest);
Ok(Some(version))
}
fn ensure_manifest(profile: &Path, packages: &BTreeMap<String, String>) -> std::io::Result<()> {
let path = profile.join("package.json");
let mut data = if path.is_file() {
@@ -321,9 +436,19 @@ fn ensure_modlens_inner(
) -> std::io::Result<ModlensEnsureResult> {
std::fs::create_dir_all(profile)?;
let vision_ok = install_vision_plugin(paths, profile);
let voice_ok = install_voice_plugin(paths, profile);
let mut packages = BTreeMap::new();
if vision_ok {
packages.insert(VISION_PACKAGE.to_string(), "0.1.0".into());
packages.insert(
VISION_PACKAGE.to_string(),
local_plugin_spec(VISION_PACKAGE),
);
}
if voice_ok {
packages.insert(VOICE_PACKAGE.to_string(), local_plugin_spec(VOICE_PACKAGE));
}
if let Some(version) = install_market_plugin(paths, profile)? {
packages.insert(MARKET_PACKAGE.to_string(), version);
}
if src.is_none() && installed.is_none() {
if !packages.is_empty() {
@@ -335,8 +460,9 @@ fn ensure_modlens_inner(
message: "未找到内置 ModLens跳过插件安装".into(),
});
}
let replace_bundle = src.is_some() && bundle_should_replace(installed.as_deref(), version);
let installed_after = if let Some(src) = src {
if installed.as_deref() != Some(version) {
if replace_bundle {
install_into_profile(src, profile)?;
read_modlens_version(profile).unwrap_or_else(|| version.to_string())
} else {
@@ -372,11 +498,18 @@ fn ensure_modlens_inner(
message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"),
});
}
if installed.as_deref() == Some(version) {
if !replace_bundle {
if installed.as_deref() == Some(version) {
return Ok(ModlensEnsureResult {
status: "current",
version: Some(version.to_string()),
message: format!("内置 ModLens {version} 已就绪"),
});
}
return Ok(ModlensEnsureResult {
status: "current",
version: Some(version.to_string()),
message: format!("内置 ModLens {version} 已就绪"),
version: Some(installed_after.clone()),
message: format!("已保留用户更新的 ModLens {installed_after}(内置版本 {version}"),
});
}
if installed.is_some() {
@@ -431,7 +564,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 +595,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);
}
@@ -470,4 +605,80 @@ ui-theme:
assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)"));
assert!(HIDE_PLAIN_TWINS_JS.contains("MutationObserver"));
}
#[test]
fn bundled_market_is_added_to_profile() {
let tmp = tempfile::TempDir::new().unwrap();
let market = tmp.path().join("market/node_modules/dshmarket");
std::fs::create_dir_all(&market).unwrap();
std::fs::write(
market.join("package.json"),
r#"{"name":"dshmarket","version":"1.9.0"}"#,
)
.unwrap();
std::fs::write(market.join("index.js"), "export {}\n").unwrap();
let paths = BundledPaths::default().with_resource_dir(tmp.path().to_path_buf());
let profile = tmp.path().join("profile");
ensure_modlens_inner(&paths, None, &profile, None, MODLENS_VERSION).unwrap();
let manifest: Value =
serde_json::from_str(&std::fs::read_to_string(profile.join("package.json")).unwrap())
.unwrap();
assert_eq!(manifest["dependencies"]["dshmarket"], "1.9.0");
assert!(manifest["dsh"]["profile"]["bundles"]
.as_array()
.unwrap()
.iter()
.any(|item| item == "dshmarket"));
assert!(profile
.join("node_modules/dshmarket/package.json")
.is_file());
}
#[test]
fn bundled_local_plugins_use_file_dependencies() {
let tmp = tempfile::TempDir::new().unwrap();
for (dir, name, version) in [
("vision", VISION_PACKAGE, VISION_PLUGIN_VERSION),
("voice", VOICE_PACKAGE, VOICE_PLUGIN_VERSION),
] {
let plugin = tmp.path().join(dir);
std::fs::create_dir_all(&plugin).unwrap();
std::fs::write(
plugin.join("package.json"),
format!(r#"{{"name":"{name}","version":"{version}"}}"#),
)
.unwrap();
std::fs::write(plugin.join("client.js"), "export {}\n").unwrap();
std::fs::write(plugin.join("index.js"), "export {}\n").unwrap();
}
let paths = BundledPaths::default().with_resource_dir(tmp.path().to_path_buf());
let profile = tmp.path().join("profile");
ensure_modlens_inner(&paths, None, &profile, None, MODLENS_VERSION).unwrap();
let manifest: Value =
serde_json::from_str(&std::fs::read_to_string(profile.join("package.json")).unwrap())
.unwrap();
for name in [VISION_PACKAGE, VOICE_PACKAGE] {
assert_eq!(
manifest["dependencies"][name],
format!("file:.dsh-desktop/bundles/{name}")
);
assert!(profile
.join(".dsh-desktop/bundles")
.join(name)
.join("package.json")
.is_file());
}
}
#[test]
fn newer_marketplace_modlens_is_not_replaced_by_bundle() {
assert!(!bundle_should_replace(Some("3.17.3"), "3.16.6"));
assert!(!bundle_should_replace(Some("3.16.6"), "3.16.6"));
assert!(bundle_should_replace(Some("3.16.5"), "3.16.6"));
assert!(bundle_should_replace(None, "3.16.6"));
}
}

View File

@@ -42,7 +42,7 @@ pub fn is_flatpak() -> bool {
Path::new("/.flatpak-info").exists()
}
/// Roots that may contain bundled ModLens / presets / vision plugin.
/// Roots that may contain bundled ModLens / market / preset / vision / voice components.
///
/// Search order: extra roots (Tauri resource dir), `$XDG_DATA_HOME/dsh-desktop`,
/// `/app/share/dsh-desktop`, `/usr/share/dsh-desktop`, `~/.local/share/dsh-desktop`,

View File

@@ -155,7 +155,10 @@ fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult
return PresetEnsureResult {
status: "current",
version: version.clone(),
message: format!("已配置已安装的{label}{}", short_version(version.as_deref())),
message: format!(
"已配置已安装的{label}{}",
short_version(version.as_deref())
),
};
}
return PresetEnsureResult {
@@ -336,7 +339,11 @@ order: 5
#[test]
fn inserts_description_after_name() {
let updated = localize_preset_yml("name: Anchored Standard\norder: 5\n", PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH);
let updated = localize_preset_yml(
"name: Anchored Standard\norder: 5\n",
PRESET_NAME_ZH,
PRESET_DESCRIPTION_ZH,
);
assert!(updated.contains(PRESET_NAME_ZH));
assert!(!updated.contains("name: Anchored Standard\n"));
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
@@ -371,7 +378,8 @@ name: Zero-Anchored Standard (experimental)
description: Inject one zero-tool anchor turn.
order: 6
";
let updated = localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH);
let updated =
localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH);
assert!(updated.contains(ZERO_PRESET_NAME_ZH));
assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH));
assert!(!updated.contains("Zero-Anchored"));

View File

@@ -1,3 +1,4 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -6,13 +7,14 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::paths::{cache_home, data_home};
use crate::ENV_NO_UPDATE;
use crate::{ENV_NO_UPDATE, ENV_NPM_REGISTRY};
pub const DSH_PACKAGE: &str = "@deepseek-ai/dsh";
pub const VIEW_TIMEOUT_SECONDS: u64 = 20;
pub const INSTALL_TIMEOUT_SECONDS: u64 = 180;
pub const BUNDLED_PREFIX: &str = "/app";
pub const BUNDLED_DSH: &[&str] = &["/app/bin/dsh", "/app/node24/bin/dsh"];
pub const DEFAULT_NPM_REGISTRY: &str = "https://registry.npmmirror.com";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateResult {
@@ -83,9 +85,30 @@ fn find_node_dir() -> Option<PathBuf> {
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
}
fn selected_npm_registry<'a>(
desktop_override: Option<&'a OsStr>,
npm_override: Option<&'a OsStr>,
) -> &'a OsStr {
desktop_override
.filter(|value| !value.is_empty())
.or_else(|| npm_override.filter(|value| !value.is_empty()))
.unwrap_or_else(|| OsStr::new(DEFAULT_NPM_REGISTRY))
}
pub(crate) fn configure_npm_registry(command: &mut Command) {
let desktop_override = std::env::var_os(ENV_NPM_REGISTRY);
let npm_override =
std::env::var_os("npm_config_registry").or_else(|| std::env::var_os("NPM_CONFIG_REGISTRY"));
command.env(
"npm_config_registry",
selected_npm_registry(desktop_override.as_deref(), npm_override.as_deref()),
);
}
fn npm_command(npm: &Path) -> Command {
let mut cmd = Command::new(npm);
let cache = cache_home().join("dsh-desktop/npm");
configure_npm_registry(&mut cmd);
let _ = std::fs::create_dir_all(&cache);
cmd.env("npm_config_cache", &cache);
cmd.env("npm_config_update_notifier", "false");
@@ -169,8 +192,12 @@ pub fn mark_update_checked() {
}
pub fn fetch_latest_version(npm: &Path) -> Option<String> {
let out = run_npm(npm, &["view", DSH_PACKAGE, "version"], Duration::from_secs(VIEW_TIMEOUT_SECONDS))
.ok()?;
let out = run_npm(
npm,
&["view", DSH_PACKAGE, "version"],
Duration::from_secs(VIEW_TIMEOUT_SECONDS),
)
.ok()?;
if !out.status.success() {
return None;
}
@@ -213,12 +240,14 @@ fn env_skips_update() -> bool {
pub fn update_dsh(enabled: bool) -> UpdateResult {
if !enabled || env_skips_update() {
let version = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let version =
read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
return UpdateResult::new("skipped", version, "已跳过 dsh 更新");
}
let npm = find_npm();
let current = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let current =
read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let Some(npm) = npm else {
let extra = current
.as_deref()
@@ -247,11 +276,7 @@ pub fn update_dsh(enabled: bool) -> UpdateResult {
};
if current.as_deref() == Some(latest.as_str()) {
return UpdateResult::new(
"current",
current,
format!("内置 dsh 已是最新({latest}"),
);
return UpdateResult::new("current", current, format!("内置 dsh 已是最新({latest}"));
}
let dest = update_prefix();
@@ -321,6 +346,32 @@ mod tests {
fn read_version_missing() {
assert_eq!(read_version(Path::new("/no/such/prefix")), None);
}
#[test]
fn npm_commands_default_to_mainland_reachable_registry() {
assert_eq!(
selected_npm_registry(None, None),
OsStr::new(DEFAULT_NPM_REGISTRY)
);
}
#[test]
fn desktop_registry_override_wins() {
assert_eq!(
selected_npm_registry(
Some(OsStr::new("https://registry.example.cn")),
Some(OsStr::new("https://registry.npmjs.org")),
),
OsStr::new("https://registry.example.cn")
);
}
#[test]
fn npm_registry_override_is_preserved() {
assert_eq!(
selected_npm_registry(None, Some(OsStr::new("https://packages.example.com/npm")),),
OsStr::new("https://packages.example.com/npm")
);
}
#[test]
fn skip_when_disabled() {

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,22 +14,19 @@
<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>
<launchable type="desktop-id">io.github.tommyfang.DshDesktop.desktop</launchable>
<url type="homepage">https://github.com/TommyFang2077/dsh-desktop</url>
<url type="bugtracker">https://github.com/TommyFang2077/dsh-desktop/issues</url>
<url type="homepage">https://github.com/TommyFang2077/dsh-easy-desktop</url>
<url type="bugtracker">https://github.com/TommyFang2077/dsh-easy-desktop/issues</url>
<provides>
<binary>dsh-desktop</binary>
@@ -44,6 +41,11 @@
<content_rating type="oars-1.1" />
<releases>
<release version="0.1.1" date="2026-08-16">
<description>
<p>Signed in-app updater from the mainland mirror; HTTPS Gitea distribution; Market install warnings for GitHub-only sources.</p>
</description>
</release>
<release version="0.1.0" date="2026-08-15">
<description>
<p>Tauri shell with an Apple-style title bar, bundled dsh, and on-launch updates.</p>

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.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fkysly and dsh-market contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 FunASR
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 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

@@ -82,6 +82,11 @@
.chip {
border: 0.5px solid var(--line); border-radius: 999px; padding: 4px 10px; background: #fff;
}
.send-cluster { display: flex; align-items: center; gap: 8px; }
.mic {
width: 28px; height: 28px; border-radius: 8px;
display: grid; place-items: center; font-size: 14px; color: var(--muted);
}
.send {
width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff;
display: grid; place-items: center; font-size: 14px;
@@ -126,7 +131,10 @@
<span class="chip">锚定式标准(实验)</span>
<span class="chip">工作区</span>
</div>
<div class="send"></div>
<div class="send-cluster">
<div class="mic" title="语音输入">🎤</div>
<div class="send"></div>
</div>
</div>
</div>
</section>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
docs/screenshots/voice.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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
@@ -15,6 +15,8 @@ finish-args:
- --share=network
- --filesystem=host
- --filesystem=xdg-download
- --socket=pulseaudio
- --filesystem=xdg-run/pipewire-0:ro
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.a11y.Bus
@@ -50,10 +52,13 @@ modules:
build-args:
- --share=network
build-commands:
- mkdir -p vendor/modlens vendor/dshmarket 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
- cp -a plugins/dsh-desktop-vision/. ${FLATPAK_DEST}/share/dsh-desktop/vision
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/voice
- cp -a plugins/dsh-desktop-voice/. ${FLATPAK_DEST}/share/dsh-desktop/voice
- install -Dm644 data/applications/io.github.tommyfang.DshDesktop.desktop ${FLATPAK_DEST}/share/applications/io.github.tommyfang.DshDesktop.desktop
- install -Dm644 data/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml ${FLATPAK_DEST}/share/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml
- for size in 16 24 32 48 64 128 256 512; do install -Dm644 data/icons/hicolor/${size}x${size}/apps/io.github.tommyfang.DshDesktop.png ${FLATPAK_DEST}/share/icons/hicolor/${size}x${size}/apps/io.github.tommyfang.DshDesktop.png; done
@@ -78,6 +83,15 @@ modules:
- type: dir
path: ../vendor/modlens
- name: plugin-market
buildsystem: simple
build-commands:
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/market
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/market
sources:
- type: dir
path: ../vendor/dshmarket
- name: anchored-standard-preset
buildsystem: simple
build-commands:

View File

@@ -0,0 +1,892 @@
window.__ModuleLoader__.load({
id: 'dsh-desktop-voice',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
var React = require('react')
var jsx = require('react/jsx-runtime')
var css = [
'.dshdvoice{width:100%;max-width:640px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary)}',
'.dshdvoice h3{margin:0;font-size:15px;font-weight:600;line-height:22px}',
'.dshdvoice p{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}',
'.dshdvoice label{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
'.dshdvoice .head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
'.dshdvoice .row{flex-direction:row;align-items:center;gap:10px}',
'.dshdvoice .openai{display:flex;flex-direction:column;gap:14px}',
'.dshdvoice a{color:var(--dsw-alias-state-business-primary);text-decoration:none;font-size:12px;line-height:18px}',
'.dshdvoice a:hover{text-decoration:underline}',
'.dshdvoice input,.dshdvoice select{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font:inherit}',
'.dshdvoice input:focus,.dshdvoice select:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}',
'.dshdvoice input[type=checkbox]{width:16px;height:16px;padding:0}',
'.dshdvoice button{align-self:flex-start;height:32px;padding:0 14px;border:0;border-radius:8px;background:var(--dsw-alias-state-business-primary);color:#fff;font:inherit;cursor:pointer}',
'.dshdvoice button:disabled{opacity:.55;cursor:default}',
'.dshdvoice .ok{color:var(--dsw-alias-state-success-primary)}',
'.dshdvoice .err{color:var(--dsw-alias-state-error-primary)}',
'.dshdvoice-mic{appearance:none;border:0;background:transparent;width:28px;height:28px;border-radius:8px;color:var(--dsw-alias-label-secondary);display:grid;place-items:center;cursor:pointer;padding:0}',
'.dshdvoice-mic:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
'.dshdvoice-mic:disabled{opacity:.4;cursor:default}',
'.dshdvoice-mic.is-live{color:var(--dsw-alias-state-error-primary)}',
'.dshdvoice-mic.is-live svg{animation:dshdvoice-pulse 1.1s ease-in-out infinite}',
'@keyframes dshdvoice-pulse{0%,100%{opacity:1}50%{opacity:.45}}',
'#dsh-desktop-dictation{position:fixed;left:50%;bottom:48px;z-index:2147483645;transform:translateX(-50%);max-width:min(36rem,calc(100vw - 3rem));display:none;align-items:center;gap:8px;padding:6px 12px;border-radius:10px;background:rgba(28,28,30,.92);color:#f5f5f7;font:13px/1.3 -apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang SC",system-ui,sans-serif;box-shadow:0 8px 28px rgba(0,0,0,.28)}',
'#dsh-desktop-dictation.open{display:flex}',
'#dsh-desktop-dictation .label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'#dsh-desktop-dictation .stop{appearance:none;border:0;background:rgba(255,255,255,.12);color:#f5f5f7;width:22px;height:22px;border-radius:6px;display:grid;place-items:center;cursor:pointer;padding:0}',
'#dsh-desktop-dictation .kbd{opacity:.55;font-size:11px;letter-spacing:.02em;flex:none}',
'#dsh-desktop-dictation .progress{width:120px;height:6px;accent-color:var(--dsw-alias-state-business-primary)}',
'#dsh-desktop-dictation .percent{min-width:30px;text-align:right;font-variant-numeric:tabular-nums;font-size:11px;opacity:.75}',
].join('')
function ensureCss() {
if (typeof document === 'undefined') return
if (document.querySelector('style[data-plugin-css="dsh-desktop-voice"]')) return
var tag = document.createElement('style')
tag.dataset.pluginCss = 'dsh-desktop-voice'
tag.textContent = css
document.head.appendChild(tag)
}
function isMac() {
return /Mac|iPhone|iPad/.test(navigator.platform || '')
}
function shortcutLabel() {
return isMac() ? '⌘E' : 'Ctrl+E'
}
function isDictationHotkey(event) {
if (event.repeat || event.altKey || event.shiftKey) return false
var mod = isMac() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (!mod) return false
return event.key === 'e' || event.key === 'E' || event.code === 'KeyE'
}
var WORD_BOUNDARY = /^[\p{L}\p{N}]$/u
var CJK_BOUNDARY = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]$/u
var NO_SPACE_BEFORE = /^[,.;:!?%。,、!?;:))\]}]$/u
var NO_SPACE_AFTER = /^[([{(《「『]$/u
var SPACE_AFTER = /^[,.;:!?%]$/u
function firstChar(text) {
return Array.from(String(text || '').trimStart())[0] || ''
}
function lastChar(text) {
var chars = Array.from(String(text || '').trimEnd())
return chars.length ? chars[chars.length - 1] : ''
}
function formatSegment(previous, next) {
var text = String(next || '').trim()
if (!text) return ''
if (!previous || /\s$/.test(previous) || /^\s/.test(text)) return text
var prev = lastChar(previous)
var cur = firstChar(text)
if (!prev || !cur) return text
if (CJK_BOUNDARY.test(prev) || CJK_BOUNDARY.test(cur) || NO_SPACE_BEFORE.test(cur) || NO_SPACE_AFTER.test(prev)) {
return text
}
if ((WORD_BOUNDARY.test(prev) || SPACE_AFTER.test(prev)) && WORD_BOUNDARY.test(cur)) return ' ' + text
return text
}
function remote() {
return fetch('/dsh-desktop/voice').then(function (res) {
if (!res.ok) throw new Error('load failed ' + res.status)
return res.json()
})
}
function installSenseVoice(form) {
if (form && form.modelInstalled) return Promise.resolve(form)
var size = (form && form.modelDownloadSizeMb) || 245
if (!window.confirm('SenseVoice 离线语音模型尚未安装(约 ' + size + ' MB。现在安装吗')) {
return Promise.reject(new Error('需要先安装 SenseVoice 离线语音模型'))
}
setState({ state: 'installing', partial: '', error: '', installPercent: 0, installStage: '正在准备安装…' })
var stopped = false
function pollProgress() {
return fetch('/dsh-desktop/voice/model')
.then(function (res) {
if (!res.ok) return null
return res.json()
})
.then(function (progress) {
if (!progress || stopped || session.state !== 'installing') return
setState({
installPercent: Math.max(0, Math.min(100, Number(progress.percent) || 0)),
installStage: progress.stage || '正在安装 SenseVoice…',
})
})
.catch(function () {})
}
pollProgress()
var timer = setInterval(pollProgress, 250)
if (timer && typeof timer.unref === 'function') timer.unref()
return fetch('/dsh-desktop/voice/model', { method: 'POST' }).then(function (res) {
return res.json().then(function (body) {
if (!res.ok) throw new Error(body.error || 'SenseVoice 安装失败')
setState({ installPercent: 100, installStage: 'SenseVoice 已安装' })
return body
})
})
.finally(function () {
stopped = true
clearInterval(timer)
})
}
function transcribeBlob(blob, language) {
var mime = blob.type || 'application/octet-stream'
return fetch(
'/dsh-desktop/voice/transcribe?language=' + encodeURIComponent(language || '') + '&mime=' + encodeURIComponent(mime),
{ method: 'POST', headers: { 'content-type': mime }, body: blob },
).then(function (res) {
return res.json().then(function (body) {
if (!res.ok) throw new Error(body.error || 'transcribe failed ' + res.status)
return String(body.text || '').trim()
})
})
}
function pickRecorderMime() {
if (!window.MediaRecorder) return ''
var types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']
for (var i = 0; i < types.length; i++) {
try {
if (MediaRecorder.isTypeSupported(types[i])) return types[i]
} catch (err) {}
}
return ''
}
function encodeWav(chunks, sampleRate) {
var length = 0
for (var i = 0; i < chunks.length; i++) length += chunks[i].length
var samples = new Float32Array(length)
var offset = 0
for (var j = 0; j < chunks.length; j++) {
samples.set(chunks[j], offset)
offset += chunks[j].length
}
var buffer = new ArrayBuffer(44 + samples.length * 2)
var view = new DataView(buffer)
function str(at, value) {
for (var n = 0; n < value.length; n++) view.setUint8(at + n, value.charCodeAt(n))
}
str(0, 'RIFF')
view.setUint32(4, 36 + samples.length * 2, true)
str(8, 'WAVE')
str(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, 1, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * 2, true)
view.setUint16(32, 2, true)
view.setUint16(34, 16, true)
str(36, 'data')
view.setUint32(40, samples.length * 2, true)
var idx = 44
for (var s = 0; s < samples.length; s++, idx += 2) {
var v = Math.max(-1, Math.min(1, samples[s]))
view.setInt16(idx, v < 0 ? v * 0x8000 : v * 0x7fff, true)
}
return new Blob([buffer], { type: 'audio/wav' })
}
function openMic(deviceId) {
var audio = {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
if (deviceId) audio.deviceId = { exact: deviceId }
return navigator.mediaDevices.getUserMedia({ audio: audio }).catch(function (error) {
if (!deviceId) throw error
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true } })
})
}
var session = {
state: 'idle',
partial: '',
error: '',
stream: null,
recorder: null,
parts: [],
wav: null,
inserted: '',
target: null,
installPercent: 0,
installStage: '',
run: 0,
}
var listeners = []
var cachedForm = null
function currentForm() {
return session.liveForm || cachedForm || (session.target && session.target.form && session.target.form()) || null
}
function emit() {
for (var i = 0; i < listeners.length; i++) listeners[i]()
}
function setState(next) {
for (var key in next) session[key] = next[key]
renderIndicator()
emit()
}
function indicatorEl() {
var el = document.getElementById('dsh-desktop-dictation')
if (el) return el
el = document.createElement('div')
el.id = 'dsh-desktop-dictation'
el.innerHTML =
'<svg class="mic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="19" x2="12" y2="22"/></svg>' +
'<span class="label"></span>' +
'<progress class="progress" max="100" value="0" aria-label="SenseVoice 安装进度"></progress>' +
'<span class="percent"></span>' +
'<span class="kbd"></span>' +
'<button class="stop" type="button" aria-label="停止听写"><svg width="10" height="10" viewBox="0 0 10 10"><rect x="1" y="1" width="8" height="8" rx="1.5" fill="currentColor"/></svg></button>'
document.documentElement.appendChild(el)
el.querySelector('.stop').addEventListener('mousedown', function (event) {
event.preventDefault()
})
el.querySelector('.stop').addEventListener('click', function () {
stopDictation()
})
return el
}
function renderIndicator() {
ensureCss()
var el = indicatorEl()
var open =
session.state === 'installing' || session.state === 'starting' || session.state === 'listening' || session.state === 'stopping'
el.classList.toggle('open', open)
var label = el.querySelector('.label')
var progress = el.querySelector('.progress')
var percent = el.querySelector('.percent')
var kbd = el.querySelector('.kbd')
if (session.state === 'installing') label.textContent = session.installStage || '正在安装 SenseVoice…'
else if (session.state === 'starting') label.textContent = '正在启动…'
else if (session.state === 'stopping') label.textContent = '正在识别…'
else label.textContent = session.partial || '正在听…'
var installing = session.state === 'installing'
progress.hidden = !installing
progress.value = session.installPercent || 0
percent.hidden = !installing
percent.textContent = installing ? Math.round(session.installPercent || 0) + '%' : ''
kbd.textContent = shortcutLabel()
el.querySelector('.stop').hidden = session.state === 'stopping' || installing
}
function cleanupCapture() {
if (session.recorder) {
try {
if (session.recorder.state !== 'inactive') session.recorder.stop()
} catch (err) {}
session.recorder = null
}
if (session.wav) {
try {
session.wav.processor.disconnect()
session.wav.source.disconnect()
session.wav.context.close()
} catch (err) {}
session.wav = null
}
if (session.stream) {
session.stream.getTracks().forEach(function (track) {
track.stop()
})
session.stream = null
}
session.parts = []
}
function appendToComposer(text) {
var target = session.target
if (!target || !text) return
var chunk = formatSegment(session.inserted || (target.draft ? target.draft() : ''), text)
if (!chunk) return
if (target.setDraft && target.draft) {
target.setDraft((target.draft() || '') + chunk)
}
session.inserted += chunk
}
function finishError(message) {
cleanupCapture()
setState({ state: 'idle', partial: '', error: message || '' })
}
function useSenseVoice(form) {
return form.engine === 'sensevoice'
}
function startRecorder(form, runId) {
return openMic(form.microphoneDeviceId).then(function (stream) {
if (session.run !== runId) {
stream.getTracks().forEach(function (track) {
track.stop()
})
return
}
session.stream = stream
var local = useSenseVoice(form)
var mime = local ? '' : pickRecorderMime()
if (!local && (mime || window.MediaRecorder)) {
var recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream)
session.parts = []
recorder.ondataavailable = function (event) {
if (event.data && event.data.size) session.parts.push(event.data)
}
session.recorder = recorder
recorder.start(250)
} else {
var context = new AudioContext()
var source = context.createMediaStreamSource(stream)
var processor = context.createScriptProcessor(4096, 1, 1)
var chunks = []
processor.onaudioprocess = function (event) {
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)))
}
source.connect(processor)
processor.connect(context.destination)
session.wav = { context: context, source: source, processor: processor, chunks: chunks, sampleRate: context.sampleRate }
}
setState({ state: 'listening', partial: '', error: '' })
})
}
function recordedBlob() {
if (session.wav) return encodeWav(session.wav.chunks, session.wav.sampleRate)
if (!session.parts.length) return null
return new Blob(session.parts, { type: session.parts[0].type || 'audio/webm' })
}
function startDictation() {
if (session.state !== 'idle') return
var target = session.target
if (!target) {
setState({ error: '对话框还没就绪' })
return
}
var runId = session.run + 1
session.run = runId
session.inserted = target.draft ? target.draft() : ''
setState({ state: 'starting', partial: '', error: '' })
remote()
.catch(function () {
return target.form ? target.form() : null
})
.then(function (form) {
if (session.run !== runId) return
if (!form || form.enabled === false) throw new Error('语音输入已关闭,请到设置 → 语音输入开启')
session.liveForm = form
cachedForm = form
if (useSenseVoice(form)) {
return installSenseVoice(form).then(function (installed) {
if (session.run !== runId) return
session.liveForm = installed
cachedForm = installed
return startRecorder(installed, runId)
})
}
if (!form.apiKey) throw new Error('请先在设置 → 语音输入里填写 OpenAI API 密钥')
return startRecorder(form, runId)
})
.catch(function (error) {
if (session.run !== runId) return
var message = String(error && error.message ? error.message : error)
if (/NotAllowed|Permission/i.test(message)) message = '麦克风权限被拒绝,请在系统设置中允许后重试'
finishError(message)
})
}
function stopDictation() {
if (session.state === 'idle' || session.state === 'stopping' || session.state === 'installing') return
var runId = session.run
var form = currentForm()
setState({ state: 'stopping' })
var recorder = session.recorder
function afterBlob(blob) {
cleanupCapture()
if (session.run !== runId) return
if (!blob || !blob.size) {
setState({ state: 'idle', partial: '', error: '没有识别到语音' })
return
}
transcribeBlob(blob, form && form.language)
.then(function (text) {
if (session.run !== runId) return
if (text) appendToComposer(text)
else setState({ error: '没有识别到语音' })
setState({ state: 'idle', partial: '' })
})
.catch(function (error) {
if (session.run !== runId) return
finishError(String(error && error.message ? error.message : error))
})
}
if (recorder && recorder.state !== 'inactive') {
recorder.onstop = function () {
afterBlob(recordedBlob())
}
try {
recorder.stop()
} catch (err) {
afterBlob(recordedBlob())
}
return
}
afterBlob(recordedBlob())
}
function toggleDictation() {
if (session.state === 'installing') return
if (session.state === 'listening' || session.state === 'starting') stopDictation()
else startDictation()
}
if (typeof window !== 'undefined' && !window.__dshDesktopVoiceKeys) {
window.__dshDesktopVoiceKeys = true
window.addEventListener(
'keydown',
function (event) {
if (!isDictationHotkey(event)) return
var form = currentForm()
var mode = form && form.dictationMode === 'hold' ? 'hold' : 'toggle'
event.preventDefault()
event.stopPropagation()
if (mode === 'hold') {
if (session.state === 'idle') startDictation()
return
}
toggleDictation()
},
true,
)
window.addEventListener(
'keyup',
function (event) {
var form = currentForm()
if (!form || form.dictationMode !== 'hold') return
var key = event.key === 'e' || event.key === 'E' || event.code === 'KeyE'
var mod = event.key === 'Control' || event.key === 'Meta' || event.code.indexOf('Control') === 0 || event.code.indexOf('Meta') === 0
if (!key && !mod) return
event.preventDefault()
stopDictation()
},
true,
)
}
function MicIcon() {
return jsx.jsxs('svg', {
width: '16',
height: '16',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': true,
children: [
jsx.jsx('path', { d: 'M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z' }),
jsx.jsx('path', { d: 'M19 10v1a7 7 0 0 1-14 0v-1' }),
jsx.jsx('line', { x1: '12', y1: '19', x2: '12', y2: '22' }),
],
})
}
function SquareIcon() {
return jsx.jsx('svg', {
width: '12',
height: '12',
viewBox: '0 0 12 12',
'aria-hidden': true,
children: jsx.jsx('rect', { x: '2', y: '2', width: '8', height: '8', rx: '1.5', fill: 'currentColor' }),
})
}
function MicButton(props) {
ensureCss()
var formState = React.useState(null)
var form = formState[0]
var setForm = formState[1]
var tickState = React.useState(0)
var setTick = tickState[1]
var draft = props.useInput ? props.useInput(function (s) { return s.draft }) : ''
var draftRef = React.useRef(draft)
draftRef.current = draft
var formRef = React.useRef(form)
formRef.current = form
React.useEffect(function () {
var live = true
remote()
.then(function (next) {
if (live) {
cachedForm = next
setForm(next)
}
})
.catch(function () {
if (live) setForm({ enabled: true, engine: 'sensevoice', dictationMode: 'toggle', language: 'zh', modelInstalled: false })
})
return function () {
live = false
}
}, [])
React.useEffect(function () {
function onChange() {
setTick(function (n) { return n + 1 })
}
listeners.push(onChange)
return function () {
listeners = listeners.filter(function (fn) { return fn !== onChange })
}
}, [])
React.useEffect(function () {
session.target = {
setDraft: props.inputActions && props.inputActions.setDraft,
draft: function () {
return draftRef.current || ''
},
form: function () {
return formRef.current
},
}
})
var installing = session.state === 'installing'
var live = installing || session.state === 'starting' || session.state === 'listening' || session.state === 'stopping'
var title = installing
? '正在安装 SenseVoice…'
: live
? '停止听写(' + shortcutLabel() + ''
: '语音输入(' + shortcutLabel() + ''
return jsx.jsx('button', {
type: 'button',
className: 'dshdvoice-mic' + (session.state === 'listening' ? ' is-live' : ''),
'aria-label': title,
title: session.error ? session.error : title,
disabled: session.state === 'stopping' || installing,
onMouseDown: function (event) {
event.preventDefault()
},
onClick: function () {
toggleDictation()
},
children: live && !installing && session.state !== 'starting' ? jsx.jsx(SquareIcon, {}) : jsx.jsx(MicIcon, {}),
})
}
function VoiceSettings() {
ensureCss()
var state = React.useState({ status: 'loading' })
var snap = state[0]
var setSnap = state[1]
var devicesState = React.useState([])
var devices = devicesState[0]
var setDevices = devicesState[1]
React.useEffect(function () {
var live = true
remote()
.then(function (form) {
if (live) setSnap({ status: 'ready', form: form, message: '' })
})
.catch(function (error) {
if (live) setSnap({ status: 'error', message: String(error.message || error) })
})
return function () {
live = false
}
}, [])
function patch(field, value) {
setSnap(function (cur) {
if (cur.status !== 'ready') return cur
return { status: 'ready', form: Object.assign({}, cur.form, { [field]: value }), message: '' }
})
}
function save() {
if (snap.status !== 'ready' || snap.saving) return
setSnap(Object.assign({}, snap, { saving: true, message: '' }))
fetch('/dsh-desktop/voice', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(snap.form),
})
.then(function (res) {
return res.json().then(function (body) {
if (!res.ok) throw new Error(body.error || 'save failed')
return body
})
})
.then(function (form) {
cachedForm = form
setSnap({ status: 'ready', form: form, message: '已保存', saving: false })
})
.catch(function (error) {
setSnap(Object.assign({}, snap, { saving: false, message: String(error.message || error) }))
})
}
function listMics() {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
navigator.mediaDevices
.getUserMedia({ audio: true })
.then(function (stream) {
stream.getTracks().forEach(function (track) { track.stop() })
return navigator.mediaDevices.enumerateDevices()
})
.then(function (list) {
setDevices(
list
.filter(function (item) { return item.kind === 'audioinput' })
.map(function (item, index) {
return { id: item.deviceId, label: item.label || '麦克风 ' + (index + 1) }
}),
)
})
.catch(function () {})
}
if (snap.status === 'loading') {
return jsx.jsx('div', { className: 'dshdvoice', children: jsx.jsx('p', { children: '正在读取语音输入配置…' }) })
}
if (snap.status === 'error') {
return jsx.jsx('div', { className: 'dshdvoice', children: jsx.jsx('p', { className: 'err', children: snap.message }) })
}
var form = snap.form
var options = form.options || {}
var engines = options.engines || []
var modes = options.modes || []
var languages = options.languages || []
var openai = form.engine === 'openai'
var local = form.engine === 'sensevoice'
return jsx.jsxs('div', {
className: 'dshdvoice',
children: [
jsx.jsx('h3', { children: '语音输入' }),
jsx.jsx('p', {
children:
'对着麦克风说话,文字写进对话框。快捷键 ' +
shortcutLabel() +
',和 Orca 一样点按开关或按住说话。默认使用本机 SenseVoice模型首次点击麦克风时按需安装不包含在安装包内。',
}),
jsx.jsxs('label', {
className: 'row',
children: [
jsx.jsx('input', {
type: 'checkbox',
checked: form.enabled !== false,
onChange: function (event) {
patch('enabled', event.target.checked)
},
}),
'启用语音输入',
],
}),
jsx.jsxs('label', {
children: [
'听写方式',
jsx.jsx('select', {
value: form.dictationMode || 'toggle',
onChange: function (event) {
patch('dictationMode', event.target.value)
},
children: modes.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
}),
],
}),
jsx.jsxs('label', {
children: [
'引擎',
jsx.jsx('select', {
value: form.engine || 'sensevoice',
onChange: function (event) {
patch('engine', event.target.value)
},
children: engines.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
}),
],
}),
local
? jsx.jsx('p', {
className: form.modelInstalled ? 'ok' : '',
children: form.modelInstalled
? 'SenseVoice 离线模型已安装。'
: 'SenseVoice 离线模型未安装;点击对话框麦克风后会提示安装(约 ' +
(form.modelDownloadSizeMb || 245) +
' MB。',
})
: null,
jsx.jsxs('label', {
children: [
'语言',
jsx.jsx('select', {
value: form.language || '',
onChange: function (event) {
patch('language', event.target.value)
},
children: languages.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
}),
],
}),
openai
? jsx.jsxs('div', {
className: 'openai',
children: [
jsx.jsxs('label', {
children: [
jsx.jsxs('span', {
className: 'head',
children: [
'接口地址',
jsx.jsx('a', { href: 'https://console.groq.com/keys', children: 'Groq 密钥' }),
],
}),
jsx.jsx('input', {
value: form.baseUrl || '',
placeholder: 'https://api.openai.com/v1',
onChange: function (event) {
patch('baseUrl', event.target.value)
},
}),
],
}),
jsx.jsxs('label', {
children: [
jsx.jsxs('span', {
className: 'head',
children: [
'API 密钥',
jsx.jsx('a', {
href: 'https://platform.openai.com/api-keys',
children: '获取 API',
}),
],
}),
jsx.jsx('input', {
type: 'password',
value: form.apiKey || '',
onChange: function (event) {
patch('apiKey', event.target.value)
},
}),
],
}),
jsx.jsxs('label', {
children: [
'Whisper 模型',
jsx.jsx('input', {
value: form.model || '',
placeholder: '例如 whisper-1 或 whisper-large-v3',
onChange: function (event) {
patch('model', event.target.value)
},
}),
],
}),
],
})
: null,
jsx.jsxs('label', {
children: [
jsx.jsxs('span', {
className: 'head',
children: [
'麦克风',
jsx.jsx('a', {
href: '#',
onClick: function (event) {
event.preventDefault()
listMics()
},
children: '列出设备',
}),
],
}),
jsx.jsxs('select', {
value: form.microphoneDeviceId || '',
onChange: function (event) {
var id = event.target.value
var match = devices.filter(function (item) { return item.id === id })[0]
patch('microphoneDeviceId', id)
patch('microphoneDeviceLabel', match ? match.label : '')
},
children: [jsx.jsx('option', { value: '', children: '系统默认' })].concat(
devices.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
),
}),
],
}),
jsx.jsx('button', {
type: 'button',
disabled: !!snap.saving,
onClick: save,
children: snap.saving ? '保存中…' : '保存',
}),
snap.message
? jsx.jsx('p', {
className: /失败|failed|error/i.test(snap.message) ? 'err' : 'ok',
children: snap.message,
})
: null,
],
})
}
function apply(ctx) {
ctx.slots.inject('settings.section', function () {
return ctx.slots.register(
{
name: 'settings.section',
id: 'dsh-desktop-voice',
order: 13,
label: '语音输入',
},
VoiceSettings,
)
})
ctx.slots.inject('conversation.input.right', function () {
return ctx.slots.register(
{
name: 'conversation.input.right',
id: 'dsh-desktop-voice',
order: 20,
label: '语音输入',
},
MicButton,
)
})
}
exports.apply = apply
exports.inject = ['slots']
return module.exports
},
})

View File

@@ -0,0 +1,4 @@
# Settings page + composer mic + host route for desktop dictation.
- insert:
- id: dsh-desktop-voice
name: dsh-desktop-voice

View File

@@ -0,0 +1,576 @@
import { createHash, randomUUID } from 'node:crypto'
import { execFile } from 'node:child_process'
import {
chmodSync,
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { Transform, Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { promisify } from 'node:util'
export const name = 'dsh-desktop-voice'
export const inject = []
const execFileAsync = promisify(execFile)
const SHERPA_VERSION = '1.13.5'
const SENSEVOICE_VERSION = '2365baeacb507f821a0c8120fcee3d484dba7a07'
const SENSEVOICE_MODEL_BYTES = 239233841
const SENSEVOICE_TOKENS_BYTES = 315894
const SENSEVOICE_FILES = [
{
name: 'model.int8.onnx',
bytes: SENSEVOICE_MODEL_BYTES,
sha256: 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51',
},
{
name: 'tokens.txt',
bytes: SENSEVOICE_TOKENS_BYTES,
sha256: 'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc',
},
]
const SENSEVOICE_BASE_URL =
'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/' +
SENSEVOICE_VERSION
const SENSEVOICE_DOWNLOAD_BYTES = SENSEVOICE_FILES.reduce((sum, file) => sum + file.bytes, 0)
const DOWNLOAD_PROGRESS_LIMIT = 95
let installPromise = null
let installState = { status: 'missing', percent: 0, stage: '' }
let sherpa = null
const recognizers = new Map()
let recognitionQueue = Promise.resolve()
const ENGINES = [
{ id: 'sensevoice', label: 'SenseVoice本机离线推荐' },
{ id: 'openai', label: 'OpenAI 兼容接口' },
]
const ENGINE_IDS = new Set(ENGINES.map((item) => item.id))
const MODES = [
{ id: 'toggle', label: '点按开关(再按一次快捷键结束)' },
{ id: 'hold', label: '按住说话(松开快捷键结束)' },
]
const MODE_IDS = new Set(MODES.map((item) => item.id))
const LANGUAGES = [
{ id: '', label: '自动检测' },
{ id: 'zh', label: '中文' },
{ id: 'yue', label: '粤语' },
{ id: 'en', label: 'English' },
{ id: 'ja', label: '日本語' },
{ id: 'ko', label: '한국어' },
]
const LANGUAGE_IDS = new Set(LANGUAGES.map((item) => item.id))
function configDir() {
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'dsh-desktop')
return join(homedir(), '.config', 'dsh-desktop')
}
function configPath() {
return join(configDir(), 'voice.json')
}
function voiceDataDir() {
if (process.env.DSH_DESKTOP_VOICE_HOME) return process.env.DSH_DESKTOP_VOICE_HOME
if (process.env.XDG_CACHE_HOME) return join(process.env.XDG_CACHE_HOME, 'dsh-desktop', 'voice')
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
return join(process.env.LOCALAPPDATA, 'dsh-desktop', 'Cache', 'voice')
}
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Caches', 'dsh-desktop', 'voice')
return join(homedir(), '.cache', 'dsh-desktop', 'voice')
}
function senseVoiceDir() {
return join(voiceDataDir(), 'sensevoice')
}
function runtimeDir() {
return join(voiceDataDir(), 'runtime')
}
function markerPath() {
return join(senseVoiceDir(), 'installed.json')
}
function senseVoiceInstalled() {
try {
const marker = JSON.parse(readFileSync(markerPath(), 'utf8'))
const runtime = JSON.parse(readFileSync(join(runtimeDir(), 'node_modules', 'sherpa-onnx', 'package.json'), 'utf8'))
return (
marker.modelVersion === SENSEVOICE_VERSION &&
marker.runtimeVersion === SHERPA_VERSION &&
runtime.version === SHERPA_VERSION &&
SENSEVOICE_FILES.every((file) => statSync(join(senseVoiceDir(), file.name)).size === file.bytes)
)
} catch {
return false
}
}
function senseVoiceProgress() {
if (senseVoiceInstalled()) return { status: 'installed', percent: 100, stage: 'SenseVoice 已安装' }
if (installPromise) return { ...installState }
if (installState.status === 'failed') return { ...installState }
return { status: 'missing', percent: 0, stage: '' }
}
function senseVoiceStatus() {
return senseVoiceProgress().status
}
function defaults() {
return {
enabled: true,
engine: 'sensevoice',
dictationMode: 'toggle',
language: 'zh',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'whisper-1',
microphoneDeviceId: '',
microphoneDeviceLabel: '',
}
}
function loadConfig() {
try {
const data = JSON.parse(readFileSync(configPath(), 'utf8'))
return data && typeof data === 'object' ? data : {}
} catch {
return {}
}
}
function formOf(cfg) {
const base = defaults()
const engine = ENGINE_IDS.has(cfg.engine) ? cfg.engine : base.engine
const dictationMode = MODE_IDS.has(cfg.dictationMode) ? cfg.dictationMode : base.dictationMode
const language = LANGUAGE_IDS.has(String(cfg.language ?? base.language))
? String(cfg.language ?? base.language)
: base.language
return {
enabled: cfg.enabled !== false,
engine,
dictationMode,
language,
apiKey: String(cfg.apiKey || ''),
baseUrl: String(cfg.baseUrl || base.baseUrl),
model: String(cfg.model || base.model),
microphoneDeviceId: String(cfg.microphoneDeviceId || ''),
microphoneDeviceLabel: String(cfg.microphoneDeviceLabel || ''),
modelInstalled: senseVoiceInstalled(),
modelStatus: senseVoiceStatus(),
modelDownloadSizeMb: 245,
modelProgress: senseVoiceProgress(),
options: {
engines: ENGINES,
modes: MODES,
languages: LANGUAGES,
},
}
}
function saveForm(values) {
const next = formOf({ ...loadConfig(), ...values })
const path = configPath()
mkdirSync(dirname(path), { recursive: true })
writeFileSync(
path,
`${JSON.stringify(
{
enabled: next.enabled,
engine: next.engine,
dictationMode: next.dictationMode,
language: next.language,
apiKey: next.apiKey,
baseUrl: next.baseUrl,
model: next.model,
microphoneDeviceId: next.microphoneDeviceId,
microphoneDeviceLabel: next.microphoneDeviceLabel,
},
null,
2,
)}\n`,
{ encoding: 'utf8' },
)
try {
chmodSync(path, 0o600)
} catch {
// best-effort; some filesystems ignore mode
}
return formOf(loadConfig())
}
function whisperLanguage(code) {
const raw = String(code || '').trim().toLowerCase()
if (!raw || raw === 'auto') return ''
return raw.split(/[-_]/, 1)[0]
}
function transcriptionUrl(baseUrl) {
const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '')
if (!trimmed) return 'https://api.openai.com/v1/audio/transcriptions'
if (/\/audio\/transcriptions$/i.test(trimmed)) return trimmed
return `${trimmed}/audio/transcriptions`
}
function filenameFor(mime) {
const type = String(mime || '').toLowerCase()
if (type.includes('wav')) return 'audio.wav'
if (type.includes('mpeg') || type.includes('mp3')) return 'audio.mp3'
if (type.includes('mp4') || type.includes('m4a')) return 'audio.m4a'
if (type.includes('ogg')) return 'audio.ogg'
return 'audio.webm'
}
async function readBody(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
return Buffer.concat(chunks)
}
async function readJsonBody(req) {
const raw = (await readBody(req)).toString('utf8').trim()
if (!raw) return {}
return JSON.parse(raw)
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(body))
}
async function sha256File(path) {
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) hash.update(chunk)
return hash.digest('hex')
}
async function ensureModelFile(file, reportBytes) {
const dest = join(senseVoiceDir(), file.name)
try {
if (statSync(dest).size === file.bytes && (await sha256File(dest)) === file.sha256) {
reportBytes(file.bytes)
return
}
} catch {}
mkdirSync(dirname(dest), { recursive: true })
const partial = `${dest}.part`
rmSync(partial, { force: true })
const response = await fetch(`${SENSEVOICE_BASE_URL}/${file.name}?download=true`, {
signal: AbortSignal.timeout(900000),
})
if (!response.ok || !response.body) {
throw new Error(`下载 ${file.name} 失败HTTP ${response.status}`)
}
const hash = createHash('sha256')
let bytes = 0
const verify = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length
reportBytes(chunk.length)
hash.update(chunk)
callback(null, chunk)
},
})
try {
await pipeline(Readable.fromWeb(response.body), verify, createWriteStream(partial, { mode: 0o600 }))
const digest = hash.digest('hex')
if (bytes !== file.bytes || digest !== file.sha256) {
throw new Error(`下载的 ${file.name} 校验失败`)
}
renameSync(partial, dest)
} catch (error) {
rmSync(partial, { force: true })
throw error
}
}
function npmInvocation() {
if (process.env.npm_execpath && existsSync(process.env.npm_execpath)) {
return { file: process.execPath, prefix: [process.env.npm_execpath] }
}
const configured = process.env.DSH_DESKTOP_NPM
if (configured) return { file: configured, prefix: [] }
for (const candidate of ['/app/bin/npm', '/app/node24/bin/npm']) {
if (existsSync(candidate)) return { file: candidate, prefix: [] }
}
if (process.platform === 'win32') return { file: 'cmd.exe', prefix: ['/d', '/s', '/c', 'npm.cmd'] }
return { file: 'npm', prefix: [] }
}
async function installSherpaRuntime() {
try {
const pkg = JSON.parse(readFileSync(join(runtimeDir(), 'node_modules', 'sherpa-onnx', 'package.json'), 'utf8'))
if (pkg.version === SHERPA_VERSION) return
} catch {}
mkdirSync(runtimeDir(), { recursive: true })
const npm = npmInvocation()
try {
await execFileAsync(
npm.file,
[
...npm.prefix,
'install',
'--prefix',
runtimeDir(),
'--no-audit',
'--no-fund',
'--omit=dev',
'--save-exact',
`sherpa-onnx@${SHERPA_VERSION}`,
],
{ timeout: 300000, maxBuffer: 1024 * 1024 },
)
} catch (error) {
const detail = String(error?.stderr || error?.message || error).trim()
throw new Error(`安装离线识别运行时失败:${detail}`)
}
}
async function installSenseVoice() {
if (senseVoiceInstalled()) return { ...formOf(loadConfig()), installed: true }
if (!installPromise) {
installState = { status: 'installing', percent: 0, stage: '正在准备离线识别运行时…' }
installPromise = (async () => {
mkdirSync(senseVoiceDir(), { recursive: true })
rmSync(markerPath(), { force: true })
let downloadedBytes = 0
const reportBytes = (bytes) => {
downloadedBytes += bytes
installState = {
status: 'installing',
percent: Math.min(
DOWNLOAD_PROGRESS_LIMIT,
Math.floor((downloadedBytes / SENSEVOICE_DOWNLOAD_BYTES) * DOWNLOAD_PROGRESS_LIMIT),
),
stage: '正在下载 SenseVoice 模型…',
}
}
const runtime = installSherpaRuntime()
await Promise.all(SENSEVOICE_FILES.map((file) => ensureModelFile(file, reportBytes)))
installState = {
status: 'installing',
percent: DOWNLOAD_PROGRESS_LIMIT,
stage: '正在完成离线识别运行时…',
}
await runtime
const partial = `${markerPath()}.part`
writeFileSync(
partial,
`${JSON.stringify({ modelVersion: SENSEVOICE_VERSION, runtimeVersion: SHERPA_VERSION }, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
)
renameSync(partial, markerPath())
installState = { status: 'installed', percent: 100, stage: 'SenseVoice 已安装' }
})()
.catch((error) => {
installState = {
status: 'failed',
percent: installState.percent,
stage: String(error?.message || error),
}
throw error
})
.finally(() => {
installPromise = null
})
}
await installPromise
return { ...formOf(loadConfig()), installed: true }
}
function sherpaModule() {
if (!sherpa) {
const require = createRequire(join(runtimeDir(), 'package.json'))
sherpa = require('sherpa-onnx')
}
return sherpa
}
function senseVoiceRecognizer(language) {
const code = whisperLanguage(language)
if (recognizers.has(code)) return recognizers.get(code)
const runtime = sherpaModule()
const recognizer = runtime.createOfflineRecognizer({
modelConfig: {
senseVoice: {
model: join(senseVoiceDir(), 'model.int8.onnx'),
language: code,
useInverseTextNormalization: 1,
},
tokens: join(senseVoiceDir(), 'tokens.txt'),
},
})
recognizers.set(code, recognizer)
return recognizer
}
function enqueueRecognition(task) {
const next = recognitionQueue.then(task, task)
recognitionQueue = next.catch(() => {})
return next
}
async function transcribeSenseVoice(audio, mime, language) {
if (!senseVoiceInstalled()) {
const error = new Error('SenseVoice 离线模型尚未安装')
error.status = 409
throw error
}
if (!String(mime).toLowerCase().includes('wav')) {
const error = new Error('SenseVoice 离线识别需要 WAV 音频')
error.status = 415
throw error
}
const path = join(tmpdir(), `dsh-desktop-voice-${randomUUID()}.wav`)
writeFileSync(path, audio, { mode: 0o600 })
try {
return await enqueueRecognition(() => {
const runtime = sherpaModule()
const recognizer = senseVoiceRecognizer(language)
const wave = runtime.readWave(path)
const stream = recognizer.createStream()
try {
stream.acceptWaveform(wave.sampleRate, wave.samples)
recognizer.decode(stream)
return String(recognizer.getResult(stream).text || '').trim()
} finally {
stream.free()
}
})
} finally {
rmSync(path, { force: true })
}
}
async function transcribeOpenAI(cfg, audio, mime, language) {
if (!cfg.apiKey) {
const error = new Error('未配置 OpenAI API 密钥')
error.status = 400
throw error
}
const form = new FormData()
form.append('file', new Blob([audio], { type: mime }), filenameFor(mime))
form.append('model', cfg.model || 'whisper-1')
form.append('response_format', 'json')
if (language) form.append('language', language)
const response = await fetch(transcriptionUrl(cfg.baseUrl), {
method: 'POST',
headers: { authorization: `Bearer ${cfg.apiKey}` },
body: form,
signal: AbortSignal.timeout(60000),
})
const text = await response.text()
let body = {}
try {
body = text ? JSON.parse(text) : {}
} catch {
body = { error: text.slice(0, 400) }
}
if (!response.ok) {
const detail = body.error?.message || body.error || body.message || text.slice(0, 400)
const error = new Error(String(detail || `OpenAI 接口返回 ${response.status}`))
error.status = response.status
throw error
}
return String(body.text || body.transcript || '').trim()
}
async function transcribe(req) {
const cfg = formOf(loadConfig())
const url = new URL(req.url, 'http://localhost')
const mime = String(req.headers['content-type'] || url.searchParams.get('mime') || 'application/octet-stream')
const language = whisperLanguage(url.searchParams.get('language') || cfg.language)
const audio = await readBody(req)
if (!audio.length) {
const error = new Error('没有收到音频')
error.status = 400
throw error
}
const transcript =
cfg.engine === 'openai'
? await transcribeOpenAI(cfg, audio, mime, language)
: await transcribeSenseVoice(audio, mime, language)
if (!transcript) {
const error = new Error('没有识别到语音')
error.status = 422
throw error
}
return { text: transcript }
}
export function apply(ctx) {
if (typeof ctx.inject !== 'function') return
ctx.inject(['webServer'], (scope) => {
try {
scope.webServer.register({
name: 'dsh-desktop-voice',
kind: 'exact',
path: '/dsh-desktop/voice',
handler: async (req, res) => {
try {
if (req.method === 'GET') {
json(res, 200, formOf(loadConfig()))
return
}
if (req.method === 'PUT' || req.method === 'POST') {
json(res, 200, { ...saveForm(await readJsonBody(req)), saved: true })
return
}
res.writeHead(405).end()
} catch (error) {
json(res, error.status || 500, { error: String(error?.message || error) })
}
},
})
scope.webServer.register({
name: 'dsh-desktop-voice-model',
kind: 'exact',
path: '/dsh-desktop/voice/model',
handler: async (req, res) => {
try {
if (req.method === 'GET') {
json(res, 200, senseVoiceProgress())
return
}
if (req.method === 'POST') {
json(res, 200, await installSenseVoice())
return
}
res.writeHead(405).end()
} catch (error) {
json(res, error.status || 500, { error: String(error?.message || error) })
}
},
})
scope.webServer.register({
name: 'dsh-desktop-voice-transcribe',
kind: 'exact',
path: '/dsh-desktop/voice/transcribe',
handler: async (req, res) => {
try {
if (req.method !== 'POST') {
res.writeHead(405).end()
return
}
json(res, 200, await transcribe(req))
} catch (error) {
json(res, error.status || 500, { error: String(error?.message || error) })
}
},
})
} catch (error) {
console.error(`[dsh-desktop-voice] routes skipped: ${error}`)
}
})
}

View File

@@ -0,0 +1,24 @@
{
"name": "dsh-desktop-voice",
"version": "0.4.0",
"private": true,
"type": "module",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./package.json": "./package.json"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web",
"immediately": true
}
}
}

135
scripts/build-gitea-update.py Executable file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Normalize Tauri artifacts and build the static updater manifest."""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
MATRIX_TARGETS = {
"gitea-macos-arm64": ("darwin", "aarch64"),
"gitea-macos-x64": ("darwin", "x86_64"),
"gitea-linux": ("linux", "x86_64"),
"gitea-windows": ("windows", "x86_64"),
"gitea-flatpak": ("linux", "x86_64"),
}
DELIVERABLE_SUFFIXES = (
".app.tar.gz.sig",
".app.tar.gz",
".AppImage.sig",
".AppImage",
".flatpak",
".dmg",
".deb",
".rpm",
".exe.sig",
".exe",
".msi.sig",
".msi",
)
UPDATER_SUFFIX = {
"darwin": ".app.tar.gz",
"linux": ".AppImage",
"windows": ".exe",
}
INSTALLER_KIND = {
"darwin": "app",
"linux": "appimage",
"windows": "nsis",
}
def artifact_target(path: Path, root: Path) -> tuple[str, str] | None:
relative = path.relative_to(root)
for part in relative.parts:
if part in MATRIX_TARGETS:
return MATRIX_TARGETS[part]
return None
def deliverable_suffix(name: str) -> str | None:
return next((suffix for suffix in DELIVERABLE_SUFFIXES if name.endswith(suffix)), None)
def stage_artifacts(source: Path, output: Path, version: str) -> dict[tuple[str, str, str], Path]:
output.mkdir(parents=True, exist_ok=True)
staged: dict[tuple[str, str, str], Path] = {}
for path in sorted(source.rglob("*")):
if not path.is_file():
continue
target = artifact_target(path, source)
suffix = deliverable_suffix(path.name)
if target is None or suffix is None:
continue
os_name, arch = target
key = (os_name, arch, suffix)
if key in staged:
raise ValueError(f"duplicate {os_name}-{arch}{suffix}: {staged[key]} and {path}")
destination = output / f"dsh-easy-desktop_{version}_{os_name}_{arch}{suffix}"
shutil.copy2(path, destination)
staged[key] = destination
return staged
def build_manifest(
staged: dict[tuple[str, str, str], Path],
version: str,
package_base_url: str,
notes: str,
pub_date: str | None,
) -> dict[str, object]:
platforms: dict[str, dict[str, str]] = {}
for (os_name, arch, suffix), artifact in staged.items():
if suffix != UPDATER_SUFFIX.get(os_name):
continue
signature = staged.get((os_name, arch, suffix + ".sig"))
if signature is None:
raise ValueError(f"missing signature for {artifact.name}")
entry = {
"url": f"{package_base_url.rstrip('/')}/{version}/{artifact.name}",
"signature": signature.read_text(encoding="utf-8").strip(),
}
platforms[f"{os_name}-{arch}-{INSTALLER_KIND[os_name]}"] = entry
platforms[f"{os_name}-{arch}"] = entry
required = {"darwin-aarch64", "darwin-x86_64", "linux-x86_64", "windows-x86_64"}
missing = required.difference(platforms)
if missing:
raise ValueError(f"missing updater targets: {', '.join(sorted(missing))}")
manifest: dict[str, object] = {
"version": version,
"notes": notes,
"platforms": platforms,
}
if pub_date:
manifest["pub_date"] = pub_date
return manifest
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--artifacts", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--version", required=True)
parser.add_argument("--package-base-url", required=True)
parser.add_argument("--notes", default="DeepSeek Harness Desktop update")
parser.add_argument("--pub-date")
args = parser.parse_args()
staged = stage_artifacts(args.artifacts, args.output, args.version)
manifest = build_manifest(
staged,
args.version,
args.package_base_url,
args.notes,
args.pub_date,
)
(args.output / "latest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if __name__ == "__main__":
main()

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

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Apply the downstream mainland-connectivity warning to vendored dshmarket."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MARKET = ROOT / "vendor" / "dshmarket" / "node_modules" / "dshmarket"
def insert_after(path: Path, anchor: str, addition: str) -> None:
text = path.read_text(encoding="utf-8")
if addition.strip() in text:
return
if text.count(anchor) != 1:
raise RuntimeError(f"expected one patch anchor in {path}: {anchor!r}")
path.write_text(text.replace(anchor, anchor + addition), encoding="utf-8")
def insert_before(path: Path, anchor: str, addition: str) -> None:
text = path.read_text(encoding="utf-8")
if addition.strip() in text:
return
if text.count(anchor) != 1:
raise RuntimeError(f"expected one patch anchor in {path}: {anchor!r}")
path.write_text(text.replace(anchor, addition + anchor), encoding="utf-8")
def main() -> None:
locales = MARKET / "src" / "client" / "locales.ts"
insert_after(
locales,
" terminalWarn: '这看起来是终端/命令行插件:装进网页版可能无效,甚至导致 DeepSeek Harness 无法启动。建议先看它的使用说明,按说明装进对应的 profile。',\n",
" githubInstallWarn: '此插件没有 npm 安装包,安装时必须从 github.com 下载源码;中国大陆网络通常无法直连。请仅在当前网络能访问 GitHub 时继续。',\n",
)
insert_after(
locales,
" terminalWarn: 'This looks like a terminal/CLI plugin: installing it into the web profile may do nothing, or even break DeepSeek Harness startup. Read its README and install it into the profile it targets.',\n",
" githubInstallWarn: 'This plugin has no npm package. Installation must download its source from github.com and will fail where GitHub is unreachable. Continue only if this network can access GitHub.',\n",
)
section = MARKET / "src" / "client" / "MarketSection.tsx"
source_anchor = """ <p className={css.modalNote}><IconWarningOutline16 size={14} className={css.bannerIcon} />{' ' + t('confirmWarn')}</p>\n"""
source_addition = """ {typeof confirming.npm !== 'string' && (\n <p className={css.warnLine}>\n <IconWarningOutline16 size={14} className={css.bannerIcon} />\n {' ' + t('githubInstallWarn')}\n </p>\n )}\n"""
insert_before(section, source_anchor, source_addition)
client = MARKET / "client" / "client.js"
insert_after(
client,
'\t\t\tterminalWarn: "这看起来是终端/命令行插件:装进网页版可能无效,甚至导致 DeepSeek Harness 无法启动。建议先看它的使用说明,按说明装进对应的 profile。",\n',
'\t\t\tgithubInstallWarn: "此插件没有 npm 安装包,安装时必须从 github.com 下载源码;中国大陆网络通常无法直连。请仅在当前网络能访问 GitHub 时继续。",\n',
)
insert_after(
client,
'\t\t\tterminalWarn: "This looks like a terminal/CLI plugin: installing it into the web profile may do nothing, or even break DeepSeek Harness startup. Read its README and install it into the profile it targets.",\n',
'\t\t\tgithubInstallWarn: "This plugin has no npm package. Installation must download its source from github.com and will fail where GitHub is unreachable. Continue only if this network can access GitHub.",\n',
)
built_anchor = """\t\t\t\t\t\t\t/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(\"p\", {\n\t\t\t\t\t\t\t\tclassName: Market_module_css_default.modalNote,\n"""
built_addition = """\t\t\t\t\t\t\ttypeof confirming.npm !== \"string\" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(\"p\", {\n\t\t\t\t\t\t\t\tclassName: Market_module_css_default.warnLine,\n\t\t\t\t\t\t\t\tchildren: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutline16, {\n\t\t\t\t\t\t\t\t\tsize: 14,\n\t\t\t\t\t\t\t\t\tclassName: Market_module_css_default.bannerIcon\n\t\t\t\t\t\t\t\t}), \" \" + t(\"githubInstallWarn\")]\n\t\t\t\t\t\t\t}),\n"""
insert_before(client, built_anchor, built_addition)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Publish normalized installers and signed updater artifacts to Gitea.
set -euo pipefail
STAGING_DIR="${1:?usage: publish-gitea-release.sh STAGING_DIR}"
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
: "${GITEA_BASE_URL:?GITEA_BASE_URL is required}"
: "${GITEA_OWNER:?GITEA_OWNER is required}"
: "${GITEA_REPO:?GITEA_REPO is required}"
: "${RELEASE_TAG:?RELEASE_TAG is required}"
: "${RELEASE_VERSION:?RELEASE_VERSION is required}"
API="${GITEA_BASE_URL%/}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}"
PACKAGE_BASE="${GITEA_BASE_URL%/}/api/packages/${GITEA_OWNER}/generic/dsh-easy-desktop-updater"
AUTH="Authorization: token ${GITEA_TOKEN}"
# Retry transient gateway errors (504) coming from the front proxy while the
# NAS reaches github.com over a mainland link: requests can stall past the
# proxy read timeout. 404s are NOT retried here (release/package state).
curl_retry() {
local attempts=0
while ! "$@"; do
attempts=$((attempts + 1))
if (( attempts >= 4 )); then
echo "curl failed after 4 attempts: $*" >&2
return 1
fi
echo "curl transient failure (attempt ${attempts}/3), retrying: $*" >&2
sleep 10
done
}
# The Gitea repository is a pull mirror. Kick a background sync; the trigger
# response itself is irrelevant (it can 504 while the sync runs on Gitea).
# The tag poll below is the actual gate.
curl --silent --show-error -X POST -H "$AUTH" "$API/mirror-sync" >/dev/null || true
# Wait (up to 10 min) until the tag has synced; then fail loudly if it never did.
for _ in $(seq 1 60); do
if curl --fail --silent --show-error -H "$AUTH" "$API/tags/$RELEASE_TAG" >/dev/null 2>&1; then
synced=1
break
fi
sleep 10
done
[[ "${synced:-0}" == 1 ]] || {
echo "tag $RELEASE_TAG did not appear on the Gitea mirror after 10 minutes" >&2
exit 1
}
release_json=$(curl_retry curl --fail --silent --show-error -H "$AUTH" "$API/releases/tags/$RELEASE_TAG")
release_id=$(printf '%s' "$release_json" | jq -r '.id // empty')
if [[ -z "$release_id" ]]; then
release_payload=$(jq -n \
--arg tag "$RELEASE_TAG" \
--arg name "DeepSeek Harness Desktop $RELEASE_TAG" \
--arg body "大陆镜像安装包;文件与 GitHub Release 同源。应用内更新包由 Tauri 签名校验。" \
'{tag_name:$tag,target_commitish:$tag,name:$name,body:$body,draft:false,prerelease:false}')
release_json=$(curl_retry curl --fail --silent --show-error \
-X POST -H "$AUTH" -H 'Content-Type: application/json' \
--data "$release_payload" "$API/releases")
release_id=$(printf '%s' "$release_json" | jq -r '.id')
fi
# Versioned generic package: immutable URLs consumed by latest.json.
curl --silent --show-error -X DELETE -H "$AUTH" \
"$PACKAGE_BASE/$RELEASE_VERSION" >/dev/null || true
for file in "$STAGING_DIR"/*; do
[[ -f "$file" && "$(basename "$file")" != "latest.json" ]] || continue
curl_retry curl --fail --silent --show-error -H "$AUTH" --upload-file "$file" \
"$PACKAGE_BASE/$RELEASE_VERSION/$(basename "$file")" >/dev/null
done
# Stable updater endpoint. Gitea generic packages are immutable, so replace
# the synthetic "latest" version on each completed release.
curl --silent --show-error -X DELETE -H "$AUTH" "$PACKAGE_BASE/latest" >/dev/null || true
curl_retry curl --fail --silent --show-error -H "$AUTH" --upload-file "$STAGING_DIR/latest.json" \
"$PACKAGE_BASE/latest/latest.json" >/dev/null
assets=$(curl_retry curl --fail --silent --show-error -H "$AUTH" "$API/releases/$release_id/assets")
for file in "$STAGING_DIR"/*; do
[[ -f "$file" ]] || continue
name=$(basename "$file")
case "$name" in
*.dmg|*.deb|*.rpm|*.exe|*.msi|*.AppImage|*.flatpak) ;;
*) continue ;;
esac
old_id=$(printf '%s' "$assets" | jq -r --arg name "$name" '[.[] | select(.name == $name) | .id][0] // empty')
if [[ -n "$old_id" ]]; then
curl_retry curl --fail --silent --show-error -X DELETE -H "$AUTH" \
"$API/releases/$release_id/assets/$old_id" >/dev/null
fi
curl_retry curl --fail --silent --show-error -H "$AUTH" \
-F "attachment=@$file" "$API/releases/$release_id/assets?name=$name" >/dev/null
done

93
scripts/setup-gitea-release.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Interactive one-time setup for Gitea publishing and Tauri update signing.
set -euo pipefail
BOLD='\033[1m'
DIM='\033[2m'
RED='\033[31m'
GREEN='\033[32m'
RESET='\033[0m'
GITEA_BASE_URL='https://git.fangsiyuan.top'
GITEA_OWNER='TomHanck4'
GITEA_REPO='dsh-easy-desktop'
step() {
printf '\n%b%s%b\n' "$BOLD" "$1" "$RESET"
printf '%b%s%b\n\n' "$DIM" "$2" "$RESET"
}
confirm() {
local answer
read -r -p "$1 [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
need() {
command -v "$1" >/dev/null 2>&1 || {
printf '%b缺少命令%s%b\n' "$RED" "$1" "$RESET" >&2
exit 1
}
}
need curl
need gh
need jq
printf '%bGitea 大陆发行通道设置%b\n' "$BOLD" "$RESET"
printf '共 3 步:验证 Gitea → 配置签名 → 写入 GitHub Secrets\n'
step '1/3 验证 Gitea' '需要一个可写入 TomHanck4/dsh-easy-desktop 发行版与 Generic Package 的 Gitea token。'
printf '%bHTTPS 已启用:%btoken 与发行包传输均受 TLS 保护。\n' "$GREEN" "$RESET"
read -r -s -p '粘贴 Gitea token: ' GITEA_TOKEN
printf '\n'
[[ -n "$GITEA_TOKEN" ]] || { printf '%btoken 不能为空%b\n' "$RED" "$RESET" >&2; exit 1; }
AUTH="Authorization: token $GITEA_TOKEN"
user=$(curl --fail --silent --show-error -H "$AUTH" "$GITEA_BASE_URL/api/v1/user")
repo=$(curl --fail --silent --show-error -H "$AUTH" "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO")
printf '已认证:%s仓库%s\n' "$(printf '%s' "$user" | jq -r .login)" "$(printf '%s' "$repo" | jq -r .full_name)"
[[ "$(printf '%s' "$repo" | jq -r .mirror)" == 'true' ]] || {
printf '%b仓库不是 pull mirror发布脚本无法同步 GitHub tag。%b\n' "$RED" "$RESET" >&2
exit 1
}
step '2/3 配置 Tauri 签名' '已有密钥可复用;否则向导通过 Tauri CLI 在 ~/.tauri 生成。私钥不会写入仓库。'
if confirm '已有 Tauri updater 私钥吗?'; then
read -r -e -p '私钥路径: ' PRIVATE_KEY_PATH
read -r -e -p '公钥路径: ' PUBLIC_KEY_PATH
read -r -s -p '私钥密码(没有则留空): ' SIGNING_PASSWORD
printf '\n'
else
need npx
PRIVATE_KEY_PATH="$HOME/.tauri/dsh-easy-desktop.key"
PUBLIC_KEY_PATH="$PRIVATE_KEY_PATH.pub"
mkdir -p "$(dirname "$PRIVATE_KEY_PATH")"
read -r -s -p '设置私钥密码(可留空): ' SIGNING_PASSWORD
printf '\n正在生成签名密钥…\n'
npm_config_registry='https://registry.npmmirror.com' \
npx --yes @tauri-apps/cli@2 signer generate --ci --password "$SIGNING_PASSWORD" -w "$PRIVATE_KEY_PATH"
fi
[[ -f "$PRIVATE_KEY_PATH" ]] || { printf '%b找不到私钥%s%b\n' "$RED" "$PRIVATE_KEY_PATH" "$RESET" >&2; exit 1; }
[[ -f "$PUBLIC_KEY_PATH" ]] || { printf '%b找不到公钥%s%b\n' "$RED" "$PUBLIC_KEY_PATH" "$RESET" >&2; exit 1; }
chmod 600 "$PRIVATE_KEY_PATH"
printf '私钥:%s\n公钥%s\n' "$PRIVATE_KEY_PATH" "$PUBLIC_KEY_PATH"
step '3/3 写入 GitHub Secrets' 'gh 会把四项机密直接写到当前仓库;终端不会打印 secret 内容。'
gh auth status >/dev/null
confirm '现在写入 GITEA_TOKEN 与 Tauri 签名 secrets 吗?' || exit 1
printf '%s' "$GITEA_TOKEN" | gh secret set GITEA_TOKEN
cat "$PRIVATE_KEY_PATH" | gh secret set TAURI_SIGNING_PRIVATE_KEY
python3 - "$PUBLIC_KEY_PATH" <<'PY'
import json, pathlib, sys
conf = pathlib.Path("src-tauri/tauri.conf.json")
data = json.loads(conf.read_text(encoding="utf-8"))
data["plugins"]["updater"]["pubkey"] = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip()
conf.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
PY
echo "公钥已写入 src-tauri/tauri.conf.json随仓库提交"
if [[ -n "$SIGNING_PASSWORD" ]]; then
printf '%s' "$SIGNING_PASSWORD" | gh secret set TAURI_SIGNING_PRIVATE_KEY_PASSWORD
fi
unset GITEA_TOKEN SIGNING_PASSWORD AUTH
printf '\n%b设置完成。%b 下次推送 v* tag 时Release 工作流会发布 GitHub 与 Gitea 两套安装包。\n' "$GREEN" "$RESET"
printf '首个版本发布后检查:%s/%s/%s/releases/latest\n' "$GITEA_BASE_URL" "$GITEA_OWNER" "$GITEA_REPO"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Fetch ModLens + Anchored Standard into vendor/ for Tauri resource bundling.
# Fetch ModLens, dshmarket, and Anchored Standard into vendor/ for Tauri resources.
# Does not vendor @deepseek-ai/dsh (that is Flatpak-only; see `make vendor`).
set -euo pipefail
@@ -7,6 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
MODLENS_VERSION="${MODLENS_VERSION:-3.16.6}"
MARKET_VERSION="${MARKET_VERSION:-1.9.0}"
ANCHORED_COMMIT="${ANCHORED_COMMIT:-ffb845c5480adc953392a6db6f8a98ede621174b}"
ANCHORED_REPO="${ANCHORED_REPO:-https://github.com/xiaobright/dsh-anchored-standard.git}"
if [ -z "${PYTHON:-}" ]; then
@@ -20,6 +21,7 @@ fi
ANCHORED_DIR="vendor/anchored-standard"
ZERO_DIR="vendor/zero-anchored-standard"
MODLENS_DIR="vendor/modlens"
MARKET_DIR="vendor/dshmarket"
rm -rf vendor/.anchored-src "$ANCHORED_DIR" "$ZERO_DIR"
mkdir -p vendor/.anchored-src
@@ -42,7 +44,13 @@ rm -rf "$MODLENS_DIR"
mkdir -p "$MODLENS_DIR"
npm install --prefix "$MODLENS_DIR" --prefer-offline --no-audit --no-fund "@liustack/modlens@${MODLENS_VERSION}"
rm -rf "$MARKET_DIR"
mkdir -p "$MARKET_DIR"
npm install --prefix "$MARKET_DIR" --prefer-offline --no-audit --no-fund "dshmarket@${MARKET_VERSION}"
"$PYTHON" scripts/patch-dshmarket-mainland.py
test -f "$ANCHORED_DIR/preset.yml"
test -f "$ZERO_DIR/preset.yml"
test -d "$MODLENS_DIR/node_modules/@liustack/modlens"
echo "vendored ModLens ${MODLENS_VERSION} and Anchored Standard ${ANCHORED_COMMIT}"
test -d "$MARKET_DIR/node_modules/dshmarket"
echo "vendored ModLens ${MODLENS_VERSION}, dshmarket ${MARKET_VERSION}, and Anchored Standard ${ANCHORED_COMMIT}"

View File

@@ -22,11 +22,17 @@ dsh-core = { path = "../crates/dsh-core" }
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "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"
webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }

8
src-tauri/Info.plist Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>语音输入需要使用麦克风,以便把说话内容写进对话框。</string>
</dict>
</plist>

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,16 @@
mod shell_updater;
use std::io::Cursor;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
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;
@@ -36,7 +41,9 @@ pub struct Args {
impl Args {
pub fn parse() -> Self {
let mut args = Args {
dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE).ok().filter(|s| !s.is_empty()),
dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE)
.ok()
.filter(|s| !s.is_empty()),
cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE)
.ok()
.filter(|s| !s.is_empty())
@@ -89,6 +96,7 @@ struct AppState {
paths: BundledPaths,
process: Mutex<Option<Arc<DshProcess>>>,
url: Mutex<Option<String>>,
initial_boot_started: AtomicBool,
}
impl AppState {
@@ -121,6 +129,41 @@ struct ReadyPayload {
fn restart(app: AppHandle) {
thread::spawn(move || boot(app));
}
fn start_initial_boot(app: AppHandle) {
let Some(state) = app.try_state::<AppState>() else {
return;
};
if state.initial_boot_started.swap(true, Ordering::AcqRel) {
return;
}
thread::spawn(move || boot(app));
}
#[tauri::command]
async fn check_shell_update(app: AppHandle) -> Option<shell_updater::UpdateInfo> {
match shell_updater::check(&app).await {
Ok(Some(update)) => Some(update),
Ok(None) => {
start_initial_boot(app);
None
}
Err(error) => {
log::warn!("shell update check failed: {error}");
start_initial_boot(app);
None
}
}
}
#[tauri::command]
async fn install_shell_update(app: AppHandle) -> Result<(), String> {
shell_updater::install(&app).await
}
#[tauri::command]
fn skip_shell_update(app: AppHandle) {
start_initial_boot(app);
}
#[tauri::command]
fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
@@ -134,41 +177,131 @@ 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() {
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 mut png = Vec::new();
if 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 Some(file) = read_native_image() {
return Ok(vec![file]);
}
if let Ok(text) = clipboard.get_text() {
let loaded = files_from_paths(parse_uri_list(&text));
if !loaded.is_empty() {
return Ok(loaded);
}
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();
let buffer = image::RgbaImage::from_raw(width, height, bytes)?;
let mut png = Vec::new();
buffer
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.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() {
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);
}
}
None
}
fn enable_microphone(window: &tauri::WebviewWindow) {
#[cfg(target_os = "linux")]
{
let _ = window.with_webview(|platform| {
use gtk::glib::{object::ObjectExt, StaticType};
use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt};
let webview = platform.inner();
if let Some(settings) = webview.settings() {
settings.set_enable_media_stream(true);
settings.set_enable_mediasource(true);
}
webview.connect_permission_request(|_, request| {
if request
.type_()
.is_a(webkit2gtk::UserMediaPermissionRequest::static_type())
{
request.allow();
true
} else {
false
}
});
});
}
let _ = window;
}
fn is_internal(url: &Url) -> bool {
matches!(url.scheme(), "tauri" | "asset" | "about" | "data" | "blob")
|| matches!(
@@ -303,17 +436,21 @@ fn boot(app: AppHandle) {
pub fn run() {
let args = Args::parse();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(if args.verbose {
"debug"
} else {
"info"
}))
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or(if args.verbose { "debug" } else { "info" }),
)
.init();
let mut paths = BundledPaths::discover();
let dev = args.dev;
let updater = match shell_updater::public_key() {
Some(public_key) => tauri_plugin_updater::Builder::new().pubkey(public_key),
None => tauri_plugin_updater::Builder::new(),
}
.build();
tauri::Builder::default()
.plugin(updater)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
@@ -330,23 +467,25 @@ pub fn run() {
paths,
process: Mutex::new(None),
url: Mutex::new(None),
initial_boot_started: AtomicBool::new(false),
});
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title(APP_NAME)
.inner_size(1320.0, 860.0)
.min_inner_size(800.0, 560.0)
.decorations(false)
.resizable(true)
.initialization_script(INJECT)
.on_navigation(|url| {
if is_internal(&url) {
true
} else {
let _ = open::that(url.as_str());
false
}
});
let mut builder =
WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title(APP_NAME)
.inner_size(1320.0, 860.0)
.min_inner_size(800.0, 560.0)
.decorations(false)
.resizable(true)
.initialization_script(INJECT)
.on_navigation(|url| {
if is_internal(&url) {
true
} else {
let _ = open::that(url.as_str());
false
}
});
if let Some(icon) = app.default_window_icon().cloned() {
builder = builder.icon(icon)?;
@@ -360,16 +499,18 @@ pub fn run() {
if dev {
window.open_devtools();
}
enable_microphone(&window);
let app_handle = app.handle().clone();
let _ = window;
thread::spawn(move || boot(app_handle));
Ok(())
})
.invoke_handler(tauri::generate_handler![
restart,
open_in_browser,
read_clipboard_images
read_clipboard_images,
check_shell_update,
install_shell_update,
skip_shell_update,
])
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {

View File

@@ -0,0 +1,126 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::{Update, UpdaterExt};
use url::Url;
const UPDATE_ENDPOINT: &str = "https://git.fangsiyuan.top/api/packages/TomHanck4/generic/dsh-easy-desktop-updater/latest/latest.json";
const UPDATE_TIMEOUT_SECONDS: u64 = 8;
pub fn public_key() -> Option<&'static str> {
option_env!("DSH_DESKTOP_UPDATER_PUBKEY").filter(|key| !key.trim().is_empty())
}
#[derive(Clone, Serialize)]
pub struct UpdateInfo {
pub version: String,
pub notes: Option<String>,
}
#[derive(Clone, Serialize)]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
percent: Option<u8>,
}
fn supported_install() -> bool {
#[cfg(target_os = "linux")]
{
// Tauri's Linux updater replaces the running AppImage. A deb, rpm, or
// Flatpak install must be updated by its package manager instead.
std::env::var_os("APPIMAGE").is_some()
}
#[cfg(not(target_os = "linux"))]
{
true
}
}
async fn find_update(app: &AppHandle) -> Result<Option<Update>, String> {
let Some(public_key) = public_key() else {
return Ok(None);
};
if !supported_install() {
return Ok(None);
}
let endpoint = Url::parse(UPDATE_ENDPOINT).map_err(|error| error.to_string())?;
app.updater_builder()
.endpoints(vec![endpoint])
.map_err(|error| error.to_string())?
.pubkey(public_key)
.timeout(Duration::from_secs(UPDATE_TIMEOUT_SECONDS))
.build()
.map_err(|error| error.to_string())?
.check()
.await
.map_err(|error| error.to_string())
}
pub async fn check(app: &AppHandle) -> Result<Option<UpdateInfo>, String> {
Ok(find_update(app).await?.map(|update| UpdateInfo {
version: update.version,
notes: update.body,
}))
}
pub async fn install(app: &AppHandle) -> Result<(), String> {
let update = find_update(app)
.await?
.ok_or_else(|| "没有可安装的壳更新".to_string())?;
let downloaded = Arc::new(AtomicU64::new(0));
let progress_app = app.clone();
let progress_downloaded = Arc::clone(&downloaded);
update
.download_and_install(
move |chunk, total| {
let downloaded =
progress_downloaded.fetch_add(chunk as u64, Ordering::Relaxed) + chunk as u64;
let percent = total
.filter(|total| *total > 0)
.map(|total| ((downloaded.saturating_mul(100) / total).min(100)) as u8);
let _ = progress_app.emit(
"shell-update-progress",
UpdateProgress {
downloaded,
total,
percent,
},
);
},
{
let app = app.clone();
move || {
let total = downloaded.load(Ordering::Relaxed);
let _ = app.emit(
"shell-update-progress",
UpdateProgress {
downloaded: total,
total: Some(total),
percent: Some(100),
},
);
}
},
)
.await
.map_err(|error| error.to_string())?;
app.restart();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_is_the_public_gitea_package() {
let endpoint = Url::parse(UPDATE_ENDPOINT).unwrap();
assert_eq!(endpoint.scheme(), "https");
assert_eq!(endpoint.host_str(), Some("git.fangsiyuan.top"));
assert!(endpoint.path().ends_with("/latest/latest.json"));
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "DeepSeek Harness",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "io.github.tommyfang.DshDesktop",
"build": {
"frontendDist": "../ui"
@@ -15,15 +15,21 @@
"csp": null
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1OEI5NkQwMjJCNkFDODMKUldTRHJMWWkwSmFMTlcza3Vkd1UxclQydm0xOW9MTVNxU1JIckkreWRwc1dMTm1HOVVaMHI1SysK"
}
},
"bundle": {
"createUpdaterArtifacts": true,
"active": true,
"targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"],
"publisher": "TommyFang2077",
"homepage": "https://github.com/TommyFang2077/dsh-desktop",
"homepage": "https://github.com/TommyFang2077/dsh-easy-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). On-demand offline SenseVoice dictation writes speech into the composer (Ctrl+E); its model is not bundled. 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,7 +42,9 @@
],
"resources": [
"../plugins/dsh-desktop-vision/",
"../plugins/dsh-desktop-voice/",
"../vendor/modlens/",
"../vendor/dshmarket/",
"../vendor/anchored-standard/",
"../vendor/zero-anchored-standard/"
],
@@ -62,6 +70,7 @@
},
"macOS": {
"minimumSystemVersion": "10.15",
"infoPlist": "Info.plist",
"dmg": {
"appPosition": { "x": 180, "y": 170 },
"applicationFolderPosition": { "x": 480, "y": 170 }

View File

@@ -0,0 +1,63 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const nextTurn = () => new Promise(resolve => setImmediate(resolve))
function element(hidden = false) {
return {
disabled: false,
hidden,
textContent: '',
listeners: new Map(),
addEventListener(name, listener) {
this.listeners.set(name, listener)
},
}
}
test('startup waits for an available signed shell update decision', async () => {
const elements = new Map([
['status', element()],
['spinner', element()],
['retry', element(true)],
['detail', element(true)],
['update-panel', element(true)],
['update-notes', element()],
['install-update', element()],
['skip-update', element()],
])
const invokes = []
globalThis.document = {
readyState: 'complete',
getElementById(id) {
return elements.get(id)
},
}
globalThis.window = {
__TAURI__: {
event: { async listen() {} },
core: {
async invoke(command) {
invokes.push(command)
if (command === 'check_shell_update') {
return { version: '1.2.3', notes: 'signed release' }
}
return null
},
},
},
}
await import(new URL('../ui/app.js?update-test', import.meta.url))
await nextTurn()
assert.deepEqual(invokes, ['check_shell_update'])
assert.equal(elements.get('status').textContent, '发现壳更新 1.2.3')
assert.equal(elements.get('update-notes').textContent, 'signed release')
assert.equal(elements.get('update-panel').hidden, false)
await elements.get('skip-update').listeners.get('click')()
await nextTurn()
assert.deepEqual(invokes, ['check_shell_update', 'skip_shell_update'])
assert.equal(elements.get('update-panel').hidden, true)
})

View File

@@ -9,6 +9,12 @@ ROOT = Path(__file__).resolve().parents[1]
class VisionPluginFilesTests(unittest.TestCase):
def test_client_registers_system_settings_slots(self):
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text(
encoding="utf-8"
)
self.assertIn("settings.section", client)
self.assertNotIn("settings.plugins.tab", client)
self.assertIn("modlens-vision", client)
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text(
encoding="utf-8"
)
@@ -36,6 +42,59 @@ class VisionPluginFilesTests(unittest.TestCase):
self.assertIn("example: 'haiku'", host)
class VoicePluginFilesTests(unittest.TestCase):
def test_client_registers_composer_and_settings(self):
client = (ROOT / "plugins" / "dsh-desktop-voice" / "client.js").read_text(
encoding="utf-8"
)
host = (ROOT / "plugins" / "dsh-desktop-voice" / "index.js").read_text(
encoding="utf-8"
)
pkg = (ROOT / "plugins" / "dsh-desktop-voice" / "package.json").read_text(
encoding="utf-8"
)
self.assertIn("settings.section", client)
self.assertIn("conversation.input.right", client)
self.assertIn("dsh-desktop-voice", client)
self.assertIn("Ctrl+E", client)
self.assertIn("dictationMode", client)
self.assertIn("whisper-1", host)
self.assertIn("/dsh-desktop/voice", host)
self.assertIn("/dsh-desktop/voice/transcribe", host)
self.assertIn("/dsh-desktop/voice/model", host)
self.assertIn("SenseVoice", client)
self.assertIn("sherpa-onnx@", host)
self.assertNotIn("SpeechRecognition", client)
self.assertNotIn("engine === 'browser'", client)
self.assertIn("engine === 'openai'", client)
self.assertIn("cfg.engine === 'openai'", host)
self.assertFalse(
(ROOT / "plugins" / "dsh-desktop-voice" / "model.int8.onnx").exists()
)
self.assertIn("https://api.openai.com/v1", host)
self.assertIn("@deepseek-ai/dsh-client-ui-conversation", pkg)
self.assertIn("@deepseek-ai/dsh-client-ui-settings", pkg)
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")
@@ -48,11 +107,17 @@ class BundledAttributionTests(unittest.TestCase):
self.assertIn("3.16.6", text)
self.assertIn("ffb845c5480adc953392a6db6f8a98ede621174b", text)
self.assertIn("dsh-desktop-vision", text)
self.assertIn("dsh-desktop-voice", text)
self.assertIn("https://github.com/dsh-market/dsh-market", text)
self.assertIn("1.9.0", 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()
)
self.assertTrue((ROOT / "docs" / "licenses" / "dshmarket.LICENSE").is_file())
for name in ("splash.png", "session.png", "vision.png", "menu.png"):
self.assertTrue((ROOT / "docs" / "screenshots" / name).is_file())

72
tests/test_release.py Normal file
View File

@@ -0,0 +1,72 @@
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "build-gitea-update.py"
class GiteaUpdateManifestTests(unittest.TestCase):
def test_normalizes_matrix_artifacts_and_embeds_signatures(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
artifacts = root / "artifacts"
output = root / "staged"
fixtures = {
"gitea-macos-arm64": ".app.tar.gz",
"gitea-macos-x64": ".app.tar.gz",
"gitea-linux": ".AppImage",
"gitea-windows": ".exe",
}
for matrix, suffix in fixtures.items():
bundle = artifacts / matrix / "target" / "release" / "bundle"
bundle.mkdir(parents=True)
artifact = bundle / f"DeepSeek Harness{suffix}"
artifact.write_bytes(matrix.encode())
artifact.with_name(artifact.name + ".sig").write_text(
f"signature-{matrix}\n", encoding="utf-8"
)
subprocess.run(
[
"python3",
str(SCRIPT),
"--artifacts",
str(artifacts),
"--output",
str(output),
"--version",
"1.2.3",
"--package-base-url",
"http://gitea.example/api/packages/u/generic/app",
"--pub-date",
"2026-08-16T00:00:00Z",
],
check=True,
)
manifest = json.loads((output / "latest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], "1.2.3")
self.assertEqual(
set(manifest["platforms"]),
{
"darwin-aarch64-app",
"darwin-aarch64",
"darwin-x86_64-app",
"darwin-x86_64",
"linux-x86_64-appimage",
"linux-x86_64",
"windows-x86_64-nsis",
"windows-x86_64",
},
)
windows = manifest["platforms"]["windows-x86_64"]
self.assertEqual(windows["signature"], "signature-gitea-windows")
self.assertTrue(windows["url"].endswith("/1.2.3/dsh-easy-desktop_1.2.3_windows_x86_64.exe"))
if __name__ == "__main__":
unittest.main()

233
tests/voice_client.test.mjs Normal file
View File

@@ -0,0 +1,233 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
const CLIENT = new URL('../plugins/dsh-desktop-voice/client.js', import.meta.url)
function loadVoiceClient({ confirmInstall = () => false, engine = 'sensevoice', holdInstall = false } = {}) {
let plugin
const registered = new Map()
const indicatorParts = {
'.label': { textContent: '' },
'.kbd': { textContent: '' },
'.stop': { hidden: false },
'.progress': { hidden: true, value: 0 },
'.percent': { hidden: true, textContent: '' },
}
const indicator = {
classList: { toggle() {} },
querySelector(selector) {
return indicatorParts[selector]
},
}
const window = {
__ModuleLoader__: {
load(definition) {
plugin = definition.factory((id) => {
if (id === 'react') return React
if (id === 'react/jsx-runtime') return jsx
throw new Error(`unexpected module: ${id}`)
})
},
},
addEventListener() {},
confirm: confirmInstall,
}
const document = {
querySelector() {
return {}
},
getElementById() {
return indicator
},
}
const jsx = {
jsx(type, props, key) {
return { type, props: props || {}, key }
},
jsxs(type, props, key) {
return { type, props: props || {}, key }
},
}
let hooks = []
let hookIndex = 0
const React = {
useState(initial) {
const index = hookIndex++
if (!(index in hooks)) hooks[index] = typeof initial === 'function' ? initial() : initial
return [hooks[index], (next) => {
hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
}]
},
useRef(initial) {
const index = hookIndex++
if (!(index in hooks)) hooks[index] = { current: initial }
return hooks[index]
},
useEffect(effect, dependencies) {
const index = hookIndex++
const previous = hooks[index]
const changed = !dependencies || !previous || dependencies.some((value, i) => value !== previous[i])
hooks[index] = dependencies || null
if (changed) effect()
},
}
const requests = []
let micRequests = 0
const context = {
AudioContext: function AudioContext() {},
Blob,
DataView,
Float32Array,
MediaRecorder: undefined,
URL,
clearTimeout,
clearInterval,
console,
document,
encodeURIComponent,
fetch: async (url, options = {}) => {
requests.push({ url, method: options.method || 'GET' })
if (url === '/dsh-desktop/voice') {
return {
ok: true,
async json() {
return {
enabled: true,
engine,
dictationMode: 'toggle',
language: 'zh',
modelInstalled: false,
}
},
}
}
if (url === '/dsh-desktop/voice/model' && (!options.method || options.method === 'GET')) {
return {
ok: true,
async json() {
return { status: 'installing', percent: 42, stage: '正在下载 SenseVoice 模型…' }
},
}
}
if (url === '/dsh-desktop/voice/model' && options.method === 'POST') {
if (holdInstall) return new Promise(() => {})
return {
ok: true,
async json() {
return { installed: true }
},
}
}
throw new Error(`unexpected request: ${options.method || 'GET'} ${url}`)
},
navigator: {
language: 'zh-CN',
platform: 'Linux x86_64',
mediaDevices: {
getUserMedia() {
micRequests += 1
return new Promise(() => {})
},
},
},
setInterval,
setTimeout,
window,
}
vm.runInNewContext(readFileSync(CLIENT, 'utf8'), context, { filename: CLIENT.pathname })
plugin.apply({
slots: {
inject(_name, register) {
register()
},
register(meta, component) {
registered.set(meta.name, component)
},
},
})
const MicButton = registered.get('conversation.input.right')
assert.equal(typeof MicButton, 'function')
const VoiceSettings = registered.get('settings.section')
assert.equal(typeof VoiceSettings, 'function')
const props = {
inputActions: { setDraft() {} },
useInput() {
return ''
},
}
return {
indicatorParts,
requests,
get micRequests() {
return micRequests
},
async renderMicReady() {
hooks = []
hookIndex = 0
MicButton(props)
await new Promise((resolve) => setTimeout(resolve, 0))
hookIndex = 0
return MicButton(props)
},
async renderSettingsReady() {
hooks = []
hookIndex = 0
VoiceSettings({})
await new Promise((resolve) => setTimeout(resolve, 0))
hookIndex = 0
return VoiceSettings({})
},
}
}
test('microphone click offers to install a missing SenseVoice model', async () => {
let prompts = 0
const harness = loadVoiceClient({
confirmInstall(message) {
prompts += 1
assert.match(message, /SenseVoice/)
assert.match(message, /安装/)
return true
},
})
const button = await harness.renderMicReady()
button.props.onClick()
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(prompts, 1)
assert.deepEqual(
harness.requests.filter((request) => request.method === 'POST'),
[{ url: '/dsh-desktop/voice/model', method: 'POST' }],
)
assert.equal(harness.micRequests, 1)
})
function textOf(node) {
if (node == null || typeof node === 'boolean') return ''
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(textOf).join(' ')
return textOf(node.props && node.props.children)
}
test('OpenAI fields only render for the OpenAI engine', async () => {
const localSettings = textOf(await loadVoiceClient({ engine: 'sensevoice' }).renderSettingsReady())
assert.doesNotMatch(localSettings, /接口地址|API 密钥|Whisper 模型/)
const openAISettings = textOf(await loadVoiceClient({ engine: 'openai' }).renderSettingsReady())
assert.match(openAISettings, /接口地址/)
assert.match(openAISettings, /API 密钥/)
assert.match(openAISettings, /Whisper 模型/)
})
test('model installation renders live progress', async () => {
const harness = loadVoiceClient({ confirmInstall: () => true, holdInstall: true })
const button = await harness.renderMicReady()
button.props.onClick()
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(harness.indicatorParts['.progress'].hidden, false)
assert.equal(harness.indicatorParts['.progress'].value, 42)
assert.equal(harness.indicatorParts['.percent'].textContent, '42%')
})

112
tests/voice_host.test.mjs Normal file
View File

@@ -0,0 +1,112 @@
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
function response() {
return {
status: 0,
body: '',
writeHead(status) {
this.status = status
return this
},
end(body = '') {
this.body = body
},
}
}
function request(method, url) {
return {
method,
url,
headers: {},
async *[Symbol.asyncIterator]() {},
}
}
test('voice host reports and accepts an on-demand SenseVoice installation', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-voice-test-'))
process.env.DSH_DESKTOP_VOICE_HOME = home
process.env.XDG_CONFIG_HOME = join(home, 'config')
const configDir = join(home, 'config', 'dsh-desktop')
mkdirSync(configDir, { recursive: true })
writeFileSync(join(configDir, 'voice.json'), JSON.stringify({ engine: 'auto' }))
try {
const routes = new Map()
const plugin = await import(`../plugins/dsh-desktop-voice/index.js?test=${Date.now()}`)
plugin.apply({
inject(_dependencies, load) {
load({
webServer: {
register(route) {
routes.set(route.path, route.handler)
},
},
})
},
})
const configResponse = response()
await routes.get('/dsh-desktop/voice')(request('GET', '/dsh-desktop/voice'), configResponse)
assert.equal(configResponse.status, 200)
const missing = JSON.parse(configResponse.body)
assert.equal(missing.engine, 'sensevoice')
assert.equal(missing.modelInstalled, false)
assert.equal(missing.modelStatus, 'missing')
assert.deepEqual(missing.options.engines.map((engine) => engine.id), ['sensevoice', 'openai'])
const missingProgressResponse = response()
await routes.get('/dsh-desktop/voice/model')(
request('GET', '/dsh-desktop/voice/model'),
missingProgressResponse,
)
assert.deepEqual(JSON.parse(missingProgressResponse.body), {
status: 'missing',
percent: 0,
stage: '',
})
const modelDir = join(home, 'sensevoice')
const packageDir = join(home, 'runtime', 'node_modules', 'sherpa-onnx')
mkdirSync(modelDir, { recursive: true })
mkdirSync(packageDir, { recursive: true })
writeFileSync(join(modelDir, 'model.int8.onnx'), '')
truncateSync(join(modelDir, 'model.int8.onnx'), 239233841)
writeFileSync(join(modelDir, 'tokens.txt'), '')
truncateSync(join(modelDir, 'tokens.txt'), 315894)
writeFileSync(
join(modelDir, 'installed.json'),
JSON.stringify({
modelVersion: '2365baeacb507f821a0c8120fcee3d484dba7a07',
runtimeVersion: '1.13.5',
}),
)
writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ version: '1.13.5' }))
const installResponse = response()
await routes.get('/dsh-desktop/voice/model')(request('POST', '/dsh-desktop/voice/model'), installResponse)
assert.equal(installResponse.status, 200)
const installed = JSON.parse(installResponse.body)
assert.equal(installed.installed, true)
assert.equal(installed.modelInstalled, true)
assert.equal(installed.modelStatus, 'installed')
const installedProgressResponse = response()
await routes.get('/dsh-desktop/voice/model')(
request('GET', '/dsh-desktop/voice/model'),
installedProgressResponse,
)
assert.deepEqual(JSON.parse(installedProgressResponse.body), {
status: 'installed',
percent: 100,
stage: 'SenseVoice 已安装',
})
} finally {
delete process.env.XDG_CONFIG_HOME
delete process.env.DSH_DESKTOP_VOICE_HOME
rmSync(home, { recursive: true, force: true })
}
})

View File

@@ -2,6 +2,10 @@ const statusEl = document.getElementById("status");
const spinner = document.getElementById("spinner");
const retry = document.getElementById("retry");
const detail = document.getElementById("detail");
const updatePanel = document.getElementById("update-panel");
const updateNotes = document.getElementById("update-notes");
const installUpdate = document.getElementById("install-update");
const skipUpdate = document.getElementById("skip-update");
function tauri() {
return window.__TAURI__;
@@ -18,6 +22,13 @@ function setStatus(message, kind) {
}
}
function continueBoot(api) {
updatePanel.hidden = true;
spinner.hidden = false;
setStatus("正在启动…", "ok");
return api.core.invoke("skip_shell_update");
}
async function boot() {
const api = tauri();
if (!api) {
@@ -39,10 +50,42 @@ async function boot() {
window.location.replace(event.payload.url);
}
});
await api.event.listen("shell-update-progress", function (event) {
const progress = event.payload || {};
const suffix = typeof progress.percent === "number" ? ` ${progress.percent}%` : "";
setStatus(`正在下载更新…${suffix}`, "ok");
});
retry.addEventListener("click", function () {
setStatus("正在重新启动…", "ok");
api.core.invoke("restart");
});
skipUpdate.addEventListener("click", function () {
continueBoot(api);
});
installUpdate.addEventListener("click", async function () {
installUpdate.disabled = true;
skipUpdate.disabled = true;
spinner.hidden = false;
setStatus("正在准备签名更新…", "ok");
try {
await api.core.invoke("install_shell_update");
} catch (error) {
installUpdate.disabled = false;
skipUpdate.disabled = false;
spinner.hidden = true;
statusEl.textContent = `更新失败:${String(error)}`;
}
});
const update = await api.core.invoke("check_shell_update");
if (update) {
spinner.hidden = true;
retry.hidden = true;
statusEl.textContent = `发现壳更新 ${update.version}`;
updateNotes.textContent = update.notes || "更新包已经签名,安装前会在本机完成校验。";
updatePanel.hidden = false;
}
}
if (document.readyState === "loading") {

View File

@@ -13,6 +13,13 @@
<h1>DeepSeek Harness</h1>
<p id="status">正在启动…</p>
<div class="ring" id="spinner" aria-hidden="true"></div>
<section id="update-panel" class="update-panel" hidden>
<p id="update-notes"></p>
<div class="update-actions">
<button type="button" class="pill" id="install-update">下载并安装</button>
<button type="button" class="pill secondary" id="skip-update">稍后</button>
</div>
</section>
<button type="button" class="pill" id="retry" hidden>重试</button>
<pre id="detail" hidden></pre>
</section>

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 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();
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;
}
const files = await t.core.invoke("read_clipboard_images");
if (files && files.length) {
e.preventDefault();
e.stopImmediatePropagation();
if (window.__dshDesktopPasteFiles) window.__dshDesktopPasteFiles(files);
}
} catch (err) {}
} catch (err) {
window.__dshDesktopIngesting = false;
}
}, 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) {}
}
}
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) {}
}, true);
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();
}
};
})();

View File

@@ -98,6 +98,40 @@ h1 {
.pill[hidden] { display: none; }
.pill.secondary {
background: var(--card);
color: var(--text);
}
.pill:disabled { opacity: 0.55; }
.update-panel {
margin-top: 20px;
padding: 16px;
border-radius: 14px;
background: var(--card);
}
.update-panel[hidden] { display: none; }
#update-notes {
max-height: 120px;
margin: 0;
overflow: auto;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.update-actions {
display: flex;
justify-content: center;
gap: 8px;
}
.update-actions .pill { margin-top: 16px; }
#detail {
margin: 22px 0 0;
text-align: left;