13 Commits

Author SHA1 Message Date
b15292dd03 fix: wait on asset downloads in the main shell 2026-08-17 01:05:14 +08:00
5e9ac59c59 fix: gate on latest.json version; pin script fetch to GITHUB_SHA 2026-08-17 01:02:21 +08:00
97e5ae50b5 release: v0.1.2
- bundle dshmarket 1.10.1; drop the duplicate vision settings section (modlens card is the single config surface)
- embed the updater signing public key in every build (Makefile / CI / Flatpak); in-app updates now actually run
- structured release notes per version: docs/release-notes/v<version>.md feeds GitHub, Gitea and the in-app update panel
- gate releases on the notes file; Gitea publish reads/patches the same body
2026-08-17 00:48:38 +08:00
c71a87c92a fix: gate Gitea publish on package feed presence 2026-08-17 00:45:55 +08:00
303e64790b fix: use PUBLISH_TOKEN secret name (GITEA_ prefix forbidden) 2026-08-17 00:44:35 +08:00
c94374b62a feat: publish Gitea releases via Gitea Actions runner 2026-08-17 00:39:52 +08:00
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
55 changed files with 4025 additions and 642 deletions

View File

@@ -0,0 +1,25 @@
name: publish-gitea
on:
workflow_dispatch:
inputs:
tag:
description: "GitHub release tag to publish (default: latest)"
required: false
default: latest
jobs:
publish:
runs-on: [self-hosted, linux, x64]
steps:
- name: Download publish scripts
run: |
BASE=https://raw.githubusercontent.com/TommyFang2077/dsh-easy-desktop/$GITHUB_SHA
curl -fsSL -o build-gitea-update.py "$BASE/scripts/build-gitea-update.py"
curl -fsSL -o publish-gitea-release.sh "$BASE/scripts/publish-gitea-release.sh"
curl -fsSL -o publish-gitea-actions.sh "$BASE/scripts/publish-gitea-actions.sh"
- name: Publish to Gitea
run: bash publish-gitea-actions.sh
env:
GITEA_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
GITEA_BASE_URL: http://192.168.30.33:3000
RELEASE_TAG: ${{ github.event.inputs.tag }}

View File

@@ -41,6 +41,10 @@ jobs:
VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
test -f "docs/release-notes/v${VERSION}.md" || {
echo "docs/release-notes/v${VERSION}.md is required for a release (see docs/release-notes/v0.1.1.md)" >&2
exit 1
}
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "v${VERSION}" ]; then
echo "Git tag ${GITHUB_REF_NAME} must match Cargo.toml version v${VERSION}" >&2
exit 1
@@ -84,7 +88,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: ""
@@ -125,9 +129,14 @@ jobs:
shell: bash
run: bash scripts/vendor-native.sh
- name: Export embedded updater public key
run: echo "DSH_DESKTOP_UPDATER_PUBKEY=$(jq -r '.plugins.updater.pubkey' src-tauri/tauri.conf.json)" >> "$GITHUB_ENV"
- 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,12 +148,32 @@ 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]
@@ -176,3 +205,61 @@ 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
release-notes:
name: Release notes
needs: [version, package, flatpak]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set structured release body
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: DeepSeek Harness Desktop v${{ needs.version.outputs.version }}
body_path: docs/release-notes/v${{ needs.version.outputs.version }}.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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)" \
--notes "$(python3 scripts/release-notes.py notes "docs/release-notes/v${VERSION}.md")" \
--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

