Step through the last turn's edits, one diff at a time.

<img src="client/public/dshub-mark.png" width="88" alt="DSHub 蓝鲸标志" />
你的 AI 伙伴与跨端工作空间
连接模型、文件、插件与自动化,用自然语言管理项目、协作与日常事务。
A cross-platform AI workspace, built on DeepSeek Harness and Lynx.
界面预览 · 快速开始 · 功能与进度 · 开发文档 · 作者
当前为预览原型。 已有可运行的网页、Linux 调试窗口及本地 Android 预览 APK;完整跨平台能力、真实模型与真机端到端仍在推进。下文区分已经实现的界面、已执行的检查和产品目标。
原版侧栏、会话、模型、插件与主题;深色模式使用原版白色鲸鱼图标,默认进入对话页。

<table> <tr> <th align="center">聊天</th> <th align="center">AI 伙伴通讯录</th> <th align="center">侧栏菜单</th> </tr> <tr> <td align="center"><img src="docs/assets/previews/mobile-chat.png" width="250" alt="移动端深色聊天与一体式输入框" /></td> <td align="center"><img src="docs/assets/previews/mobile-contacts.png" width="250" alt="移动端浅色 AI 伙伴通讯录" /></td> <td align="center"><img src="docs/assets/previews/mobile-sidebar.png" width="250" alt="移动端插件、自动化、工作区和日程侧栏" /></td> </tr> </table>
<sub>截图来自独立本机演示环境。通讯录使用专门创建的演示预设,不包含真实账号、私人会话或模型回复;预览图不代表真机验收。截图说明见 预览资源。</sub>
| 模块 | 当前实现 | 验证边界 |
|---|---|---|
| 原版对话 | 官方 Harness 会话、模型设置、插件及工作区 | 独立服务与浏览器验证;未以此宣称真实模型通过 |
| 移动聊天 | 聊天 / 通讯录切换、真实预设伙伴、一体式输入框与抽屉菜单 | 浏览器适配已验证;键盘、安全区及后台行为待真机验收 |
| AI 群聊 | 选择多个伙伴,独立官方会话,支持彼此 @ 与有限轮次接力 | 会话创建与路由测试通过;真实模型群聊待验收 |
| 临时聊天 | 独立临时实例,点击直接进入,结束后停止并清理目录 | 本机启动与删除检查;手机端清理尚未验收 |
| 附件与语音 | 拍照 / 相册 / 文件入口、按住转写、上滑取消与草稿保留 | Android 桥已构建;识别测试使用模拟结果 |
| 日程与自动化 | 全部任务列表和月历视图,连接原版任务目录 | 界面与导航已检查;真实定时任务另行验收 |
| 个人微信 | 独立本机连接器、授权入口、问答与日报配置 | 授权、真实收发及模型端到端尚未完成验收 |
桌面端沿用 Harness 原版界面。移动端首页不显示底部 Dock,侧栏顺序为 插件 → 自动化任务 → 工作区 → 日程 → 查看更多;账号、模型和 Agent 预设统一从「我的」进入。
跨平台分为两组:Linux / macOS / Windows 共用桌面设计,Android / iOS / 鸿蒙共用移动设计。该分组是产品约定,不表示六个平台均已支持或完成验收。
需要 Node.js 24.9+。在仓库根目录运行:
npm ci --prefix runtime
npm ci --prefix client
npm run build --prefix client
npm run build:preview --prefix client
npm run preview:static --prefix client
打开 http://127.0.0.1:5182/,移动布局可使用 http://127.0.0.1:5182/?simulate=mobile。
宿主通过 iframe 加载官方网页,原生宿主使用 WebView。账号、模型和插件在官方页面配置;切换宿主页面保留同一网页实例。预览服务管理项目独立的 DSH 后端,不加载已有用户的全局 DSH 账号与会话。
需要本机 GTK / WebKitGTK 构建环境,具体依赖见 客户端说明。
npm run build:linux --prefix client
npm run open:linux --prefix client
桌面窗口使用独立官方运行时,保留原版侧栏,不添加 Dock,也不默认打开画布。支持 Ctrl+R / F5 跳过缓存刷新。
同时对照移动端和桌面端:
npm run open:dual --prefix client
双窗口排列脚本目前使用本机 niri 窗口管理器,详见 桌面界面说明。
当前 APK 基于固定版本的 Lynx Explorer 4.1.2 构建,首页和 DSHub 主宿主包均内置。原版网页与模型仍依赖电脑上的 DSH 服务及 USB 转发。
python3 client/scripts/build-android-preview.py \
--source-apk /path/LynxExplorer-4.1.2.apk \
--apktool /path/apktool_2.12.1.jar
输出为 .runtime/android-container/dshub-preview.apk,独立包名 com.dshub.preview。源码 APK 按 SHA-256 校验,构建需要 Java 与 Android SDK 35;输入文件、SDK 路径和 USB 打开方式见 客户端说明。
APK 已完成本地构建、签名、对齐及内置资源检查;本次尚未完成手机安装验收。APK 与本机签名不提交到 Git 仓库,仓库当前没有承诺可直接下载的正式发布包。
迁入源码已完成本机完整构建,并有单独的试运行入口:
npm run preview:source --prefix client
打开 http://127.0.0.1:5212/ 。源码版后端为 5213,使用 .runtime/harness-source-state/;与默认 npm 版预览相互独立。首次使用须按 源码接入记录 完成构建。
| 目录 | 用途 |
|---|---|
client/ | Lynx 宿主、浏览器预览、移动适配、原生桥与本机连接器 |
runtime/ | 锁定官方 npm @deepseek-ai/dsh@0.2.1-rc.3,作为默认运行入口 |
packages/ · apps/ · native/ · vendor/ | 按模块归位的 Harness 与原生相关资源 |
sdk/python/ · tests/harness/ · sites/harness-docs/ | 上游 SDK、测试与文档站资源 |
client/design/harness/ | 图标、深浅色变量、字体、基础组件样式与来源记录 |
docs/ | 产品、技术、接口、数据模型、测试与实施文档 |
tools/harness/ | 迁移清单、SHA-256 校验、源码重建与构建工具 |
.runtime/ | 本机服务、账号数据、构建产物与备份;已忽略 |
已归位 19,188 个 Harness 版本受控文件。默认 npm 入口和迁入源码分别管理,不宣称二者字节相同。上游参考版本固定为 22ddee3792402c3e70ec5f86a63c3e3e895a2f60;原始参考 checkout 不随仓库上传。
| 从这里开始 | 深入了解 |
|---|---|
| 产品需求 PRD · 技术设计 TDD · 工作分解 WBS | 规格 TSD · 架构 HLD · 详细设计 LLD |
| 文档索引 · 贡献指南 · 变更记录 | OpenAPI · 数据模型 ERD · 需求追踪 RTM |
| 移动聊天适配 · AI 群聊 | 输入交互规范 · 远程连接 |
| 测试计划 · 可行性台账 | 资源对照 · 模块索引 |
PRD 与 WBS 描述产品目标和实施计划;其中的计划、文档合同通过和本机原型结果,均不等同于产品或真机验收完成。
基础开发检查:
npm run typecheck --prefix client
npm test --prefix client
npm run build --prefix client
npm run build:preview --prefix client
PREVIEW_URL=http://127.0.0.1:5182 npm run test:browser --prefix client
文档依赖安装和合同检查见 贡献指南。资源校验:
python3 tools/harness/migrate.py verify
浏览器用例中的部分测试需要指定运行环境、独立服务或可选依赖;请结合对应测试文件运行。本机截图及详细取证位于被忽略的 client/artifacts/,不是仓库中的 CI 通过徽章。真实模型、语音服务、微信收发与真机安装结果另行记录。
.runtime/ 保存本机配置、会话、工作区和连接器状态;不载入全局用户 DSH 数据。漏洞与安全问题请按 SECURITY.md 反馈,不要在公开 Issue 中粘贴密钥、认证头或私人会话。
Wang-Yang-source · DSHub 作者与维护者
欢迎通过 Issue 反馈使用体验,或通过 Pull Request 参与文档、界面与工程改进。
DSHub 基于 DeepSeek Harness 与 Lynx 构建,复用其官方界面、插件机制和宿主能力。迁入源码及依赖保留各自的许可证与来源记录,Harness 许可证见 第三方许可。
DSHub 鲸鱼图像由项目提供方提供,原图与透明版保存在 client/public/;本仓库不对该图像另行授予许可。桌面欢迎区使用 Harness 原版单色 SVG,移动端使用项目彩色鲸鱼素材。
hooks/replay-theater.mjs 134 lines1// Replay Theater, from "Getting started with Claude Code mods"
2// (https://claude.dev/blog/getting-started-with-claude-code-mods/, Anthropic, 2026-10-01).
3// The post publishes the five hooks that record edits and register /replay
4// unchanged; the rest of the module (stepsFor, the diff, openReplay, the pane
5// and the hint) is completed to the post's description, marked where it starts.
6
7// Replay Theater: step through the last turn's edits, one diff at a time.
8
9// —— completed to the post's description (not in the published excerpt) ——
10
11const EDIT_TOOLS = new Set(["Edit", "Write"]);
12
13// Edits of the running turn, the sealed replay of the last one, and where the pane is.
14const state = { pending: [], replay: [], index: 0, open: false, placed: false };
15
16export function register(on) {
17 on("tool.call", async ($, e, next) => {
18 if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e))); // old/new text → diff
19 return next(e); // the edit runs untouched
20 });
21
22 on("turn.start", ($, e, next) => { if (!e.agentId) state.pending = []; return next(e); });
23
24 on("turn.complete", async ($, e, next) => {
25 const r = await next(e);
26 if (!e.agentId && state.pending.length) state.replay = state.pending; // one replay per turn
27 return r;
28 });
29
30 on("session.start", async ($, e, next) => {
31 const r = await next(e);
32 await $.command.register({ name: "replay", description: "Step through the last turn's file edits" });
33 return r;
34 });
35 on("command.run", { command: "replay" }, async ($, e) => ({ text: (await openReplay($)) ? "Replaying" : "No edits" }));
36
37 // —— completed to the post's description (not in the published excerpt) ——
38
39 on("ui.render", { component: "Pane" }, ($, e, next) => {
40 if (e.requestId !== "replay" || !state.open) return next(e);
41 return theater($, e);
42 });
43
44 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
45 if (state.open && !state.placed) return theater($, e); // no pane could be placed: the same tree, inline
46 if (state.open || state.replay.length === 0) return next(e);
47 const { Box, Text, Button } = $.ui.resolve(e);
48 const n = state.replay.length;
49 return Box({
50 flexDirection: "row",
51 paddingX: 1,
52 gap: 1,
53 children: [
54 Text({ color: "magenta", bold: true, children: "Replay Theater" }),
55 Text({ dimColor: true, children: `${n} edit${n === 1 ? "" : "s"} this turn — press r or type /replay` }),
56 Button({ label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
57 ],
58 });
59 });
60}
61
62// One replay step per edit: the file, and the text before and after.
63async function stepsFor($, e) {
64 const file = String(e.file_path ?? "");
65 if (e.tool === "Edit") {
66 return [{ file, before: String(e.old_string ?? ""), after: String(e.new_string ?? "") }];
67 }
68 // For a Write, the old contents are read just before the write lands, so the diff is real.
69 const before = await $.fs.read(file).catch(() => "");
70 return [{ file, before, after: String(e.content ?? "") }];
71}
72
73async function openReplay($) {
74 if (state.replay.length === 0) return false;
75 state.index = 0;
76 state.open = true;
77 const opened = await $.ui.open({ id: "replay", title: "Replay Theater", focus: true });
78 state.placed = opened.isPlaced;
79 $.ui.invalidate("ui.render");
80 return true;
81}
82
83function step(delta, $) {
84 state.index = Math.min(Math.max(state.index + delta, 0), state.replay.length - 1);
85 $.ui.invalidate("ui.render");
86}
87
88async function close($) {
89 state.open = false;
90 await $.ui.close({ id: "replay" });
91 $.ui.invalidate("ui.render");
92}
93
94// Lines the edit removed and added, as a unified diff shows them.
95function diffLines(before, after) {
96 const old = before.split("\n");
97 const neu = after.split("\n");
98 const common = new Set(neu);
99 const commonOld = new Set(old);
100 return [
101 ...old.filter((line) => !common.has(line)).map((line) => ({ sign: "-", line })),
102 ...neu.filter((line) => !commonOld.has(line)).map((line) => ({ sign: "+", line })),
103 ];
104}
105
106function theater($, e) {
107 const { Box, Text, Button } = $.ui.resolve(e);
108 const current = state.replay[state.index];
109 const strip = state.replay.map((_, i) => Text({ color: i === state.index ? "magenta" : undefined, bold: i === state.index, children: ` ${i + 1} ` }));
110 const diff = diffLines(current.before, current.after).map(({ sign, line }) =>
111 Text({ color: sign === "+" ? "green" : "red", children: `${sign} ${line}` }),
112 );
113 return Box({
114 flexDirection: "column",
115 border: true,
116 borderColor: "magenta",
117 paddingX: 1,
118 children: [
119 Box({ flexDirection: "row", children: [Text({ bold: true, children: "Replay Theater " }), ...strip] }),
120 Text({ dimColor: true, children: `${current.file} (step ${state.index + 1} of ${state.replay.length})` }),
121 ...diff,
122 Box({
123 flexDirection: "row",
124 gap: 2,
125 children: [
126 Button({ label: "Prev", hotkey: "p", disabled: state.index === 0, onPress: () => step(-1, $) }),
127 Button({ label: "Next", hotkey: "n", disabled: state.index >= state.replay.length - 1, onPress: () => step(1, $) }),
128 Button({ label: "Close", hotkey: "q", onPress: () => close($) }),
129 ],
130 }),
131 ],
132 });
133}
134