Add README, plugin citations, and multi-platform release CI.
Ship screenshots and third-party notices for the desktop shell, and package Windows, macOS, deb, rpm, and Flatpak from GitHub Releases. Co-authored-by: Cursor <cursoragent@cursor.com>
27
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
178
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
version:
|
||||
name: Version
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
tag: ${{ steps.meta.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: meta
|
||||
run: |
|
||||
VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "v${VERSION}" ]; then
|
||||
echo "Git tag ${GITHUB_REF_NAME} must match Cargo.toml version v${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
vendor:
|
||||
name: Vendor Flatpak runtime
|
||||
needs: test
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Vendor dsh, ModLens, and presets
|
||||
run: make vendor
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vendor
|
||||
path: vendor
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
|
||||
package:
|
||||
name: Package ${{ matrix.name }}
|
||||
needs: [test, version]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macos-arm64
|
||||
platform: macos-latest
|
||||
rust_targets: aarch64-apple-darwin
|
||||
args: --target aarch64-apple-darwin --bundles app,dmg
|
||||
- name: macos-x64
|
||||
platform: macos-latest
|
||||
rust_targets: x86_64-apple-darwin
|
||||
args: --target x86_64-apple-darwin --bundles app,dmg
|
||||
- name: linux
|
||||
platform: ubuntu-22.04
|
||||
rust_targets: ""
|
||||
args: --bundles deb,rpm
|
||||
- name: windows
|
||||
platform: windows-latest
|
||||
rust_targets: ""
|
||||
args: --bundles nsis,msi
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_targets }}
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
- name: Install Linux packaging deps
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
xdg-utils \
|
||||
rpm
|
||||
|
||||
- name: Vendor bundled plugins
|
||||
shell: bash
|
||||
run: bash scripts/vendor-native.sh
|
||||
|
||||
- uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: .
|
||||
tauriScript: cargo tauri
|
||||
tagName: v__VERSION__
|
||||
releaseName: DeepSeek Harness Desktop v__VERSION__
|
||||
releaseBody: |
|
||||
跨平台安装包:Windows NSIS/MSI、macOS DMG(Apple Silicon 与 Intel)、Linux deb/rpm。
|
||||
Flatpak 由同一次 Release 工作流另外上传。
|
||||
|
||||
- Windows / macOS / deb / rpm 需要本机已安装 `dsh`(`npm i -g @deepseek-ai/dsh`),或设置 `DSH_DESKTOP_DSH_BIN`。
|
||||
- Flatpak 自带 Node.js 与 `dsh`。
|
||||
- macOS 包未公证,需在「系统设置 → 隐私与安全性」中允许打开。
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
includeUpdaterJson: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
flatpak:
|
||||
name: Flatpak
|
||||
needs: [test, version, vendor]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47
|
||||
options: --privileged
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vendor
|
||||
path: vendor
|
||||
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
bundle: io.github.tommyfang.DshDesktop.flatpak
|
||||
manifest-path: flatpak/io.github.tommyfang.DshDesktop.yml
|
||||
cache-key: flatpak-gnome-47-${{ hashFiles('flatpak/io.github.tommyfang.DshDesktop.yml', 'Cargo.lock') }}
|
||||
upload-artifact: false
|
||||
|
||||
- name: Attach Flatpak to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: DeepSeek Harness Desktop v${{ needs.version.outputs.version }}
|
||||
files: io.github.tommyfang.DshDesktop.flatpak
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Python helpers (scripts/, tests/)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
|
||||
# Rust / Cargo
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
|
||||
# Tauri generated schemas
|
||||
src-tauri/gen/
|
||||
|
||||
# Vendored dsh, ModLens, presets (make vendor)
|
||||
/vendor/
|
||||
|
||||
# Packaging
|
||||
/dist/
|
||||
/build/
|
||||
*.flatpak
|
||||
.flatpak-build/
|
||||
.flatpak-builder/
|
||||
.flatpak-repo/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
*~
|
||||
5419
Cargo.lock
generated
Normal file
9
Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[workspace]
|
||||
members = ["crates/dsh-core", "src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["TommyFang2077"]
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 TommyFang2077
|
||||
|
||||
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 ANY 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.
|
||||
136
Makefile
Normal file
@@ -0,0 +1,136 @@
|
||||
PYTHON ?= python3
|
||||
CARGO ?= cargo
|
||||
PREFIX ?= $(HOME)/.local
|
||||
APP_ID := io.github.tommyfang.DshDesktop
|
||||
DSH_VERSION := 0.1.0-rc.6
|
||||
MODLENS_VERSION := 3.16.6
|
||||
ANCHORED_COMMIT := ffb845c5480adc953392a6db6f8a98ede621174b
|
||||
ANCHORED_REPO := https://github.com/xiaobright/dsh-anchored-standard.git
|
||||
VENDOR_DIR := vendor/dsh-prefix
|
||||
MODLENS_DIR := vendor/modlens
|
||||
ANCHORED_DIR := vendor/anchored-standard
|
||||
ZERO_DIR := vendor/zero-anchored-standard
|
||||
FLATPAK ?= flatpak
|
||||
BUILDER ?= flatpak run --user org.flatpak.Builder
|
||||
BUILD_DIR ?= .flatpak-build
|
||||
REPO_DIR ?= .flatpak-repo
|
||||
MANIFEST := flatpak/$(APP_ID).yml
|
||||
VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
BUNDLE := dist/$(APP_ID)-$(VERSION).flatpak
|
||||
BIN := target/release/dsh-desktop
|
||||
|
||||
.PHONY: all run dev test vendor vendor-native vendor-anchored build install uninstall flatpak-build flatpak-export flatpak-install flatpak-bundle flatpak-run clean
|
||||
|
||||
all: test
|
||||
|
||||
run:
|
||||
$(CARGO) run -p dsh-desktop -- --no-update
|
||||
|
||||
dev:
|
||||
$(CARGO) run -p dsh-desktop -- --dev --verbose
|
||||
|
||||
build:
|
||||
$(CARGO) build -p dsh-desktop --release
|
||||
|
||||
test:
|
||||
$(CARGO) test -p dsh-core
|
||||
$(PYTHON) -m unittest discover -s tests -v
|
||||
|
||||
vendor:
|
||||
mkdir -p vendor
|
||||
rm -rf $(VENDOR_DIR) $(MODLENS_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)
|
||||
$(MAKE) vendor-anchored
|
||||
|
||||
vendor-native:
|
||||
MODLENS_VERSION=$(MODLENS_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)
|
||||
mkdir -p vendor/.anchored-src
|
||||
git -C vendor/.anchored-src init --initial-branch=main
|
||||
git -C vendor/.anchored-src remote add origin $(ANCHORED_REPO)
|
||||
git -C vendor/.anchored-src fetch --depth 1 origin $(ANCHORED_COMMIT)
|
||||
git -C vendor/.anchored-src checkout --detach FETCH_HEAD
|
||||
mkdir -p $(ANCHORED_DIR) $(ZERO_DIR)
|
||||
cp -R vendor/.anchored-src/preset/. $(ANCHORED_DIR)/
|
||||
cp -R vendor/.anchored-src/zero-anchored-standard/. $(ZERO_DIR)/
|
||||
cp vendor/.anchored-src/LICENSE vendor/.anchored-src/NOTICE $(ANCHORED_DIR)/
|
||||
cp vendor/.anchored-src/LICENSE vendor/.anchored-src/NOTICE $(ZERO_DIR)/
|
||||
printf '%s\n' $(ANCHORED_COMMIT) > $(ANCHORED_DIR)/.dsh-desktop-source
|
||||
printf '%s\n' $(ANCHORED_COMMIT) > $(ZERO_DIR)/.dsh-desktop-source
|
||||
$(PYTHON) scripts/localize_preset.py $(ANCHORED_DIR)/preset.yml
|
||||
$(PYTHON) scripts/localize_preset.py $(ZERO_DIR)/preset.yml zero
|
||||
rm -rf vendor/.anchored-src
|
||||
|
||||
install: build
|
||||
install -Dm755 $(BIN) $(DESTDIR)$(PREFIX)/bin/dsh-desktop
|
||||
install -Dm644 data/applications/$(APP_ID).desktop $(DESTDIR)$(PREFIX)/share/applications/$(APP_ID).desktop
|
||||
install -Dm644 data/metainfo/$(APP_ID).metainfo.xml $(DESTDIR)$(PREFIX)/share/metainfo/$(APP_ID).metainfo.xml
|
||||
for size in 16 24 32 48 64 128 256 512; do \
|
||||
install -Dm644 "data/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png" "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$${size}x$${size}/apps/$(APP_ID).png"; \
|
||||
done
|
||||
if [ -z "$(DESTDIR)" ]; then \
|
||||
update-desktop-database "$(PREFIX)/share/applications" >/dev/null 2>&1 || true; \
|
||||
fi
|
||||
if [ -d $(MODLENS_DIR)/node_modules/@liustack/modlens ]; then \
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
cp -R $(MODLENS_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/modlens; \
|
||||
fi
|
||||
if [ -f plugins/dsh-desktop-vision/package.json ]; then \
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
cp -R plugins/dsh-desktop-vision $(DESTDIR)$(PREFIX)/share/dsh-desktop/vision; \
|
||||
fi
|
||||
if [ -f $(ANCHORED_DIR)/preset.yml ]; then \
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
cp -R $(ANCHORED_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/anchored-standard; \
|
||||
fi
|
||||
if [ -f $(ZERO_DIR)/preset.yml ]; then \
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop/zero-anchored-standard; \
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/dsh-desktop; \
|
||||
cp -R $(ZERO_DIR) $(DESTDIR)$(PREFIX)/share/dsh-desktop/zero-anchored-standard; \
|
||||
fi
|
||||
@echo "installed to $(PREFIX) — make sure $(PREFIX)/bin is on PATH"
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(PREFIX)/bin/dsh-desktop
|
||||
rm -rf $(DESTDIR)$(PREFIX)/share/dsh-desktop
|
||||
rm -f $(DESTDIR)$(PREFIX)/share/applications/$(APP_ID).desktop
|
||||
rm -f $(DESTDIR)$(PREFIX)/share/metainfo/$(APP_ID).metainfo.xml
|
||||
for size in 16 24 32 48 64 128 256 512; do \
|
||||
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
|
||||
$(BUILDER) --user --force-clean --install-deps-from=flathub $(BUILD_DIR) $(MANIFEST)
|
||||
|
||||
$(VENDOR_DIR)/bin/dsh:
|
||||
$(MAKE) vendor
|
||||
|
||||
$(MODLENS_DIR)/node_modules/@liustack/modlens:
|
||||
$(MAKE) vendor
|
||||
|
||||
$(ANCHORED_DIR)/preset.yml $(ZERO_DIR)/preset.yml:
|
||||
$(MAKE) vendor-anchored
|
||||
|
||||
flatpak-export:
|
||||
$(FLATPAK) build-export $(REPO_DIR) $(BUILD_DIR) master
|
||||
|
||||
flatpak-install: flatpak-build flatpak-export
|
||||
$(FLATPAK) --user install -y "$(CURDIR)/$(REPO_DIR)" $(APP_ID)
|
||||
|
||||
flatpak-bundle: flatpak-build flatpak-export
|
||||
mkdir -p dist
|
||||
$(FLATPAK) build-bundle $(REPO_DIR) "$(BUNDLE)" $(APP_ID) master
|
||||
|
||||
flatpak-run:
|
||||
$(FLATPAK) run $(APP_ID)
|
||||
|
||||
clean:
|
||||
$(CARGO) clean
|
||||
rm -rf $(BUILD_DIR) $(REPO_DIR) build dist
|
||||
find . -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||
215
README.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# DeepSeek Harness Desktop
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/icon.png" width="96" alt="DeepSeek Harness" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>把官方 <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a>(<code>dsh</code>)WebUI 放进原生窗口。</strong><br />
|
||||
Tauri 2 壳 · 系统 WebView · 苹果风薄标题栏 · Linux / Windows / macOS
|
||||
</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="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>
|
||||
</p>
|
||||
|
||||
本仓库是第三方桌面壳,**不包含** DeepSeek Harness 源码。官方 WebUI 由本机或 Flatpak 内的 `dsh web` 提供;升级 `dsh` 后界面跟着升级,不必重打包前端。
|
||||
|
||||

|
||||
|
||||
## 功能
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| 原生窗口 | 启动 `dsh web --host 127.0.0.1 --port 0`,解析 stdout 里的随机端口,用系统 WebView 加载官方 WebUI |
|
||||
| 薄标题栏 | 左侧 `•••` 菜单(重新启动 / 在浏览器中打开),右侧最小化 · 缩放 · 关闭;不再占用一排后退/前进/刷新 |
|
||||
| 零重写 | 官方会话、工作区、插件、技能全部保留 |
|
||||
| 内置 ModLens | 启动时写入 `~/.dsh/profiles/web`,纯文本模型自动套视觉桥 |
|
||||
| 视觉设置页 | WebUI **设置 → 视觉模型** 配置引擎,写入 `~/.modlens/config.json` |
|
||||
| 内置锚定预设 | **锚定式标准(实验)**、**零工具锚定式标准(实验)** 写入 `~/.dsh/.agent-presets/` |
|
||||
| 生命周期 | 启动页显示状态;关窗口停掉 `dsh web`;崩溃可从标题栏重新启动 |
|
||||
|
||||

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

|
||||
|
||||
## 内置插件与预设
|
||||
|
||||
应用启动时会把下面这些东西同步到用户目录。版本钉死在 [Makefile](Makefile);第三方原文许可证见 [docs/licenses/](docs/licenses/) 与 [THIRD_PARTY.md](THIRD_PARTY.md)。
|
||||
|
||||
### 1. DeepSeek Harness(`dsh`)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| 上游 | [@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` |
|
||||
|
||||
解析顺序:
|
||||
|
||||
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) |
|
||||
| 作用 | 给纯文本对话模型补视觉能力(粘贴图片即可)。已声明视觉能力的模型(如 Qwen)不会走这条桥 |
|
||||
| 安装位置 | 启动时复制到 `~/.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` 本地化) |
|
||||
| 安装位置 | `~/.dsh/.agent-presets/anchored-standard` 与 `zero-anchored-standard` |
|
||||
|
||||
NOTICE 写明:预设改编自 DeepSeek Harness Standard agent preset([deepseek-harness@47f9438](https://github.com/deepseek-ai/deepseek-harness))。这是社区实验 preset,**不是** DeepSeek 官方预设。若用户还没有默认 preset,桌面会把默认设为锚定式标准。
|
||||
|
||||
## 安装包
|
||||
|
||||
打 `v*` 标签(例如 `git tag v0.1.0 && git push origin v0.1.0`)后,[Release 工作流](.github/workflows/release.yml)会测试、打包并发布:
|
||||
|
||||
| 平台 | 产物 | 运行时要求 |
|
||||
| --- | --- | --- |
|
||||
| Windows | NSIS `.exe`、MSI | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载);本机 `dsh` |
|
||||
| macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「系统设置 → 隐私与安全性」允许;本机 `dsh` |
|
||||
| Linux | `.deb`、`.rpm` | WebKitGTK 4.1;本机 `dsh` |
|
||||
| Linux | `.flatpak` | **自带** Node.js 24 与 `@deepseek-ai/dsh`,不需要本机安装 dsh |
|
||||
|
||||
从 [GitHub Releases](https://github.com/TommyFang2077/dsh-desktop/releases) 下载对应文件。
|
||||
|
||||
本机 `dsh`:
|
||||
|
||||
```bash
|
||||
npm install -g @deepseek-ai/dsh
|
||||
# 或
|
||||
npx --yes @deepseek-ai/dsh --version
|
||||
```
|
||||
|
||||
## 从源码运行
|
||||
|
||||
开发依赖:Rust stable、系统 WebView。
|
||||
|
||||
- Linux:GTK 3 + WebKitGTK 4.1(Fedora:`gtk3-devel webkit2gtk4.1-devel`;Debian/Ubuntu:`libgtk-3-dev libwebkit2gtk-4.1-dev`)
|
||||
- macOS:WKWebView(Xcode Command Line Tools)
|
||||
- Windows:WebView2
|
||||
|
||||
```bash
|
||||
git clone https://github.com/TommyFang2077/dsh-desktop.git
|
||||
cd dsh-desktop
|
||||
make vendor-native # ModLens + 锚定预设(Tauri 打包资源)
|
||||
make run # 普通模式(跳过更新,便于开发)
|
||||
make dev # 开 WebView 检查器和调试日志
|
||||
cargo run -p dsh-desktop -- --cwd ~/your-project
|
||||
```
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
`make install` 把二进制装到 `~/.local/bin/dsh-desktop`,应用菜单里会出现 **DeepSeek Harness**。
|
||||
|
||||
本地打原生包(先 `make vendor-native`):
|
||||
|
||||
```bash
|
||||
cargo tauri build --bundles deb,rpm # Linux
|
||||
cargo tauri build --bundles nsis,msi # Windows
|
||||
cargo tauri build --bundles app,dmg # macOS
|
||||
```
|
||||
|
||||
## Flatpak
|
||||
|
||||
Flatpak 是唯一把 `dsh` 打进包内的渠道。
|
||||
|
||||
```bash
|
||||
flatpak remote-add --user --if-not-exists flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install --user -y flathub org.gnome.Sdk//47 org.flatpak.Builder \
|
||||
org.freedesktop.Sdk.Extension.rust-stable//24.08 \
|
||||
org.freedesktop.Sdk.Extension.node24//24.08
|
||||
|
||||
make vendor
|
||||
make flatpak-build
|
||||
make flatpak-install
|
||||
make flatpak-run
|
||||
make flatpak-bundle
|
||||
```
|
||||
|
||||
清单:[flatpak/io.github.tommyfang.DshDesktop.yml](flatpak/io.github.tommyfang.DshDesktop.yml)。权限:网络、宿主文件系统、Wayland/X11、下载目录。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
dsh-desktop/
|
||||
├── ui/ # 启动页 + 注入到 WebUI 的标题栏
|
||||
├── src-tauri/ # Tauri 窗口、命令、deb/rpm/nsis/dmg
|
||||
├── crates/dsh-core/ # 启动 / 更新 / ModLens / 预设 / 剪贴板
|
||||
├── plugins/dsh-desktop-vision/ # 设置 → 视觉模型
|
||||
├── data/ # .desktop、图标、AppStream
|
||||
├── flatpak/
|
||||
├── docs/screenshots/ # README 截图
|
||||
├── docs/licenses/ # 第三方许可证副本
|
||||
├── vendor/ # make vendor 生成(git 忽略)
|
||||
├── scripts/vendor-native.sh
|
||||
├── scripts/localize_preset.py
|
||||
└── .github/workflows/ # 测试 + 多平台发布
|
||||
```
|
||||
|
||||
## 图标与商标
|
||||
|
||||
应用图标使用 [Icons8 上的 DeepSeek 图标](https://icons8.com/icon/YWOidjGxCpFW/deepseek)。DeepSeek 名称与鲸鱼标志归 DeepSeek 所有。本项目是独立第三方桌面壳,与 DeepSeek、ModLens、Anchored Standard 的作者均无从属关系。
|
||||
|
||||
## License
|
||||
|
||||
本仓库源码为 [MIT](LICENSE),Copyright © 2026 TommyFang2077。
|
||||
|
||||
运行时还会用到上游 MIT 组件,版权仍归原作者,详见 [THIRD_PARTY.md](THIRD_PARTY.md)。
|
||||
34
THIRD_PARTY.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Third-party notices
|
||||
|
||||
DeepSeek Harness Desktop (this repository) is MIT-licensed. It is a native
|
||||
shell around other projects. Those projects keep their own copyright and
|
||||
license. Copies of the relevant texts live in `docs/licenses/`.
|
||||
|
||||
This project is **not** affiliated with, endorsed by, or maintained by
|
||||
DeepSeek, liustack, or xiaobright.
|
||||
|
||||
## Bundled or launched at runtime
|
||||
|
||||
| Component | Upstream | Version / pin | License | How this app uses it |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| DeepSeek Harness (`@deepseek-ai/dsh`) | [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) | `0.1.0-rc.6` | MIT, © 2026 DeepSeek | Official WebUI. Not shipped as source. Flatpak vendors the npm package; other packages call a host `dsh`. See `docs/licenses/deepseek-harness.LICENSE`. |
|
||||
| ModLens (`@liustack/modlens`) | [liustack/modlens](https://github.com/liustack/modlens) | `3.16.6` | MIT, © 2026 Leon Liu (liustack) | Copied into `~/.dsh/profiles/web` so text-only models can read images. See `docs/licenses/modlens.LICENSE`. |
|
||||
| Anchored Standard | [xiaobright/dsh-anchored-standard](https://github.com/xiaobright/dsh-anchored-standard) | commit `ffb845c5480adc953392a6db6f8a98ede621174b` | MIT, © 2026 xiaobright; portions © 2026 DeepSeek | Localized as **锚定式标准(实验)** and **零工具锚定式标准(实验)**, written to `~/.dsh/.agent-presets/`. See `docs/licenses/dsh-anchored-standard.LICENSE` and `.NOTICE`. |
|
||||
| `dsh-desktop-vision` | this repo `plugins/dsh-desktop-vision/` | `0.1.4` | MIT, © 2026 TommyFang2077 | Settings page **设置 → 视觉模型**; writes `~/.modlens/config.json`. |
|
||||
|
||||
The Anchored Standard NOTICE records that the presets adapt the DeepSeek
|
||||
Harness Standard agent preset from
|
||||
https://github.com/deepseek-ai/deepseek-harness
|
||||
(commit `47f943859bef60e4160492346772ded9b24f765a`).
|
||||
|
||||
## App icon
|
||||
|
||||
The application icon is the [Icons8 DeepSeek icon](https://icons8.com/icon/YWOidjGxCpFW/deepseek).
|
||||
DeepSeek and the whale mark belong to their owners.
|
||||
|
||||
## Tauri and system WebView
|
||||
|
||||
The window is built with [Tauri 2](https://tauri.app/) (MIT / Apache-2.0) and
|
||||
the platform WebView (WebKitGTK 4.1, WKWebView, or WebView2). Those components
|
||||
are linked or bundled by the respective platform toolchains, not copied into
|
||||
this source tree.
|
||||
13
bin/dsh-desktop
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Run a built binary from the checkout, or fall back to cargo.
|
||||
set -e
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
for candidate in \
|
||||
"$ROOT/target/release/dsh-desktop" \
|
||||
"$ROOT/target/debug/dsh-desktop"
|
||||
do
|
||||
if [ -x "$candidate" ]; then
|
||||
exec "$candidate" "$@"
|
||||
fi
|
||||
done
|
||||
exec cargo run --manifest-path "$ROOT/Cargo.toml" -p dsh-desktop -- "$@"
|
||||
23
crates/dsh-core/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "dsh-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Launcher, updater, and profile helpers for DeepSeek Harness Desktop"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
dirs = "6"
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shlex = "1"
|
||||
thiserror = "2"
|
||||
which = "8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
227
crates/dsh-core/src/clipboard.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
|
||||
pub const IMAGE_MIMES: &[&str] = &[
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
pub const INGEST_JS: &str = include_str!("../../../ui/inject/ingest.js");
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ClipboardFile {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub mime: String,
|
||||
pub b64: String,
|
||||
}
|
||||
|
||||
pub fn is_image_mime(mime: &str) -> bool {
|
||||
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
|
||||
let mime = if mime == "image/jpg" {
|
||||
"image/jpeg"
|
||||
} else {
|
||||
mime.as_str()
|
||||
};
|
||||
IMAGE_MIMES.contains(&mime) || mime == "image/jpeg"
|
||||
}
|
||||
|
||||
pub fn filename_for_mime(mime: &str, index: usize) -> String {
|
||||
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",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/bmp" => "bmp",
|
||||
"image/tiff" => "tiff",
|
||||
_ => "png",
|
||||
};
|
||||
if index == 0 {
|
||||
format!("clipboard.{ext}")
|
||||
} else {
|
||||
format!("clipboard-{}.{ext}", index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_ingest_call(files: &[ClipboardFile]) -> String {
|
||||
let payload = serde_json::to_string(files).unwrap_or_else(|_| "[]".into());
|
||||
format!("window.__dshDesktopPasteFiles && window.__dshDesktopPasteFiles({payload});")
|
||||
}
|
||||
|
||||
pub fn parse_uri_list(payload: &str) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
for raw in payload.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("file:") {
|
||||
if let Ok(url) = url_parse_file(rest) {
|
||||
paths.push(url);
|
||||
}
|
||||
} else if line.starts_with('/') {
|
||||
paths.push(line.to_string());
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn url_parse_file(rest: &str) -> Result<String, ()> {
|
||||
// file:///home/me/Pictures/shot.png or file://localhost/home/...
|
||||
let uri = if rest.starts_with("//") {
|
||||
format!("file:{rest}")
|
||||
} else {
|
||||
format!("file:{rest}")
|
||||
};
|
||||
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);
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
if let Some(path) = decoded.strip_prefix("file:") {
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
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)
|
||||
{
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
pub fn files_from_paths<I, S>(paths: I) -> Vec<ClipboardFile>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
for (index, raw) in paths.into_iter().enumerate() {
|
||||
let path = Path::new(raw.as_ref());
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let suffix = path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let mime = match suffix.as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
"bmp" => "image/bmp",
|
||||
"tif" | "tiff" => "image/tiff",
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(data) = std::fs::read(path) else {
|
||||
continue;
|
||||
};
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| filename_for_mime(mime, index));
|
||||
out.push(ClipboardFile {
|
||||
name,
|
||||
mime: mime.to_string(),
|
||||
b64: base64::engine::general_purpose::STANDARD.encode(data),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn file_from_bytes(name: String, mime: String, data: Vec<u8>) -> Option<ClipboardFile> {
|
||||
if data.is_empty() || !is_image_mime(&mime) {
|
||||
return None;
|
||||
}
|
||||
Some(ClipboardFile {
|
||||
name,
|
||||
mime: mime.split(';').next().unwrap_or(&mime).trim().to_string(),
|
||||
b64: base64::engine::general_purpose::STANDARD.encode(data),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mime_and_filename() {
|
||||
assert!(is_image_mime("image/png"));
|
||||
assert!(is_image_mime("image/jpeg; charset=binary"));
|
||||
assert!(is_image_mime("image/jpg"));
|
||||
assert!(!is_image_mime("text/plain"));
|
||||
assert_eq!(filename_for_mime("image/png", 0), "clipboard.png");
|
||||
assert_eq!(filename_for_mime("image/jpeg", 1), "clipboard-2.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_js_defines_helper() {
|
||||
assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles"));
|
||||
assert!(INGEST_JS.contains("DragEvent"));
|
||||
assert!(INGEST_JS.contains("new File"));
|
||||
assert!(INGEST_JS.contains("text/uri-list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ingest_call_embeds_payload() {
|
||||
let files = [ClipboardFile {
|
||||
name: "shot.png".into(),
|
||||
mime: "image/png".into(),
|
||||
b64: base64::engine::general_purpose::STANDARD.encode(b"\x89PNG"),
|
||||
}];
|
||||
let script = build_ingest_call(&files);
|
||||
assert!(script.contains("__dshDesktopPasteFiles"));
|
||||
assert!(script.contains("shot.png"));
|
||||
assert!(script.contains("image/png"));
|
||||
assert!(!script.contains(", \"hi\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_file_uri() {
|
||||
let payload = "# comment\nfile:///home/me/Pictures/shot.png\n";
|
||||
assert_eq!(
|
||||
parse_uri_list(payload),
|
||||
vec!["/home/me/Pictures/shot.png".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plain_path() {
|
||||
assert_eq!(parse_uri_list("/tmp/a.jpg"), vec!["/tmp/a.jpg".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_images() {
|
||||
assert!(files_from_paths(["/no/such/file.txt"]).is_empty());
|
||||
}
|
||||
}
|
||||
437
crates/dsh-core/src/launcher.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
use regex::Regex;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::paths::{home_dir, is_flatpak};
|
||||
use crate::updater::update_dsh_bin;
|
||||
use crate::{ENV_BIN_OVERRIDE, ENV_CWD_OVERRIDE};
|
||||
|
||||
pub const DSH_DEFAULT_HOST: &str = "127.0.0.1";
|
||||
pub const URL_TIMEOUT_SECONDS: u64 = 120;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DshNotFound {
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
}
|
||||
|
||||
impl DshNotFound {
|
||||
fn new(msg: impl Into<String>) -> Self {
|
||||
Self::Message(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strip_ansi(text: &str) -> String {
|
||||
let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").expect("ansi regex");
|
||||
re.replace_all(text, "").into_owned()
|
||||
}
|
||||
|
||||
pub fn parse_dsh_url(line: &str) -> Option<String> {
|
||||
let re = Regex::new(r"https?://(?:\[[0-9a-fA-F:]+\]|[^/\s:]+)(?::\d+)?(?:/[^\s]*)?")
|
||||
.expect("url regex");
|
||||
re.find(&strip_ansi(line)).map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
pub fn default_workspace() -> PathBuf {
|
||||
if let Ok(raw) = std::env::var(ENV_CWD_OVERRIDE) {
|
||||
if !raw.is_empty() {
|
||||
return PathBuf::from(&raw)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| PathBuf::from(raw));
|
||||
}
|
||||
}
|
||||
if is_flatpak() {
|
||||
return home_dir();
|
||||
}
|
||||
std::env::current_dir().unwrap_or_else(|_| home_dir())
|
||||
}
|
||||
|
||||
pub fn find_npx_dsh_bins() -> Vec<PathBuf> {
|
||||
let pattern = home_dir().join(".npm/_npx/*/node_modules/.bin/dsh");
|
||||
let mut bins = Vec::new();
|
||||
if let Some(globbed) = pattern.to_str() {
|
||||
if let Ok(paths) = glob_simple(globbed) {
|
||||
for path in paths {
|
||||
if is_executable(&path) {
|
||||
bins.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bins.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
|
||||
bins.reverse();
|
||||
bins
|
||||
}
|
||||
|
||||
fn glob_simple(pattern: &str) -> std::io::Result<Vec<PathBuf>> {
|
||||
// Only the `_npx/*/` segment is a wildcard.
|
||||
let Some((prefix, rest)) = pattern.split_once('*') else {
|
||||
return Ok(vec![PathBuf::from(pattern)]);
|
||||
};
|
||||
let suffix = rest.trim_start_matches('/');
|
||||
let parent = Path::new(prefix);
|
||||
let mut out = Vec::new();
|
||||
if parent.is_dir() {
|
||||
for entry in std::fs::read_dir(parent)? {
|
||||
let entry = entry?;
|
||||
let candidate = entry.path().join(suffix);
|
||||
if candidate.is_file() {
|
||||
out.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub struct DshLauncher {
|
||||
dsh_bin_override: Option<String>,
|
||||
in_flatpak: bool,
|
||||
pub workspace: PathBuf,
|
||||
}
|
||||
|
||||
impl DshLauncher {
|
||||
pub fn new(dsh_bin: Option<String>, workspace: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
dsh_bin_override: dsh_bin,
|
||||
in_flatpak: is_flatpak(),
|
||||
workspace: workspace.unwrap_or_else(default_workspace),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(&self) -> Result<Vec<String>, DshNotFound> {
|
||||
let mut candidates: Vec<Vec<String>> = Vec::new();
|
||||
|
||||
if let Ok(env_override) = std::env::var(ENV_BIN_OVERRIDE) {
|
||||
if !env_override.is_empty() {
|
||||
let parts = shlex::split(&env_override).ok_or_else(|| {
|
||||
DshNotFound::new(format!("{ENV_BIN_OVERRIDE} 不是合法的命令"))
|
||||
})?;
|
||||
candidates.push(parts);
|
||||
}
|
||||
}
|
||||
if let Some(cli) = &self.dsh_bin_override {
|
||||
let parts = shlex::split(cli)
|
||||
.ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?;
|
||||
candidates.push(parts);
|
||||
}
|
||||
|
||||
let updated = update_dsh_bin();
|
||||
if is_executable(&updated) {
|
||||
candidates.push(vec![updated.to_string_lossy().into_owned()]);
|
||||
}
|
||||
|
||||
if self.in_flatpak {
|
||||
if let Some(bundled) = which::which("dsh").ok() {
|
||||
candidates.push(vec![bundled.to_string_lossy().into_owned()]);
|
||||
}
|
||||
for path in crate::updater::BUNDLED_DSH {
|
||||
let path = PathBuf::from(path);
|
||||
if is_executable(&path)
|
||||
&& candidates
|
||||
.first()
|
||||
.and_then(|c| c.first())
|
||||
.map(|s| s.as_str())
|
||||
!= Some(path.to_string_lossy().as_ref())
|
||||
{
|
||||
candidates.push(vec![path.to_string_lossy().into_owned()]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let local_bin = dsh_bin_name(&home_dir().join(".local/bin"));
|
||||
if is_executable(&local_bin) {
|
||||
candidates.push(vec![local_bin.to_string_lossy().into_owned()]);
|
||||
}
|
||||
if let Some(cached) = find_npx_dsh_bins().into_iter().next() {
|
||||
candidates.push(vec![cached.to_string_lossy().into_owned()]);
|
||||
}
|
||||
if let Ok(host) = which::which("dsh") {
|
||||
candidates.push(vec![host.to_string_lossy().into_owned()]);
|
||||
}
|
||||
if which::which("npx").is_ok() {
|
||||
candidates.push(vec![
|
||||
"npx".into(),
|
||||
"--yes".into(),
|
||||
"@deepseek-ai/dsh".into(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
let chosen = candidates.into_iter().next().ok_or_else(|| {
|
||||
if self.in_flatpak {
|
||||
DshNotFound::new(
|
||||
"此 Flatpak 没有内置 dsh 可执行文件。\n请重新构建/安装 io.github.tommyfang.DshDesktop。",
|
||||
)
|
||||
} else {
|
||||
DshNotFound::new(
|
||||
"找不到 DeepSeek Harness (dsh),也没有可用的 npx。\n请先安装:npm install -g @deepseek-ai/dsh\n或用 DSH_DESKTOP_DSH_BIN 指定 dsh 的完整路径。",
|
||||
)
|
||||
}
|
||||
})?;
|
||||
Ok(chosen)
|
||||
}
|
||||
|
||||
pub fn web_argv(&self) -> Result<Vec<String>, DshNotFound> {
|
||||
let mut argv = self.resolve()?;
|
||||
argv.extend([
|
||||
"web".into(),
|
||||
"--host".into(),
|
||||
DSH_DEFAULT_HOST.into(),
|
||||
"--port".into(),
|
||||
"0".into(),
|
||||
]);
|
||||
Ok(argv)
|
||||
}
|
||||
|
||||
pub fn start(&self) -> Result<DshProcess, DshNotFound> {
|
||||
let argv = self.web_argv()?;
|
||||
let mut cmd = Command::new(&argv[0]);
|
||||
cmd.args(&argv[1..])
|
||||
.current_dir(&self.workspace)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.stdin(Stdio::null());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
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 stdout = child.stdout.take();
|
||||
Ok(DshProcess::new(child, stdout, self.in_flatpak))
|
||||
}
|
||||
}
|
||||
|
||||
fn dsh_bin_name(dir: &Path) -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let cmd = dir.join("dsh.cmd");
|
||||
if cmd.is_file() {
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
dir.join("dsh")
|
||||
}
|
||||
|
||||
pub fn is_executable(path: &Path) -> bool {
|
||||
if !path.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DshProcess {
|
||||
child: Arc<Mutex<Child>>,
|
||||
pub in_flatpak: bool,
|
||||
pub url: Arc<Mutex<Option<String>>>,
|
||||
pub lines: Arc<Mutex<VecDeque<String>>>,
|
||||
pub started_at: Instant,
|
||||
}
|
||||
|
||||
impl DshProcess {
|
||||
fn new(child: Child, stdout: Option<std::process::ChildStdout>, in_flatpak: bool) -> Self {
|
||||
let proc = Self {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
in_flatpak,
|
||||
url: Arc::new(Mutex::new(None)),
|
||||
lines: Arc::new(Mutex::new(VecDeque::with_capacity(400))),
|
||||
started_at: Instant::now(),
|
||||
};
|
||||
if let Some(stdout) = stdout {
|
||||
let lines = Arc::clone(&proc.lines);
|
||||
let url = Arc::clone(&proc.url);
|
||||
thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let clean = strip_ansi(&line);
|
||||
let trimmed = clean.trim_end();
|
||||
if !trimmed.is_empty() {
|
||||
if let Ok(mut buf) = lines.lock() {
|
||||
if buf.len() == 400 {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(found) = parse_dsh_url(&line) {
|
||||
if let Ok(mut slot) = url.lock() {
|
||||
if slot.is_none() {
|
||||
*slot = Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
proc
|
||||
}
|
||||
|
||||
pub fn poll(&self) -> Option<i32> {
|
||||
self.child
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut child| child.try_wait().ok().flatten().and_then(|s| s.code()))
|
||||
}
|
||||
|
||||
pub fn take_url(&self) -> Option<String> {
|
||||
self.url.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
pub fn snapshot_lines(&self) -> Vec<String> {
|
||||
self.lines
|
||||
.lock()
|
||||
.map(|g| g.iter().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
let Ok(mut child) = self.child.lock() else {
|
||||
return;
|
||||
};
|
||||
if child.try_wait().ok().flatten().is_some() {
|
||||
return;
|
||||
}
|
||||
let pid = child.id();
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::killpg(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < std::time::Duration::from_secs(3) {
|
||||
if child.try_wait().ok().flatten().is_some() {
|
||||
return;
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::killpg(pid as i32, libc::SIGKILL);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DshProcess {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn parse_ipv4_url() {
|
||||
assert_eq!(
|
||||
parse_dsh_url("dsh web: http://127.0.0.1:39095"),
|
||||
Some("http://127.0.0.1:39095".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ipv6_url() {
|
||||
assert_eq!(
|
||||
parse_dsh_url("dsh web: http://[::1]:39096"),
|
||||
Some("http://[::1]:39096".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_with_trailing_text() {
|
||||
assert_eq!(
|
||||
parse_dsh_url("booted profile web on http://127.0.0.1:39095 (ready)"),
|
||||
Some("http://127.0.0.1:39095".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_url() {
|
||||
assert_eq!(parse_dsh_url("waiting for network…"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_ansi_codes() {
|
||||
assert_eq!(strip_ansi("\x1b[36mhttp://x\x1b[0m"), "http://x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_uses_env_override() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var(ENV_CWD_OVERRIDE, "/tmp/some-workspace");
|
||||
let ws = default_workspace();
|
||||
std::env::remove_var(ENV_CWD_OVERRIDE);
|
||||
assert!(ws.ends_with("some-workspace") || ws == PathBuf::from("/tmp/some-workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var(ENV_BIN_OVERRIDE, "echo");
|
||||
let argv = DshLauncher::new(None, None).resolve().unwrap();
|
||||
std::env::remove_var(ENV_BIN_OVERRIDE);
|
||||
assert_eq!(argv, vec!["echo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_env_override_raises() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var(ENV_BIN_OVERRIDE, "'unterminated");
|
||||
let err = DshLauncher::new(None, None).resolve();
|
||||
std::env::remove_var(ENV_BIN_OVERRIDE);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_override() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var(ENV_BIN_OVERRIDE);
|
||||
let argv = DshLauncher::new(Some("/opt/dsh/bin/dsh".into()), None)
|
||||
.resolve()
|
||||
.unwrap();
|
||||
assert_eq!(argv, vec!["/opt/dsh/bin/dsh"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_argv_shape() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var(ENV_BIN_OVERRIDE, "dsh-test");
|
||||
let argv = DshLauncher::new(None, None).web_argv().unwrap();
|
||||
std::env::remove_var(ENV_BIN_OVERRIDE);
|
||||
assert_eq!(&argv[0..4], ["dsh-test", "web", "--host", "127.0.0.1"]);
|
||||
assert!(argv.contains(&"--port".into()));
|
||||
assert_eq!(argv.last().map(String::as_str), Some("0"));
|
||||
}
|
||||
}
|
||||
21
crates/dsh-core/src/lib.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Shared desktop-shell logic for DeepSeek Harness Desktop.
|
||||
//!
|
||||
//! This crate has no GUI dependency. The Tauri (or any other) shell starts
|
||||
//! `dsh web`, keeps the bundled plugins/presets current, and embeds the
|
||||
//! official WebUI.
|
||||
|
||||
pub mod clipboard;
|
||||
pub mod launcher;
|
||||
pub mod modlens;
|
||||
pub mod paths;
|
||||
pub mod preset;
|
||||
pub mod updater;
|
||||
|
||||
pub const APP_ID: &str = "io.github.tommyfang.DshDesktop";
|
||||
pub const APP_NAME: &str = "DeepSeek Harness";
|
||||
pub const APP_SUMMARY: &str = "Desktop shell for DeepSeek Harness";
|
||||
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";
|
||||
473
crates/dsh-core/src/modlens.rs
Normal file
@@ -0,0 +1,473 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths};
|
||||
|
||||
pub const PACKAGE: &str = "@liustack/modlens";
|
||||
pub const VISION_PACKAGE: &str = "dsh-desktop-vision";
|
||||
pub const MODLENS_VERSION: &str = "3.16.6";
|
||||
pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js");
|
||||
|
||||
pub const MANAGED_OVERLAY: &str = "\
|
||||
# dsh-desktop manages this modlens overlay (wrap every text-only model).
|
||||
- id: modlens
|
||||
config:
|
||||
autoRead: true
|
||||
families:
|
||||
- \"\"
|
||||
";
|
||||
|
||||
const OFFICIAL_TEXT_PROVIDERS: &[(&str, &str)] = &[
|
||||
("deepseek-official", "deepseek-modlens"),
|
||||
("deepseek", "deepseek-modlens"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModlensEnsureResult {
|
||||
pub status: &'static str,
|
||||
pub version: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn web_profile_dir() -> PathBuf {
|
||||
dsh_home().join("profiles/web")
|
||||
}
|
||||
|
||||
fn package_dir(prefix: &Path) -> PathBuf {
|
||||
prefix.join("node_modules/@liustack/modlens")
|
||||
}
|
||||
|
||||
pub fn read_modlens_version(prefix: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(package_dir(prefix).join("package.json")).ok()?;
|
||||
let data: Value = serde_json::from_str(&text).ok()?;
|
||||
data.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option<PathBuf> {
|
||||
paths
|
||||
.find_dir("vision", "package.json")
|
||||
.or_else(|| paths.find_dir("dsh-desktop-vision", "package.json"))
|
||||
.or_else(|| paths.find_dir("plugins/dsh-desktop-vision", "package.json"))
|
||||
.filter(|p| p.join("client.js").is_file())
|
||||
}
|
||||
|
||||
pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
|
||||
paths
|
||||
.find_dir("modlens", "node_modules/@liustack/modlens")
|
||||
.or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens"))
|
||||
}
|
||||
|
||||
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)?;
|
||||
let dest_nm = profile.join("node_modules");
|
||||
let src_nm = src_prefix.join("node_modules");
|
||||
for dep in ["commander", "undici"] {
|
||||
let src_dep = src_nm.join(dep);
|
||||
if src_dep.is_dir() {
|
||||
copy_tree(&src_dep, &dest_nm.join(dep), true)?;
|
||||
}
|
||||
}
|
||||
let fallback = dsh_home().join("profiles/node_modules/@liustack/modlens");
|
||||
let _ = replace_symlink(&fallback, &dest_pkg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_pkg_version(dir: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(dir.join("package.json")).ok()?;
|
||||
let data: Value = serde_json::from_str(&text).ok()?;
|
||||
data.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
|
||||
let Some(src) = bundled_vision_plugin(paths) 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() {
|
||||
return false;
|
||||
}
|
||||
let fallback = dsh_home().join("profiles/node_modules").join(VISION_PACKAGE);
|
||||
let _ = replace_symlink(&fallback, &dest);
|
||||
true
|
||||
}
|
||||
|
||||
fn ensure_manifest(profile: &Path, packages: &BTreeMap<String, String>) -> std::io::Result<()> {
|
||||
let path = profile.join("package.json");
|
||||
let mut data = if path.is_file() {
|
||||
serde_json::from_str(&std::fs::read_to_string(&path)?).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({
|
||||
"name": "dsh-profile-web",
|
||||
"private": true,
|
||||
"dsh": {"profile": {"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]}}
|
||||
})
|
||||
};
|
||||
{
|
||||
let obj = data.as_object_mut().unwrap();
|
||||
obj.entry("dependencies").or_insert_with(|| json!({}));
|
||||
let dsh = obj.entry("dsh").or_insert_with(|| json!({}));
|
||||
let profile_meta = dsh
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.entry("profile")
|
||||
.or_insert_with(|| json!({}));
|
||||
let bundles = profile_meta
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.entry("bundles")
|
||||
.or_insert_with(|| json!(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]));
|
||||
if !bundles.is_array() {
|
||||
*bundles = json!(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]);
|
||||
}
|
||||
}
|
||||
for (name, version) in packages {
|
||||
data["dependencies"][name] = json!(version);
|
||||
let arr = data["dsh"]["profile"]["bundles"].as_array_mut().unwrap();
|
||||
if !arr.iter().any(|v| v.as_str() == Some(name)) {
|
||||
arr.push(json!(name));
|
||||
}
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(&data)? + "\n")
|
||||
}
|
||||
|
||||
pub fn ensure_modlens_overlay(text: &str) -> String {
|
||||
let body = text.replace("\r\n", "\n");
|
||||
let stripped = body.trim();
|
||||
if stripped.is_empty() || stripped == "[]" || stripped == "#" {
|
||||
return MANAGED_OVERLAY.to_string();
|
||||
}
|
||||
let re = Regex::new(r"(?m)^- id:\s*modlens\s*$").unwrap();
|
||||
if re.is_match(&body) {
|
||||
return patch_existing_modlens_entry(&body);
|
||||
}
|
||||
let mut body = body;
|
||||
if !body.ends_with('\n') {
|
||||
body.push('\n');
|
||||
}
|
||||
body.push('\n');
|
||||
body.push_str(MANAGED_OVERLAY);
|
||||
body
|
||||
}
|
||||
|
||||
fn patch_existing_modlens_entry(body: &str) -> String {
|
||||
let lines: Vec<&str> = body.split('\n').collect();
|
||||
let mut start = None;
|
||||
let start_re = Regex::new(r"^- id:\s*modlens\s*$").unwrap();
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
if start_re.is_match(line) {
|
||||
start = Some(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(start) = start else {
|
||||
let mut out = body.trim_end().to_string();
|
||||
out.push_str("\n\n");
|
||||
out.push_str(MANAGED_OVERLAY);
|
||||
return out;
|
||||
};
|
||||
let mut end = lines.len();
|
||||
for (index, line) in lines.iter().enumerate().skip(start + 1) {
|
||||
if line.starts_with("- ") && !line.starts_with(' ') {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let block: Vec<String> = lines[start..end].iter().map(|s| (*s).to_string()).collect();
|
||||
let new_block = force_wrap_all_config(block).join("\n");
|
||||
let new_block = new_block.trim_end();
|
||||
let mut out = String::new();
|
||||
out.push_str(&lines[..start].join("\n"));
|
||||
if start > 0 && !out.ends_with('\n') && !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
// reconstruct like Python: lines[:start] + new_block.split + lines[end:]
|
||||
let mut combined: Vec<String> = lines[..start].iter().map(|s| (*s).to_string()).collect();
|
||||
combined.extend(new_block.split('\n').map(|s| s.to_string()));
|
||||
combined.extend(lines[end..].iter().map(|s| (*s).to_string()));
|
||||
let mut text = combined.join("\n");
|
||||
text = text.trim_end().to_string();
|
||||
text.push('\n');
|
||||
text
|
||||
}
|
||||
|
||||
fn force_wrap_all_config(block: Vec<String>) -> Vec<String> {
|
||||
let config_re = Regex::new(r"^\s+config:\s*$").unwrap();
|
||||
let mut block = block;
|
||||
if !block.iter().any(|line| config_re.is_match(line)) {
|
||||
block.push(" config:".into());
|
||||
}
|
||||
let mut stripped = Vec::new();
|
||||
let mut skipping_families = false;
|
||||
let families_re = Regex::new(r"^\s+families:\s*").unwrap();
|
||||
let item_re = Regex::new(r"^\s+- ").unwrap();
|
||||
for line in block {
|
||||
if skipping_families {
|
||||
if item_re.is_match(&line) || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
skipping_families = false;
|
||||
}
|
||||
if families_re.is_match(&line) {
|
||||
skipping_families = true;
|
||||
continue;
|
||||
}
|
||||
stripped.push(line);
|
||||
}
|
||||
let autoread_re = Regex::new(r"^\s+autoRead:\s*").unwrap();
|
||||
let has_autoread = stripped.iter().any(|line| autoread_re.is_match(line));
|
||||
let mut out = Vec::new();
|
||||
let mut inserted = false;
|
||||
for line in stripped {
|
||||
if autoread_re.is_match(&line) {
|
||||
let replaced = Regex::new(r"^(\s+autoRead:\s*).*$")
|
||||
.unwrap()
|
||||
.replace(&line, "${1}true");
|
||||
out.push(replaced.into_owned());
|
||||
out.push(" families:".into());
|
||||
out.push(" - \"\"".into());
|
||||
inserted = true;
|
||||
continue;
|
||||
}
|
||||
let is_config = config_re.is_match(&line);
|
||||
out.push(line);
|
||||
if is_config && !has_autoread && !inserted {
|
||||
out.push(" autoRead: true".into());
|
||||
out.push(" families:".into());
|
||||
out.push(" - \"\"".into());
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
if !inserted {
|
||||
out.push(" autoRead: true".into());
|
||||
out.push(" families:".into());
|
||||
out.push(" - \"\"".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn remap_default_text_model(settings_text: &str) -> String {
|
||||
let mut text = settings_text.to_string();
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
let re = Regex::new(r"(?m)^(agent-default-model:\n(?: .*\n)*)").unwrap();
|
||||
let Some(caps) = re.captures(&text) else {
|
||||
return settings_text.to_string();
|
||||
};
|
||||
let block = caps.get(1).unwrap().as_str().to_string();
|
||||
let provider_re = Regex::new(r"(?m)^ provider:\s*(\S+)\s*$").unwrap();
|
||||
let Some(provider) = provider_re.captures(&block) else {
|
||||
return settings_text.to_string();
|
||||
};
|
||||
let current = provider[1].trim_matches(|c| c == '"' || c == '\'');
|
||||
let Some((_, wrapped)) = OFFICIAL_TEXT_PROVIDERS.iter().find(|(k, _)| *k == current) else {
|
||||
return settings_text.to_string();
|
||||
};
|
||||
let new_block = Regex::new(r"(?m)^( provider:\s*)\S+\s*$")
|
||||
.unwrap()
|
||||
.replacen(&block, 1, format!("${{1}}{wrapped}"));
|
||||
let start = caps.get(1).unwrap().start();
|
||||
let end = caps.get(1).unwrap().end();
|
||||
let mut out = String::new();
|
||||
out.push_str(&text[..start]);
|
||||
out.push_str(&new_block);
|
||||
out.push_str(&text[end..]);
|
||||
if !settings_text.ends_with('\n') && out.ends_with('\n') {
|
||||
// keep a trailing newline; Python operated on the padded copy
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn ensure_modlens(paths: &BundledPaths) -> ModlensEnsureResult {
|
||||
let src = bundled_modlens_prefix(paths);
|
||||
let profile = web_profile_dir();
|
||||
let installed = read_modlens_version(&profile);
|
||||
let version = src
|
||||
.as_ref()
|
||||
.and_then(|p| read_modlens_version(p))
|
||||
.or_else(|| installed.clone())
|
||||
.unwrap_or_else(|| MODLENS_VERSION.to_string());
|
||||
|
||||
match ensure_modlens_inner(paths, src.as_deref(), &profile, installed, &version) {
|
||||
Ok(result) => result,
|
||||
Err(exc) => ModlensEnsureResult {
|
||||
status: "failed",
|
||||
version: Some(version),
|
||||
message: format!("内置 ModLens 安装失败:{exc}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_modlens_inner(
|
||||
paths: &BundledPaths,
|
||||
src: Option<&Path>,
|
||||
profile: &Path,
|
||||
installed: Option<String>,
|
||||
version: &str,
|
||||
) -> std::io::Result<ModlensEnsureResult> {
|
||||
std::fs::create_dir_all(profile)?;
|
||||
let vision_ok = install_vision_plugin(paths, profile);
|
||||
let mut packages = BTreeMap::new();
|
||||
if vision_ok {
|
||||
packages.insert(VISION_PACKAGE.to_string(), "0.1.0".into());
|
||||
}
|
||||
if src.is_none() && installed.is_none() {
|
||||
if !packages.is_empty() {
|
||||
ensure_manifest(profile, &packages)?;
|
||||
}
|
||||
return Ok(ModlensEnsureResult {
|
||||
status: "skipped",
|
||||
version: None,
|
||||
message: "未找到内置 ModLens,跳过插件安装".into(),
|
||||
});
|
||||
}
|
||||
let installed_after = if let Some(src) = src {
|
||||
if installed.as_deref() != Some(version) {
|
||||
install_into_profile(src, profile)?;
|
||||
read_modlens_version(profile).unwrap_or_else(|| version.to_string())
|
||||
} else {
|
||||
installed.clone().unwrap_or_else(|| version.to_string())
|
||||
}
|
||||
} else {
|
||||
installed.clone().unwrap_or_else(|| version.to_string())
|
||||
};
|
||||
packages.insert(PACKAGE.to_string(), installed_after.clone());
|
||||
ensure_manifest(profile, &packages)?;
|
||||
let patch = profile.join("cordis.patch.yml");
|
||||
let previous = if patch.is_file() {
|
||||
std::fs::read_to_string(&patch)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let updated = ensure_modlens_overlay(&previous);
|
||||
if updated != previous {
|
||||
std::fs::write(&patch, updated)?;
|
||||
}
|
||||
let settings = dsh_home().join("settings.yaml");
|
||||
if settings.is_file() {
|
||||
let original = std::fs::read_to_string(&settings)?;
|
||||
let remapped = remap_default_text_model(&original);
|
||||
if remapped != original {
|
||||
std::fs::write(&settings, remapped)?;
|
||||
}
|
||||
}
|
||||
if src.is_none() {
|
||||
return Ok(ModlensEnsureResult {
|
||||
status: "current",
|
||||
version: Some(installed_after.clone()),
|
||||
message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"),
|
||||
});
|
||||
}
|
||||
if installed.as_deref() == Some(version) {
|
||||
return Ok(ModlensEnsureResult {
|
||||
status: "current",
|
||||
version: Some(version.to_string()),
|
||||
message: format!("内置 ModLens {version} 已就绪"),
|
||||
});
|
||||
}
|
||||
if installed.is_some() {
|
||||
return Ok(ModlensEnsureResult {
|
||||
status: "updated",
|
||||
version: Some(version.to_string()),
|
||||
message: format!("已将内置 ModLens 更新到 {version}"),
|
||||
});
|
||||
}
|
||||
Ok(ModlensEnsureResult {
|
||||
status: "installed",
|
||||
version: Some(version.to_string()),
|
||||
message: format!("已启用内置 ModLens {version}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_file_gets_managed_overlay() {
|
||||
let text = ensure_modlens_overlay("");
|
||||
assert!(text.contains("id: modlens"));
|
||||
assert!(text.contains("autoRead: true"));
|
||||
assert!(text.contains("families:"));
|
||||
assert!(text.contains("- \"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_modlens_gains_wrap_all_families() {
|
||||
let text = ensure_modlens_overlay("- id: modlens\n config:\n autoRead: true\n");
|
||||
assert!(text.contains("autoRead: true"));
|
||||
assert!(text.contains(" - \"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_narrow_families() {
|
||||
let original = "\
|
||||
- id: modlens
|
||||
config:
|
||||
autoRead: true
|
||||
families:
|
||||
- deepseek
|
||||
- glm
|
||||
";
|
||||
let text = ensure_modlens_overlay(original);
|
||||
assert!(!text.contains("deepseek"));
|
||||
assert!(!text.contains("glm"));
|
||||
assert!(text.contains("- \"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_other_entries() {
|
||||
let original = "- id: other\n config:\n x: 1\n- id: modlens\n config:\n autoRead: false\n";
|
||||
let text = ensure_modlens_overlay(original);
|
||||
assert!(text.contains("id: other"));
|
||||
assert!(text.contains("autoRead: true"));
|
||||
assert!(text.contains(" - \"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_deepseek_becomes_modlens() {
|
||||
let original = "\
|
||||
agent-default-model:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-pro
|
||||
ui-theme:
|
||||
preference: dark
|
||||
";
|
||||
let updated = remap_default_text_model(original);
|
||||
assert!(updated.contains("provider: deepseek-modlens"));
|
||||
assert!(updated.contains("model: deepseek-v4-pro"));
|
||||
assert!(updated.contains("preference: dark"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen_is_left_alone() {
|
||||
let original = "agent-default-model:\n provider: qwen\n model: qwen-agent\n";
|
||||
assert_eq!(remap_default_text_model(original), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_wrapped_is_left_alone() {
|
||||
let original = "agent-default-model:\n provider: deepseek-modlens\n model: deepseek-v4-pro\n";
|
||||
assert_eq!(remap_default_text_model(original), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_twins_script_targets_suffix() {
|
||||
assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)"));
|
||||
assert!(HIDE_PLAIN_TWINS_JS.contains("MutationObserver"));
|
||||
}
|
||||
}
|
||||
179
crates/dsh-core/src/paths.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// XDG-style data directory (`~/.local/share` on Linux).
|
||||
pub fn data_home() -> PathBuf {
|
||||
if let Some(raw) = env::var_os("XDG_DATA_HOME") {
|
||||
if !raw.is_empty() {
|
||||
return PathBuf::from(raw);
|
||||
}
|
||||
}
|
||||
dirs::data_dir().unwrap_or_else(|| home_dir().join(".local/share"))
|
||||
}
|
||||
|
||||
/// XDG-style cache directory (`~/.cache` on Linux).
|
||||
pub fn cache_home() -> PathBuf {
|
||||
if let Some(raw) = env::var_os("XDG_CACHE_HOME") {
|
||||
if !raw.is_empty() {
|
||||
return PathBuf::from(raw);
|
||||
}
|
||||
}
|
||||
dirs::cache_dir().unwrap_or_else(|| home_dir().join(".cache"))
|
||||
}
|
||||
|
||||
pub fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
pub fn dsh_home() -> PathBuf {
|
||||
if let Some(raw) = env::var_os("DSH_HOME") {
|
||||
if !raw.is_empty() {
|
||||
return PathBuf::from(raw);
|
||||
}
|
||||
}
|
||||
home_dir().join(".dsh")
|
||||
}
|
||||
|
||||
pub fn downloads_dir() -> PathBuf {
|
||||
dirs::download_dir().unwrap_or_else(|| home_dir().join("Downloads"))
|
||||
}
|
||||
|
||||
pub fn is_flatpak() -> bool {
|
||||
Path::new("/.flatpak-info").exists()
|
||||
}
|
||||
|
||||
/// Roots that may contain bundled ModLens / presets / vision plugin.
|
||||
///
|
||||
/// Search order: extra roots (Tauri resource dir), `$XDG_DATA_HOME/dsh-desktop`,
|
||||
/// `/app/share/dsh-desktop`, `/usr/share/dsh-desktop`, `~/.local/share/dsh-desktop`,
|
||||
/// then the source-tree `vendor/` / `plugins/` next to the executable or crate.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BundledPaths {
|
||||
extra_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl BundledPaths {
|
||||
pub fn discover() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_resource_dir(mut self, dir: PathBuf) -> Self {
|
||||
if dir.is_dir() {
|
||||
self.extra_roots.insert(0, dir);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn roots(&self) -> Vec<PathBuf> {
|
||||
let mut roots = self.extra_roots.clone();
|
||||
if let Some(xdg) = env::var_os("XDG_DATA_HOME") {
|
||||
roots.push(PathBuf::from(xdg).join("dsh-desktop"));
|
||||
}
|
||||
roots.push(PathBuf::from("/app/share/dsh-desktop"));
|
||||
roots.push(PathBuf::from("/usr/share/dsh-desktop"));
|
||||
roots.push(home_dir().join(".local/share/dsh-desktop"));
|
||||
for repo in repo_roots() {
|
||||
roots.push(repo);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
pub fn find_dir(&self, rel: &str, marker: &str) -> Option<PathBuf> {
|
||||
for root in self.roots() {
|
||||
let candidate = root.join(rel);
|
||||
if candidate.join(marker).is_file() || candidate.join(marker).is_dir() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
if let Ok(exe) = env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
roots.push(parent.to_path_buf());
|
||||
if let Some(grand) = parent.parent() {
|
||||
roots.push(grand.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
// crates/dsh-core -> repo root; src-tauri -> repo root
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
if let Some(parent) = manifest.parent() {
|
||||
roots.push(parent.to_path_buf());
|
||||
if let Some(grand) = parent.parent() {
|
||||
roots.push(grand.to_path_buf());
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
pub fn copy_tree(src: &Path, dest: &Path, keep_symlinks: bool) -> std::io::Result<()> {
|
||||
if dest.exists() {
|
||||
std::fs::remove_dir_all(dest)?;
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
copy_tree_inner(src, dest, keep_symlinks)
|
||||
}
|
||||
|
||||
fn copy_tree_inner(src: &Path, dest: &Path, keep_symlinks: bool) -> std::io::Result<()> {
|
||||
if src
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n == ".git")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if src.is_symlink() && keep_symlinks {
|
||||
let target = std::fs::read_link(src)?;
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(target, dest)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if src.is_dir() {
|
||||
std::os::windows::fs::symlink_dir(target, dest)?;
|
||||
} else {
|
||||
std::os::windows::fs::symlink_file(target, dest)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if src.is_dir() {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
copy_tree_inner(&entry.path(), &dest.join(entry.file_name()), keep_symlinks)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::copy(src, dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_symlink(link: &Path, target: &Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = link.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if link.exists() || link.is_symlink() {
|
||||
if link.is_dir() && !link.is_symlink() {
|
||||
std::fs::remove_dir_all(link)?;
|
||||
} else {
|
||||
std::fs::remove_file(link)?;
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(target, link)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if target.is_dir() {
|
||||
std::os::windows::fs::symlink_dir(target, link)?;
|
||||
} else {
|
||||
std::os::windows::fs::symlink_file(target, link)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
379
crates/dsh-core/src/preset.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::paths::{copy_tree, dsh_home, BundledPaths};
|
||||
|
||||
pub const PRESET_ID: &str = "anchored-standard";
|
||||
pub const ZERO_PRESET_ID: &str = "zero-anchored-standard";
|
||||
pub const SOURCE_MARKER: &str = ".dsh-desktop-source";
|
||||
pub const DEFAULT_BLOCK: &str = "agent-presets:\n default: anchored-standard\n";
|
||||
pub const PRESET_NAME_ZH: &str = "锚定式标准(实验)";
|
||||
pub const PRESET_DESCRIPTION_ZH: &str =
|
||||
"首轮使用 Minimal 的真实工具对(持久 bash + str_replace_editor),不自动注入工作区或技能上下文;首次工具调用或回复后开放完整 Standard 工具。";
|
||||
pub const ZERO_PRESET_NAME_ZH: &str = "零工具锚定式标准(实验)";
|
||||
pub const ZERO_PRESET_DESCRIPTION_ZH: &str =
|
||||
"先插入一轮无工具的锚定对话(固定提示),从下一轮起开放完整 Standard 工具。";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BundledPreset {
|
||||
pub preset_id: &'static str,
|
||||
pub name_zh: &'static str,
|
||||
pub description_zh: &'static str,
|
||||
}
|
||||
|
||||
pub const BUNDLED_PRESETS: &[BundledPreset] = &[
|
||||
BundledPreset {
|
||||
preset_id: PRESET_ID,
|
||||
name_zh: PRESET_NAME_ZH,
|
||||
description_zh: PRESET_DESCRIPTION_ZH,
|
||||
},
|
||||
BundledPreset {
|
||||
preset_id: ZERO_PRESET_ID,
|
||||
name_zh: ZERO_PRESET_NAME_ZH,
|
||||
description_zh: ZERO_PRESET_DESCRIPTION_ZH,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PresetEnsureResult {
|
||||
pub status: &'static str,
|
||||
pub version: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn preset_install_dir(preset_id: &str) -> PathBuf {
|
||||
dsh_home().join(".agent-presets").join(preset_id)
|
||||
}
|
||||
|
||||
fn is_preset_dir(path: &Path) -> bool {
|
||||
path.is_dir() && path.join("preset.yml").is_file()
|
||||
}
|
||||
|
||||
pub fn read_source_version(path: &Path) -> Option<String> {
|
||||
let version = std::fs::read_to_string(path.join(SOURCE_MARKER)).ok()?;
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(version.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bundled_preset_dir(paths: &BundledPaths, preset_id: &str) -> Option<PathBuf> {
|
||||
paths
|
||||
.find_dir(preset_id, "preset.yml")
|
||||
.or_else(|| paths.find_dir(&format!("vendor/{preset_id}"), "preset.yml"))
|
||||
.filter(|p| is_preset_dir(p))
|
||||
}
|
||||
|
||||
pub fn ensure_default_preset(text: &str) -> String {
|
||||
let mut body = text.replace("\r\n", "\n");
|
||||
if !body.ends_with('\n') {
|
||||
body.push('\n');
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
return DEFAULT_BLOCK.to_string();
|
||||
}
|
||||
let re = Regex::new(r"(?m)^agent-presets:\n((?:[ \t]+.*\n)*)").unwrap();
|
||||
let Some(caps) = re.captures(&body) else {
|
||||
body.push('\n');
|
||||
body.push_str(DEFAULT_BLOCK);
|
||||
return body;
|
||||
};
|
||||
let inner = caps.get(1).unwrap().as_str();
|
||||
if Regex::new(r"(?m)^[ \t]+default:\s*\S+")
|
||||
.unwrap()
|
||||
.is_match(inner)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
let insert = format!(" default: {PRESET_ID}\n");
|
||||
let start = caps.get(1).unwrap().start();
|
||||
let end = caps.get(1).unwrap().end();
|
||||
format!("{}{}{}{}", &body[..start], insert, inner, &body[end..])
|
||||
}
|
||||
|
||||
pub fn localize_preset_yml(text: &str, name: &str, description: &str) -> String {
|
||||
let mut body = text.replace("\r\n", "\n");
|
||||
if !body.ends_with('\n') {
|
||||
body.push('\n');
|
||||
}
|
||||
let name_line = format!("name: {}\n", serde_json::to_string(name).unwrap());
|
||||
let desc_line = format!(
|
||||
"description: {}\n",
|
||||
serde_json::to_string(description).unwrap()
|
||||
);
|
||||
let name_re = Regex::new(r"(?m)^name:.*\n").unwrap();
|
||||
if let Some(m) = name_re.find(&body) {
|
||||
body = format!("{}{}{}", &body[..m.start()], name_line, &body[m.end()..]);
|
||||
} else {
|
||||
body = format!("{name_line}{body}");
|
||||
}
|
||||
let desc_re = Regex::new(r"(?m)^description:(?:[ \t].*)?\n(?:[ \t].+\n)*").unwrap();
|
||||
if let Some(m) = desc_re.find(&body) {
|
||||
return format!("{}{}{}", &body[..m.start()], desc_line, &body[m.end()..]);
|
||||
}
|
||||
if let Some(m) = name_re.find(&body) {
|
||||
return format!("{}{}{}", &body[..m.end()], desc_line, &body[m.end()..]);
|
||||
}
|
||||
format!("{desc_line}{body}")
|
||||
}
|
||||
|
||||
fn write_localized_preset(dest: &Path, spec: &BundledPreset) -> std::io::Result<()> {
|
||||
let path = dest.join("preset.yml");
|
||||
let previous = std::fs::read_to_string(&path)?;
|
||||
let updated = localize_preset_yml(&previous, spec.name_zh, spec.description_zh);
|
||||
if updated != previous {
|
||||
std::fs::write(path, updated)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn short_version(version: Option<&str>) -> String {
|
||||
match version {
|
||||
None => "bundled".into(),
|
||||
Some(v) if v.len() > 12 => v[..12].into(),
|
||||
Some(v) => v.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult {
|
||||
let src = bundled_preset_dir(paths, spec.preset_id);
|
||||
let dest = preset_install_dir(spec.preset_id);
|
||||
let bundled = src.as_ref().and_then(|p| read_source_version(p));
|
||||
let installed = if dest.is_dir() {
|
||||
read_source_version(&dest)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let version = bundled.clone().or_else(|| installed.clone());
|
||||
let label = spec.name_zh;
|
||||
if src.is_none() {
|
||||
if dest.is_dir() && dest.join("preset.yml").is_file() {
|
||||
let _ = write_localized_preset(&dest, spec);
|
||||
return PresetEnsureResult {
|
||||
status: "current",
|
||||
version: version.clone(),
|
||||
message: format!("已配置已安装的{label}({})", short_version(version.as_deref())),
|
||||
};
|
||||
}
|
||||
return PresetEnsureResult {
|
||||
status: "skipped",
|
||||
version: None,
|
||||
message: format!("未找到内置{label},跳过安装"),
|
||||
};
|
||||
}
|
||||
let src = src.unwrap();
|
||||
if let Err(exc) = (|| -> std::io::Result<()> {
|
||||
if installed != bundled || !dest.join("preset.yml").is_file() {
|
||||
copy_tree(&src, &dest, false)?;
|
||||
}
|
||||
write_localized_preset(&dest, spec)?;
|
||||
Ok(())
|
||||
})() {
|
||||
return PresetEnsureResult {
|
||||
status: "failed",
|
||||
version,
|
||||
message: format!("内置{label}安装失败:{exc}"),
|
||||
};
|
||||
}
|
||||
let sha = short_version(bundled.as_deref());
|
||||
if installed == bundled && dest.join("preset.yml").is_file() {
|
||||
return PresetEnsureResult {
|
||||
status: "current",
|
||||
version: bundled,
|
||||
message: format!("内置{label} {sha} 已就绪"),
|
||||
};
|
||||
}
|
||||
if installed.is_some() {
|
||||
return PresetEnsureResult {
|
||||
status: "updated",
|
||||
version: bundled,
|
||||
message: format!("已将内置{label}更新到 {sha}"),
|
||||
};
|
||||
}
|
||||
PresetEnsureResult {
|
||||
status: "installed",
|
||||
version: bundled,
|
||||
message: format!("已启用内置{label} {sha}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(results: &[PresetEnsureResult]) -> PresetEnsureResult {
|
||||
if results.iter().any(|item| item.status == "failed") {
|
||||
let failed: Vec<_> = results
|
||||
.iter()
|
||||
.filter(|item| item.status == "failed")
|
||||
.map(|item| item.message.clone())
|
||||
.collect();
|
||||
return PresetEnsureResult {
|
||||
status: "failed",
|
||||
version: None,
|
||||
message: failed.join(";"),
|
||||
};
|
||||
}
|
||||
let active: Vec<_> = results
|
||||
.iter()
|
||||
.filter(|item| item.status != "skipped")
|
||||
.collect();
|
||||
if active.is_empty() {
|
||||
return PresetEnsureResult {
|
||||
status: "skipped",
|
||||
version: None,
|
||||
message: "未找到内置锚定预设,跳过安装".into(),
|
||||
};
|
||||
}
|
||||
let version = active.iter().find_map(|item| item.version.clone());
|
||||
let (status, lead) = if active.iter().any(|item| item.status == "updated") {
|
||||
("updated", "已更新内置锚定预设")
|
||||
} else if active.iter().any(|item| item.status == "installed") {
|
||||
("installed", "已启用内置锚定预设")
|
||||
} else {
|
||||
("current", "内置锚定预设已就绪")
|
||||
};
|
||||
let detail = active
|
||||
.iter()
|
||||
.map(|item| item.message.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
PresetEnsureResult {
|
||||
status,
|
||||
version,
|
||||
message: format!("{lead}。{detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_anchored_standard(paths: &BundledPaths) -> PresetEnsureResult {
|
||||
let results: Vec<_> = BUNDLED_PRESETS
|
||||
.iter()
|
||||
.map(|spec| ensure_one(paths, spec))
|
||||
.collect();
|
||||
let combined = combine(&results);
|
||||
if combined.status == "failed" {
|
||||
return combined;
|
||||
}
|
||||
let settings = dsh_home().join("settings.yaml");
|
||||
match (|| -> std::io::Result<()> {
|
||||
let previous = if settings.is_file() {
|
||||
std::fs::read_to_string(&settings)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let updated = ensure_default_preset(&previous);
|
||||
if updated != previous {
|
||||
if let Some(parent) = settings.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&settings, updated)?;
|
||||
}
|
||||
Ok(())
|
||||
})() {
|
||||
Ok(()) => combined,
|
||||
Err(exc) => PresetEnsureResult {
|
||||
status: "failed",
|
||||
version: combined.version,
|
||||
message: format!("写入默认 preset 失败:{exc}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::paths::BundledPaths;
|
||||
|
||||
#[test]
|
||||
fn empty_file_gets_default_block() {
|
||||
assert_eq!(
|
||||
ensure_default_preset(""),
|
||||
"agent-presets:\n default: anchored-standard\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_when_section_missing() {
|
||||
let updated = ensure_default_preset("ui-theme:\n preference: dark\n");
|
||||
assert!(updated.contains("preference: dark"));
|
||||
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_default_into_empty_section() {
|
||||
let updated = ensure_default_preset("agent-presets:\nui-theme:\n preference: dark\n");
|
||||
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
|
||||
assert!(updated.contains("preference: dark"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_default() {
|
||||
let original = "agent-presets:\n default: standard\n";
|
||||
assert_eq!(ensure_default_preset(original), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_match_permission_default_preset() {
|
||||
let original = "permission:\n defaultPreset: danger-full-access\n";
|
||||
let updated = ensure_default_preset(original);
|
||||
assert!(updated.contains("defaultPreset: danger-full-access"));
|
||||
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_english_name_and_description() {
|
||||
let original = "\
|
||||
name: Anchored Standard (experimental)
|
||||
description: Bootstrap with the Minimal preset's real tool pair.
|
||||
order: 5
|
||||
";
|
||||
let updated = localize_preset_yml(original, PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH);
|
||||
assert!(updated.contains(PRESET_NAME_ZH));
|
||||
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
|
||||
assert!(!updated.contains("Anchored Standard (experimental)"));
|
||||
assert!(!updated.contains("Bootstrap"));
|
||||
assert!(updated.contains("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);
|
||||
assert!(updated.contains(PRESET_NAME_ZH));
|
||||
assert!(!updated.contains("name: Anchored Standard\n"));
|
||||
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
|
||||
assert!(updated.contains("order: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_from_bundle_and_sets_default() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let src = tmp.path().join("anchored-standard");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join("preset.yml"), "name: Anchored Standard\n").unwrap();
|
||||
std::fs::write(src.join(SOURCE_MARKER), "abc123def456\n").unwrap();
|
||||
let home = tmp.path().join(".dsh");
|
||||
std::env::set_var("DSH_HOME", &home);
|
||||
let paths = BundledPaths::discover().with_resource_dir(tmp.path().to_path_buf());
|
||||
let result = ensure_anchored_standard(&paths);
|
||||
std::env::remove_var("DSH_HOME");
|
||||
assert_eq!(result.status, "installed");
|
||||
let dest = home.join(".agent-presets").join(PRESET_ID);
|
||||
assert!(dest.join("preset.yml").is_file());
|
||||
let yml = std::fs::read_to_string(dest.join("preset.yml")).unwrap();
|
||||
assert!(yml.contains(PRESET_DESCRIPTION_ZH));
|
||||
let settings = std::fs::read_to_string(home.join("settings.yaml")).unwrap();
|
||||
assert!(settings.contains("default: anchored-standard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localizes_zero_preset() {
|
||||
let original = "\
|
||||
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);
|
||||
assert!(updated.contains(ZERO_PRESET_NAME_ZH));
|
||||
assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH));
|
||||
assert!(!updated.contains("Zero-Anchored"));
|
||||
}
|
||||
}
|
||||
348
crates/dsh-core/src/updater.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::paths::{cache_home, data_home};
|
||||
use crate::ENV_NO_UPDATE;
|
||||
|
||||
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"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateResult {
|
||||
pub status: &'static str,
|
||||
pub version: Option<String>,
|
||||
pub previous: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl UpdateResult {
|
||||
fn new(status: &'static str, version: Option<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
version,
|
||||
previous: None,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_prefix() -> PathBuf {
|
||||
data_home().join("dsh-desktop/dsh-prefix")
|
||||
}
|
||||
|
||||
pub fn update_dsh_bin() -> PathBuf {
|
||||
let prefix = update_prefix();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let cmd = prefix.join("dsh.cmd");
|
||||
if cmd.is_file() {
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
prefix.join("bin/dsh")
|
||||
}
|
||||
|
||||
pub fn package_json(prefix: &Path) -> PathBuf {
|
||||
prefix.join("lib/node_modules/@deepseek-ai/dsh/package.json")
|
||||
}
|
||||
|
||||
pub fn read_version(prefix: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(package_json(prefix)).ok()?;
|
||||
let data: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
data.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
pub fn find_npm() -> Option<PathBuf> {
|
||||
for candidate in ["/app/bin/npm", "/app/node24/bin/npm"] {
|
||||
let path = PathBuf::from(candidate);
|
||||
if crate::launcher::is_executable(&path) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
which::which("npm").ok()
|
||||
}
|
||||
|
||||
fn find_node_dir() -> Option<PathBuf> {
|
||||
for candidate in ["/app/bin/node", "/app/node24/bin/node"] {
|
||||
let path = PathBuf::from(candidate);
|
||||
if crate::launcher::is_executable(&path) {
|
||||
return path.parent().map(|p| p.to_path_buf());
|
||||
}
|
||||
}
|
||||
which::which("node")
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||
}
|
||||
|
||||
fn npm_command(npm: &Path) -> Command {
|
||||
let mut cmd = Command::new(npm);
|
||||
let cache = cache_home().join("dsh-desktop/npm");
|
||||
let _ = std::fs::create_dir_all(&cache);
|
||||
cmd.env("npm_config_cache", &cache);
|
||||
cmd.env("npm_config_update_notifier", "false");
|
||||
cmd.env("npm_config_fund", "false");
|
||||
cmd.env("npm_config_audit", "false");
|
||||
if let Some(node_dir) = find_node_dir() {
|
||||
let mut path = node_dir.into_os_string();
|
||||
path.push(if cfg!(windows) { ";" } else { ":" });
|
||||
if let Some(existing) = std::env::var_os("PATH") {
|
||||
path.push(existing);
|
||||
}
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
fn run_npm(npm: &Path, args: &[&str], timeout: Duration) -> Result<std::process::Output, String> {
|
||||
let mut cmd = npm_command(npm);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
let child = cmd.spawn().map_err(|e| e.to_string())?;
|
||||
let pid = child.id();
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let flag = Arc::clone(&done);
|
||||
thread::spawn(move || {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if flag.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
if flag.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGKILL);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.status();
|
||||
}
|
||||
});
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string());
|
||||
done.store(true, Ordering::Relaxed);
|
||||
out
|
||||
}
|
||||
|
||||
fn last_check_path() -> PathBuf {
|
||||
cache_home().join("dsh-desktop/last-update-check")
|
||||
}
|
||||
|
||||
/// True when a network update check has not run in the last 24 hours.
|
||||
pub fn update_check_due() -> bool {
|
||||
let path = last_check_path();
|
||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
||||
return true;
|
||||
};
|
||||
let ts: u64 = raw.trim().parse().unwrap_or(0);
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
now.saturating_sub(ts) >= 24 * 60 * 60
|
||||
}
|
||||
|
||||
pub fn mark_update_checked() {
|
||||
let path = last_check_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let _ = std::fs::write(path, format!("{now}\n"));
|
||||
}
|
||||
|
||||
pub fn fetch_latest_version(npm: &Path) -> Option<String> {
|
||||
let out = run_npm(npm, &["view", DSH_PACKAGE, "version"], Duration::from_secs(VIEW_TIMEOUT_SECONDS))
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.last()
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn seed_from_bundle(dest: &Path) {
|
||||
let src_pkg = Path::new(BUNDLED_PREFIX).join("lib/node_modules/@deepseek-ai/dsh");
|
||||
if !src_pkg.is_dir() {
|
||||
return;
|
||||
}
|
||||
let dest_pkg = dest.join("lib/node_modules/@deepseek-ai/dsh");
|
||||
if dest_pkg.exists() {
|
||||
return;
|
||||
}
|
||||
if crate::paths::copy_tree(&src_pkg, &dest_pkg, true).is_err() {
|
||||
return;
|
||||
}
|
||||
let dest_bin = dest.join("bin");
|
||||
let _ = std::fs::create_dir_all(&dest_bin);
|
||||
let link = dest_bin.join("dsh");
|
||||
if !link.exists() {
|
||||
let _ = crate::paths::replace_symlink(
|
||||
&link,
|
||||
Path::new("../lib/node_modules/@deepseek-ai/dsh/lib/bin.js"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn env_skips_update() -> bool {
|
||||
std::env::var(ENV_NO_UPDATE)
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
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)));
|
||||
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 Some(npm) = npm else {
|
||||
let extra = current
|
||||
.as_deref()
|
||||
.map(|v| format!("({v})"))
|
||||
.unwrap_or_default();
|
||||
return UpdateResult::new(
|
||||
"skipped",
|
||||
current,
|
||||
format!("未找到 npm,使用已安装的 dsh{extra}"),
|
||||
);
|
||||
};
|
||||
|
||||
let latest = match fetch_latest_version(&npm) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let extra = current
|
||||
.as_deref()
|
||||
.map(|v| format!("({v})"))
|
||||
.unwrap_or_default();
|
||||
return UpdateResult::new(
|
||||
"failed",
|
||||
current,
|
||||
format!("无法获取 dsh 最新版本,使用已安装版本{extra}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if current.as_deref() == Some(latest.as_str()) {
|
||||
return UpdateResult::new(
|
||||
"current",
|
||||
current,
|
||||
format!("内置 dsh 已是最新({latest})"),
|
||||
);
|
||||
}
|
||||
|
||||
let dest = update_prefix();
|
||||
let _ = std::fs::create_dir_all(&dest);
|
||||
if read_version(&dest).is_none() {
|
||||
seed_from_bundle(&dest);
|
||||
}
|
||||
|
||||
let prefix_arg = format!("--prefix={}", dest.display());
|
||||
let pkg = format!("{DSH_PACKAGE}@latest");
|
||||
let previous = current.clone();
|
||||
match run_npm(
|
||||
&npm,
|
||||
&[
|
||||
"install",
|
||||
&prefix_arg,
|
||||
"--global",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
&pkg,
|
||||
],
|
||||
Duration::from_secs(INSTALL_TIMEOUT_SECONDS),
|
||||
) {
|
||||
Ok(out) if out.status.success() => {
|
||||
let installed = read_version(&dest).or(current);
|
||||
UpdateResult {
|
||||
status: "updated",
|
||||
version: installed.clone(),
|
||||
previous,
|
||||
message: format!(
|
||||
"已将内置 dsh 更新到 {}",
|
||||
installed.as_deref().unwrap_or("latest")
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(_) | Err(_) => {
|
||||
let installed = read_version(&dest).or(current);
|
||||
let extra = installed
|
||||
.as_deref()
|
||||
.map(|v| format!("({v})"))
|
||||
.unwrap_or_default();
|
||||
UpdateResult {
|
||||
status: "failed",
|
||||
version: installed,
|
||||
previous,
|
||||
message: format!("dsh 更新失败,使用已安装版本{extra}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn read_version_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let pkg = package_json(tmp.path());
|
||||
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
|
||||
std::fs::write(&pkg, r#"{"version":"0.1.0-rc.6"}"#).unwrap();
|
||||
assert_eq!(read_version(tmp.path()).as_deref(), Some("0.1.0-rc.6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_version_missing() {
|
||||
assert_eq!(read_version(Path::new("/no/such/prefix")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_when_disabled() {
|
||||
let result = update_dsh(false);
|
||||
assert_eq!(result.status, "skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_when_env_set() {
|
||||
std::env::set_var(ENV_NO_UPDATE, "1");
|
||||
let result = update_dsh(true);
|
||||
std::env::remove_var(ENV_NO_UPDATE);
|
||||
assert_eq!(result.status, "skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_check_due_without_stamp() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::env::set_var("XDG_CACHE_HOME", tmp.path());
|
||||
assert!(update_check_due());
|
||||
mark_update_checked();
|
||||
assert!(!update_check_due());
|
||||
std::env::remove_var("XDG_CACHE_HOME");
|
||||
}
|
||||
}
|
||||
16
data/applications/io.github.tommyfang.DshDesktop.desktop
Normal file
@@ -0,0 +1,16 @@
|
||||
[Desktop Entry]
|
||||
Name=DeepSeek Harness
|
||||
Name[zh_CN]=DeepSeek Harness
|
||||
GenericName=AI Agent Harness
|
||||
GenericName[zh_CN]=AI 智能体工作台
|
||||
Comment=Desktop shell for DeepSeek Harness
|
||||
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness
|
||||
Exec=dsh-desktop
|
||||
Icon=io.github.tommyfang.DshDesktop
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Development;
|
||||
Keywords=AI;DeepSeek;agent;harness;dsh;assistant;
|
||||
StartupNotify=true
|
||||
StartupWMClass=io.github.tommyfang.DshDesktop
|
||||
X-Flatpak-RenamedFrom=dsh-desktop.desktop;
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
BIN
data/icons/hicolor/16x16/apps/io.github.tommyfang.DshDesktop.png
Normal file
|
After Width: | Height: | Size: 728 B |
BIN
data/icons/hicolor/24x24/apps/io.github.tommyfang.DshDesktop.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
data/icons/hicolor/32x32/apps/io.github.tommyfang.DshDesktop.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
data/icons/hicolor/48x48/apps/io.github.tommyfang.DshDesktop.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
data/icons/hicolor/64x64/apps/io.github.tommyfang.DshDesktop.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
53
data/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.tommyfang.DshDesktop</id>
|
||||
<name>DeepSeek Harness Desktop</name>
|
||||
<summary>Desktop shell for DeepSeek Harness</summary>
|
||||
<summary xml:lang="zh_CN">DeepSeek Harness 的原生桌面壳</summary>
|
||||
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<developer id="io.github.tommyfang">
|
||||
<name>TommyFang2077</name>
|
||||
</developer>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
DeepSeek Harness Desktop runs the official DeepSeek Harness (dsh) WebUI
|
||||
in a native Tauri window. It starts dsh web, waits for its local URL and
|
||||
embeds the UI with the system WebView. Credentials, plugins and agent
|
||||
permissions stay in ~/.dsh.
|
||||
</p>
|
||||
<p>
|
||||
The app bundles ModLens for text-only vision, a settings page at
|
||||
设置 → 视觉模型, and the community Anchored Standard presets.
|
||||
The Flatpak also bundles Node.js and @deepseek-ai/dsh.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">io.github.tommyfang.DshDesktop.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/TommyFang2077/dsh-desktop</url>
|
||||
<url type="bugtracker">https://github.com/TommyFang2077/dsh-desktop/issues</url>
|
||||
|
||||
<provides>
|
||||
<binary>dsh-desktop</binary>
|
||||
</provides>
|
||||
|
||||
<categories>
|
||||
<category>Development</category>
|
||||
<category>Utility</category>
|
||||
<category>Network</category>
|
||||
</categories>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<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>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
21
docs/licenses/deepseek-harness.LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DeepSeek
|
||||
|
||||
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.
|
||||
22
docs/licenses/dsh-anchored-standard.LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 xiaobright
|
||||
Portions Copyright (c) 2026 DeepSeek
|
||||
|
||||
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.
|
||||
14
docs/licenses/dsh-anchored-standard.NOTICE
Normal file
@@ -0,0 +1,14 @@
|
||||
dsh-anchored-standard includes an adapted copy of the DeepSeek Harness
|
||||
Standard agent preset from:
|
||||
|
||||
https://github.com/deepseek-ai/deepseek-harness
|
||||
commit 47f943859bef60e4160492346772ded9b24f765a
|
||||
|
||||
DeepSeek Harness is distributed under the MIT License:
|
||||
|
||||
Copyright (c) 2026 DeepSeek
|
||||
|
||||
The full MIT permission notice is included in this repository's LICENSE file.
|
||||
|
||||
DeepSeek and DeepSeek Harness are names of their respective owner. This
|
||||
community project is not affiliated with or endorsed by DeepSeek.
|
||||
21
docs/licenses/modlens.LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Leon Liu (liustack)
|
||||
|
||||
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.
|
||||
BIN
docs/screenshots/icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/screenshots/menu.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/screenshots/session.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/screenshots/splash.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
61
docs/screenshots/src/chrome.css
Normal file
@@ -0,0 +1,61 @@
|
||||
:root { --dsh-desktop-titlebar-h: 36px; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
html.dsh-desktop-offset {
|
||||
box-sizing: border-box;
|
||||
padding-top: var(--dsh-desktop-titlebar-h);
|
||||
}
|
||||
#dsh-desktop-titlebar {
|
||||
position: fixed; top: 0; left: 0; right: 0; height: var(--dsh-desktop-titlebar-h);
|
||||
z-index: 2147483646; display: flex; align-items: center;
|
||||
padding: 0 12px; box-sizing: border-box;
|
||||
background: rgba(246, 246, 248, 0.92);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
user-select: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
}
|
||||
#dsh-desktop-titlebar .traffic {
|
||||
display: flex; gap: 8px; align-items: center; height: 100%;
|
||||
}
|
||||
#dsh-desktop-titlebar .tl {
|
||||
width: 12px; height: 12px; min-width: 12px; min-height: 12px;
|
||||
max-width: 12px; max-height: 12px; flex: none; overflow: hidden;
|
||||
box-sizing: border-box; border-radius: 50%; border: 0;
|
||||
padding: 0; margin: 0; display: grid; place-items: center; cursor: default;
|
||||
position: relative;
|
||||
}
|
||||
#dsh-desktop-titlebar .tl.close { background: #ff5f57; }
|
||||
#dsh-desktop-titlebar .tl.min { background: #febc2e; }
|
||||
#dsh-desktop-titlebar .tl.zoom { background: #28c840; }
|
||||
#dsh-desktop-titlebar .tl::after {
|
||||
content: ""; font-size: 9px; line-height: 1; font-weight: 700;
|
||||
color: rgba(0,0,0,0.55); opacity: 0;
|
||||
}
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.close::after { content: "×"; opacity: 1; }
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.min::after { content: "–"; opacity: 1; }
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.zoom::after { content: "+"; opacity: 1; }
|
||||
#dsh-desktop-titlebar .drag { flex: 1; height: 100%; }
|
||||
#dsh-desktop-titlebar .more {
|
||||
appearance: none; border: 0; background: transparent;
|
||||
color: rgba(0,0,0,0.35); font-size: 15px; letter-spacing: 0.08em;
|
||||
width: 28px; height: 20px; border-radius: 6px; cursor: default;
|
||||
}
|
||||
#dsh-desktop-titlebar .more:hover,
|
||||
#dsh-desktop-titlebar .more.open { background: rgba(0,0,0,0.06); color: rgba(0,0,0,0.7); }
|
||||
#dsh-desktop-titlebar .menu {
|
||||
position: absolute; top: calc(var(--dsh-desktop-titlebar-h) + 2px); left: 8px; min-width: 168px;
|
||||
padding: 4px; border-radius: 10px;
|
||||
background: rgba(255,255,255,0.96);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.16);
|
||||
display: none; flex-direction: column;
|
||||
}
|
||||
#dsh-desktop-titlebar .menu.open { display: flex; }
|
||||
#dsh-desktop-titlebar .menu button {
|
||||
appearance: none; border: 0; background: transparent;
|
||||
text-align: left; padding: 7px 10px; border-radius: 6px;
|
||||
font: 13px/1.3 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
color: #1d1d1f; cursor: default;
|
||||
}
|
||||
#dsh-desktop-titlebar .menu button:hover { background: rgba(0,0,0,0.05); }
|
||||
42
docs/screenshots/src/menu.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dsh-desktop-offset">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>标题栏菜单</title>
|
||||
<link rel="stylesheet" href="chrome.css" />
|
||||
<link rel="stylesheet" href="../../../ui/styles.css" />
|
||||
<style>
|
||||
:root { color-scheme: light; --bg: #f5f5f7; --text: #1d1d1f; --muted: #86868b; }
|
||||
html, body { width: 1280px; height: 800px; overflow: hidden; background: #f5f5f7; color: #1d1d1f; }
|
||||
.stage { min-height: calc(800px - 36px); }
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.close::after,
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.min::after,
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.zoom::after { opacity: 1; }
|
||||
#dsh-desktop-titlebar .tl.close::after { content: "×"; opacity: 1; }
|
||||
#dsh-desktop-titlebar .tl.min::after { content: "–"; opacity: 1; }
|
||||
#dsh-desktop-titlebar .tl.zoom::after { content: "+"; opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="dsh-desktop-titlebar">
|
||||
<button class="more open" type="button">•••</button>
|
||||
<div class="menu open">
|
||||
<button type="button">重新启动</button>
|
||||
<button type="button">在浏览器中打开</button>
|
||||
</div>
|
||||
<div class="drag"></div>
|
||||
<div class="traffic">
|
||||
<button class="tl min"></button>
|
||||
<button class="tl zoom"></button>
|
||||
<button class="tl close"></button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="stage">
|
||||
<section class="hero">
|
||||
<img class="mark" src="../../../ui/icon.png" alt="" />
|
||||
<h1>DeepSeek Harness</h1>
|
||||
<p id="status">已连接到本地 dsh web</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
135
docs/screenshots/src/session.html
Normal file
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dsh-desktop-offset">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>新会话</title>
|
||||
<link rel="stylesheet" href="chrome.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f6f8;
|
||||
--sidebar: #efeff2;
|
||||
--text: #1d1d1f;
|
||||
--muted: #86868b;
|
||||
--line: rgba(0,0,0,0.06);
|
||||
--accent: #0071e3;
|
||||
--card: #ffffff;
|
||||
}
|
||||
html, body {
|
||||
margin: 0; width: 1280px; height: 800px; overflow: hidden;
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
height: calc(800px - 36px);
|
||||
}
|
||||
.sidebar {
|
||||
background: var(--sidebar);
|
||||
border-right: 0.5px solid var(--line);
|
||||
padding: 16px 12px 18px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 4px 8px 8px;
|
||||
font-weight: 600; font-size: 13px;
|
||||
}
|
||||
.brand img { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.new-session {
|
||||
height: 34px; border: 0; border-radius: 10px;
|
||||
background: var(--card); color: var(--text);
|
||||
font: 600 13px/1 inherit; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.section-label {
|
||||
margin: 8px 8px 4px; font-size: 11px; color: var(--muted); letter-spacing: 0.04em;
|
||||
}
|
||||
.session {
|
||||
padding: 8px 10px; border-radius: 8px; font-size: 13px;
|
||||
background: rgba(255,255,255,0.7);
|
||||
}
|
||||
.session small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
|
||||
.main {
|
||||
display: flex; flex-direction: column; min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
.hero {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 10px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.hero .mark {
|
||||
width: 56px; height: 56px; border-radius: 14px; margin-bottom: 8px;
|
||||
}
|
||||
.hero h1 { margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.03em; }
|
||||
.badge {
|
||||
font-size: 11px; color: var(--muted); background: rgba(0,0,0,0.04);
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
}
|
||||
.hint { color: var(--muted); font-size: 13px; margin: 0; }
|
||||
.composer {
|
||||
margin: 0 28px 22px; background: var(--card);
|
||||
border: 0.5px solid var(--line); border-radius: 16px;
|
||||
min-height: 92px; padding: 14px 16px 12px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.04);
|
||||
}
|
||||
.placeholder { color: #aeaeb2; font-size: 14px; }
|
||||
.tools { display: flex; justify-content: space-between; align-items: center; }
|
||||
.chips { display: flex; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
.chip {
|
||||
border: 0.5px solid var(--line); border-radius: 999px; padding: 4px 10px; background: #fff;
|
||||
}
|
||||
.send {
|
||||
width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff;
|
||||
display: grid; place-items: center; font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="dsh-desktop-titlebar">
|
||||
<button class="more" type="button">•••</button>
|
||||
<div class="menu"></div>
|
||||
<div class="drag"></div>
|
||||
<div class="traffic">
|
||||
<button class="tl min"></button>
|
||||
<button class="tl zoom"></button>
|
||||
<button class="tl close"></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="../../../ui/icon.png" alt="" />
|
||||
DeepSeek Harness
|
||||
</div>
|
||||
<div class="new-session">+ 新会话</div>
|
||||
<div class="section-label">工作区</div>
|
||||
<div class="session">
|
||||
新会话
|
||||
<small>锚定式标准(实验)</small>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="main">
|
||||
<div class="hero">
|
||||
<img class="mark" src="../../../ui/icon.png" alt="" />
|
||||
<h1>探索未至之境</h1>
|
||||
<span class="badge">预览版</span>
|
||||
<p class="hint">官方 WebUI 运行在原生窗口中,凭据仍保存在 ~/.dsh</p>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<div class="placeholder">给 Harness 发送消息</div>
|
||||
<div class="tools">
|
||||
<div class="chips">
|
||||
<span class="chip">锚定式标准(实验)</span>
|
||||
<span class="chip">工作区</span>
|
||||
</div>
|
||||
<div class="send">↑</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
34
docs/screenshots/src/splash.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dsh-desktop-offset">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>启动页</title>
|
||||
<link rel="stylesheet" href="../../../ui/styles.css" />
|
||||
<link rel="stylesheet" href="chrome.css" />
|
||||
<style>
|
||||
:root { color-scheme: light; --bg: #f5f5f7; --text: #1d1d1f; --muted: #86868b; }
|
||||
html, body { width: 1280px; height: 800px; overflow: hidden; background: #f5f5f7; color: #1d1d1f; }
|
||||
.stage { min-height: calc(800px - 36px); padding-top: 48px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="dsh-desktop-titlebar">
|
||||
<button class="more" type="button">•••</button>
|
||||
<div class="menu"></div>
|
||||
<div class="drag"></div>
|
||||
<div class="traffic">
|
||||
<button class="tl min"></button>
|
||||
<button class="tl zoom"></button>
|
||||
<button class="tl close"></button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="stage">
|
||||
<section class="hero">
|
||||
<img class="mark" src="../../../ui/icon.png" alt="" />
|
||||
<h1>DeepSeek Harness</h1>
|
||||
<p id="status">正在启动官方 WebUI…</p>
|
||||
<div class="ring" aria-hidden="true"></div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
115
docs/screenshots/src/vision.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dsh-desktop-offset">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>视觉模型</title>
|
||||
<link rel="stylesheet" href="chrome.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f6f8;
|
||||
--sidebar: #efeff2;
|
||||
--text: #1d1d1f;
|
||||
--muted: #86868b;
|
||||
--line: rgba(0,0,0,0.08);
|
||||
--accent: #0071e3;
|
||||
--card: #ffffff;
|
||||
--ok: #248a3d;
|
||||
}
|
||||
html, body {
|
||||
margin: 0; width: 1280px; height: 800px; overflow: hidden;
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
height: calc(800px - 36px);
|
||||
}
|
||||
.nav {
|
||||
background: var(--sidebar);
|
||||
border-right: 0.5px solid var(--line);
|
||||
padding: 22px 12px;
|
||||
}
|
||||
.nav h2 { margin: 0 10px 16px; font-size: 18px; font-weight: 600; }
|
||||
.nav a {
|
||||
display: block; padding: 8px 12px; border-radius: 8px;
|
||||
color: var(--text); text-decoration: none; font-size: 13px;
|
||||
}
|
||||
.nav a.active { background: #fff; font-weight: 600; }
|
||||
.nav a.muted { color: var(--muted); }
|
||||
.content { padding: 28px 40px; overflow: hidden; }
|
||||
.content h1 { margin: 0 0 6px; font-size: 22px; font-weight: 600; }
|
||||
.lead { margin: 0 0 22px; color: var(--muted); font-size: 13px; }
|
||||
.dshdv { width: 100%; max-width: 640px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.dshdv h3 { margin: 0; font-size: 15px; font-weight: 600; line-height: 22px; }
|
||||
.dshdv p { margin: 0; color: var(--muted); font-size: 13px; line-height: 20px; }
|
||||
.dshdv label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: #6e6e73; }
|
||||
.dshdv .head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.dshdv a { color: var(--accent); text-decoration: none; font-size: 12px; }
|
||||
.dshdv input, .dshdv select {
|
||||
height: 36px; border: 1px solid var(--line); background: var(--card);
|
||||
color: var(--text); border-radius: 8px; padding: 0 12px; font: inherit;
|
||||
}
|
||||
.dshdv button {
|
||||
align-self: flex-start; height: 32px; padding: 0 14px; border: 0;
|
||||
border-radius: 8px; background: var(--accent); color: #fff; font: inherit;
|
||||
}
|
||||
.dshdv .ok { color: var(--ok); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="dsh-desktop-titlebar">
|
||||
<button class="more" type="button">•••</button>
|
||||
<div class="menu"></div>
|
||||
<div class="drag"></div>
|
||||
<div class="traffic">
|
||||
<button class="tl min"></button>
|
||||
<button class="tl zoom"></button>
|
||||
<button class="tl close"></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell">
|
||||
<nav class="nav">
|
||||
<h2>设置</h2>
|
||||
<a class="muted" href="#">通用</a>
|
||||
<a class="muted" href="#">模型</a>
|
||||
<a class="active" href="#">视觉模型</a>
|
||||
<a class="muted" href="#">插件</a>
|
||||
<a class="muted" href="#">技能</a>
|
||||
</nav>
|
||||
<section class="content">
|
||||
<h1>视觉模型</h1>
|
||||
<p class="lead">内置插件 dsh-desktop-vision · 写入 ~/.modlens/config.json</p>
|
||||
<div class="dshdv">
|
||||
<h3>ModLens 视觉模型</h3>
|
||||
<p>纯文本对话模型读图时使用这里的引擎。已声明视觉能力的模型(如 Qwen)不会走这条桥。</p>
|
||||
<label>
|
||||
引擎
|
||||
<select>
|
||||
<option selected>OpenAI 兼容</option>
|
||||
<option>Gemini API</option>
|
||||
<option>Anthropic</option>
|
||||
<option>Antigravity CLI</option>
|
||||
<option>Claude CLI</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
接口地址
|
||||
<input value="https://api.openai.com/v1" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="head">API 密钥 <a href="#">获取 API</a></span>
|
||||
<input type="password" value="sk-••••••••••••••••" />
|
||||
</label>
|
||||
<label>
|
||||
视觉模型
|
||||
<input value="" placeholder="例如 gpt-4o" />
|
||||
</label>
|
||||
<button type="button">保存</button>
|
||||
<p class="ok">已保存</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/screenshots/vision.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
97
flatpak/io.github.tommyfang.DshDesktop.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
app-id: io.github.tommyfang.DshDesktop
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '47'
|
||||
sdk: org.gnome.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.node24
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: dsh-desktop
|
||||
|
||||
finish-args:
|
||||
- --device=dri
|
||||
- --share=ipc
|
||||
- --socket=fallback-x11
|
||||
- --socket=wayland
|
||||
- --share=network
|
||||
- --filesystem=host
|
||||
- --filesystem=xdg-download
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
- --talk-name=org.a11y.Bus
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
|
||||
cleanup:
|
||||
- /include
|
||||
- /lib/pkgconfig
|
||||
- /share/man
|
||||
- '*.a'
|
||||
- '*.la'
|
||||
|
||||
modules:
|
||||
- name: dsh-runtime
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p ${FLATPAK_DEST}/bin
|
||||
- cp -a /usr/lib/sdk/node24 ${FLATPAK_DEST}/node24
|
||||
- ln -sf ../node24/bin/node ${FLATPAK_DEST}/bin/node
|
||||
- ln -sf ../node24/bin/npm ${FLATPAK_DEST}/bin/npm
|
||||
- ln -sf ../node24/bin/npx ${FLATPAK_DEST}/bin/npx
|
||||
- cp -a . ${FLATPAK_DEST}/
|
||||
sources:
|
||||
- type: dir
|
||||
path: ../vendor/dsh-prefix
|
||||
|
||||
- name: dsh-desktop
|
||||
buildsystem: simple
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
env:
|
||||
CARGO_HOME: /run/build/dsh-desktop/cargo
|
||||
build-args:
|
||||
- --share=network
|
||||
build-commands:
|
||||
- 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
|
||||
- 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
|
||||
sources:
|
||||
- type: dir
|
||||
path: ..
|
||||
skip:
|
||||
- .flatpak-build
|
||||
- .flatpak-builder
|
||||
- .flatpak-repo
|
||||
- dist
|
||||
- vendor
|
||||
- .git
|
||||
- target
|
||||
|
||||
- name: modlens-plugin
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/modlens
|
||||
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/modlens
|
||||
sources:
|
||||
- type: dir
|
||||
path: ../vendor/modlens
|
||||
|
||||
- name: anchored-standard-preset
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/anchored-standard
|
||||
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/anchored-standard
|
||||
sources:
|
||||
- type: dir
|
||||
path: ../vendor/anchored-standard
|
||||
|
||||
- name: zero-anchored-standard-preset
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p ${FLATPAK_DEST}/share/dsh-desktop/zero-anchored-standard
|
||||
- cp -a . ${FLATPAK_DEST}/share/dsh-desktop/zero-anchored-standard
|
||||
sources:
|
||||
- type: dir
|
||||
path: ../vendor/zero-anchored-standard
|
||||
209
plugins/dsh-desktop-vision/client.js
Normal file
@@ -0,0 +1,209 @@
|
||||
window.__ModuleLoader__.load({
|
||||
id: 'dsh-desktop-vision',
|
||||
factory: (require) => {
|
||||
var module = { exports: {} }
|
||||
var exports = module.exports
|
||||
var React = require('react')
|
||||
var jsx = require('react/jsx-runtime')
|
||||
|
||||
var css = [
|
||||
'.dshdv{width:100%;max-width:640px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary)}',
|
||||
'.dshdv h3{margin:0;font-size:15px;font-weight:600;line-height:22px}',
|
||||
'.dshdv p{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}',
|
||||
'.dshdv label{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
||||
'.dshdv .head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
|
||||
'.dshdv a{color:var(--dsw-alias-state-business-primary);text-decoration:none;font-size:12px;line-height:18px}',
|
||||
'.dshdv a:hover{text-decoration:underline}',
|
||||
'.dshdv input,.dshdv select{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font:inherit}',
|
||||
'.dshdv input:focus,.dshdv select:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}',
|
||||
'.dshdv button{align-self:flex-start;height:32px;padding:0 14px;border:0;border-radius:8px;background:var(--dsw-alias-state-business-primary);color:#fff;font:inherit;cursor:pointer}',
|
||||
'.dshdv button:disabled{opacity:.55;cursor:default}',
|
||||
'.dshdv .ok{color:var(--dsw-alias-state-success-primary)}',
|
||||
'.dshdv .err{color:var(--dsw-alias-state-error-primary)}',
|
||||
].join('')
|
||||
|
||||
function ensureCss() {
|
||||
if (typeof document === 'undefined') return
|
||||
if (document.querySelector('style[data-plugin-css="dsh-desktop-vision"]')) return
|
||||
var tag = document.createElement('style')
|
||||
tag.dataset.pluginCss = 'dsh-desktop-vision'
|
||||
tag.textContent = css
|
||||
document.head.appendChild(tag)
|
||||
}
|
||||
|
||||
function remote(provider) {
|
||||
var q = provider ? '?provider=' + encodeURIComponent(provider) : ''
|
||||
return fetch('/dsh-desktop/modlens' + q).then(function (res) {
|
||||
if (!res.ok) throw new Error('load failed ' + res.status)
|
||||
return res.json()
|
||||
})
|
||||
}
|
||||
|
||||
function VisionSettings() {
|
||||
ensureCss()
|
||||
var state = React.useState({ status: 'loading' })
|
||||
var snap = state[0]
|
||||
var setSnap = state[1]
|
||||
React.useEffect(function () {
|
||||
var live = true
|
||||
remote()
|
||||
.then(function (form) {
|
||||
if (live) setSnap({ status: 'ready', form: form, message: '' })
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (live) setSnap({ status: 'error', message: String(error.message || error) })
|
||||
})
|
||||
return function () {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
function patch(field, value) {
|
||||
setSnap(function (cur) {
|
||||
if (cur.status !== 'ready') return cur
|
||||
return { status: 'ready', form: Object.assign({}, cur.form, { [field]: value }), message: '' }
|
||||
})
|
||||
}
|
||||
|
||||
function onProvider(id) {
|
||||
remote(id)
|
||||
.then(function (form) {
|
||||
setSnap({ status: 'ready', form: form, message: '' })
|
||||
})
|
||||
.catch(function () {
|
||||
patch('provider', id)
|
||||
})
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (snap.status !== 'ready' || snap.saving) return
|
||||
setSnap(Object.assign({}, snap, { saving: true, message: '' }))
|
||||
fetch('/dsh-desktop/modlens', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(snap.form),
|
||||
})
|
||||
.then(function (res) {
|
||||
return res.json().then(function (body) {
|
||||
if (!res.ok) throw new Error(body.error || 'save failed')
|
||||
return body
|
||||
})
|
||||
})
|
||||
.then(function (form) {
|
||||
setSnap({ status: 'ready', form: form, message: '已保存', saving: false })
|
||||
})
|
||||
.catch(function (error) {
|
||||
setSnap(Object.assign({}, snap, { saving: false, message: String(error.message || error) }))
|
||||
})
|
||||
}
|
||||
|
||||
if (snap.status === 'loading') {
|
||||
return jsx.jsx('div', { className: 'dshdv', children: jsx.jsx('p', { children: '正在读取 ModLens 配置…' }) })
|
||||
}
|
||||
if (snap.status === 'error') {
|
||||
return jsx.jsx('div', { className: 'dshdv', children: jsx.jsx('p', { className: 'err', children: snap.message }) })
|
||||
}
|
||||
var form = snap.form
|
||||
var remoteEngine = form.provider === 'openai' || form.provider === 'gemini-api' || form.provider === 'anthropic'
|
||||
var options = form.options || []
|
||||
var current = options.filter(function (item) { return item.id === form.provider })[0]
|
||||
var apiUrl = (current && current.apiUrl) || form.apiUrl || ''
|
||||
var example = (current && current.example) || form.example || ''
|
||||
var getApi = apiUrl
|
||||
? jsx.jsx('a', { href: apiUrl, children: '获取 API' })
|
||||
: null
|
||||
return jsx.jsxs('div', {
|
||||
className: 'dshdv',
|
||||
children: [
|
||||
jsx.jsx('h3', { children: 'ModLens 视觉模型' }),
|
||||
jsx.jsx('p', {
|
||||
children:
|
||||
'纯文本对话模型读图时使用这里的引擎。已声明视觉能力的模型(如 Qwen)不会走这条桥。',
|
||||
}),
|
||||
jsx.jsxs('label', {
|
||||
children: [
|
||||
'引擎',
|
||||
jsx.jsx('select', {
|
||||
value: form.provider,
|
||||
onChange: function (event) {
|
||||
onProvider(event.target.value)
|
||||
},
|
||||
children: options.map(function (item) {
|
||||
return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
jsx.jsxs('label', {
|
||||
children: [
|
||||
'接口地址',
|
||||
jsx.jsx('input', {
|
||||
value: form.baseUrl || '',
|
||||
disabled: !remoteEngine,
|
||||
placeholder: form.officialBaseUrl || '',
|
||||
onChange: function (event) {
|
||||
patch('baseUrl', event.target.value)
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
jsx.jsxs('label', {
|
||||
children: [
|
||||
jsx.jsxs('span', { className: 'head', children: ['API 密钥', getApi] }),
|
||||
jsx.jsx('input', {
|
||||
type: 'password',
|
||||
value: form.apiKey || '',
|
||||
disabled: !remoteEngine,
|
||||
onChange: function (event) {
|
||||
patch('apiKey', event.target.value)
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
jsx.jsxs('label', {
|
||||
children: [
|
||||
'视觉模型',
|
||||
jsx.jsx('input', {
|
||||
value: form.model || '',
|
||||
placeholder: example ? '例如 ' + example : '',
|
||||
onChange: function (event) {
|
||||
patch('model', event.target.value)
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
jsx.jsx('button', {
|
||||
type: 'button',
|
||||
disabled: !!snap.saving,
|
||||
onClick: save,
|
||||
children: snap.saving ? '保存中…' : '保存',
|
||||
}),
|
||||
snap.message
|
||||
? jsx.jsx('p', {
|
||||
className: /失败|failed|error/i.test(snap.message) ? 'err' : 'ok',
|
||||
children: snap.message,
|
||||
})
|
||||
: null,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function apply(ctx) {
|
||||
ctx.slots.inject('settings.section', function () {
|
||||
return ctx.slots.register(
|
||||
{
|
||||
name: 'settings.section',
|
||||
id: 'modlens-vision',
|
||||
order: 12,
|
||||
label: '视觉模型',
|
||||
},
|
||||
VisionSettings,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
exports.apply = apply
|
||||
exports.inject = ['slots']
|
||||
return module.exports
|
||||
},
|
||||
})
|
||||
4
plugins/dsh-desktop-vision/cordis.patch.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Settings page + host route for the ModLens vision engine.
|
||||
- insert:
|
||||
- id: dsh-desktop-vision
|
||||
name: dsh-desktop-vision
|
||||
181
plugins/dsh-desktop-vision/index.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
export const name = 'dsh-desktop-vision'
|
||||
export const inject = []
|
||||
|
||||
const PROVIDERS = [
|
||||
{
|
||||
id: 'openai',
|
||||
label: 'OpenAI 兼容',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiUrl: 'https://platform.openai.com/api-keys',
|
||||
example: 'gpt-4o',
|
||||
},
|
||||
{
|
||||
id: 'gemini-api',
|
||||
label: 'Gemini API',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
apiUrl: 'https://aistudio.google.com/apikey',
|
||||
example: 'gemini-3.6-flash',
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic API',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
apiUrl: 'https://console.anthropic.com/settings/keys',
|
||||
example: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
{
|
||||
id: 'antigravity-cli',
|
||||
label: 'Antigravity CLI(免费)',
|
||||
baseUrl: '',
|
||||
apiUrl: 'https://antigravity.google/',
|
||||
example: 'gemini-3.6-flash-low',
|
||||
},
|
||||
{
|
||||
id: 'claude-cli',
|
||||
label: 'Claude Code 登录',
|
||||
baseUrl: '',
|
||||
apiUrl: 'https://code.claude.com',
|
||||
example: 'haiku',
|
||||
},
|
||||
]
|
||||
const PROVIDER_IDS = new Set(PROVIDERS.map((item) => item.id))
|
||||
|
||||
function providerOf(id) {
|
||||
return PROVIDERS.find((entry) => entry.id === id)
|
||||
}
|
||||
|
||||
function officialBaseUrl(provider) {
|
||||
const item = providerOf(provider)
|
||||
return item ? item.baseUrl : ''
|
||||
}
|
||||
|
||||
function officialApiUrl(provider) {
|
||||
const item = providerOf(provider)
|
||||
return item ? item.apiUrl : ''
|
||||
}
|
||||
|
||||
function officialExample(provider) {
|
||||
const item = providerOf(provider)
|
||||
return item ? item.example : ''
|
||||
}
|
||||
const FORM_FIELDS = ['apiKey', 'baseUrl', 'model']
|
||||
|
||||
function configPath() {
|
||||
const home = process.env.MODLENS_HOME || join(homedir(), '.modlens')
|
||||
return join(home, 'config.json')
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(configPath(), 'utf8'))
|
||||
return data && typeof data === 'object' ? data : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function formOf(cfg, provider) {
|
||||
const id = PROVIDER_IDS.has(provider) ? provider : 'openai'
|
||||
const entry =
|
||||
cfg.providers && typeof cfg.providers === 'object' && typeof cfg.providers[id] === 'object'
|
||||
? cfg.providers[id]
|
||||
: {}
|
||||
return {
|
||||
provider: id,
|
||||
apiKey: String(entry.apiKey || ''),
|
||||
baseUrl: String(entry.baseUrl || officialBaseUrl(id)),
|
||||
officialBaseUrl: officialBaseUrl(id),
|
||||
apiUrl: officialApiUrl(id),
|
||||
example: officialExample(id),
|
||||
model: String(entry.model || ''),
|
||||
}
|
||||
}
|
||||
|
||||
function saveForm(values) {
|
||||
const provider = String(values.provider || 'openai').trim()
|
||||
if (!PROVIDER_IDS.has(provider)) {
|
||||
const error = new Error(`unknown provider: ${provider}`)
|
||||
error.status = 400
|
||||
throw error
|
||||
}
|
||||
const cfg = loadConfig()
|
||||
cfg.provider = provider
|
||||
if (!cfg.providers || typeof cfg.providers !== 'object') cfg.providers = {}
|
||||
if (!cfg.providers[provider] || typeof cfg.providers[provider] !== 'object') {
|
||||
cfg.providers[provider] = {}
|
||||
}
|
||||
const entry = cfg.providers[provider]
|
||||
for (const field of FORM_FIELDS) {
|
||||
const raw = String(values[field] || '').trim()
|
||||
if (raw) entry[field] = raw
|
||||
else delete entry[field]
|
||||
}
|
||||
const path = configPath()
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8' })
|
||||
try {
|
||||
chmodSync(path, 0o600)
|
||||
} catch {
|
||||
// best-effort; some filesystems ignore mode
|
||||
}
|
||||
return formOf(cfg, provider)
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
||||
if (!raw) return {}
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
if (typeof ctx.inject !== 'function') return
|
||||
ctx.inject(['webServer'], (scope) => {
|
||||
try {
|
||||
scope.webServer.register({
|
||||
name: 'dsh-desktop-modlens',
|
||||
kind: 'exact',
|
||||
path: '/dsh-desktop/modlens',
|
||||
handler: async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const cfg = loadConfig()
|
||||
const provider = new URL(req.url, 'http://localhost').searchParams.get('provider')
|
||||
json(res, 200, {
|
||||
...formOf(cfg, provider || cfg.provider || 'openai'),
|
||||
options: PROVIDERS.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
officialBaseUrl: item.baseUrl,
|
||||
apiUrl: item.apiUrl,
|
||||
example: item.example,
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (req.method === 'PUT' || req.method === 'POST') {
|
||||
const body = await readJsonBody(req)
|
||||
json(res, 200, { ...saveForm(body), saved: true })
|
||||
return
|
||||
}
|
||||
res.writeHead(405).end()
|
||||
} catch (error) {
|
||||
json(res, error.status || 500, { error: String(error?.message || error) })
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[dsh-desktop-vision] config route skipped: ${error}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
23
plugins/dsh-desktop-vision/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "dsh-desktop-vision",
|
||||
"version": "0.1.4",
|
||||
"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"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
}
|
||||
}
|
||||
29
scripts/capture-screenshots.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT="$ROOT/docs/screenshots"
|
||||
SRC="$OUT/src"
|
||||
CHROME="${CHROME:-google-chrome}"
|
||||
|
||||
capture() {
|
||||
local name="$1"
|
||||
local html="$2"
|
||||
"$CHROME" \
|
||||
--headless=new \
|
||||
--disable-gpu \
|
||||
--no-sandbox \
|
||||
--hide-scrollbars \
|
||||
--force-device-scale-factor=2 \
|
||||
--window-size=1280,800 \
|
||||
--default-background-color=00000000 \
|
||||
--screenshot="$OUT/$name.png" \
|
||||
"file://$html"
|
||||
echo "wrote $OUT/$name.png"
|
||||
}
|
||||
|
||||
mkdir -p "$OUT"
|
||||
capture splash "$SRC/splash.html"
|
||||
capture session "$SRC/session.html"
|
||||
capture vision "$SRC/vision.html"
|
||||
capture menu "$SRC/menu.html"
|
||||
cp -f "$ROOT/ui/icon.png" "$OUT/icon.png"
|
||||
58
scripts/localize_preset.py
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Localize vendored anchored-standard preset.yml files (used by make vendor)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PRESET_NAME_ZH = "锚定式标准(实验)"
|
||||
PRESET_DESCRIPTION_ZH = (
|
||||
"首轮使用 Minimal 的真实工具对(持久 bash + str_replace_editor),"
|
||||
"不自动注入工作区或技能上下文;首次工具调用或回复后开放完整 Standard 工具。"
|
||||
)
|
||||
ZERO_PRESET_NAME_ZH = "零工具锚定式标准(实验)"
|
||||
ZERO_PRESET_DESCRIPTION_ZH = (
|
||||
"先插入一轮无工具的锚定对话(固定提示),从下一轮起开放完整 Standard 工具。"
|
||||
)
|
||||
|
||||
|
||||
def localize_preset_yml(text: str, name: str, description: str) -> str:
|
||||
body = text.replace("\r\n", "\n")
|
||||
if not body.endswith("\n"):
|
||||
body += "\n"
|
||||
name_line = f"name: {json.dumps(name, ensure_ascii=False)}\n"
|
||||
desc_line = f"description: {json.dumps(description, ensure_ascii=False)}\n"
|
||||
name_match = re.search(r"(?m)^name:.*\n", body)
|
||||
if name_match:
|
||||
body = body[: name_match.start()] + name_line + body[name_match.end() :]
|
||||
else:
|
||||
body = name_line + body
|
||||
desc_match = re.search(r"(?m)^description:(?:[ \t].*)?\n(?:[ \t].+\n)*", body)
|
||||
if desc_match:
|
||||
return body[: desc_match.start()] + desc_line + body[desc_match.end() :]
|
||||
name_written = re.search(r"(?m)^name:.*\n", body)
|
||||
if name_written:
|
||||
return body[: name_written.end()] + desc_line + body[name_written.end() :]
|
||||
return desc_line + body
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) < 2:
|
||||
print("usage: localize_preset.py <preset.yml> [zero]", file=sys.stderr)
|
||||
return 2
|
||||
path = Path(argv[1])
|
||||
zero = len(argv) > 2 and argv[2] == "zero"
|
||||
name = ZERO_PRESET_NAME_ZH if zero else PRESET_NAME_ZH
|
||||
description = ZERO_PRESET_DESCRIPTION_ZH if zero else PRESET_DESCRIPTION_ZH
|
||||
path.write_text(
|
||||
localize_preset_yml(path.read_text(encoding="utf-8"), name, description),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
48
scripts/vendor-native.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fetch ModLens + Anchored Standard into vendor/ for Tauri resource bundling.
|
||||
# Does not vendor @deepseek-ai/dsh (that is Flatpak-only; see `make vendor`).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
MODLENS_VERSION="${MODLENS_VERSION:-3.16.6}"
|
||||
ANCHORED_COMMIT="${ANCHORED_COMMIT:-ffb845c5480adc953392a6db6f8a98ede621174b}"
|
||||
ANCHORED_REPO="${ANCHORED_REPO:-https://github.com/xiaobright/dsh-anchored-standard.git}"
|
||||
if [ -z "${PYTHON:-}" ]; then
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON=python3
|
||||
else
|
||||
PYTHON=python
|
||||
fi
|
||||
fi
|
||||
|
||||
ANCHORED_DIR="vendor/anchored-standard"
|
||||
ZERO_DIR="vendor/zero-anchored-standard"
|
||||
MODLENS_DIR="vendor/modlens"
|
||||
|
||||
rm -rf vendor/.anchored-src "$ANCHORED_DIR" "$ZERO_DIR"
|
||||
mkdir -p vendor/.anchored-src
|
||||
git -C vendor/.anchored-src init --initial-branch=main
|
||||
git -C vendor/.anchored-src remote add origin "$ANCHORED_REPO"
|
||||
git -C vendor/.anchored-src fetch --depth 1 origin "$ANCHORED_COMMIT"
|
||||
git -C vendor/.anchored-src checkout --detach FETCH_HEAD
|
||||
mkdir -p "$ANCHORED_DIR" "$ZERO_DIR"
|
||||
cp -R vendor/.anchored-src/preset/. "$ANCHORED_DIR/"
|
||||
cp -R vendor/.anchored-src/zero-anchored-standard/. "$ZERO_DIR/"
|
||||
cp vendor/.anchored-src/LICENSE vendor/.anchored-src/NOTICE "$ANCHORED_DIR/"
|
||||
cp vendor/.anchored-src/LICENSE vendor/.anchored-src/NOTICE "$ZERO_DIR/"
|
||||
printf '%s\n' "$ANCHORED_COMMIT" > "$ANCHORED_DIR/.dsh-desktop-source"
|
||||
printf '%s\n' "$ANCHORED_COMMIT" > "$ZERO_DIR/.dsh-desktop-source"
|
||||
"$PYTHON" scripts/localize_preset.py "$ANCHORED_DIR/preset.yml"
|
||||
"$PYTHON" scripts/localize_preset.py "$ZERO_DIR/preset.yml" zero
|
||||
rm -rf vendor/.anchored-src
|
||||
|
||||
rm -rf "$MODLENS_DIR"
|
||||
mkdir -p "$MODLENS_DIR"
|
||||
npm install --prefix "$MODLENS_DIR" --prefer-offline --no-audit --no-fund "@liustack/modlens@${MODLENS_VERSION}"
|
||||
|
||||
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}"
|
||||
32
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "dsh-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Tauri desktop shell for DeepSeek Harness"
|
||||
|
||||
[lib]
|
||||
name = "dsh_desktop_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "dsh-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
dsh-core = { path = "../crates/dsh-core" }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
arboard = "3"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
open = "5"
|
||||
url = "2"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
18
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Main window, including the embedded dsh WebUI",
|
||||
"windows": ["main"],
|
||||
"remote": {
|
||||
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
|
||||
},
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
15
src-tauri/linux/dsh-desktop.desktop
Normal file
@@ -0,0 +1,15 @@
|
||||
[Desktop Entry]
|
||||
Categories={{categories}}
|
||||
Comment={{comment}}
|
||||
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness
|
||||
Exec={{exec}}
|
||||
GenericName=AI Agent Harness
|
||||
GenericName[zh_CN]=AI 智能体工作台
|
||||
Icon={{icon}}
|
||||
Keywords=AI;DeepSeek;agent;harness;dsh;assistant;
|
||||
Name={{name}}
|
||||
Name[zh_CN]=DeepSeek Harness
|
||||
StartupNotify=true
|
||||
StartupWMClass=io.github.tommyfang.DshDesktop
|
||||
Terminal=false
|
||||
Type=Application
|
||||
383
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dsh_core::clipboard::{
|
||||
file_from_bytes, filename_for_mime, files_from_paths, parse_uri_list, ClipboardFile,
|
||||
};
|
||||
use dsh_core::launcher::{DshLauncher, DshProcess, URL_TIMEOUT_SECONDS};
|
||||
use dsh_core::modlens::ensure_modlens;
|
||||
use dsh_core::paths::BundledPaths;
|
||||
use dsh_core::preset::ensure_anchored_standard;
|
||||
use dsh_core::updater::{mark_update_checked, update_check_due, update_dsh};
|
||||
use dsh_core::{APP_NAME, ENV_NO_UPDATE};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use url::Url;
|
||||
|
||||
const INJECT: &str = concat!(
|
||||
include_str!("../../ui/inject/ingest.js"),
|
||||
include_str!("../../ui/inject/hide-twins.js"),
|
||||
include_str!("../../ui/inject/chrome.js"),
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Args {
|
||||
pub dsh: Option<String>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub no_update: bool,
|
||||
pub force_update: bool,
|
||||
pub dev: bool,
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
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()),
|
||||
cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from),
|
||||
no_update: std::env::var(ENV_NO_UPDATE)
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false),
|
||||
force_update: false,
|
||||
dev: false,
|
||||
verbose: false,
|
||||
};
|
||||
let mut argv = std::env::args().skip(1);
|
||||
while let Some(arg) = argv.next() {
|
||||
match arg.as_str() {
|
||||
"--dsh" => args.dsh = argv.next(),
|
||||
"--cwd" => args.cwd = argv.next().map(PathBuf::from),
|
||||
"--no-update" => args.no_update = true,
|
||||
"--update" => args.force_update = true,
|
||||
"--dev" => args.dev = true,
|
||||
"--verbose" => args.verbose = true,
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
"--version" | "-V" => {
|
||||
println!("{APP_NAME} {}", dsh_core::VERSION);
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"{APP_NAME} — native window around the official dsh WebUI.\n\n\
|
||||
--dsh PATH dsh 可执行文件路径或命令\n\
|
||||
--cwd DIR 传给 dsh 的工作目录\n\
|
||||
--no-update 不检查 dsh 更新\n\
|
||||
--update 启动前强制检查/安装 dsh 更新\n\
|
||||
--dev 打开 WebView 开发者工具\n\
|
||||
--verbose 输出调试日志"
|
||||
);
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
args: Args,
|
||||
paths: BundledPaths,
|
||||
process: Mutex<Option<Arc<DshProcess>>>,
|
||||
url: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn stop(&self) {
|
||||
if let Ok(mut slot) = self.process.lock() {
|
||||
if let Some(proc) = slot.take() {
|
||||
proc.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct StatusPayload {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ErrorPayload {
|
||||
message: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ReadyPayload {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart(app: AppHandle) {
|
||||
thread::spawn(move || boot(app));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
let url = state
|
||||
.url
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
.ok_or_else(|| "服务尚未就绪".to_string())?;
|
||||
open::that(&url).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_clipboard_images() -> Result<Vec<ClipboardFile>, String> {
|
||||
read_images().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn read_images() -> Result<Vec<ClipboardFile>, String> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
if let Ok(img) = clipboard.get_image() {
|
||||
let width = img.width as u32;
|
||||
let height = img.height as u32;
|
||||
let bytes = img.bytes.into_owned();
|
||||
if let Some(buffer) = image::RgbaImage::from_raw(width, height, bytes) {
|
||||
let mut png = Vec::new();
|
||||
if buffer
|
||||
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.is_ok()
|
||||
{
|
||||
if let Some(file) = file_from_bytes(
|
||||
filename_for_mime("image/png", 0),
|
||||
"image/png".into(),
|
||||
png,
|
||||
) {
|
||||
return Ok(vec![file]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
let loaded = files_from_paths(parse_uri_list(&text));
|
||||
if !loaded.is_empty() {
|
||||
return Ok(loaded);
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn is_internal(url: &Url) -> bool {
|
||||
matches!(url.scheme(), "tauri" | "asset" | "about" | "data" | "blob")
|
||||
|| matches!(
|
||||
url.host_str(),
|
||||
Some("127.0.0.1" | "localhost" | "::1" | "tauri.localhost")
|
||||
)
|
||||
}
|
||||
|
||||
fn boot(app: AppHandle) {
|
||||
let Some(state) = app.try_state::<AppState>() else {
|
||||
return;
|
||||
};
|
||||
state.stop();
|
||||
let started = Instant::now();
|
||||
let _ = app.emit(
|
||||
"status",
|
||||
StatusPayload {
|
||||
message: "正在启动…".into(),
|
||||
},
|
||||
);
|
||||
|
||||
if state.args.force_update && !state.args.no_update {
|
||||
let _ = app.emit(
|
||||
"status",
|
||||
StatusPayload {
|
||||
message: "正在检查 dsh 更新…".into(),
|
||||
},
|
||||
);
|
||||
let result = update_dsh(true);
|
||||
mark_update_checked();
|
||||
log::info!("dsh update: {} ({})", result.status, result.message);
|
||||
}
|
||||
|
||||
let plugin = ensure_modlens(&state.paths);
|
||||
log::info!(
|
||||
"modlens: {} ({}) in {:?}",
|
||||
plugin.status,
|
||||
plugin.message,
|
||||
started.elapsed()
|
||||
);
|
||||
let preset = ensure_anchored_standard(&state.paths);
|
||||
log::info!(
|
||||
"anchored-standard: {} ({}) in {:?}",
|
||||
preset.status,
|
||||
preset.message,
|
||||
started.elapsed()
|
||||
);
|
||||
let _ = app.emit(
|
||||
"status",
|
||||
StatusPayload {
|
||||
message: "正在启动 dsh web 服务…".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let launcher = DshLauncher::new(state.args.dsh.clone(), state.args.cwd.clone());
|
||||
let process = match launcher.start() {
|
||||
Ok(p) => Arc::new(p),
|
||||
Err(err) => {
|
||||
let _ = app.emit(
|
||||
"error",
|
||||
ErrorPayload {
|
||||
message: err.to_string(),
|
||||
detail: String::new(),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Ok(mut slot) = state.process.lock() {
|
||||
*slot = Some(Arc::clone(&process));
|
||||
}
|
||||
log::info!("dsh web spawned in {:?}", started.elapsed());
|
||||
|
||||
if !state.args.no_update && !state.args.force_update && update_check_due() {
|
||||
thread::spawn(|| {
|
||||
log::info!("background dsh update check");
|
||||
let result = update_dsh(true);
|
||||
mark_update_checked();
|
||||
log::info!(
|
||||
"background dsh update: {} ({})",
|
||||
result.status,
|
||||
result.message
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(URL_TIMEOUT_SECONDS);
|
||||
loop {
|
||||
if let Some(url) = process.take_url() {
|
||||
if let Ok(mut slot) = state.url.lock() {
|
||||
*slot = Some(url.clone());
|
||||
}
|
||||
let _ = app.emit("ready", ReadyPayload { url });
|
||||
log::info!("dsh web ready in {:?}", started.elapsed());
|
||||
return;
|
||||
}
|
||||
if let Some(code) = process.poll() {
|
||||
let detail = process.snapshot_lines().join("\n");
|
||||
let _ = app.emit(
|
||||
"error",
|
||||
ErrorPayload {
|
||||
message: format!("服务已退出(exit {code})。"),
|
||||
detail,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let detail = process
|
||||
.snapshot_lines()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(80)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
process.stop();
|
||||
let _ = app.emit(
|
||||
"error",
|
||||
ErrorPayload {
|
||||
message: format!("等待 dsh web 输出 URL 超时({URL_TIMEOUT_SECONDS} 秒)。"),
|
||||
detail,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(80));
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}))
|
||||
.init();
|
||||
|
||||
let mut paths = BundledPaths::discover();
|
||||
let dev = args.dev;
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.setup(move |app| {
|
||||
if let Ok(dir) = app.path().resource_dir() {
|
||||
paths = paths.with_resource_dir(dir);
|
||||
}
|
||||
app.manage(AppState {
|
||||
args: args.clone(),
|
||||
paths,
|
||||
process: Mutex::new(None),
|
||||
url: Mutex::new(None),
|
||||
});
|
||||
|
||||
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
|
||||
.title(APP_NAME)
|
||||
.inner_size(1320.0, 860.0)
|
||||
.min_inner_size(800.0, 560.0)
|
||||
.decorations(false)
|
||||
.resizable(true)
|
||||
.initialization_script(INJECT)
|
||||
.on_navigation(|url| {
|
||||
if is_internal(&url) {
|
||||
true
|
||||
} else {
|
||||
let _ = open::that(url.as_str());
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
builder = builder.icon(icon)?;
|
||||
}
|
||||
|
||||
if dev {
|
||||
builder = builder.devtools(true);
|
||||
}
|
||||
|
||||
let window = builder.build()?;
|
||||
if dev {
|
||||
window.open_devtools();
|
||||
}
|
||||
|
||||
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
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if let Some(state) = window.try_state::<AppState>() {
|
||||
state.stop();
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running DeepSeek Harness Desktop");
|
||||
}
|
||||
5
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
dsh_desktop_lib::run();
|
||||
}
|
||||
71
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "DeepSeek Harness",
|
||||
"version": "0.1.0",
|
||||
"identifier": "io.github.tommyfang.DshDesktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"enableGTKAppId": true,
|
||||
"macOSPrivateApi": false,
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"],
|
||||
"publisher": "TommyFang2077",
|
||||
"homepage": "https://github.com/TommyFang2077/dsh-desktop",
|
||||
"copyright": "Copyright © 2026 TommyFang2077",
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Desktop shell for DeepSeek Harness",
|
||||
"longDescription": "Runs the official DeepSeek Harness (dsh) WebUI in a native window. Starts dsh web, embeds the UI with the system WebView, and ships ModLens plus Anchored Standard presets. Credentials stay in ~/.dsh.",
|
||||
"licenseFile": "../LICENSE",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [
|
||||
"../plugins/dsh-desktop-vision/",
|
||||
"../vendor/modlens/",
|
||||
"../vendor/anchored-standard/",
|
||||
"../vendor/zero-anchored-standard/"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"section": "devel",
|
||||
"desktopTemplate": "linux/dsh-desktop.desktop"
|
||||
},
|
||||
"rpm": {
|
||||
"release": "1",
|
||||
"desktopTemplate": "linux/dsh-desktop.desktop"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"displayLanguageSelector": true,
|
||||
"languages": ["SimpChinese", "English"]
|
||||
},
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"dmg": {
|
||||
"appPosition": { "x": 180, "y": 170 },
|
||||
"applicationFolderPosition": { "x": 480, "y": 170 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
tests/test_plugins.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Sanity checks for the bundled dsh WebUI plugin (no desktop runtime)."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class VisionPluginFilesTests(unittest.TestCase):
|
||||
def test_client_registers_system_settings_slots(self):
|
||||
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("settings.section", client)
|
||||
self.assertNotIn("settings.plugins.tab", client)
|
||||
self.assertIn("modlens-vision", client)
|
||||
host = (ROOT / "plugins" / "dsh-desktop-vision" / "index.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("/dsh-desktop/modlens", host)
|
||||
self.assertIn("'OpenAI 兼容'", host)
|
||||
self.assertNotIn("Qwen / 自建网关", host)
|
||||
self.assertIn("https://api.openai.com/v1", host)
|
||||
self.assertIn("https://generativelanguage.googleapis.com", host)
|
||||
self.assertIn("https://api.anthropic.com", host)
|
||||
self.assertIn("https://platform.openai.com/api-keys", host)
|
||||
self.assertIn("https://aistudio.google.com/apikey", host)
|
||||
self.assertIn("https://console.anthropic.com/settings/keys", host)
|
||||
self.assertIn("获取 API", client)
|
||||
self.assertNotIn("qwen-agent", client)
|
||||
self.assertIn("example: 'gpt-4o'", host)
|
||||
self.assertIn("example: 'gemini-3.6-flash'", host)
|
||||
self.assertIn("example: 'claude-haiku-4-5-20251001'", host)
|
||||
self.assertIn("example: 'gemini-3.6-flash-low'", host)
|
||||
self.assertIn("example: 'haiku'", host)
|
||||
|
||||
|
||||
class BundledAttributionTests(unittest.TestCase):
|
||||
def test_readme_cites_upstream_plugins(self):
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
third = (ROOT / "THIRD_PARTY.md").read_text(encoding="utf-8")
|
||||
for text in (readme, third):
|
||||
self.assertIn("https://github.com/deepseek-ai/deepseek-harness", text)
|
||||
self.assertIn("https://github.com/liustack/modlens", text)
|
||||
self.assertIn("https://github.com/xiaobright/dsh-anchored-standard", text)
|
||||
self.assertIn("0.1.0-rc.6", text)
|
||||
self.assertIn("3.16.6", text)
|
||||
self.assertIn("ffb845c5480adc953392a6db6f8a98ede621174b", text)
|
||||
self.assertIn("dsh-desktop-vision", text)
|
||||
self.assertTrue((ROOT / "docs" / "licenses" / "modlens.LICENSE").is_file())
|
||||
self.assertTrue(
|
||||
(ROOT / "docs" / "licenses" / "dsh-anchored-standard.NOTICE").is_file()
|
||||
)
|
||||
for name in ("splash.png", "session.png", "vision.png", "menu.png"):
|
||||
self.assertTrue((ROOT / "docs" / "screenshots" / name).is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
52
ui/app.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const statusEl = document.getElementById("status");
|
||||
const spinner = document.getElementById("spinner");
|
||||
const retry = document.getElementById("retry");
|
||||
const detail = document.getElementById("detail");
|
||||
|
||||
function tauri() {
|
||||
return window.__TAURI__;
|
||||
}
|
||||
|
||||
function setStatus(message, kind) {
|
||||
statusEl.textContent = message;
|
||||
const failed = kind === "error";
|
||||
spinner.hidden = failed;
|
||||
retry.hidden = !failed;
|
||||
if (!failed) {
|
||||
detail.hidden = true;
|
||||
detail.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const api = tauri();
|
||||
if (!api) {
|
||||
setStatus("桌面接口未就绪", "error");
|
||||
return;
|
||||
}
|
||||
await api.event.listen("status", function (event) {
|
||||
setStatus(event.payload.message || "正在启动…", "ok");
|
||||
});
|
||||
await api.event.listen("error", function (event) {
|
||||
setStatus(event.payload.message || "启动失败", "error");
|
||||
if (event.payload.detail) {
|
||||
detail.hidden = false;
|
||||
detail.textContent = event.payload.detail;
|
||||
}
|
||||
});
|
||||
await api.event.listen("ready", function (event) {
|
||||
if (event.payload && event.payload.url) {
|
||||
window.location.replace(event.payload.url);
|
||||
}
|
||||
});
|
||||
retry.addEventListener("click", function () {
|
||||
setStatus("正在重新启动…", "ok");
|
||||
api.core.invoke("restart");
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
BIN
ui/icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
22
ui/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DeepSeek Harness</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="stage">
|
||||
<section class="hero">
|
||||
<img class="mark" src="icon.png" alt="" />
|
||||
<h1>DeepSeek Harness</h1>
|
||||
<p id="status">正在启动…</p>
|
||||
<div class="ring" id="spinner" aria-hidden="true"></div>
|
||||
<button type="button" class="pill" id="retry" hidden>重试</button>
|
||||
<pre id="detail" hidden></pre>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
168
ui/inject/chrome.js
Normal file
@@ -0,0 +1,168 @@
|
||||
(function () {
|
||||
if (window.__dshDesktopChrome) return;
|
||||
window.__dshDesktopChrome = true;
|
||||
|
||||
const css = `
|
||||
:root { --dsh-desktop-titlebar-h: 36px; }
|
||||
html.dsh-desktop-offset {
|
||||
box-sizing: border-box !important;
|
||||
padding-top: var(--dsh-desktop-titlebar-h);
|
||||
}
|
||||
#dsh-desktop-titlebar {
|
||||
position: fixed; top: 0; left: 0; right: 0; height: var(--dsh-desktop-titlebar-h);
|
||||
z-index: 2147483646; display: flex; align-items: center;
|
||||
padding: 0 12px; box-sizing: border-box;
|
||||
background: rgba(246, 246, 248, 0.72);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
user-select: none; -webkit-user-select: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#dsh-desktop-titlebar {
|
||||
background: rgba(28, 28, 30, 0.72);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
#dsh-desktop-titlebar .more { color: rgba(255,255,255,0.45); }
|
||||
#dsh-desktop-titlebar .menu {
|
||||
background: rgba(44, 44, 46, 0.96);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.45);
|
||||
}
|
||||
#dsh-desktop-titlebar .menu button { color: #f5f5f7; }
|
||||
#dsh-desktop-titlebar .menu button:hover { background: rgba(255,255,255,0.08); }
|
||||
}
|
||||
#dsh-desktop-titlebar .traffic {
|
||||
display: flex; gap: 8px; align-items: center; height: 100%;
|
||||
}
|
||||
#dsh-desktop-titlebar .tl {
|
||||
width: 12px; height: 12px; min-width: 12px; min-height: 12px;
|
||||
max-width: 12px; max-height: 12px; flex: none; overflow: hidden;
|
||||
box-sizing: border-box; border-radius: 50%; border: 0;
|
||||
padding: 0; margin: 0; display: grid; place-items: center; cursor: default;
|
||||
position: relative;
|
||||
}
|
||||
#dsh-desktop-titlebar .tl.close { background: #ff5f57; }
|
||||
#dsh-desktop-titlebar .tl.min { background: #febc2e; }
|
||||
#dsh-desktop-titlebar .tl.zoom { background: #28c840; }
|
||||
#dsh-desktop-titlebar .tl::after {
|
||||
content: ""; font-size: 9px; line-height: 1; font-weight: 700;
|
||||
color: rgba(0,0,0,0.55); opacity: 0;
|
||||
}
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.close::after { content: "×"; opacity: 1; }
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.min::after { content: "–"; opacity: 1; }
|
||||
#dsh-desktop-titlebar .traffic:hover .tl.zoom::after { content: "+"; opacity: 1; }
|
||||
#dsh-desktop-titlebar .drag { flex: 1; height: 100%; }
|
||||
#dsh-desktop-titlebar .more {
|
||||
appearance: none; border: 0; background: transparent;
|
||||
color: rgba(0,0,0,0.35); font-size: 15px; letter-spacing: 0.08em;
|
||||
width: 28px; height: 20px; border-radius: 6px; cursor: default;
|
||||
}
|
||||
#dsh-desktop-titlebar .more:hover { background: rgba(0,0,0,0.06); color: rgba(0,0,0,0.7); }
|
||||
#dsh-desktop-titlebar .menu {
|
||||
position: absolute; top: calc(var(--dsh-desktop-titlebar-h) + 2px); left: 8px; min-width: 168px;
|
||||
padding: 4px; border-radius: 10px;
|
||||
background: rgba(255,255,255,0.96);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.16);
|
||||
display: none; flex-direction: column;
|
||||
}
|
||||
#dsh-desktop-titlebar .menu.open { display: flex; }
|
||||
#dsh-desktop-titlebar .menu button {
|
||||
appearance: none; border: 0; background: transparent;
|
||||
text-align: left; padding: 7px 10px; border-radius: 6px;
|
||||
font: 13px/1.3 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
color: #1d1d1f; cursor: default;
|
||||
}
|
||||
#dsh-desktop-titlebar .menu button:hover { background: rgba(0,0,0,0.05); }
|
||||
#dsh-desktop-titlebar .menu button:disabled { opacity: 0.35; }
|
||||
`;
|
||||
|
||||
function api() {
|
||||
return window.__TAURI__ || null;
|
||||
}
|
||||
|
||||
function inject() {
|
||||
if (document.getElementById("dsh-desktop-titlebar")) return;
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.documentElement.appendChild(style);
|
||||
|
||||
const bar = document.createElement("header");
|
||||
bar.id = "dsh-desktop-titlebar";
|
||||
bar.innerHTML =
|
||||
'<button class="more" type="button" aria-label="更多">•••</button>' +
|
||||
'<div class="menu">' +
|
||||
'<button type="button" data-cmd="restart">重新启动</button>' +
|
||||
'<button type="button" data-cmd="open_in_browser">在浏览器中打开</button>' +
|
||||
"</div>" +
|
||||
'<div class="drag" data-tauri-drag-region></div>' +
|
||||
'<div class="traffic">' +
|
||||
'<button class="tl min" data-win="minimize" aria-label="最小化"></button>' +
|
||||
'<button class="tl zoom" data-win="zoom" aria-label="缩放"></button>' +
|
||||
'<button class="tl close" data-win="close" aria-label="关闭"></button>' +
|
||||
"</div>";
|
||||
document.documentElement.appendChild(bar);
|
||||
if (/^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname)) {
|
||||
document.documentElement.classList.add("dsh-desktop-offset");
|
||||
}
|
||||
|
||||
const menu = bar.querySelector(".menu");
|
||||
const more = bar.querySelector(".more");
|
||||
more.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle("open");
|
||||
});
|
||||
document.addEventListener("click", function () {
|
||||
menu.classList.remove("open");
|
||||
});
|
||||
|
||||
bar.addEventListener("dblclick", function (e) {
|
||||
if (e.target.closest(".tl, .more, .menu")) return;
|
||||
const t = api();
|
||||
if (t && t.window) t.window.getCurrentWindow().toggleMaximize();
|
||||
});
|
||||
|
||||
bar.addEventListener("click", function (e) {
|
||||
const t = api();
|
||||
const winBtn = e.target.closest("[data-win]");
|
||||
if (winBtn && t && t.window) {
|
||||
const w = t.window.getCurrentWindow();
|
||||
const act = winBtn.getAttribute("data-win");
|
||||
if (act === "close") w.close();
|
||||
else if (act === "minimize") w.minimize();
|
||||
else if (act === "zoom") w.toggleMaximize();
|
||||
return;
|
||||
}
|
||||
const cmd = e.target.closest("[data-cmd]");
|
||||
if (cmd && t && t.core) {
|
||||
menu.classList.remove("open");
|
||||
t.core.invoke(cmd.getAttribute("data-cmd"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", inject);
|
||||
} else {
|
||||
inject();
|
||||
}
|
||||
|
||||
const harness = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname);
|
||||
if (harness && location.protocol.indexOf("http") === 0) {
|
||||
document.addEventListener("paste", async function (e) {
|
||||
const t = api();
|
||||
if (!t || !t.core) return;
|
||||
try {
|
||||
const items = e.clipboardData ? Array.from(e.clipboardData.items) : [];
|
||||
if (items.some(function (item) { return item.kind === "file" && item.getAsFile(); })) {
|
||||
return;
|
||||
}
|
||||
const files = await t.core.invoke("read_clipboard_images");
|
||||
if (files && files.length) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
if (window.__dshDesktopPasteFiles) window.__dshDesktopPasteFiles(files);
|
||||
}
|
||||
} catch (err) {}
|
||||
}, true);
|
||||
}
|
||||
})();
|
||||
35
ui/inject/hide-twins.js
Normal file
@@ -0,0 +1,35 @@
|
||||
(function () {
|
||||
const SUFFIX = " (modlens vision)";
|
||||
function textOf(el) {
|
||||
return (el.textContent || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
function hideTwins(root) {
|
||||
const nodes = root.querySelectorAll("button, [role='menuitem'], [role='option'], [role='group'], h1, h2, h3, h4, span, div, p");
|
||||
const visionBases = new Set();
|
||||
for (const el of nodes) {
|
||||
if (el.childElementCount > 3) continue;
|
||||
const t = textOf(el);
|
||||
if (t.endsWith(SUFFIX) && t.length > SUFFIX.length) {
|
||||
visionBases.add(t.slice(0, -SUFFIX.length));
|
||||
}
|
||||
}
|
||||
if (visionBases.size === 0) return;
|
||||
for (const el of nodes) {
|
||||
if (el.childElementCount > 3) continue;
|
||||
const t = textOf(el);
|
||||
if (!visionBases.has(t)) continue;
|
||||
const row = el.closest("[role='group'], [role='menuitem'], li, section") || el;
|
||||
if (row && row.style.display !== "none") row.style.display = "none";
|
||||
}
|
||||
}
|
||||
const run = function () {
|
||||
try { hideTwins(document.body); } catch (e) {}
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
const obs = new MutationObserver(run);
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
45
ui/inject/ingest.js
Normal file
@@ -0,0 +1,45 @@
|
||||
(function () {
|
||||
const IMAGE_PATH = /^(?:file:\/\/|(?:\/|\.{1,2}\/)).+\.(?:png|jpe?g|gif|webp|bmp|tiff?)$/i;
|
||||
function looksLikeImagePath(text) {
|
||||
const first = (text || "").trim().split(/\s+/, 1)[0] || "";
|
||||
return IMAGE_PATH.test(first);
|
||||
}
|
||||
document.addEventListener("paste", function (e) {
|
||||
try {
|
||||
const items = Array.from(e.clipboardData ? e.clipboardData.items : []);
|
||||
const files = items.filter(function (item) { return item.kind === "file"; })
|
||||
.map(function (item) { return item.getAsFile(); })
|
||||
.filter(Boolean);
|
||||
if (files.length > 0) return;
|
||||
const text = e.clipboardData ? (e.clipboardData.getData("text/plain") || "") : "";
|
||||
const uris = e.clipboardData ? (e.clipboardData.getData("text/uri-list") || "") : "";
|
||||
if (looksLikeImagePath(text) || looksLikeImagePath(uris)) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
} catch (err) {}
|
||||
}, true);
|
||||
|
||||
window.__dshDesktopPasteFiles = function (items) {
|
||||
try {
|
||||
const dt = new DataTransfer();
|
||||
for (const item of items) {
|
||||
const bin = atob(item.b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
dt.items.add(new File([bytes], item.name, { type: item.type }));
|
||||
}
|
||||
const drop = new DragEvent("drop", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer: dt
|
||||
});
|
||||
try {
|
||||
Object.defineProperty(drop, "dataTransfer", { value: dt, configurable: true });
|
||||
} catch (e) {}
|
||||
document.dispatchEvent(drop);
|
||||
} catch (err) {
|
||||
console.error("dsh-desktop paste image failed", err);
|
||||
}
|
||||
};
|
||||
})();
|
||||
114
ui/styles.css
Normal file
@@ -0,0 +1,114 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f5f5f7;
|
||||
--text: #1d1d1f;
|
||||
--muted: #86868b;
|
||||
--accent: #0071e3;
|
||||
--card: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--text: #f5f5f7;
|
||||
--muted: #86868b;
|
||||
--accent: #0a84ff;
|
||||
--card: rgba(28, 28, 30, 0.72);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
|
||||
"Helvetica Neue", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.stage {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 72px 32px 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
width: min(420px, 100%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: block;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 22px;
|
||||
border-radius: 18px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
#status {
|
||||
margin: 10px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.ring {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 28px auto 0;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(134, 134, 139, 0.28);
|
||||
border-top-color: var(--text);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.ring[hidden] { display: none; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.pill {
|
||||
margin-top: 28px;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 980px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font: 600 13px/1 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
padding: 9px 18px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pill[hidden] { display: none; }
|
||||
|
||||
#detail {
|
||||
margin: 22px 0 0;
|
||||
text-align: left;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#detail[hidden] { display: none; }
|
||||