385
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"
@@ -799,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"
@@ -929,12 +949,13 @@ dependencies = [
[[package]]
name = "dsh-core"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"base64 0.22.1",
"dirs",
"libc",
"regex",
"semver",
"serde",
"serde_json",
"shlex 1.3.0",
@@ -945,7 +966,7 @@ dependencies = [
[[package]]
name = "dsh-desktop"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"arboard",
"dsh-core",
@@ -961,7 +982,9 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"url",
"webkit2gtk",
]
[[package]]
@@ -1160,6 +1183,16 @@ 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"
@@ -1746,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"
@@ -2088,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"
@@ -2277,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"
@@ -2529,6 +2613,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2544,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"
@@ -2624,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"
@@ -2647,7 +2750,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"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]]
@@ -3079,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",
@@ -3099,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"
@@ -3127,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"
@@ -3142,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"
@@ -3199,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"
@@ -3426,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"
@@ -3538,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"
@@ -3632,7 +3895,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3665,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"
@@ -3688,7 +3962,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3838,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"
@@ -3848,7 +4155,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3871,7 +4178,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -4083,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"
@@ -4407,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"
@@ -4726,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"
@@ -4971,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"
@@ -5284,7 +5625,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -5348,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"
@@ -5482,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"
@@ -5515,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.2"
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.10.1
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
@@ -19,6 +21,11 @@ VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
BUNDLE := dist/$(APP_ID)-$(VERSION).flatpak
BIN := target/release/dsh-desktop
# The shell updater gates the update check on an embedded public key
# (option_env in shell_updater.rs). Single source of truth is the tauri
# config; without it every build silently disables in-app updates.
export DSH_DESKTOP_UPDATER_PUBKEY := $(shell jq -r '.plugins.updater.pubkey' src-tauri/tauri.conf.json)
.PHONY: all run dev test vendor vendor-native vendor-anchored build install uninstall flatpak-build flatpak-export flatpak-install flatpak-bundle flatpak-run clean
all: test
@@ -35,16 +42,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,10 +89,15 @@ install: build
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
cp -R $(MODLENS_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \
fi
if [ -f plugins/dsh-desktop-vision/package.json ]; then \
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
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 plugins/dsh-desktop-vision $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
cp -R $(MARKET_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \
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; \
@@ -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

182
README.md
View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/screenshots/banner.png" alt="DeepSeek Harness Desktop官方 dsh 原生壳,给 DeepSeek 带上眼睛" />
<img src="docs/screenshots/banner.png" alt="DeepSeek Harness Desktop内置离线语音、插件市场与视觉模型配置" />
</p>
<h1 align="center">DeepSeek Harness Desktop</h1>
@@ -9,158 +9,109 @@
</p>
<p align="center">
👁️ 给 DeepSeek <strong>带上眼睛</strong> —— 粘贴图片直接识别&nbsp;&nbsp;·&nbsp;&nbsp;⚓ 锚定模式实测比官方 Standard <strong>约 +8%</strong>
<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/releases/latest"><b>下载</b></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> ·
<a href="#从源码运行">从源码运行</a>
</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/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>
dsh 只能开在浏览器标签页里?这个仓库把它变成一个真正的桌面应用:Tauri 2 原生窗口 + 系统 WebViewLinux / Windows / macOS 通用。打开就是官方 WebUI——会话、工作区、插件技能一个不少`dsh` 升级后界面跟着升级,永远不用重打包前端。
dsh 原本运行在浏览器标签页中;本项目用 Tauri 2 系统 WebView 把官方 WebUI 变成原生桌面窗口。会话、工作区、插件技能全部保留`dsh` 更新后界面也会随之更新,不需要重新打包前端。
这是作者自用的第三方壳,**不包含** DeepSeek Harness 源码,会持续跟着 dsh 和内置组件更新;踩到坑欢迎 [提 issue](https://github.com/TommyFang2077/dsh-desktop/issues)。仓库按官方 [贡献指南](https://github.com/deepseek-ai/deepseek-harness/blob/master/CONTRIBUTING.md) 挂了 [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic
这是作者维护的第三方壳,**不包含** DeepSeek Harness 源码。重点补齐官方 WebUI 在桌面端缺少的输入和扩展体验:直接说话、直接安装插件、直接配置视觉模型
## 为什么值得一试
## 核心体验
### 👁️ 给 DeepSeek 带上眼睛
### 内置语音SenseVoice 本机离线听写
DeepSeek 主力对话模型是纯文本的——你贴一张截图,它两眼一抹黑。本应用内置 [ModLens](https://github.com/liustack/modlens)(全网第一个 dsh 视觉插件):外挂视觉引擎后,**图片直接粘贴进对话框就能识别**,不用先存盘再填路径。配套的 **设置 → 视觉模型** 页面把 OpenAI / Gemini / Anthropic / 本机 CLI 引擎全配齐,免费的 Gemini key 就能跑
对话框旁直接提供麦克风按钮;按 `Ctrl+E`macOS 为 `⌘E`)即可开始或结束听写,也可切换为按住说话。默认引擎是本机离线 **SenseVoiceSmall**,支持中文、粤语、英语、日语和韩语,识别结果直接写入当前输入框
![设置 → 视觉模型](docs/screenshots/vision.png)
模型和 sherpa-onnx WASM 运行时**不塞进安装包**。首次点击麦克风时会明确提示下载约 245 MB底部状态条持续显示下载与校验进度安装完成后保存在系统缓存目录录音不离开本机。需要云端识别时也可切换到 OpenAI 兼容的 `/v1/audio/transcriptions` 接口。
### ⚓ 锚定模式:比官方 Standard 约 +8%
![设置 → 语音输入SenseVoice 本机离线听写](docs/screenshots/voice.webp)
DeepSeek V4 Pro 会按「第一眼看到的工具表」选执行轨迹Project2 评测里官方 Minimal 拿 **99**Standard 只有 **91**——但常驻 Minimal 又缺工具。内置的 [锚定式标准](https://github.com/xiaobright/dsh-anchored-standard) 两头都要:**首轮用 Minimal 真工具对钉住高分轨迹,从第二轮起解锁完整 Standard 工具目录**,同配置实测 Ability **98 / 99**,相对 Standard 的 91 约 **+8% / +9%**。另附零工具锚定变体。新会话默认就是它,开箱即用。
### 内置插件市场:发现、安装和更新社区插件
![模式菜单:锚定式标准(实验)已选中](docs/screenshots/anchored.png)
无需记包名或离开应用。在 **设置 → Plugin Market** 中可以浏览目录、搜索分类、查看已安装插件并直接安装、更新、备份或恢复社区插件。ModLens 等默认组件也能从这里正常更新;桌面启动不会再把用户更新的版本降回内置基线。
*百分比按预设作者在 Project2 / DeepSeek V4 Pro 上的 Ability 计算:`(9891)/91 ≈ 8%`。同配置可复现;社区实验预设,不是官方出品,不代表所有任务都涨。*
![设置 → Plugin Market浏览并安装社区插件](docs/screenshots/market.webp)
### 🪟 像个真正的 Mac / Linux / Windows 应用
### 视觉模型配置:给 DeepSeek 带上眼睛
36px 苹果风薄标题栏,不占一排后退/前进/刷新;左侧 `•••` 菜单可重启 dsh 或跳回浏览器。关窗口自动停掉 `dsh web`,崩溃一键拉起。凭据、权限、会话全部还在 `~/.dsh`,卸载壳不丢任何东西
纯文本 DeepSeek 配合视觉桥后,可以直接粘贴截图识别内容。引擎配置在 **设置 → 插件 → 插件配置 → 视觉引擎ModLens**:支持 OpenAI 兼容接口、Gemini API、Anthropic API、Antigravity CLI 和 Claude Code 登录,只展示当前引擎需要的字段,密钥保存在本机 `~/.modlens/config.json` 中,相关外链由系统浏览器打开
![主窗口:官方 WebUI 嵌在原生壳里](docs/screenshots/session.png)
![设置 → 插件 → 插件配置视觉引擎ModLens](docs/screenshots/vision.webp)
![标题栏菜单:重新启动 / 在浏览器中打开](docs/screenshots/menu.png)
### 锚定模式与原生窗口
内置的锚定式标准预设首轮使用 Minimal 工具表固定执行轨迹,从第二轮起恢复完整 Standard 工具目录。Project2 / DeepSeek V4 Pro 同配置 Ability 为 **98 / 99**,相对官方 Standard 的 91 约 **+8% / +9%**。这是社区实验预设,不代表所有任务都会提升。
36px 薄标题栏保留更多对话空间;左侧 `•••` 菜单可重新启动 dsh 或在浏览器中打开。关闭窗口会停止对应的 `dsh web` 进程,凭据、权限和会话仍保存在 `~/.dsh`
![主窗口:官方 WebUI 嵌在原生壳中](docs/screenshots/session.png)
## 三十秒上手
[GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases/latest) 下载对应平台的安装包
[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 | `.flatpak` | **零依赖**:自带 Node.js 24 与 `@deepseek-ai/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` |
除 Flatpak 外需要本机有 `dsh`
除 Flatpak 外需要先安装 `dsh`
```bash
npm install -g @deepseek-ai/dsh
```
装好后启动,等启动页转完就是官方 WebUI。
![启动页:正在启动官方 WebUI](docs/screenshots/splash.png)
安装后直接启动,等启动页完成即可进入官方 WebUI。
## 功能一览
| 能力 | 说明 |
| --- | --- |
| 原生窗口 | 启动 `dsh web --host 127.0.0.1 --port 0`,解析随机端口后用系统 WebView 加载 |
| 零重写 | 官方会话、工作区、插件、技能全部保留dsh 升级即界面升级 |
| 内置视图 | 👁️ 纯文本 DeepSeek 也能粘贴识图ModLens + 设置页) |
| 内置锚定 | ⚓ 相对官方 Standard 约 +8%Project2 Ability 91 → 98/99 |
| 薄标题栏 | `•••` 菜单(重新启动 / 在浏览器中打开)+ 右侧最小化 · 缩放 · 关闭 |
| 生命周期 | 关窗口停掉 `dsh web`;崩溃可从标题栏一键重启 |
| 离线语音 | 对话框麦克风、`Ctrl+E` / `⌘E` 快捷键、SenseVoice 模型按需下载、本机识别 |
| 插件市场 | 在设置内浏览、搜索、安装、更新、备份和恢复社区插件 |
| 视觉模型 | 粘贴图片直接识别;用表单配置五类视觉引擎,不必手改 JSON |
| 官方 WebUI | 会话、工作区、插件和技能原样保留dsh 更新后界面同步更新 |
| 锚定预设 | Project2 / DeepSeek V4 Pro 相对官方 Standard 约 +8% |
| 原生生命周期 | 随机本地端口启动 `dsh web`关闭窗口停止服务;崩溃可一键重启 |
## 内置插件与预设
## 内置能力与数据位置
应用启动时把下面这些同步到用户目录。版本钉死在 [Makefile](Makefile);第三方原文许可证见 [docs/licenses/](docs/licenses/) 与 [THIRD_PARTY.md](THIRD_PARTY.md)。
| 组件 | 当前基线 | 用途 | 本地位置 |
| --- | --- | --- | --- |
| 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.10.1` | 社区插件的发现、安装和更新 | `~/.dsh/profiles/web` |
| 视觉桥 | `ModLens 3.16.6` | 让纯文本模型读取粘贴的图片;可从市场更新;引擎配置在「设置 → 插件」 | `~/.dsh/profiles/web`;配置 `~/.modlens/config.json` |
| 锚定预设 | `ffb845c5480a` | 锚定式标准与零工具锚定式标准 | `~/.dsh/.agent-presets/` |
### 1. DeepSeek Harness`dsh`
应用启动时会同步桌面自带组件,但会保留用户从市场更新到更新版本的 ModLens。捆绑版本固定在 [Makefile](Makefile) 中。
| | |
| --- | --- |
| 上游 | [@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` |
`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 更新检查。
解析顺序:
## 安全与边界
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`
`DSH_DESKTOP_NO_UPDATE=1` 或传 `--no-update` 可关掉启动时的 npm 更新检查。
### 2. ModLens`@liustack/modlens`
| | |
| --- | --- |
| 上游 | [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) |
| 亮点 | 给 DeepSeek 带上眼睛:纯文本模型粘贴即可读图 |
| 安装位置 | 启动时复制到 `~/.dsh/profiles/web/node_modules/@liustack/modlens` |
官方安装方式(本应用已内置,一般不必再跑):
```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 配置里。
### 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` 本地化) |
| 亮点 | 相对官方 Standard 约 **+8%**Project2 Ability 91 → 98/99同时拿回完整工具目录 |
| 安装位置 | `~/.dsh/.agent-presets/anchored-standard``zero-anchored-standard` |
NOTICE 写明:预设改编自 DeepSeek Harness Standard agent preset[deepseek-harness@47f9438](https://github.com/deepseek-ai/deepseek-harness))。这是社区实验 preset**不是** DeepSeek 官方预设。若用户还没有默认 preset桌面会把默认设为锚定式标准。
- WebUI 只监听随机的 `127.0.0.1` 端口。
- 语音模型按需下载SenseVoice 识别在本机执行。
- API 密钥只写入本机配置,不进入仓库或远端服务。
- 卸载桌面壳不会删除 `~/.dsh` 中的会话、权限和工作区设置。
## 从源码运行
@@ -171,8 +122,8 @@ NOTICE 写明:预设改编自 DeepSeek Harness Standard agent preset[deepse
- 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 检查器和调试日志
@@ -222,7 +173,7 @@ dsh-desktop/
├── ui/ # 启动页 + 注入到 WebUI 的标题栏
├── 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 截图
@@ -235,7 +186,20 @@ dsh-desktop/
## 反馈
自用项目会持续更新。bug、想法、打包问题都欢迎开 [issue](https://github.com/TommyFang2077/dsh-desktop/issues)。
自用项目会持续更新。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) |
## 图标与商标

View File

@@ -14,7 +14,10 @@ DeepSeek, liustack, or xiaobright.
| DeepSeek Harness (`@deepseek-ai/dsh`) | [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) | `0.1.0-rc.6` | MIT, © 2026 DeepSeek | Official WebUI. Not shipped as source. Flatpak vendors the npm package; other packages call a host `dsh`. See `docs/licenses/deepseek-harness.LICENSE`. |
| 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.10.1` | 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;
@@ -220,7 +229,10 @@ mod tests {
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(&[0xFF, 0xD8, 0xFF, 0xE0]),
Some("image/jpeg")
);
assert_eq!(detect_image_mime(b"not-an-image"), None);
}

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

@@ -7,8 +7,12 @@ use serde_json::{json, Value};
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 VOICE_PLUGIN_VERSION: &str = "0.4.0";
const MARKET_PLUGIN_VERSION: &str = "1.10.1";
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 +52,35 @@ pub fn read_modlens_version(prefix: &Path) -> Option<String> {
.map(|s| s.to_string())
}
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())
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())
})
}
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 +89,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 +120,83 @@ 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 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_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(VISION_PACKAGE);
.join(MARKET_PACKAGE);
let _ = replace_symlink(&fallback, &dest);
true
Ok(Some(version))
}
fn ensure_manifest(profile: &Path, packages: &BTreeMap<String, String>) -> std::io::Result<()> {
@@ -322,10 +417,13 @@ fn ensure_modlens_inner(
version: &str,
) -> 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());
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() {
@@ -337,8 +435,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 {
@@ -374,11 +473,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() {
@@ -474,4 +580,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"),
format!(r#"{{"name":"dshmarket","version":"{MARKET_PLUGIN_VERSION}"}}"#),
)
.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"],
MARKET_PLUGIN_VERSION
);
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 [("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 [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

@@ -25,8 +25,8 @@
<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>
@@ -41,6 +41,21 @@
<content_rating type="oars-1.1" />
<releases>
<release version="0.1.2" date="2026-08-17">
<description>
<ul>
<li>Bundled plugin market upgraded to dshmarket 1.10.1 (more resilient downloads, safer installs)</li>
<li>In-app updater enabled: the signing public key is now embedded in every build</li>
<li>Removed the duplicate 视觉模型 settings section — the modlens card under Settings → Plugins is the single config surface</li>
<li>Structured release notes for every version</li>
</ul>
</description>
</release>
<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>

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.

View File

@@ -0,0 +1,19 @@
# DeepSeek Harness Desktop v0.1.1
**摘要 / Summary:** 大陆可直达的发行链路上线Gitea 镜像安装包 + 应用内签名更新HTTPS 分发、安装前校验 Tauri 签名)。
## 新功能 / Features
- 大陆发行镜像:安装包与本应用更新走 Gitea HTTPS无需额外网络工具即可下载
- 应用内更新:发现新版本后一键「下载并安装」,安装包下载完成先校验 Tauri 签名再安装
- Linux 剪贴板图片原生读取:直接粘贴截图即作为图片进输入框(此前部分桌面环境取不到)
## 修复 / Fixes
- Gitea 发布经过慢速链路时自动重试,不再因网关超时半途失败
- 壳更新公钥内嵌进打包配置,签名校验始终可用
- 仓库改名后dsh-easy-desktop的镜像与文档地址保持一致
## 安装与更新 / Install & Update
- 安装包Windows NSIS/MSI、macOS DMGApple Silicon 与 Intel、Linux deb/rpm、Flatpak
- 除 Flatpak 外需本机已安装 `dsh``npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`
- macOS 包未公证:首次打开需在「系统设置 → 隐私与安全性」中允许
- 更新提示出现在启动页:点击「下载并安装」即可(走 Gitea 镜像,签名校验后生效)

View File

@@ -0,0 +1,26 @@
# DeepSeek Harness Desktop v0.1.2
**摘要 / Summary:** 内置插件市场升级至 dshmarket 1.10.1(下载更稳、安装更安全);应用内更新真正启用(签名公钥内嵌);移除重复的视觉模型设置区;发布说明改为结构化更新日志。
## 新功能 / Features
- 内置插件市场 dshmarket 1.9.0 → 1.10.1
- pnpm 拉取超时自动加重试,弱网下安装/更新不再一次失败就放弃
- 失败的安装会回收遗留的本地缓存目录,不会越积越多
- 重复安装防护:同一插件(含别名条目)只保留一个加载项,杜绝下次启动冲突
- 每个版本提供结构化更新日志(新功能 / 修复 / 变更 / 安装GitHub 与 Gitea 的发布文案、应用内更新面板自动取同一份
## 修复 / Fixes
- **应用内更新此前在所有构建中都静默关闭**:壳更新器要求编译期内嵌签名公钥,而构建流程从未注入(`public_key() → None → 不检查更新`。现在本地、CI、Flatpak 构建都会从 `tauri.conf.json` 读取公钥注入,「发现壳更新 → 下载并安装」真正可用
- 消除市场「有新版本」横幅与桌面内嵌市场版本不一致的死结:内嵌市场已同步到上游最新,横幅不再出现;此前该更新提示在桌面版中无法真正落地(每次启动会被内嵌版本还原)
- 插件安装遇到 pnpm 默认拦截构建脚本(如 node-pty放行与重试路径按上游最新逻辑工作不再被 allowBuilds 占位符卡死
- Gitea 镜像发布链路重构:由自托管 Actions runner 经局域网直连发布,慢速公网链路超时影响正式发布流程
## 变更 / Changes
- 移除独立的「视觉模型」设置区:视觉引擎配置统一由 modlens 自带的「设置 → 插件 → 插件配置 → 视觉引擎ModLens」提供同样读写 `~/.modlens/config.json`,不再有两处重复入口)
- 构建产物不再附带 `plugins/dsh-desktop-vision`license 列表与 README 同步更新)
## 安装与更新 / Install & Update
- 安装包Windows NSIS/MSI、macOS DMGApple Silicon 与 Intel、Linux deb/rpm、Flatpak
- 除 Flatpak 外需本机已安装 `dsh``npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`
- macOS 包未公证:首次打开需在「系统设置 → 隐私与安全性」中允许
- 更新提示出现在启动页:点击「下载并安装」即可(走 Gitea 镜像,签名校验后生效)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

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>

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN" class="dsh-desktop-offset">
<head>
<meta charset="utf-8" />
<title>视觉模型</title>
<title>视觉引擎ModLens</title>
<link rel="stylesheet" href="chrome.css" />
<style>
:root {
@@ -74,15 +74,15 @@
<h2>设置</h2>
<a class="muted" href="#">通用</a>
<a class="muted" href="#">模型</a>
<a class="active" href="#">视觉模型</a>
<a class="muted" href="#">插件</a>
<a class="muted" href="#">语音输入</a>
<a class="active" href="#">插件</a>
<a class="muted" href="#">技能</a>
</nav>
<section class="content">
<h1>视觉模型</h1>
<p class="lead">内置插件 dsh-desktop-vision · 写入 ~/.modlens/config.json</p>
<h1>插件 · 插件配置</h1>
<p class="lead">视觉引擎ModLens由 modlens 插件提供 · 写入 ~/.modlens/config.json</p>
<div class="dshdv">
<h3>ModLens 视觉模型</h3>
<h3>视觉引擎(ModLens</h3>
<p>纯文本对话模型读图时使用这里的引擎。已声明视觉能力的模型(如 Qwen不会走这条桥。</p>
<label>
引擎

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/screenshots/voice.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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,11 +52,12 @@ modules:
build-args:
- --share=network
build-commands:
- mkdir -p vendor/modlens vendor/anchored-standard vendor/zero-anchored-standard
- mkdir -p vendor/modlens vendor/dshmarket vendor/anchored-standard vendor/zero-anchored-standard
- export DSH_DESKTOP_UPDATER_PUBKEY=$(python3 -c "import json;print(json.load(open('src-tauri/tauri.conf.json'))['plugins']['updater']['pubkey'])")
- 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
@@ -79,6 +82,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

@@ -1,209 +0,0 @@
window.__ModuleLoader__.load({
id: 'dsh-desktop-vision',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
var React = require('react')
var jsx = require('react/jsx-runtime')
var css = [
'.dshdv{width:100%;max-width:640px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary)}',
'.dshdv h3{margin:0;font-size:15px;font-weight:600;line-height:22px}',
'.dshdv p{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}',
'.dshdv label{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
'.dshdv .head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
'.dshdv a{color:var(--dsw-alias-state-business-primary);text-decoration:none;font-size:12px;line-height:18px}',
'.dshdv a:hover{text-decoration:underline}',
'.dshdv input,.dshdv 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}',
'.dshdv input:focus,.dshdv select:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}',
'.dshdv 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}',
'.dshdv button:disabled{opacity:.55;cursor:default}',
'.dshdv .ok{color:var(--dsw-alias-state-success-primary)}',
'.dshdv .err{color:var(--dsw-alias-state-error-primary)}',
].join('')
function ensureCss() {
if (typeof document === 'undefined') return
if (document.querySelector('style[data-plugin-css="dsh-desktop-vision"]')) return
var tag = document.createElement('style')
tag.dataset.pluginCss = 'dsh-desktop-vision'
tag.textContent = css
document.head.appendChild(tag)
}
function remote(provider) {
var q = provider ? '?provider=' + encodeURIComponent(provider) : ''
return fetch('/dsh-desktop/modlens' + q).then(function (res) {
if (!res.ok) throw new Error('load failed ' + res.status)
return res.json()
})
}
function VisionSettings() {
ensureCss()
var state = React.useState({ status: 'loading' })
var snap = state[0]
var setSnap = state[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 onProvider(id) {
remote(id)
.then(function (form) {
setSnap({ status: 'ready', form: form, message: '' })
})
.catch(function () {
patch('provider', id)
})
}
function save() {
if (snap.status !== 'ready' || snap.saving) return
setSnap(Object.assign({}, snap, { saving: true, message: '' }))
fetch('/dsh-desktop/modlens', {
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) {
setSnap({ status: 'ready', form: form, message: '已保存', saving: false })
})
.catch(function (error) {
setSnap(Object.assign({}, snap, { saving: false, message: String(error.message || error) }))
})
}
if (snap.status === 'loading') {
return jsx.jsx('div', { className: 'dshdv', children: jsx.jsx('p', { children: '正在读取 ModLens 配置…' }) })
}
if (snap.status === 'error') {
return jsx.jsx('div', { className: 'dshdv', children: jsx.jsx('p', { className: 'err', children: snap.message }) })
}
var form = snap.form
var remoteEngine = form.provider === 'openai' || form.provider === 'gemini-api' || form.provider === 'anthropic'
var options = form.options || []
var current = options.filter(function (item) { return item.id === form.provider })[0]
var apiUrl = (current && current.apiUrl) || form.apiUrl || ''
var example = (current && current.example) || form.example || ''
var getApi = apiUrl
? jsx.jsx('a', { href: apiUrl, children: '获取 API' })
: null
return jsx.jsxs('div', {
className: 'dshdv',
children: [
jsx.jsx('h3', { children: 'ModLens 视觉模型' }),
jsx.jsx('p', {
children:
'纯文本对话模型读图时使用这里的引擎。已声明视觉能力的模型(如 Qwen不会走这条桥。',
}),
jsx.jsxs('label', {
children: [
'引擎',
jsx.jsx('select', {
value: form.provider,
onChange: function (event) {
onProvider(event.target.value)
},
children: options.map(function (item) {
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
}),
}),
],
}),
jsx.jsxs('label', {
children: [
'接口地址',
jsx.jsx('input', {
value: form.baseUrl || '',
disabled: !remoteEngine,
placeholder: form.officialBaseUrl || '',
onChange: function (event) {
patch('baseUrl', event.target.value)
},
}),
],
}),
jsx.jsxs('label', {
children: [
jsx.jsxs('span', { className: 'head', children: ['API 密钥', getApi] }),
jsx.jsx('input', {
type: 'password',
value: form.apiKey || '',
disabled: !remoteEngine,
onChange: function (event) {
patch('apiKey', event.target.value)
},
}),
],
}),
jsx.jsxs('label', {
children: [
'视觉模型',
jsx.jsx('input', {
value: form.model || '',
placeholder: example ? '例如 ' + example : '',
onChange: function (event) {
patch('model', event.target.value)
},
}),
],
}),
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: 'modlens-vision',
order: 12,
label: '视觉模型',
},
VisionSettings,
)
})
}
exports.apply = apply
exports.inject = ['slots']
return module.exports
},
})

View File

@@ -1,4 +0,0 @@
# Settings page + host route for the ModLens vision engine.
- insert:
- id: dsh-desktop-vision
name: dsh-desktop-vision

View File

@@ -1,181 +0,0 @@
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export const name = 'dsh-desktop-vision'
export const inject = []
const PROVIDERS = [
{
id: 'openai',
label: 'OpenAI 兼容',
baseUrl: 'https://api.openai.com/v1',
apiUrl: 'https://platform.openai.com/api-keys',
example: 'gpt-4o',
},
{
id: 'gemini-api',
label: 'Gemini API',
baseUrl: 'https://generativelanguage.googleapis.com',
apiUrl: 'https://aistudio.google.com/apikey',
example: 'gemini-3.6-flash',
},
{
id: 'anthropic',
label: 'Anthropic API',
baseUrl: 'https://api.anthropic.com',
apiUrl: 'https://console.anthropic.com/settings/keys',
example: 'claude-haiku-4-5-20251001',
},
{
id: 'antigravity-cli',
label: 'Antigravity CLI免费',
baseUrl: '',
apiUrl: 'https://antigravity.google/',
example: 'gemini-3.6-flash-low',
},
{
id: 'claude-cli',
label: 'Claude Code 登录',
baseUrl: '',
apiUrl: 'https://code.claude.com',
example: 'haiku',
},
]
const PROVIDER_IDS = new Set(PROVIDERS.map((item) => item.id))
function providerOf(id) {
return PROVIDERS.find((entry) => entry.id === id)
}
function officialBaseUrl(provider) {
const item = providerOf(provider)
return item ? item.baseUrl : ''
}
function officialApiUrl(provider) {
const item = providerOf(provider)
return item ? item.apiUrl : ''
}
function officialExample(provider) {
const item = providerOf(provider)
return item ? item.example : ''
}
const FORM_FIELDS = ['apiKey', 'baseUrl', 'model']
function configPath() {
const home = process.env.MODLENS_HOME || join(homedir(), '.modlens')
return join(home, 'config.json')
}
function loadConfig() {
try {
const data = JSON.parse(readFileSync(configPath(), 'utf8'))
return data && typeof data === 'object' ? data : {}
} catch {
return {}
}
}
function formOf(cfg, provider) {
const id = PROVIDER_IDS.has(provider) ? provider : 'openai'
const entry =
cfg.providers && typeof cfg.providers === 'object' && typeof cfg.providers[id] === 'object'
? cfg.providers[id]
: {}
return {
provider: id,
apiKey: String(entry.apiKey || ''),
baseUrl: String(entry.baseUrl || officialBaseUrl(id)),
officialBaseUrl: officialBaseUrl(id),
apiUrl: officialApiUrl(id),
example: officialExample(id),
model: String(entry.model || ''),
}
}
function saveForm(values) {
const provider = String(values.provider || 'openai').trim()
if (!PROVIDER_IDS.has(provider)) {
const error = new Error(`unknown provider: ${provider}`)
error.status = 400
throw error
}
const cfg = loadConfig()
cfg.provider = provider
if (!cfg.providers || typeof cfg.providers !== 'object') cfg.providers = {}
if (!cfg.providers[provider] || typeof cfg.providers[provider] !== 'object') {
cfg.providers[provider] = {}
}
const entry = cfg.providers[provider]
for (const field of FORM_FIELDS) {
const raw = String(values[field] || '').trim()
if (raw) entry[field] = raw
else delete entry[field]
}
const path = configPath()
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8' })
try {
chmodSync(path, 0o600)
} catch {
// best-effort; some filesystems ignore mode
}
return formOf(cfg, provider)
}
async function readJsonBody(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const raw = Buffer.concat(chunks).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))
}
export function apply(ctx) {
if (typeof ctx.inject !== 'function') return
ctx.inject(['webServer'], (scope) => {
try {
scope.webServer.register({
name: 'dsh-desktop-modlens',
kind: 'exact',
path: '/dsh-desktop/modlens',
handler: async (req, res) => {
try {
if (req.method === 'GET') {
const cfg = loadConfig()
const provider = new URL(req.url, 'http://localhost').searchParams.get('provider')
json(res, 200, {
...formOf(cfg, provider || cfg.provider || 'openai'),
options: PROVIDERS.map((item) => ({
id: item.id,
label: item.label,
officialBaseUrl: item.baseUrl,
apiUrl: item.apiUrl,
example: item.example,
})),
})
return
}
if (req.method === 'PUT' || req.method === 'POST') {
const body = await readJsonBody(req)
json(res, 200, { ...saveForm(body), saved: true })
return
}
res.writeHead(405).end()
} catch (error) {
json(res, error.status || 500, { error: String(error?.message || error) })
}
},
})
} catch (error) {
console.error(`[dsh-desktop-vision] config route skipped: ${error}`)
}
})
}

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

@@ -1,6 +1,6 @@
{
"name": "dsh-desktop-vision",
"version": "0.1.4",
"name": "dsh-desktop-voice",
"version": "0.4.0",
"private": true,
"type": "module",
"exports": {
@@ -14,7 +14,8 @@
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-settings"
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web",
"immediately": true

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

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Normalize Tauri artifacts and build the static updater manifest."""
from __future__ import annotations
import argparse
import json
import re
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",
}
# Asset-name patterns for GitHub Release downloads (flat directory): maps a
# fileName to its canonical (os, arch, deliverable suffix).
FLAT_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [
(re.compile(r"^.*_aarch64\.app\.tar\.gz\.sig$"), "darwin", "aarch64", ".app.tar.gz.sig"),
(re.compile(r"^.*_aarch64\.app\.tar\.gz$"), "darwin", "aarch64", ".app.tar.gz"),
(re.compile(r"^.*_aarch64\.dmg$"), "darwin", "aarch64", ".dmg"),
(re.compile(r"^.*_x64\.app\.tar\.gz\.sig$"), "darwin", "x86_64", ".app.tar.gz.sig"),
(re.compile(r"^.*_x64\.app\.tar\.gz$"), "darwin", "x86_64", ".app.tar.gz"),
(re.compile(r"^.*_x64\.dmg$"), "darwin", "x86_64", ".dmg"),
(re.compile(r"^.*_amd64\.deb\.sig$"), "linux", "x86_64", ".deb.sig"),
(re.compile(r"^.*_amd64\.deb$"), "linux", "x86_64", ".deb"),
(re.compile(r"^.*_amd64\.AppImage\.sig$"), "linux", "x86_64", ".AppImage.sig"),
(re.compile(r"^.*_amd64\.AppImage$"), "linux", "x86_64", ".AppImage"),
(re.compile(r"^.*?\.x86_64\.rpm\.sig$"), "linux", "x86_64", ".rpm.sig"),
(re.compile(r"^.*?\.x86_64\.rpm$"), "linux", "x86_64", ".rpm"),
(re.compile(r"^.*_x64-setup\.exe\.sig$"), "windows", "x86_64", ".exe.sig"),
(re.compile(r"^.*_x64-setup\.exe$"), "windows", "x86_64", ".exe"),
(re.compile(r"^.*_x64_en-US\.msi\.sig$"), "windows", "x86_64", ".msi.sig"),
(re.compile(r"^.*_x64_en-US\.msi$"), "windows", "x86_64", ".msi"),
(re.compile(r"^.*\.flatpak$"), "linux", "x86_64", ".flatpak"),
]
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 stage_flat_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.iterdir()):
if not path.is_file():
continue
match = next(
((pattern, os_name, arch, suffix) for pattern, os_name, arch, suffix in FLAT_PATTERNS if pattern.match(path.name)),
None,
)
if match is None:
continue
_, os_name, arch, suffix = match
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", type=Path, help="matrix-layout artifact directory")
parser.add_argument("--flat-artifacts", type=Path, help="flat GitHub Release asset directory")
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()
if (args.artifacts is None) == (args.flat_artifacts is None):
raise SystemExit("exactly one of --artifacts or --flat-artifacts is required")
if args.artifacts is not None:
staged = stage_artifacts(args.artifacts, args.output, args.version)
else:
staged = stage_flat_artifacts(args.flat_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

@@ -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,57 @@
#!/usr/bin/env bash
# Gitea Actions publisher: download a GitHub release's assets and publish
# them to the local Gitea instance over the LAN interface (bypassing the
# public reverse proxy, which times out on large uploads).
set -euo pipefail
GITHUB_REPO="${GITHUB_REPO:-TommyFang2077/dsh-easy-desktop}"
REPO_OWNER="${GITEA_OWNER:-TomHanck4}"
REPO_NAME="${GITEA_REPO:-dsh-easy-desktop}"
GITEA_BASE_URL="${GITEA_BASE_URL:-http://192.168.30.33:3000}"
PACKAGE_BASE_URL="${PACKAGE_BASE_URL:-https://git.fangsiyuan.top/api/packages/TomHanck4/generic/dsh-easy-desktop-updater}"
RELEASE_TAG="${RELEASE_TAG:-latest}"
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
if [[ "$RELEASE_TAG" == "latest" ]]; then
RELEASE_TAG=$(curl -fsS "https://api.github.com/repos/$GITHUB_REPO/releases?per_page=1" | jq -r '.[0].tag_name')
fi
[[ -n "$RELEASE_TAG" && "$RELEASE_TAG" != "null" ]] || {
echo "no GitHub release found" >&2
exit 1
}
VERSION="${RELEASE_TAG#v}"
echo "target: $RELEASE_TAG ($VERSION)"
echo "gitea: $GITEA_BASE_URL"
if curl -fsS -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_BASE_URL/api/packages/$REPO_OWNER/generic/dsh-easy-desktop-updater/latest/latest.json" -o /tmp/latest.json 2>/dev/null \
&& [ "$(jq -r '.version' /tmp/latest.json)" = "$VERSION" ]; then
echo "version $VERSION already published on the Gitea feed; nothing to do"
exit 0
fi
rm -rf artifacts staged
mkdir -p artifacts
curl -fsS "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$RELEASE_TAG" -o /tmp/release.json
jq -r '.assets[].browser_download_url' /tmp/release.json > /tmp/asset-urls.txt
while read -r url; do
curl -fsSL --retry 3 --retry-delay 5 -o "artifacts/$(basename "$url")" "$url" &
done < /tmp/asset-urls.txt
wait
COUNT=$(find artifacts -type f | wc -l)
echo "downloaded $COUNT assets"
[ "$COUNT" -ge 15 ] || {
echo "expected at least 15 release assets, got $COUNT" >&2
exit 1
}
python3 build-gitea-update.py \
--flat-artifacts artifacts \
--output staged \
--version "$VERSION" \
--pub-date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--package-base-url "$PACKAGE_BASE_URL"
bash publish-gitea-release.sh staged
echo "published $RELEASE_TAG to Gitea"

111
scripts/publish-gitea-release.sh Executable file
View File

@@ -0,0 +1,111 @@
#!/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
}
# Structured release body from docs/release-notes/v<version>.md; legacy
# fallback only when the file is missing (the version job normally gates it).
RELEASE_NOTES="docs/release-notes/v${RELEASE_VERSION}.md"
if [[ -f "$RELEASE_NOTES" ]]; then
release_body=$(cat "$RELEASE_NOTES")
else
release_body="大陆镜像安装包;文件与 GitHub Release 同源。应用内更新包由 Tauri 签名校验。"
fi
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 "$release_body" \
'{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')
else
# The mirror may have synced an older, terse GitHub body; keep the Gitea
# copy identical to the structured notes file on every publish.
curl_retry curl --fail --silent --show-error -X PATCH -H "$AUTH" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$release_body" '{body:$body}')" \
"$API/releases/$release_id" >/dev/null
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

67
scripts/release-notes.py Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Extract release-copy fragments from a structured per-version notes file.
Notes live under docs/release-notes/v<version>.md in this layout:
# DeepSeek Harness Desktop v0.1.2
**摘要 / Summary:** one short bilingual paragraph …
## 新功能 / Features
- …
## 修复 / Fixes
- …
## 安装与更新 / Install & Update
- …
Subcommands:
body FILE the full file (GitHub / Gitea release body)
notes FILE the 摘要 / Summary paragraph, one line (in-app update notes)
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
TITLE_RE = re.compile(r"^# .+$", re.M)
SUMMARY_RE = re.compile(r"^\*\*摘要\s*/\s*Summary:\*\*\s*(.+)$", re.M)
def load(path: Path) -> str:
text = path.read_text(encoding="utf-8")
if TITLE_RE.search(text) is None:
raise SystemExit(f"{path}: expected a '# ' title line")
return text
def read_body(path: Path) -> str:
text = load(path)
if not text.endswith("\n"):
text += "\n"
return text
def read_notes(path: Path) -> str:
text = load(path)
match = SUMMARY_RE.search(text)
if match is None:
raise SystemExit(f"{path}: missing '**摘要 / Summary:**' line")
return match.group(1).strip()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("subcommand", choices=["body", "notes"])
parser.add_argument("file")
args = parser.parse_args()
out = read_body(Path(args.file)) if args.subcommand == "body" else read_notes(Path(args.file))
sys.stdout.write(out if out.endswith("\n") else out + "\n")
if __name__ == "__main__":
main()

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,6 +22,7 @@ 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 = { version = "3", features = ["wayland-data-control"] }
@@ -34,3 +35,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,6 +1,9 @@
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};
@@ -38,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())
@@ -91,6 +96,7 @@ struct AppState {
paths: BundledPaths,
process: Mutex<Option<Arc<DshProcess>>>,
url: Mutex<Option<String>>,
initial_boot_started: AtomicBool,
}
impl AppState {
@@ -123,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> {
@@ -204,9 +245,18 @@ fn read_cli_image() -> Option<ClipboardFile> {
("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"]),
(
"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() {
@@ -225,6 +275,33 @@ fn read_cli_image() -> Option<ClipboardFile> {
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!(
@@ -359,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") {
@@ -386,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)?;
@@ -416,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.2",
"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": "Give DeepSeek eyes; Anchored Standard ~+8%",
"longDescription": "Official dsh WebUI in a native window. Built-in ModLens vision gives text-only DeepSeek eyes (paste to read images). Built-in Anchored Standard raises DeepSeek about 8% over official Standard on Project2 (Ability 91 to 98/99). Credentials stay in ~/.dsh.",
"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",
@@ -35,8 +41,9 @@
"icons/icon.ico"
],
"resources": [
"../plugins/dsh-desktop-vision/",
"../plugins/dsh-desktop-voice/",
"../vendor/modlens/",
"../vendor/dshmarket/",
"../vendor/anchored-standard/",
"../vendor/zero-anchored-standard/"
],
@@ -62,6 +69,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

@@ -7,33 +7,50 @@ from pathlib import Path
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(
class VisionSettingsDocsTests(unittest.TestCase):
"""The vision engine has exactly one documented settings surface: the
modlens card under 设置 → 插件 → 插件配置 (regression guard for the
removed duplicate dsh-desktop-vision section)."""
def test_readme_points_at_the_modlens_config_card(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn("设置 → 插件 → 插件配置", readme)
self.assertIn("视觉引擎ModLens", readme)
self.assertNotIn("设置 → 视觉模型", readme)
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.assertNotIn("settings.plugins.tab", client)
self.assertIn("modlens-vision", client)
host = (ROOT / "plugins" / "dsh-desktop-vision" / "index.js").read_text(
encoding="utf-8"
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("/dsh-desktop/modlens", host)
self.assertIn("'OpenAI 兼容'", host)
self.assertNotIn("Qwen / 自建网关", host)
self.assertIn("https://api.openai.com/v1", host)
self.assertIn("https://generativelanguage.googleapis.com", host)
self.assertIn("https://api.anthropic.com", host)
self.assertIn("https://platform.openai.com/api-keys", host)
self.assertIn("https://aistudio.google.com/apikey", host)
self.assertIn("https://console.anthropic.com/settings/keys", host)
self.assertIn("获取 API", client)
self.assertNotIn("qwen-agent", client)
self.assertIn("example: 'gpt-4o'", host)
self.assertIn("example: 'gemini-3.6-flash'", host)
self.assertIn("example: 'claude-haiku-4-5-20251001'", host)
self.assertIn("example: 'gemini-3.6-flash-low'", host)
self.assertIn("example: 'haiku'", host)
self.assertIn("@deepseek-ai/dsh-client-ui-conversation", pkg)
self.assertIn("@deepseek-ai/dsh-client-ui-settings", pkg)
class ClipboardIngestTests(unittest.TestCase):
@@ -66,7 +83,9 @@ class BundledAttributionTests(unittest.TestCase):
self.assertIn("0.1.0-rc.6", text)
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.10.1", text)
self.assertIn("dsh-plugin", readme)
self.assertIn("带上眼睛", readme)
self.assertIn("+8%", readme)
@@ -74,6 +93,7 @@ class BundledAttributionTests(unittest.TestCase):
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())

135
tests/test_release.py Normal file
View File

@@ -0,0 +1,135 @@
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"))
def test_normalizes_flat_github_assets(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
artifacts = root / "artifacts"
output = root / "staged"
artifacts.mkdir()
fixtures = {
"DeepSeek.Harness_0.1.1_aarch64.app.tar.gz": "darwin-aarch64",
"DeepSeek.Harness_0.1.1_aarch64.app.tar.gz.sig": "darwin-aarch64",
"DeepSeek.Harness_0.1.1_aarch64.dmg": "darwin-aarch64",
"DeepSeek.Harness_0.1.1_x64.app.tar.gz": "darwin-x86_64",
"DeepSeek.Harness_0.1.1_x64.app.tar.gz.sig": "darwin-x86_64",
"DeepSeek.Harness_0.1.1_x64.dmg": "darwin-x86_64",
"DeepSeek.Harness_0.1.1_amd64.deb": "linux-x86_64",
"DeepSeek.Harness_0.1.1_amd64.AppImage": "linux-x86_64",
"DeepSeek.Harness_0.1.1_amd64.AppImage.sig": "linux-x86_64",
"DeepSeek.Harness-0.1.1-1.x86_64.rpm": "linux-x86_64",
"DeepSeek.Harness_0.1.1_x64-setup.exe": "windows-x86_64",
"DeepSeek.Harness_0.1.1_x64-setup.exe.sig": "windows-x86_64",
"DeepSeek.Harness_0.1.1_x64_en-US.msi": "windows-x86_64",
"io.github.tommyfang.DshDesktop.flatpak": "linux-x86_64",
}
for name, marker in fixtures.items():
(artifacts / name).write_bytes(marker.encode())
subprocess.run(
[
"python3",
str(SCRIPT),
"--flat-artifacts",
str(artifacts),
"--output",
str(output),
"--version",
"1.2.3",
"--package-base-url",
"http://gitea.example/api/packages/u/generic/app",
],
check=True,
)
manifest = json.loads((output / "latest.json").read_text(encoding="utf-8"))
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",
},
)
# Each updater target embeds its own .sig content; fixed-name
# assets (dmg/rpm/deb/flatpak) are deliverables only.
self.assertEqual(
manifest["platforms"]["linux-x86_64"]["signature"],
"linux-x86_64",
)
self.assertNotIn("linux-x86_64-app", manifest["platforms"])
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

@@ -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;