一台云服务器,
让手机也变成 DeepSeek Harness 工作台
从空的 Ubuntu 开始:装 DSH、加一层账号密码的登录墙、用 nginx 反代到公网, 再配一个 19 KB 的安卓 WebView 外壳。之后你升级 DSH、装插件、改主题, 手机端下拉刷新就同步——APK 永远不用重装。

文中所有命令、路径、端口都来自这台正在运行的服务器,不是示意代码。
0030 秒看懂
如果你只想复制粘贴,把下面三步跑完,再跳到「首次登录」。
服务器上跑起 dsh
只监听 127.0.0.1:13080,不直接暴露公网。
加一层带密码的入口
dsh-cloud-gateway 监听 3080,路径 /dsh,登录后再反代回本机。
nginx 终结 TLS
443 对外,WebSocket 长连接,静态资源可长缓存、入口一律不缓存。
手机装 19 KB 外壳
WebView 直接加载同一个网址,服务器变它就变。
# 1) 安装(Node 22 环境) npm i -g @deepseek-ai/dsh # 2) 首次启动,日志里会打印带 token 的地址 dsh web --host 127.0.0.1 --port 13080 --trusted-host dsh.example.com # 3) 装网关 + 手机适配插件 dsh plugin --profile web add github:MrLukezy/dsh-cloud-gateway dsh plugin --profile web add github:mexiaosqwq/dsh-web-mobile # 4) nginx 反代到 3080,浏览器打开 https://dsh.example.com/dsh/
为什么不让 dsh web --host 0.0.0.0 直接对外?因为官方 Web 只有 launch token,没有账号体系、没有失败限速。公网裸奔等于把整台机器的文件系统交出去。
01整体架构
四层,每一层只做一件事。手机和电脑走的是同一条链路,所以看到的内容天然一致。
一个入口
只有 /dsh 这一个公网前缀,assets、plugins、api 全部挂在它后面,少一个都白屏。
两端同源
手机和电脑打开的是同一个地址,插件与主题不存在“手机版另做一套”。
网关不碰业务
只做登录和转发,DSH 升级、插件替换都不需要改它。
回环兜底
13080 永远只绑 127.0.0.1;就算 nginx 配错,也不会把裸服务暴露出去。
02准备清单
一台 2C2G 的云服务器就够(本机 DSH 常驻内存约 600 MB)。
| 项目 | 要求 | 说明 |
|---|---|---|
| 系统 | Ubuntu 22.04 / 24.04 | 本文在 24.04 上验证 |
| Node | 22.x | nvm 或 NodeSource 均可 |
| 域名 | 一个 A 记录指向服务器 | HTTPS 需要,手机端也更省心 |
| 端口 | 80 / 443 放行 | 8443 仅在用 Windows 隧道时需要 |
| 软件 | nginx、certbot | apt install nginx certbot python3-certbot-nginx |
| 内存 | ≥ 1 GB | 跑 DSH + nginx 实测 600–800 MB |
先想清楚谁能进。DSH 能读写服务器文件、执行命令,等价于一个带 shell 的账号。账号密码请用密码管理器生成,不要和别的服务复用。
03安装 DSH
官方包名 @deepseek-ai/dsh,全局装一份即可。
装 Node 22
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v # v22.x
装 DSH 并确认版本
sudo npm i -g @deepseek-ai/dsh
dsh --version # 0.2.1-rc.2
第一次启动,记下 token 地址
前台跑一次,日志里会打印带 token 的本地地址。这个 token 是官方 Web 的唯一凭据,第一次进页面必须带它,之后浏览器会种下 cookie。
dsh web --host 127.0.0.1 --port 13080 --trusted-host dsh.example.com # 输出示例: # dsh web: http://127.0.0.1:13080/?token=xxxxxxxxxxxxxxxx
--trusted-host 不是可选项。DSH 会用 Host / Origin 做同源校验,域名没登记就会出现“页面能开、一选目录就 403”的怪事。域名带端口时,带端口和不带端口要各写一条。
04常驻 + 开机自启
用 systemd 用户服务,不占 root,日志走 journald。
[Unit] Description=DeepSeek Harness Web UI After=network-online.target Wants=network-online.target [Service] Type=simple WorkingDirectory=/home/ubuntu Environment=HOME=/home/ubuntu Environment=DSH_HOME=/home/ubuntu/.dsh Environment=PATH=/home/ubuntu/.nvm/versions/node/v22.23.0/bin:/usr/local/bin:/usr/bin:/bin ExecStart=/home/ubuntu/.nvm/versions/node/v22.23.0/bin/dsh web \ --host 127.0.0.1 --port 13080 \ --trusted-host dsh.example.com --trusted-host dsh.example.com:3080 Restart=always RestartSec=5 [Install] WantedBy=default.target
systemctl --user daemon-reload systemctl --user enable --now dsh loginctl enable-linger $USER # 关键:不 enable-linger,退出 SSH 服务会被杀 systemctl --user status dsh --no-pager journalctl --user -u dsh -f # 实时日志
DSH_HOME 一旦定下就别搬。配置、插件、会话全在里面,挪目录会同时断掉 node_modules 里的绝对路径软链。
上面的 /home/ubuntu 是本机路径。换一台机器部署时,把它换成你的用户目录,PATH 里的 Node 路径也要跟着改(用 which node 查)。
05加一层登录网关
dsh-cloud-gateway 在 3080 开一个带账号密码的入口,验证通过后反代到 13080。
dsh plugin --profile web add github:MrLukezy/dsh-cloud-gateway
dsh plugin --profile web add github:mexiaosqwq/dsh-web-mobile
# 装完重启一次,让插件 bundle 生效
systemctl --user restart dsh
写配置
在 ~/.dsh/profiles/web/cordis.patch.yml 追加下面这段(没有就新建)。这里的值只存在你自己的机器上。
- id: cloud-gateway config: listenHost: 0.0.0.0 listenPort: 3080 # ★ 必须显式写上游。0.2.x 里 ctx.webStartup.port 拿不到真实端口, # 不写会回落到 3080(网关自己),转发打回自己 → 永远登录失败。 upstreamHost: 127.0.0.1 upstreamPort: 13080 basePath: /dsh username: your-name password: your-password trustProxy: true
原文照抄最常见的坑:漏了 upstreamHost / upstreamPort。表现是登录框怎么提交都回到登录页,nginx 日志里全是 127.0.0.1:3080 自己打自己。
改完重启,确认 3080 在监听:
systemctl --user restart dsh ss -lntp | grep -E '13080|3080' # 期望看到 0.0.0.0:3080 和 127.0.0.1:13080 curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3080/dsh/ # 302(跳登录页)说明网关活着
06nginx 反向代理
这一段决定“能不能用”:WebSocket、缓存、Host 头、上传大小,四个都得对。
# WebSocket 升级(必须放在 http 上下文,站点文件就是) map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl http2; server_name dsh.example.com; ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem; client_max_body_size 50m; # 上传 PDF / 大文件 # ① 前端构建产物:文件名内嵌内容哈希,可以缓存一年 location ^~ /assets/ { proxy_pass http://127.0.0.1:3080; proxy_http_version 1.1; proxy_set_header Host $host; add_header Cache-Control "public, max-age=31536000, immutable"; } # ② 插件 bundle:同样带哈希,同样长缓存 location ^~ /plugins/ { proxy_pass http://127.0.0.1:3080; proxy_http_version 1.1; proxy_set_header Host $host; add_header Cache-Control "public, max-age=31536000, immutable"; } # ③ 入口 HTML / API / WebSocket:必须 no-cache,且不缓冲 location ^~ /dsh/ { proxy_pass http://127.0.0.1:3080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; # 流式输出不卡顿 proxy_read_timeout 86400; proxy_send_timeout 86400; add_header Cache-Control "no-cache"; } location /api { proxy_pass http://127.0.0.1:3080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 86400; } # ④ APK 与自更新清单 location /dsh-apk/ { alias /var/www/dsh-apk/; add_header Cache-Control "no-store"; } }
为什么必须四个前缀都代理
DSH 的入口 HTML 在 /dsh/ 下,但它内嵌的 boot manifest 里,资源 URL 是根绝对路径:/assets/…、/plugins/…。只代理 /dsh/ 的典型现象是:登录进去了,页面一片白,控制台一堆 404。
sudo nginx -t && sudo systemctl reload nginx curl -sI https://dsh.example.com/dsh/ | head -5
入口 HTML 一定要 no-cache。它里面写死了当前 bundle 的 rev;被 CDN 或浏览器缓存住,升级后就会一直加载旧 bundle,表现为“服务端明明更新了,界面还是老样子”。
07域名与 HTTPS
手机端尤其需要 TLS:安卓 9 起默认禁止明文 HTTP,自签证书还要额外放行。
# A 记录生效后 sudo certbot --nginx -d dsh.example.com sudo certbot renew --dry-run # 确认自动续期可用
如果你不想用域名,也可以直接对 IP 开放,但 HTTPS 就没有了,手机壳那边只能走明文或信任自签证书——体验和安全性都差一截,不推荐。
08验收检查
逐条过一遍,任何一条不对,先别急着装手机端。
| 检查 | 期望 | 不对时看哪里 |
|---|---|---|
ss -lntp | grep 13080 | 只有 127.0.0.1 | 启动参数 --host |
curl -sI …/dsh/ | 302 到 /dsh/login | nginx location、网关端口 |
| 浏览器登录后 | 进入会话列表 | cookie 域、trustProxy |
| 发一条消息 | 流式逐字返回 | proxy_buffering off |
| DevTools 控制台 | 无 404 资源 | /assets/ /plugins/ 代理 |
| 选一个工作目录 | 不报 transport failure | --trusted-host 是否带端口 |
09手机端:19 KB 的外壳
它不打包网页,只打包一个 WebView。所有内容实时来自服务器。
纯 WebView
不用 AndroidX、不引第三方库,APK 只有 19 KB,装完几乎不占空间。
登录墙记忆
第一次输入 basic auth 后存本地,之后不再弹框;菜单里可一键清除。
上传 / 下载
文件选择器接系统,下载走 DownloadManager,带 cookie 一起发。
自动更新
启动时拉 version.json,版本号更大才提示,日常不打扰。
下载地址:https://www.lukezystudio.cn/dsh-apk/mobile/(页面会实时读 version.json,显示版本、体积、SHA256)
同一套页面,手机上自动变单栏
壳里没有第二套界面。窄屏布局由插件接管:侧栏收进抽屉、会话占满宽度、输入区贴底并避开刘海。所以电脑上装了什么插件,手机上就是什么插件。
- 竖屏适配:
dsh-web-mobile(抽屉 + 全宽会话) - 登录态与电脑共用同一份 cookie,不用重复授权
- 窗口尺寸一变就重排,没有「手机版」的独立代码
它和服务器是怎么连上的
10构建与签名
需要 JDK 17 + Android SDK(platform 34)。源码就一个 Activity,逻辑全在 MainActivity.java。
git clone https://github.com/MrLukezy/dsh-mobile.git
cd dsh-mobile
export ANDROID_HOME=$HOME/android-sdk
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
./gradlew assembleRelease
# 产物:app/build/outputs/apk/release/app-release.apk(约 19 KB)
三个要改的常量
都写在 MainActivity.java 顶部,换域名或换更新地址只改这里:
/** 默认打开的地址。用户也能在菜单里改。 */ private static final String DEFAULT_URL = "https://dsh.example.com/dsh/"; /** 自更新清单地址 */ private static final String VERSION_CHECK_URL = "https://dsh.example.com/dsh-apk/mobile/version.json"; /** 必须和 version.json 的 versionCode 对齐 */ private static final int VERSION_CODE = 2;
versionCode 要同时改 两处:app/build.gradle(安卓系统的版本号)和 MainActivity.VERSION_CODE(自更新比较用)。只改一处,要么装不上,要么永远提示更新。
签名
用自己的 keystore,保存好并且不要提交到公开仓库。升级时签名必须一致,否则安卓会拒绝覆盖安装,只能卸载重装(本地数据全丢)。
keytool -genkeypair -v -keystore keystore/dsh-mobile.jks \ -alias dsh -keyalg RSA -keysize 2048 -validity 10000
11发布与自更新
把 APK 和一份清单丢到静态目录,App 下次启动就知道有没有新版。
sudo cp app/build/outputs/apk/release/app-release.apk \
/var/www/dsh-apk/mobile/dsh-mobile.apk
sha=$(sha256sum /var/www/dsh-apk/mobile/dsh-mobile.apk | cut -d' ' -f1)
size=$(stat -c%s /var/www/dsh-apk/mobile/dsh-mobile.apk)
sudo tee /var/www/dsh-apk/mobile/version.json >/dev/null <<EOF
{
"versionCode": 3,
"versionName": "1.1.0",
"url": "https://dsh.example.com/dsh-apk/mobile/dsh-mobile.apk",
"sha256": "$sha",
"size": $size,
"notes": "更新说明,会显示在升级弹窗里"
}
EOF
| 字段 | 作用 | 注意 |
|---|---|---|
versionCode | 比较是否需要更新 | 必须比 App 内的大 |
url | 点击“下载”跳转的地址 | 绝对地址,https |
sha256 | 下载页展示、人工校验 | 改包就要重算 |
size | 下载页显示体积 | 字节数 |
notes | 升级弹窗正文 | 写人话,一行就够 |
日常更新 DSH(升级版本、装插件、换主题)完全不需要动 APK。只有“壳”的能力变了——比如新增了下载、上传、菜单项——才需要发新版本。
12首次登录
安卓壳第一次进,需要把“地址”和“凭据”各教一次,之后全自动。
下载并安装 APK
用手机浏览器打开 下载页,允许“安装未知来源应用”。
返回键 → 修改地址
填你的入口地址,例如 https://dsh.example.com/dsh/。如果服务器是首次启动,先粘一次带 token 的完整地址,让 cookie 种下来,再换回普通地址。
登录墙弹框 → 保存
填网关的账号密码。填一次就记住;要换账号,走 返回键 → 清除登录凭据。
把它当网页用
下拉刷新、长按选择、附件上传都正常。返回键在页面内是“后退”,到底了再按一次是“呼出菜单”,连按两次退出——和浏览器习惯一致。
13为什么不用重装
两层热更:页面层几乎是实时的,外壳层只在必要时动。
| 你改了什么 | 手机端怎么拿到 | 要做什么 |
|---|---|---|
| DSH 升级 | 刷新页面 | 无 |
| 装 / 卸插件 | 刷新页面 | 服务端重启 dsh,手机下拉刷新 |
| 主题、字号、布局 | 即时 | 无 |
| 会话与工作区 | 实时 | 无 |
| 外壳加功能 | version.json 提示 | 发新版 APK,用户点一次升级 |
这也是为什么 nginx 上 /assets/ 和 /plugins/ 敢设一年缓存:它们的 URL 里带内容哈希,内容一变 URL 就变;而入口 HTML 必须 no-cache,否则新 rev 永远传不下去。
14排错手册
下面每一条都是这台机器上真实踩过的,不是假设。
| 现象 | 真正原因 | 处理 |
|---|---|---|
| 登录怎么提交都回登录页 | 网关 upstream 没写,转发打回自己 | 补 upstreamHost/Port,重启 |
| 能进页面,选目录报 transport failure | Host/Origin 与 --trusted-host 不一致 |
把域名、域名:端口都加进去 |
| 白屏,控制台 404 | 只代理了 /dsh/ |
补 /assets/、/plugins/、/api |
| 升级后界面没变 | 入口 HTML 被缓存 | /dsh/ 加 Cache-Control: no-cache |
| 回复整段蹦出来,不流式 | nginx 缓冲了 SSE | proxy_buffering off |
| 上传 PDF 失败 | 请求体超限 | client_max_body_size 50m |
| 统计条整行消失 | 插件引用了不存在的图标,宿主判定 slot crash | 给图标加 fallback,见下 |
| App 报 ERR_TOO_MANY_RETRIES | 登录弹框先 cancel 再 proceed,凭据传不回去 | 一次性 handler,直接 proceed(1.0.1 已修) |
| APK 一直提示升级 | versionCode 只改了一处 |
build.gradle 与 MainActivity 同步 |
| 手机连 HTTP 地址失败 | Android 9+ 默认禁止明文 | 上 HTTPS |
三个万能命令
# 服务活着吗 systemctl --user status dsh --no-pager | head -12 # 链路通吗(登录前应是 302) curl -sI https://dsh.example.com/dsh/ | head -4 # 网关和 dsh 各自在听吗 ss -lntp | grep -E '13080|3080'
插件把整行 UI 弄没了怎么查:宿主对插件崩溃是“整槽位静默移除”。打开 DevTools,找 slot entry crashed,再看插件 client.js 里 import 的图标名是否在当前 DSH 版本里存在——名字对不上就是 React #130,服务端接口却完全正常,很容易误判。
15常见问题
一定要云服务器吗?家里电脑行不行
行。用 dsh-win-tunnel 把 Windows 上的 DSH 通过 SSH 反向隧道暴露出去,手机照样能用,只是电脑得开着。
能不能只给自己用,不加登录墙
可以,但不建议。至少加 nginx basic auth + IP 白名单,否则等于把 shell 挂公网。网关的优势是能自己改密码、退出登录、看在线状态。
APK 会收集数据吗
不会。它没有联网 SDK、没有统计,只做 WebView 加载和一次版本检查。源码只有一个 Activity,可以自己审。
换手机要重新配吗
要重装、重填一次地址和密码;会话和历史都在服务器上,换设备不影响。
多个域名 / 反代套反代
把每一层的外层域名都写进 --trusted-host,并在网关开启 trustProxy。Host 头必须原样透传(用 $http_host,别用 $host,后者会丢端口)。
16GitHub 仓库
这套东西不是一个仓库,是“壳 + 网关 + 插件”的组合。
主仓库 dsh-mobile 目前是私有的。想直接拿 APK 用,走上面的下载页;想要源码,仓库地址就是 github.com/MrLukezy 下的同名项目。
17给 AI 读的版本
人和模型看同一页,但模型更适合下面这种结构化摘要;也可以直接拉纯文本。
{
"service": {
"name": "DeepSeek Harness (dsh web)",
"version": "0.2.1-rc.2",
"bind": "127.0.0.1:13080",
"home": "/home/ubuntu/.dsh",
"unit": "dsh.service (systemd --user, linger enabled)"
},
"gateway": {
"plugin": "dsh-cloud-gateway",
"listen": "0.0.0.0:3080",
"basePath": "/dsh",
"upstream": "http://127.0.0.1:13080",
"auth": "form login → session cookie"
},
"edge": {
"server": "nginx 1.24 (Ubuntu)",
"tls": "Let's Encrypt, 443",
"proxyPrefixes": ["/dsh/", "/assets/", "/plugins/", "/api"],
"cache": "hashed assets immutable; entry html no-cache"
},
"client": {
"apk": "https://www.lukezystudio.cn/dsh-apk/mobile/dsh-mobile.apk",
"size": 19344,
"versionCode": 2,
"manifest": "https://www.lukezystudio.cn/dsh-apk/mobile/version.json",
"runtime": "WebView shell, no bundled assets"
},
"repos": [
"github.com/MrLukezy/dsh-mobile",
"github.com/MrLukezy/dsh-cloud-gateway",
"github.com/MrLukezy/dsh-mobile-files",
"github.com/MrLukezy/dsh-win-tunnel"
],
"checks": [
"systemctl --user status dsh",
"curl -sI https://HOST/dsh/ # expect 302",
"ss -lntp | grep -E '13080|3080'"
]
}
给 agent 的建议:先读 facts.json 建立事实,再用 tutorial.md 取步骤,需要原文时回来看这一页的排错表。