mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
Compare commits
9 Commits
TommyFang2
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b41e989a | |||
| 33186b2ef9 | |||
| 6a3cd55f88 | |||
| 862382daf6 | |||
| 16c80dfa45 | |||
| a24fbe0037 | |||
| a3e1f11e85 | |||
| e8eeb4ac72 | |||
|
|
629a36c0af |
67
.github/workflows/release.yml
vendored
67
.github/workflows/release.yml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
- name: linux
|
||||
platform: ubuntu-22.04
|
||||
rust_targets: ""
|
||||
args: --bundles deb,rpm
|
||||
args: --bundles deb,rpm,appimage
|
||||
- name: windows
|
||||
platform: windows-latest
|
||||
rust_targets: ""
|
||||
@@ -128,6 +128,8 @@ jobs:
|
||||
- uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: .
|
||||
tagName: v__VERSION__
|
||||
@@ -139,12 +141,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 +198,44 @@ jobs:
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Collect Gitea Flatpak artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gitea-flatpak
|
||||
path: io.github.tommyfang.DshDesktop.flatpak
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish-gitea:
|
||||
name: Publish Gitea mirror
|
||||
needs: [version, package, flatpak]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: gitea-*
|
||||
path: artifacts
|
||||
|
||||
- name: Normalize artifacts and build latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
python3 scripts/build-gitea-update.py \
|
||||
--artifacts artifacts \
|
||||
--output staged \
|
||||
--version "$VERSION" \
|
||||
--pub-date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--package-base-url "https://git.fangsiyuan.top/api/packages/TomHanck4/generic/dsh-easy-desktop-updater"
|
||||
|
||||
- name: Publish Gitea release and updater feed
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_BASE_URL: https://git.fangsiyuan.top
|
||||
GITEA_OWNER: TomHanck4
|
||||
GITEA_REPO: dsh-easy-desktop
|
||||
RELEASE_TAG: ${{ needs.version.outputs.tag }}
|
||||
RELEASE_VERSION: ${{ needs.version.outputs.version }}
|
||||
run: bash scripts/publish-gitea-release.sh staged
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,9 @@ src-tauri/gen/
|
||||
.flatpak-builder/
|
||||
.flatpak-repo/
|
||||
|
||||
# Local agent scratch (wayfinder snapshots, research notes)
|
||||
.scratch/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
*~
|
||||
|
||||
15
AGENTS.md
Normal file
15
AGENTS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# DeepSeek Harness Desktop
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues live in GitHub Issues; use the `gh` CLI. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
Five canonical roles, each label string equal to its name: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain.md`.
|
||||
37
CONTEXT.md
Normal file
37
CONTEXT.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# DeepSeek Harness Desktop
|
||||
|
||||
Official dsh WebUI in a native window. This glossary is the language for the shell, what it ships, and how users get more plugins.
|
||||
|
||||
## Language
|
||||
|
||||
**壳**:
|
||||
The Tauri desktop application that launches `dsh web` and embeds the official WebUI.
|
||||
_Avoid_: 应用, 客户端, wrapper, desktop shell(对用户说话时)
|
||||
|
||||
**捆绑件**:
|
||||
A piece shipped inside the installer and synced into the user's dsh profile or preset directory on launch.
|
||||
_Avoid_: 内置插件, 三件套
|
||||
|
||||
**市场**:
|
||||
The in-WebUI storefront plugin `dshmarket`. It is itself a Bundled component.
|
||||
_Avoid_: 插件商店, awesome-dsh-plugin, 目录
|
||||
|
||||
**目录**:
|
||||
The curated awesome-dsh-plugin list that names installable Community plugins (`plugins.json`).
|
||||
_Avoid_: 市场, registry, awesome 列表(对内请用「目录」)
|
||||
|
||||
**社区插件**:
|
||||
A plugin acquired through the Market, not shipped as a Bundled component.
|
||||
_Avoid_: 内置插件, 第三方插件(捆绑件也可以是上游项目)
|
||||
|
||||
**默认安装**:
|
||||
A Community plugin the Shell installs through the Market on first launch. ModLens is one.
|
||||
_Avoid_: 捆绑件(默认安装不进安装包)
|
||||
|
||||
**降级**:
|
||||
Bundled UI stays available when a default-installed Community plugin is missing. Vision settings stay; image reading does not.
|
||||
_Avoid_: fallback(本项目里 fallback 指把引擎再打进安装包,已否决)
|
||||
|
||||
**在线更新**:
|
||||
Fetching a newer Shell, dsh, Bundled component, Market, or default-installed Community plugin over the network.
|
||||
_Avoid_: 升级(和 dsh 自己的版本号口语混淆)
|
||||
385
Cargo.lock
generated
385
Cargo.lock
generated
@@ -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.1"
|
||||
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.1"
|
||||
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"
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/dsh-core", "src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["TommyFang2077"]
|
||||
|
||||
24
Makefile
24
Makefile
@@ -4,10 +4,12 @@ PREFIX ?= $(HOME)/.local
|
||||
APP_ID := io.github.tommyfang.DshDesktop
|
||||
DSH_VERSION := 0.1.0-rc.6
|
||||
MODLENS_VERSION := 3.16.6
|
||||
MARKET_VERSION := 1.9.0
|
||||
ANCHORED_COMMIT := ffb845c5480adc953392a6db6f8a98ede621174b
|
||||
ANCHORED_REPO := https://github.com/xiaobright/dsh-anchored-standard.git
|
||||
VENDOR_DIR := vendor/dsh-prefix
|
||||
MODLENS_DIR := vendor/modlens
|
||||
MARKET_DIR := vendor/dshmarket
|
||||
ANCHORED_DIR := vendor/anchored-standard
|
||||
ZERO_DIR := vendor/zero-anchored-standard
|
||||
FLATPAK ?= flatpak
|
||||
@@ -35,16 +37,19 @@ build:
|
||||
test:
|
||||
$(CARGO) test -p dsh-core
|
||||
$(PYTHON) -m unittest discover -s tests -v
|
||||
node --test tests/*.test.mjs
|
||||
|
||||
vendor:
|
||||
mkdir -p vendor
|
||||
rm -rf $(VENDOR_DIR) $(MODLENS_DIR)
|
||||
rm -rf $(VENDOR_DIR) $(MODLENS_DIR) $(MARKET_DIR)
|
||||
npm install --prefix=$(CURDIR)/$(VENDOR_DIR) --global --prefer-offline --no-audit --no-fund @deepseek-ai/dsh@$(DSH_VERSION)
|
||||
npm install --prefix=$(CURDIR)/$(MODLENS_DIR) --prefer-offline --no-audit --no-fund @liustack/modlens@$(MODLENS_VERSION)
|
||||
npm install --prefix=$(CURDIR)/$(MARKET_DIR) --prefer-offline --no-audit --no-fund dshmarket@$(MARKET_VERSION)
|
||||
$(PYTHON) scripts/patch-dshmarket-mainland.py
|
||||
$(MAKE) vendor-anchored
|
||||
|
||||
vendor-native:
|
||||
MODLENS_VERSION=$(MODLENS_VERSION) ANCHORED_COMMIT=$(ANCHORED_COMMIT) ANCHORED_REPO=$(ANCHORED_REPO) bash scripts/vendor-native.sh
|
||||
MODLENS_VERSION=$(MODLENS_VERSION) MARKET_VERSION=$(MARKET_VERSION) ANCHORED_COMMIT=$(ANCHORED_COMMIT) ANCHORED_REPO=$(ANCHORED_REPO) bash scripts/vendor-native.sh
|
||||
|
||||
vendor-anchored:
|
||||
rm -rf vendor/.anchored-src $(ANCHORED_DIR) $(ZERO_DIR)
|
||||
@@ -79,11 +84,21 @@ install: build
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
cp -R $(MODLENS_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \
|
||||
fi
|
||||
if [ -d $(MARKET_DIR)/node_modules/dshmarket ]; then \
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
cp -R $(MARKET_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/market; \
|
||||
fi
|
||||
if [ -f plugins/dsh-desktop-vision/package.json ]; then \
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
cp -R plugins/dsh-desktop-vision $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
|
||||
fi
|
||||
if [ -f plugins/dsh-desktop-voice/package.json ]; then \
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/voice; \
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
cp -R plugins/dsh-desktop-voice $(DESTDIR)$(PREFIX)/share/dsh-desktop/voice; \
|
||||
fi
|
||||
if [ -f $(ANCHORED_DIR)/preset.yml ]; then \
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
@@ -105,7 +120,7 @@ uninstall:
|
||||
rm -f "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png"; \
|
||||
done
|
||||
|
||||
flatpak-build: $(VENDOR_DIR)/bin/dsh $(MODLENS_DIR)/node_modules/@liustack/modlens $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml
|
||||
flatpak-build: $(VENDOR_DIR)/bin/dsh $(MODLENS_DIR)/node_modules/@liustack/modlens $(MARKET_DIR)/node_modules/dshmarket $(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml
|
||||
$(BUILDER) --user --force-clean --install-deps-from=flathub $(BUILD_DIR) $(MANIFEST)
|
||||
|
||||
$(VENDOR_DIR)/bin/dsh:
|
||||
@@ -114,6 +129,9 @@ $(VENDOR_DIR)/bin/dsh:
|
||||
$(MODLENS_DIR)/node_modules/@liustack/modlens:
|
||||
$(MAKE) vendor
|
||||
|
||||
|
||||
$(MARKET_DIR)/node_modules/dshmarket:
|
||||
$(MAKE) vendor
|
||||
$(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml:
|
||||
$(MAKE) vendor-anchored
|
||||
|
||||
|
||||
182
README.md
182
README.md
@@ -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,110 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
👁️ 给 DeepSeek <strong>带上眼睛</strong> —— 粘贴图片直接识别 · ⚓ 锚定模式实测比官方 Standard <strong>约 +8%</strong>
|
||||
<strong>离线语音输入</strong> · <strong>内置插件市场</strong> · <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 原生窗口 + 系统 WebView,Linux / 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**,支持中文、粤语、英语、日语和韩语,识别结果直接写入当前输入框。
|
||||
|
||||

|
||||
模型和 sherpa-onnx WASM 运行时**不塞进安装包**。首次点击麦克风时会明确提示下载约 245 MB,底部状态条持续显示下载与校验进度;安装完成后保存在系统缓存目录,录音不离开本机。需要云端识别时,也可切换到 OpenAI 兼容的 `/v1/audio/transcriptions` 接口。
|
||||
|
||||
### ⚓ 锚定模式:比官方 Standard 约 +8%
|
||||

|
||||
|
||||
DeepSeek V4 Pro 会按「第一眼看到的工具表」选执行轨迹:Project2 评测里官方 Minimal 拿 **99**,Standard 只有 **91**——但常驻 Minimal 又缺工具。内置的 [锚定式标准](https://github.com/xiaobright/dsh-anchored-standard) 两头都要:**首轮用 Minimal 真工具对钉住高分轨迹,从第二轮起解锁完整 Standard 工具目录**,同配置实测 Ability **98 / 99**,相对 Standard 的 91 约 **+8% / +9%**。另附零工具锚定变体。新会话默认就是它,开箱即用。
|
||||
### 内置插件市场:发现、安装和更新社区插件
|
||||
|
||||

|
||||
无需记包名或离开应用。在 **设置 → Plugin Market** 中可以浏览目录、搜索分类、查看已安装插件,并直接安装、更新、备份或恢复社区插件。ModLens 等默认组件也能从这里正常更新;桌面启动不会再把用户更新的版本降回内置基线。
|
||||
|
||||
*百分比按预设作者在 Project2 / DeepSeek V4 Pro 上的 Ability 计算:`(98−91)/91 ≈ 8%`。同配置可复现;社区实验预设,不是官方出品,不代表所有任务都涨。*
|
||||

|
||||
|
||||
### 🪟 像个真正的 Mac / Linux / Windows 应用
|
||||
### 视觉模型配置:给 DeepSeek 带上眼睛
|
||||
|
||||
36px 苹果风薄标题栏,不占一排后退/前进/刷新;左侧 `•••` 菜单可重启 dsh 或跳回浏览器。关窗口自动停掉 `dsh web`,崩溃一键拉起。凭据、权限、会话全部还在 `~/.dsh`,卸载壳不丢任何东西。
|
||||
纯文本 DeepSeek 配合视觉桥后,可以直接粘贴截图识别内容。内置的 **设置 → 视觉模型** 页面集中配置 OpenAI 兼容接口、Gemini API、Anthropic API、Antigravity CLI 和 Claude Code 登录;只展示当前引擎需要的字段,密钥保存在本机配置中,相关外链由系统浏览器打开。
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
### 锚定模式与原生窗口
|
||||
|
||||
内置的锚定式标准预设首轮使用 Minimal 工具表固定执行轨迹,从第二轮起恢复完整 Standard 工具目录。Project2 / DeepSeek V4 Pro 同配置 Ability 为 **98 / 99**,相对官方 Standard 的 91 约 **+8% / +9%**。这是社区实验预设,不代表所有任务都会提升。
|
||||
|
||||
36px 薄标题栏保留更多对话空间;左侧 `•••` 菜单可重新启动 dsh 或在浏览器中打开。关闭窗口会停止对应的 `dsh web` 进程,凭据、权限和会话仍保存在 `~/.dsh`。
|
||||
|
||||

|
||||
|
||||
## 三十秒上手
|
||||
|
||||
去 [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。
|
||||
|
||||
## 功能一览
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| 原生窗口 | 启动 `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` | 官方 WebUI;Flatpak 内置,其他安装包调用本机 `dsh` | `~/.dsh` |
|
||||
| 离线语音 | `dsh-desktop-voice 0.4.0` | 麦克风、快捷键、SenseVoice / OpenAI 兼容听写 | 配置 `~/.config/dsh-desktop/voice.json`;模型在系统缓存目录 |
|
||||
| 插件市场 | `dshmarket 1.9.0` | 社区插件的发现、安装和更新 | `~/.dsh/profiles/web` |
|
||||
| 视觉配置 | `dsh-desktop-vision 0.1.4` | 配置视觉桥所使用的引擎、接口和模型 | `~/.modlens/config.json` |
|
||||
| 视觉桥 | `ModLens 3.16.6` | 让纯文本模型读取粘贴的图片;可从市场更新 | `~/.dsh/profiles/web` |
|
||||
| 锚定预设 | `ffb845c5480a` | 锚定式标准与零工具锚定式标准 | `~/.dsh/.agent-presets/` |
|
||||
|
||||
### 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` |
|
||||
| 许可证 | MIT,Copyright (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 +123,8 @@ NOTICE 写明:预设改编自 DeepSeek Harness Standard agent preset([deepse
|
||||
- Windows:WebView2
|
||||
|
||||
```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 检查器和调试日志
|
||||
@@ -223,6 +175,7 @@ dsh-desktop/
|
||||
├── src-tauri/ # Tauri 窗口、命令、deb/rpm/nsis/dmg
|
||||
├── crates/dsh-core/ # 启动 / 更新 / ModLens / 预设 / 剪贴板
|
||||
├── plugins/dsh-desktop-vision/ # 设置 → 视觉模型
|
||||
├── plugins/dsh-desktop-voice/ # 设置 → 语音输入 + 对话框麦克风
|
||||
├── data/ # .desktop、图标、AppStream
|
||||
├── flatpak/
|
||||
├── docs/screenshots/ # README 截图
|
||||
@@ -235,7 +188,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) |
|
||||
|
||||
## 图标与商标
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ DeepSeek, liustack, or xiaobright.
|
||||
| ModLens (`@liustack/modlens`) | [liustack/modlens](https://github.com/liustack/modlens) | `3.16.6` | MIT, © 2026 Leon Liu (liustack) | Copied into `~/.dsh/profiles/web` so text-only models can read images. See `docs/licenses/modlens.LICENSE`. |
|
||||
| Anchored Standard | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) | commit `ffb845c5480adc953392a6db6f8a98ede621174b` | MIT, © 2026 xiaobright; portions © 2026 DeepSeek | Localized as **锚定式标准(实验)** and **零工具锚定式标准(实验)**, written to `~/.dsh/.agent-presets/`. See `docs/licenses/dsh-anchored-standard.LICENSE` and `.NOTICE`. |
|
||||
| `dsh-desktop-vision` | this repo `plugins/dsh-desktop-vision/` | `0.1.4` | MIT, © 2026 TommyFang2077 | Settings page **设置 → 视觉模型**; writes `~/.modlens/config.json`. |
|
||||
| `dsh-desktop-voice` | this repo `plugins/dsh-desktop-voice/` | `0.4.0` | MIT, © 2026 TommyFang2077 | Settings page **设置 → 语音输入** and composer mic; writes `~/.config/dsh-desktop/voice.json`. |
|
||||
| dshmarket | [dsh-market/dsh-market](https://github.com/dsh-market/dsh-market) | `1.9.0` | MIT, © 2026 fkysly and dsh-market contributors | Bundled WebUI market for browsing and installing Community plugins. See `docs/licenses/dshmarket.LICENSE`. |
|
||||
| SenseVoiceSmall ONNX | [FunAudioLLM/SenseVoice](https://github.com/FunAudioLLM/SenseVoice) via [k2-fsa conversion](https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17) | commit `2365baeacb507f821a0c8120fcee3d484dba7a07` | MIT, © 2025 FunASR | Not bundled. Downloaded after the user confirms, then stored in the platform cache. See `docs/licenses/sensevoice.LICENSE`. |
|
||||
| sherpa-onnx WASM | [k2-fsa/sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) | `1.13.5` | Apache-2.0 | Not bundled. Installed with the model to run SenseVoice locally; the npm package carries its upstream license. |
|
||||
|
||||
The Anchored Standard NOTICE records that the presets adapt the DeepSeek
|
||||
Harness Standard agent preset from
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -8,7 +8,13 @@ use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths};
|
||||
|
||||
pub const PACKAGE: &str = "@liustack/modlens";
|
||||
pub const VISION_PACKAGE: &str = "dsh-desktop-vision";
|
||||
pub const VOICE_PACKAGE: &str = "dsh-desktop-voice";
|
||||
pub const MARKET_PACKAGE: &str = "dshmarket";
|
||||
pub const MODLENS_VERSION: &str = "3.16.6";
|
||||
const VISION_PLUGIN_VERSION: &str = "0.1.4";
|
||||
const VOICE_PLUGIN_VERSION: &str = "0.4.0";
|
||||
const MARKET_PLUGIN_VERSION: &str = "1.9.0";
|
||||
const MANAGED_BUNDLES_DIR: &str = ".dsh-desktop/bundles";
|
||||
pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js");
|
||||
|
||||
pub const MANAGED_OVERLAY: &str = "\
|
||||
@@ -48,12 +54,42 @@ pub fn read_modlens_version(prefix: &Path) -> Option<String> {
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option<PathBuf> {
|
||||
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("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())
|
||||
.find_dir(rel, "package.json")
|
||||
.filter(|p| p.join("client.js").is_file() && p.join("index.js").is_file())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option<PathBuf> {
|
||||
bundled_plugin(
|
||||
paths,
|
||||
&["vision", "dsh-desktop-vision", "plugins/dsh-desktop-vision"],
|
||||
)
|
||||
}
|
||||
|
||||
fn bundled_voice_plugin(paths: &BundledPaths) -> Option<PathBuf> {
|
||||
bundled_plugin(
|
||||
paths,
|
||||
&["voice", "dsh-desktop-voice", "plugins/dsh-desktop-voice"],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
|
||||
@@ -62,6 +98,13 @@ pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
|
||||
.or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens"))
|
||||
}
|
||||
|
||||
pub fn bundled_market_prefix(paths: &BundledPaths) -> Option<PathBuf> {
|
||||
paths
|
||||
.find_dir("market", "node_modules/dshmarket/package.json")
|
||||
.or_else(|| paths.find_dir("dshmarket", "node_modules/dshmarket/package.json"))
|
||||
.or_else(|| paths.find_dir("vendor/dshmarket", "node_modules/dshmarket/package.json"))
|
||||
}
|
||||
|
||||
fn install_into_profile(src_prefix: &Path, profile: &Path) -> std::io::Result<()> {
|
||||
let dest_pkg = package_dir(profile);
|
||||
copy_tree(&package_dir(src_prefix), &dest_pkg, true)?;
|
||||
@@ -86,22 +129,92 @@ 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_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
|
||||
install_profile_plugin(
|
||||
profile,
|
||||
VISION_PACKAGE,
|
||||
VISION_PLUGIN_VERSION,
|
||||
bundled_vision_plugin(paths),
|
||||
)
|
||||
}
|
||||
|
||||
fn install_voice_plugin(paths: &BundledPaths, profile: &Path) -> bool {
|
||||
install_profile_plugin(
|
||||
profile,
|
||||
VOICE_PACKAGE,
|
||||
VOICE_PLUGIN_VERSION,
|
||||
bundled_voice_plugin(paths),
|
||||
)
|
||||
}
|
||||
|
||||
fn install_market_plugin(paths: &BundledPaths, profile: &Path) -> std::io::Result<Option<String>> {
|
||||
let Some(prefix) = bundled_market_prefix(paths) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let src = prefix.join("node_modules").join(MARKET_PACKAGE);
|
||||
let Some(version) = read_pkg_version(&src) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if version != MARKET_PLUGIN_VERSION {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("bundled dshmarket version {version} does not match {MARKET_PLUGIN_VERSION}"),
|
||||
));
|
||||
}
|
||||
let dest = profile.join("node_modules").join(MARKET_PACKAGE);
|
||||
if read_pkg_version(&dest).as_deref() != Some(version.as_str()) {
|
||||
copy_tree(&src, &dest, true)?;
|
||||
}
|
||||
for dep in [
|
||||
"@deepseek-ai/cordis",
|
||||
"@deepseek-ai/cosmokit",
|
||||
"@standard-schema/spec",
|
||||
] {
|
||||
let src_dep = prefix.join("node_modules").join(dep);
|
||||
if src_dep.is_dir() {
|
||||
copy_tree(&src_dep, &profile.join("node_modules").join(dep), true)?;
|
||||
}
|
||||
}
|
||||
let fallback = dsh_home()
|
||||
.join("profiles/node_modules")
|
||||
.join(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<()> {
|
||||
@@ -323,9 +436,19 @@ fn ensure_modlens_inner(
|
||||
) -> std::io::Result<ModlensEnsureResult> {
|
||||
std::fs::create_dir_all(profile)?;
|
||||
let vision_ok = install_vision_plugin(paths, profile);
|
||||
let voice_ok = install_voice_plugin(paths, profile);
|
||||
let mut packages = BTreeMap::new();
|
||||
if vision_ok {
|
||||
packages.insert(VISION_PACKAGE.to_string(), "0.1.0".into());
|
||||
packages.insert(
|
||||
VISION_PACKAGE.to_string(),
|
||||
local_plugin_spec(VISION_PACKAGE),
|
||||
);
|
||||
}
|
||||
if voice_ok {
|
||||
packages.insert(VOICE_PACKAGE.to_string(), local_plugin_spec(VOICE_PACKAGE));
|
||||
}
|
||||
if let Some(version) = install_market_plugin(paths, profile)? {
|
||||
packages.insert(MARKET_PACKAGE.to_string(), version);
|
||||
}
|
||||
if src.is_none() && installed.is_none() {
|
||||
if !packages.is_empty() {
|
||||
@@ -337,8 +460,9 @@ fn ensure_modlens_inner(
|
||||
message: "未找到内置 ModLens,跳过插件安装".into(),
|
||||
});
|
||||
}
|
||||
let replace_bundle = src.is_some() && bundle_should_replace(installed.as_deref(), version);
|
||||
let installed_after = if let Some(src) = src {
|
||||
if installed.as_deref() != Some(version) {
|
||||
if replace_bundle {
|
||||
install_into_profile(src, profile)?;
|
||||
read_modlens_version(profile).unwrap_or_else(|| version.to_string())
|
||||
} else {
|
||||
@@ -374,6 +498,7 @@ fn ensure_modlens_inner(
|
||||
message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"),
|
||||
});
|
||||
}
|
||||
if !replace_bundle {
|
||||
if installed.as_deref() == Some(version) {
|
||||
return Ok(ModlensEnsureResult {
|
||||
status: "current",
|
||||
@@ -381,6 +506,12 @@ fn ensure_modlens_inner(
|
||||
message: format!("内置 ModLens {version} 已就绪"),
|
||||
});
|
||||
}
|
||||
return Ok(ModlensEnsureResult {
|
||||
status: "current",
|
||||
version: Some(installed_after.clone()),
|
||||
message: format!("已保留用户更新的 ModLens {installed_after}(内置版本 {version})"),
|
||||
});
|
||||
}
|
||||
if installed.is_some() {
|
||||
return Ok(ModlensEnsureResult {
|
||||
status: "updated",
|
||||
@@ -474,4 +605,80 @@ ui-theme:
|
||||
assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)"));
|
||||
assert!(HIDE_PLAIN_TWINS_JS.contains("MutationObserver"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_market_is_added_to_profile() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let market = tmp.path().join("market/node_modules/dshmarket");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
std::fs::write(
|
||||
market.join("package.json"),
|
||||
r#"{"name":"dshmarket","version":"1.9.0"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(market.join("index.js"), "export {}\n").unwrap();
|
||||
let paths = BundledPaths::default().with_resource_dir(tmp.path().to_path_buf());
|
||||
let profile = tmp.path().join("profile");
|
||||
|
||||
ensure_modlens_inner(&paths, None, &profile, None, MODLENS_VERSION).unwrap();
|
||||
|
||||
let manifest: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(profile.join("package.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(manifest["dependencies"]["dshmarket"], "1.9.0");
|
||||
assert!(manifest["dsh"]["profile"]["bundles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item == "dshmarket"));
|
||||
assert!(profile
|
||||
.join("node_modules/dshmarket/package.json")
|
||||
.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_local_plugins_use_file_dependencies() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
for (dir, name, version) in [
|
||||
("vision", VISION_PACKAGE, VISION_PLUGIN_VERSION),
|
||||
("voice", VOICE_PACKAGE, VOICE_PLUGIN_VERSION),
|
||||
] {
|
||||
let plugin = tmp.path().join(dir);
|
||||
std::fs::create_dir_all(&plugin).unwrap();
|
||||
std::fs::write(
|
||||
plugin.join("package.json"),
|
||||
format!(r#"{{"name":"{name}","version":"{version}"}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(plugin.join("client.js"), "export {}\n").unwrap();
|
||||
std::fs::write(plugin.join("index.js"), "export {}\n").unwrap();
|
||||
}
|
||||
let paths = BundledPaths::default().with_resource_dir(tmp.path().to_path_buf());
|
||||
let profile = tmp.path().join("profile");
|
||||
|
||||
ensure_modlens_inner(&paths, None, &profile, None, MODLENS_VERSION).unwrap();
|
||||
|
||||
let manifest: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(profile.join("package.json")).unwrap())
|
||||
.unwrap();
|
||||
for name in [VISION_PACKAGE, VOICE_PACKAGE] {
|
||||
assert_eq!(
|
||||
manifest["dependencies"][name],
|
||||
format!("file:.dsh-desktop/bundles/{name}")
|
||||
);
|
||||
assert!(profile
|
||||
.join(".dsh-desktop/bundles")
|
||||
.join(name)
|
||||
.join("package.json")
|
||||
.is_file());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_marketplace_modlens_is_not_replaced_by_bundle() {
|
||||
assert!(!bundle_should_replace(Some("3.17.3"), "3.16.6"));
|
||||
assert!(!bundle_should_replace(Some("3.16.6"), "3.16.6"));
|
||||
assert!(bundle_should_replace(Some("3.16.5"), "3.16.6"));
|
||||
assert!(bundle_should_replace(None, "3.16.6"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,7 +192,11 @@ 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))
|
||||
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() {
|
||||
|
||||
@@ -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,11 @@
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.1.1" date="2026-08-16">
|
||||
<description>
|
||||
<p>Signed in-app updater from the mainland mirror; HTTPS Gitea distribution; Market install warnings for GitHub-only sources.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.0" date="2026-08-15">
|
||||
<description>
|
||||
<p>Tauri shell with an Apple-style title bar, bundled dsh, and on-launch updates.</p>
|
||||
|
||||
51
docs/agents/domain.md
Normal file
51
docs/agents/domain.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root, or
|
||||
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo (most repos):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/adr/ ← system-wide decisions
|
||||
└── src/
|
||||
├── ordering/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/ ← context-specific decisions
|
||||
└── billing/
|
||||
├── CONTEXT.md
|
||||
└── docs/adr/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
45
docs/agents/issue-tracker.md
Normal file
45
docs/agents/issue-tracker.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Issue tracker: GitHub
|
||||
|
||||
Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
||||
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
||||
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
||||
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
||||
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
||||
- **Close**: `gh issue close <number> --comment "..."`
|
||||
|
||||
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
||||
|
||||
## Pull requests as a triage surface
|
||||
|
||||
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
|
||||
|
||||
When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
|
||||
|
||||
- **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
|
||||
- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
|
||||
- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
|
||||
|
||||
GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitHub issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `gh issue view <number> --comments`.
|
||||
|
||||
## Wayfinding operations
|
||||
|
||||
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
|
||||
|
||||
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`.
|
||||
- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
|
||||
- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>`, where `<blocker-db-id>` is the blocker's numeric **database id** (`gh api repos/<owner>/<repo>/issues/<n> --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker is closed.
|
||||
- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins.
|
||||
- **Claim**: `gh issue edit <n> --add-assignee @me` — the session's first write.
|
||||
- **Resolve**: `gh issue comment <n> --body "<answer>"`, then `gh issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.
|
||||
15
docs/agents/triage-labels.md
Normal file
15
docs/agents/triage-labels.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
21
docs/licenses/dshmarket.LICENSE
Normal file
21
docs/licenses/dshmarket.LICENSE
Normal 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.
|
||||
21
docs/licenses/sensevoice.LICENSE
Normal file
21
docs/licenses/sensevoice.LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 FunASR
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 1.5 MiB |
BIN
docs/screenshots/market.webp
Normal file
BIN
docs/screenshots/market.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -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,9 +131,12 @@
|
||||
<span class="chip">锚定式标准(实验)</span>
|
||||
<span class="chip">工作区</span>
|
||||
</div>
|
||||
<div class="send-cluster">
|
||||
<div class="mic" title="语音输入">🎤</div>
|
||||
<div class="send">↑</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
BIN
docs/screenshots/vision.webp
Normal file
BIN
docs/screenshots/vision.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
BIN
docs/screenshots/voice.webp
Normal file
BIN
docs/screenshots/voice.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -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,13 @@ 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
|
||||
- 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 +83,15 @@ modules:
|
||||
- type: dir
|
||||
path: ../vendor/modlens
|
||||
|
||||
- name: plugin-market
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/market
|
||||
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/market
|
||||
sources:
|
||||
- type: dir
|
||||
path: ../vendor/dshmarket
|
||||
|
||||
- name: anchored-standard-preset
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
|
||||
892
plugins/dsh-desktop-voice/client.js
Normal file
892
plugins/dsh-desktop-voice/client.js
Normal 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
|
||||
},
|
||||
})
|
||||
4
plugins/dsh-desktop-voice/cordis.patch.yml
Normal file
4
plugins/dsh-desktop-voice/cordis.patch.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Settings page + composer mic + host route for desktop dictation.
|
||||
- insert:
|
||||
- id: dsh-desktop-voice
|
||||
name: dsh-desktop-voice
|
||||
576
plugins/dsh-desktop-voice/index.js
Normal file
576
plugins/dsh-desktop-voice/index.js
Normal 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}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
24
plugins/dsh-desktop-voice/package.json
Normal file
24
plugins/dsh-desktop-voice/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "dsh-desktop-voice",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./client": "./client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
}
|
||||
}
|
||||
135
scripts/build-gitea-update.py
Executable file
135
scripts/build-gitea-update.py
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize Tauri artifacts and build the static updater manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
MATRIX_TARGETS = {
|
||||
"gitea-macos-arm64": ("darwin", "aarch64"),
|
||||
"gitea-macos-x64": ("darwin", "x86_64"),
|
||||
"gitea-linux": ("linux", "x86_64"),
|
||||
"gitea-windows": ("windows", "x86_64"),
|
||||
"gitea-flatpak": ("linux", "x86_64"),
|
||||
}
|
||||
DELIVERABLE_SUFFIXES = (
|
||||
".app.tar.gz.sig",
|
||||
".app.tar.gz",
|
||||
".AppImage.sig",
|
||||
".AppImage",
|
||||
".flatpak",
|
||||
".dmg",
|
||||
".deb",
|
||||
".rpm",
|
||||
".exe.sig",
|
||||
".exe",
|
||||
".msi.sig",
|
||||
".msi",
|
||||
)
|
||||
UPDATER_SUFFIX = {
|
||||
"darwin": ".app.tar.gz",
|
||||
"linux": ".AppImage",
|
||||
"windows": ".exe",
|
||||
}
|
||||
INSTALLER_KIND = {
|
||||
"darwin": "app",
|
||||
"linux": "appimage",
|
||||
"windows": "nsis",
|
||||
}
|
||||
|
||||
|
||||
def artifact_target(path: Path, root: Path) -> tuple[str, str] | None:
|
||||
relative = path.relative_to(root)
|
||||
for part in relative.parts:
|
||||
if part in MATRIX_TARGETS:
|
||||
return MATRIX_TARGETS[part]
|
||||
return None
|
||||
|
||||
|
||||
def deliverable_suffix(name: str) -> str | None:
|
||||
return next((suffix for suffix in DELIVERABLE_SUFFIXES if name.endswith(suffix)), None)
|
||||
|
||||
|
||||
def stage_artifacts(source: Path, output: Path, version: str) -> dict[tuple[str, str, str], Path]:
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
staged: dict[tuple[str, str, str], Path] = {}
|
||||
for path in sorted(source.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
target = artifact_target(path, source)
|
||||
suffix = deliverable_suffix(path.name)
|
||||
if target is None or suffix is None:
|
||||
continue
|
||||
os_name, arch = target
|
||||
key = (os_name, arch, suffix)
|
||||
if key in staged:
|
||||
raise ValueError(f"duplicate {os_name}-{arch}{suffix}: {staged[key]} and {path}")
|
||||
destination = output / f"dsh-easy-desktop_{version}_{os_name}_{arch}{suffix}"
|
||||
shutil.copy2(path, destination)
|
||||
staged[key] = destination
|
||||
return staged
|
||||
|
||||
|
||||
def build_manifest(
|
||||
staged: dict[tuple[str, str, str], Path],
|
||||
version: str,
|
||||
package_base_url: str,
|
||||
notes: str,
|
||||
pub_date: str | None,
|
||||
) -> dict[str, object]:
|
||||
platforms: dict[str, dict[str, str]] = {}
|
||||
for (os_name, arch, suffix), artifact in staged.items():
|
||||
if suffix != UPDATER_SUFFIX.get(os_name):
|
||||
continue
|
||||
signature = staged.get((os_name, arch, suffix + ".sig"))
|
||||
if signature is None:
|
||||
raise ValueError(f"missing signature for {artifact.name}")
|
||||
entry = {
|
||||
"url": f"{package_base_url.rstrip('/')}/{version}/{artifact.name}",
|
||||
"signature": signature.read_text(encoding="utf-8").strip(),
|
||||
}
|
||||
platforms[f"{os_name}-{arch}-{INSTALLER_KIND[os_name]}"] = entry
|
||||
platforms[f"{os_name}-{arch}"] = entry
|
||||
required = {"darwin-aarch64", "darwin-x86_64", "linux-x86_64", "windows-x86_64"}
|
||||
missing = required.difference(platforms)
|
||||
if missing:
|
||||
raise ValueError(f"missing updater targets: {', '.join(sorted(missing))}")
|
||||
manifest: dict[str, object] = {
|
||||
"version": version,
|
||||
"notes": notes,
|
||||
"platforms": platforms,
|
||||
}
|
||||
if pub_date:
|
||||
manifest["pub_date"] = pub_date
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--artifacts", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--package-base-url", required=True)
|
||||
parser.add_argument("--notes", default="DeepSeek Harness Desktop update")
|
||||
parser.add_argument("--pub-date")
|
||||
args = parser.parse_args()
|
||||
|
||||
staged = stage_artifacts(args.artifacts, args.output, args.version)
|
||||
manifest = build_manifest(
|
||||
staged,
|
||||
args.version,
|
||||
args.package_base_url,
|
||||
args.notes,
|
||||
args.pub_date,
|
||||
)
|
||||
(args.output / "latest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
63
scripts/patch-dshmarket-mainland.py
Executable file
63
scripts/patch-dshmarket-mainland.py
Executable 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()
|
||||
95
scripts/publish-gitea-release.sh
Executable file
95
scripts/publish-gitea-release.sh
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish normalized installers and signed updater artifacts to Gitea.
|
||||
set -euo pipefail
|
||||
|
||||
STAGING_DIR="${1:?usage: publish-gitea-release.sh STAGING_DIR}"
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
|
||||
: "${GITEA_BASE_URL:?GITEA_BASE_URL is required}"
|
||||
: "${GITEA_OWNER:?GITEA_OWNER is required}"
|
||||
: "${GITEA_REPO:?GITEA_REPO is required}"
|
||||
: "${RELEASE_TAG:?RELEASE_TAG is required}"
|
||||
: "${RELEASE_VERSION:?RELEASE_VERSION is required}"
|
||||
|
||||
API="${GITEA_BASE_URL%/}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}"
|
||||
PACKAGE_BASE="${GITEA_BASE_URL%/}/api/packages/${GITEA_OWNER}/generic/dsh-easy-desktop-updater"
|
||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||
|
||||
# Retry transient gateway errors (504) coming from the front proxy while the
|
||||
# NAS reaches github.com over a mainland link: requests can stall past the
|
||||
# proxy read timeout. 404s are NOT retried here (release/package state).
|
||||
curl_retry() {
|
||||
local attempts=0
|
||||
while ! "$@"; do
|
||||
attempts=$((attempts + 1))
|
||||
if (( attempts >= 4 )); then
|
||||
echo "curl failed after 4 attempts: $*" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "curl transient failure (attempt ${attempts}/3), retrying: $*" >&2
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
# The Gitea repository is a pull mirror. Kick a background sync; the trigger
|
||||
# response itself is irrelevant (it can 504 while the sync runs on Gitea).
|
||||
# The tag poll below is the actual gate.
|
||||
curl --silent --show-error -X POST -H "$AUTH" "$API/mirror-sync" >/dev/null || true
|
||||
|
||||
# Wait (up to 10 min) until the tag has synced; then fail loudly if it never did.
|
||||
for _ in $(seq 1 60); do
|
||||
if curl --fail --silent --show-error -H "$AUTH" "$API/tags/$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
synced=1
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
[[ "${synced:-0}" == 1 ]] || {
|
||||
echo "tag $RELEASE_TAG did not appear on the Gitea mirror after 10 minutes" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
release_json=$(curl_retry curl --fail --silent --show-error -H "$AUTH" "$API/releases/tags/$RELEASE_TAG")
|
||||
release_id=$(printf '%s' "$release_json" | jq -r '.id // empty')
|
||||
if [[ -z "$release_id" ]]; then
|
||||
release_payload=$(jq -n \
|
||||
--arg tag "$RELEASE_TAG" \
|
||||
--arg name "DeepSeek Harness Desktop $RELEASE_TAG" \
|
||||
--arg body "大陆镜像安装包;文件与 GitHub Release 同源。应用内更新包由 Tauri 签名校验。" \
|
||||
'{tag_name:$tag,target_commitish:$tag,name:$name,body:$body,draft:false,prerelease:false}')
|
||||
release_json=$(curl_retry curl --fail --silent --show-error \
|
||||
-X POST -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
--data "$release_payload" "$API/releases")
|
||||
release_id=$(printf '%s' "$release_json" | jq -r '.id')
|
||||
fi
|
||||
|
||||
# Versioned generic package: immutable URLs consumed by latest.json.
|
||||
curl --silent --show-error -X DELETE -H "$AUTH" \
|
||||
"$PACKAGE_BASE/$RELEASE_VERSION" >/dev/null || true
|
||||
for file in "$STAGING_DIR"/*; do
|
||||
[[ -f "$file" && "$(basename "$file")" != "latest.json" ]] || continue
|
||||
curl_retry curl --fail --silent --show-error -H "$AUTH" --upload-file "$file" \
|
||||
"$PACKAGE_BASE/$RELEASE_VERSION/$(basename "$file")" >/dev/null
|
||||
done
|
||||
|
||||
# Stable updater endpoint. Gitea generic packages are immutable, so replace
|
||||
# the synthetic "latest" version on each completed release.
|
||||
curl --silent --show-error -X DELETE -H "$AUTH" "$PACKAGE_BASE/latest" >/dev/null || true
|
||||
curl_retry curl --fail --silent --show-error -H "$AUTH" --upload-file "$STAGING_DIR/latest.json" \
|
||||
"$PACKAGE_BASE/latest/latest.json" >/dev/null
|
||||
|
||||
assets=$(curl_retry curl --fail --silent --show-error -H "$AUTH" "$API/releases/$release_id/assets")
|
||||
for file in "$STAGING_DIR"/*; do
|
||||
[[ -f "$file" ]] || continue
|
||||
name=$(basename "$file")
|
||||
case "$name" in
|
||||
*.dmg|*.deb|*.rpm|*.exe|*.msi|*.AppImage|*.flatpak) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
old_id=$(printf '%s' "$assets" | jq -r --arg name "$name" '[.[] | select(.name == $name) | .id][0] // empty')
|
||||
if [[ -n "$old_id" ]]; then
|
||||
curl_retry curl --fail --silent --show-error -X DELETE -H "$AUTH" \
|
||||
"$API/releases/$release_id/assets/$old_id" >/dev/null
|
||||
fi
|
||||
curl_retry curl --fail --silent --show-error -H "$AUTH" \
|
||||
-F "attachment=@$file" "$API/releases/$release_id/assets?name=$name" >/dev/null
|
||||
done
|
||||
93
scripts/setup-gitea-release.sh
Executable file
93
scripts/setup-gitea-release.sh
Executable 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"
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
8
src-tauri/Info.plist
Normal 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>
|
||||
@@ -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,9 +467,11 @@ 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()))
|
||||
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)
|
||||
@@ -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 {
|
||||
|
||||
126
src-tauri/src/shell_updater.rs
Normal file
126
src-tauri/src/shell_updater.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "DeepSeek Harness",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"identifier": "io.github.tommyfang.DshDesktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -15,15 +15,21 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1OEI5NkQwMjJCNkFDODMKUldTRHJMWWkwSmFMTlcza3Vkd1UxclQydm0xOW9MTVNxU1JIckkreWRwc1dMTm1HOVVaMHI1SysK"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true,
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"],
|
||||
"publisher": "TommyFang2077",
|
||||
"homepage": "https://github.com/TommyFang2077/dsh-desktop",
|
||||
"homepage": "https://github.com/TommyFang2077/dsh-easy-desktop",
|
||||
"copyright": "Copyright © 2026 TommyFang2077",
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "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",
|
||||
@@ -36,7 +42,9 @@
|
||||
],
|
||||
"resources": [
|
||||
"../plugins/dsh-desktop-vision/",
|
||||
"../plugins/dsh-desktop-voice/",
|
||||
"../vendor/modlens/",
|
||||
"../vendor/dshmarket/",
|
||||
"../vendor/anchored-standard/",
|
||||
"../vendor/zero-anchored-standard/"
|
||||
],
|
||||
@@ -62,6 +70,7 @@
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"infoPlist": "Info.plist",
|
||||
"dmg": {
|
||||
"appPosition": { "x": 180, "y": 170 },
|
||||
"applicationFolderPosition": { "x": 480, "y": 170 }
|
||||
|
||||
63
tests/shell_update.test.mjs
Normal file
63
tests/shell_update.test.mjs
Normal 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)
|
||||
})
|
||||
@@ -9,6 +9,12 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
class VisionPluginFilesTests(unittest.TestCase):
|
||||
def test_client_registers_system_settings_slots(self):
|
||||
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("settings.section", client)
|
||||
self.assertNotIn("settings.plugins.tab", client)
|
||||
self.assertIn("modlens-vision", client)
|
||||
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
@@ -36,6 +42,40 @@ class VisionPluginFilesTests(unittest.TestCase):
|
||||
self.assertIn("example: 'haiku'", host)
|
||||
|
||||
|
||||
class VoicePluginFilesTests(unittest.TestCase):
|
||||
def test_client_registers_composer_and_settings(self):
|
||||
client = (ROOT / "plugins" / "dsh-desktop-voice" / "client.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
host = (ROOT / "plugins" / "dsh-desktop-voice" / "index.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
pkg = (ROOT / "plugins" / "dsh-desktop-voice" / "package.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("settings.section", client)
|
||||
self.assertIn("conversation.input.right", client)
|
||||
self.assertIn("dsh-desktop-voice", client)
|
||||
self.assertIn("Ctrl+E", client)
|
||||
self.assertIn("dictationMode", client)
|
||||
self.assertIn("whisper-1", host)
|
||||
self.assertIn("/dsh-desktop/voice", host)
|
||||
self.assertIn("/dsh-desktop/voice/transcribe", host)
|
||||
self.assertIn("/dsh-desktop/voice/model", host)
|
||||
self.assertIn("SenseVoice", client)
|
||||
self.assertIn("sherpa-onnx@", host)
|
||||
self.assertNotIn("SpeechRecognition", client)
|
||||
self.assertNotIn("engine === 'browser'", client)
|
||||
self.assertIn("engine === 'openai'", client)
|
||||
self.assertIn("cfg.engine === 'openai'", host)
|
||||
self.assertFalse(
|
||||
(ROOT / "plugins" / "dsh-desktop-voice" / "model.int8.onnx").exists()
|
||||
)
|
||||
self.assertIn("https://api.openai.com/v1", host)
|
||||
self.assertIn("@deepseek-ai/dsh-client-ui-conversation", pkg)
|
||||
self.assertIn("@deepseek-ai/dsh-client-ui-settings", pkg)
|
||||
|
||||
|
||||
class ClipboardIngestTests(unittest.TestCase):
|
||||
def test_inject_delivers_images_as_paste_not_only_drop(self):
|
||||
ingest = (ROOT / "ui" / "inject" / "ingest.js").read_text(encoding="utf-8")
|
||||
@@ -67,6 +107,9 @@ class BundledAttributionTests(unittest.TestCase):
|
||||
self.assertIn("3.16.6", text)
|
||||
self.assertIn("ffb845c5480adc953392a6db6f8a98ede621174b", text)
|
||||
self.assertIn("dsh-desktop-vision", text)
|
||||
self.assertIn("dsh-desktop-voice", text)
|
||||
self.assertIn("https://github.com/dsh-market/dsh-market", text)
|
||||
self.assertIn("1.9.0", text)
|
||||
self.assertIn("dsh-plugin", readme)
|
||||
self.assertIn("带上眼睛", readme)
|
||||
self.assertIn("+8%", readme)
|
||||
@@ -74,6 +117,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())
|
||||
|
||||
|
||||
72
tests/test_release.py
Normal file
72
tests/test_release.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "build-gitea-update.py"
|
||||
|
||||
|
||||
class GiteaUpdateManifestTests(unittest.TestCase):
|
||||
def test_normalizes_matrix_artifacts_and_embeds_signatures(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
artifacts = root / "artifacts"
|
||||
output = root / "staged"
|
||||
fixtures = {
|
||||
"gitea-macos-arm64": ".app.tar.gz",
|
||||
"gitea-macos-x64": ".app.tar.gz",
|
||||
"gitea-linux": ".AppImage",
|
||||
"gitea-windows": ".exe",
|
||||
}
|
||||
for matrix, suffix in fixtures.items():
|
||||
bundle = artifacts / matrix / "target" / "release" / "bundle"
|
||||
bundle.mkdir(parents=True)
|
||||
artifact = bundle / f"DeepSeek Harness{suffix}"
|
||||
artifact.write_bytes(matrix.encode())
|
||||
artifact.with_name(artifact.name + ".sig").write_text(
|
||||
f"signature-{matrix}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
str(SCRIPT),
|
||||
"--artifacts",
|
||||
str(artifacts),
|
||||
"--output",
|
||||
str(output),
|
||||
"--version",
|
||||
"1.2.3",
|
||||
"--package-base-url",
|
||||
"http://gitea.example/api/packages/u/generic/app",
|
||||
"--pub-date",
|
||||
"2026-08-16T00:00:00Z",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
manifest = json.loads((output / "latest.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["version"], "1.2.3")
|
||||
self.assertEqual(
|
||||
set(manifest["platforms"]),
|
||||
{
|
||||
"darwin-aarch64-app",
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64-app",
|
||||
"darwin-x86_64",
|
||||
"linux-x86_64-appimage",
|
||||
"linux-x86_64",
|
||||
"windows-x86_64-nsis",
|
||||
"windows-x86_64",
|
||||
},
|
||||
)
|
||||
windows = manifest["platforms"]["windows-x86_64"]
|
||||
self.assertEqual(windows["signature"], "signature-gitea-windows")
|
||||
self.assertTrue(windows["url"].endswith("/1.2.3/dsh-easy-desktop_1.2.3_windows_x86_64.exe"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
233
tests/voice_client.test.mjs
Normal file
233
tests/voice_client.test.mjs
Normal 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
112
tests/voice_host.test.mjs
Normal 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 })
|
||||
}
|
||||
})
|
||||
43
ui/app.js
43
ui/app.js
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user