训练工作台(Training Workbench)完整手册与制作复盘
训练工作台是一个内嵌在 DSH Web GUI 会话头部的工作台插件:一个玻璃质感入口按钮 + 点击后展开的全屏操作面板。面板把训练目标、周期参数等组合成指令提交给当前会话的 agent,由 agent 用训记 Open API Skill 完成「同步分析 → 周期计划 → 推送模版 → 执行追踪」四步流程。
本手册自包含,任意 agent 拿到即可理解、复刻、维护或扩展此项目,不依赖任何特定会话的上下文。
0. 前置依赖(必读,缺了会静默失败)
本工作台只是一个面板:它自身不含任何训记 API 知识,只把表单参数拼成指令丢给当前会话的 agent。agent 能不能真正调通训记 API,取决于会话里装没装对应的 Open API skill。
| 依赖 skill | 覆盖能力 | Base URL |
|---|---|---|
xunji-training |
训练数据读写 + 官方计划 | trains.xunjiapp.cn |
xunji-agent-template |
Agent 个人模版增量同步/写入 | trains.xunjiapp.cn |
xunji-body |
身体数据查询/写入 | api.xunjiapp.cn |
xunji-diet |
饮食记录/自定义食物/食物搜索 | eatings.xunjiapp.cn |
- 装在哪:用户级
~/.dsh/skills/<name>/SKILL.md(或项目级<workspace>/.dsh/skills/)。装好后技能目录会自动热加载进技能表,无需重启。 - 从哪来:这 4 份 skill 由训记 App 按用户发放,每份内含使用者本人的 Key。本手册不附带、不代发这 4 份 skill。
- ⚠️ 失败模式(最容易踩):面板的
composePrompt写死「请使用本会话中已提供的训记 Open API Skill…」,但它不校验该 skill 是否存在。缺依赖时面板照常打开、指令照常提交,agent 却不知道 API 地址与调用格式 —— 表现为工作流静默卡住,没有任何报错。排障第一步就是确认这 4 个 skill 在技能表里。 - ⚠️ Key 不可分发:这 4 份 skill 含明文个人 Key,不要提交进仓库、不要随包分发、不要发布到技能市场。
1. 项目定位与总体架构
1.1 是什么
- 形态:DSH client-plugin(纯浏览器端插件),不是独立 Web 应用
- 入口:会话头部操作区按钮(
conversation.session.header.actionsslot,order=50,位于保险合同工作台右侧) - 面板:全屏浮层(
shell.overlayslot),Vestris 落地页风,墨绿黑 + 米白 + 活力橙配色 - 数据源:训记 App Open API(训练数据 / Agent 模版 / 身体数据 / 饮食数据 四套 Skill;Key 由使用者本人在训记 App 申请填写)
- 核心机制:面板组合指令 →
session.prompt()提交给当前会话 agent → agent 执行并逐步向用户确认
1.2 代码位置
dsh-training-workbench/ # 插件源码(工作区)
├── package.json # 插件声明(exports["./client"] + dsh.client.inject)
└── lib/
├── index.js # host 端空入口(占位)
└── client.js # 浏览器端全部代码(按钮 + 全屏面板,约 500 行)
安装副本位于:~/.dsh-desktop/profiles/node_modules/training-analysis-workbench/
注册行位于:~/.dsh-desktop/profiles/web/cordis.patch.yml
1.3 技术要点
- 纯原生 React(
window.__ModuleLoader__.load({ id, factory })+react.createElement),无 JSX 编译、无构建步骤 - 模块级 store + 订阅发布做状态管理(按钮与面板共享状态)
- 不直接调用外部 API:把指令交给 agent,由 agent 按会话中的训记 Skill 执行
2. 工作台 UI 制作全流程(核心章节)
2.1 package.json 声明(决定插件能否被 GUI 发现)
{
"name": "training-analysis-workbench",
"version": "0.1.4",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
"dsh": {
"client": { "platform": "web",
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-api-remotes"
] }
}
}
exports["./client"]是浏览器端入口,clientModules靠它发现 UI 代码dsh.client.platform: "web"才会被 Web GUI 加载
2.2 client.js 总骨架
所有工作台 client.js 都用 CommonJS factory 包裹,不用 ESM import:
window.__ModuleLoader__.load({
id: "training-analysis-workbench",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
let react = require("react");
const h = react.createElement;
// ……以下全部内容在 factory 内……
exports.apply = apply;
return module.exports;
}
});
2.3 设计令牌(Design Tokens)
工作台 UI 的第一步是定义配色与字体常量,所有组件引用它们,换主题只需改这个对象:
const C = {
bg: "#1d2427", cream: "#f4f1e9", card: "#ffffff",
ink: "#292821", muted: "#8b887c", line: "#ddd8ca",
accent: "#ef7d2a", accentDeep: "#d9691a", ok: "#3d6b4f", err: "#b4503f"
};
const SERIF = 'Georgia, "Songti SC", "STSong", "SimSun", serif';
const SANS = '-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif';
配色语义:深墨绿黑底(全屏遮罩)、米白大面板、白卡、活力橙主强调、绿=成功、红=错误。
2.4 模块级共享状态(store + listeners)
按钮和全屏面板是两个不同 slot 的组件,靠共享 store 同步:
const store = {
open: false, goal: "hypertrophy", periodization: "auto",
weeks: 8, daysPerWeek: 4, startDate: "", custom: "",
busy: false, notice: "", noticeKind: "",
submitFn: null, sessionId: null, sessionCwd: "",
remote: null, sessions: null // v0.5 起持 remote(打开文件走 remote.session)
};
const listeners = new Set();
function setState(patch) { Object.assign(store, patch); for (const fn of listeners) fn(); }
function useStore() {
const [, force] = react.useState(0);
react.useEffect(() => {
const fn = () => force((x) => x + 1);
listeners.add(fn);
return () => listeners.delete(fn);
}, []);
return store;
}
2.5 表单控件辅助(chip 胶囊选择器)
选项型输入用胶囊按钮,比下拉直观:
const labelStyle = { fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: C.muted, marginBottom: 8 };
function chip(key, label, active, onClick) {
return h("button", { key, onClick,
style: { padding: "7px 14px", borderRadius: 999, border: "1px solid " + (active ? C.accent : C.line),
background: active ? C.accent : "transparent", color: active ? "#fff" : C.ink,
cursor: "pointer", fontSize: 13, fontFamily: SANS } }, label);
}
2.6 指令组合(composePrompt)
工作台核心:把表单 + 参数组合成结构化指令,每个步骤一个 kind。指令写作要点 = 任务目标 + 编号步骤 + 输入参数 + 红线约束:
function composePrompt(kind, s) {
const goal = goalText(s), period = periodText(s), start = fmtDate(s.startDate);
const head = "请使用本会话中已提供的训记 Open API Skill 完成下面的训练工作台任务(各服务 Key 请向使用者确认已填入其本人从训记 App 申请的 Key,严禁使用示例或他人 Key)。动作名必须使用训记官方中文名(见 GitHub: Foveluy/Xunji-movements)。";
if (kind === "sync") return head + "\n任务:同步并分析训练数据。\n1) 读取最近 90 天训练记录…\n2) 汇总频率/吨位/1RM 趋势…\n3) 输出简洁中文报告(表格 + 要点)+ 指出瓶颈与可改进点;报告最后按固定小节逐行输出(- 训练频率:…/- 总组数与吨位:…/- 主要动作 1RM 趋势:…/- 瓶颈:…/- 可改进点:…,供工作台结构化展示)。红线:只基于真实数据。";
if (kind === "generate") return head + "\n任务:生成周期化训练计划。用户画像:目标「" + goal + "」,周期「" + period + "」," + s.weeks + " 周,每周 " + s.daysPerWeek + " 天…\n4) 等我审核确认再推送。";
if (kind === "push") return head + "\n任务:推送计划到训记 Agent 模版。\n1) 增量同步读取 folder/limits…\n2) 一天一个模版转换 upserts…\n3) 写入前展示清单等确认…\n4) confirmed: true + 唯一 mutation_id 写入。";
if (kind === "track") return head + "\n任务:执行追踪与自适应调整。\n1) 读取计划与实际记录…\n2) 对比计算完成率…\n3) 输出看板与调整建议。";
return "";
}
2.7 提交执行(run)
async function run(kind) {
if (store.busy) return;
if (!store.submitFn) { setState({ notice: "还没绑定到会话——请先点一下聊天区顶部的「训练工作台」按钮。", noticeKind: "err" }); return; }
const text = composePrompt(kind, store);
if (!text) { setState({ notice: "请先完成上方参数设置。", noticeKind: "err" }); return; }
setState({ busy: true, notice: "已提交,正在处理…结果会出现在对话里。", noticeKind: "info" });
try {
const res = await store.submitFn(text);
if (res && res.ok === false) setState({ notice: "提交失败:" + …, noticeKind: "err" });
else setState({ notice: "已提交任务。结果与确认请求会出现在对话里。", noticeKind: "ok" });
} catch (err) { setState({ notice: "提交异常:" + …, noticeKind: "err" }); }
finally { setState({ busy: false }); }
}
2.8 全屏工作台面板(WorkbenchPage,shell.overlay)
布局结构(可直接复用的 UI 模式):
全屏遮罩(深墨绿黑,position:absolute; inset:0,点空白关闭)
└── 居中米白大面板(min(1120px, 94vw),圆角 28,大阴影)
├── 关闭按钮(右上角圆形 ✕)
├── 标题区:衬线大标题 / 斜体副题 / 说明段 / 版本号小字
└── 两栏布局:
├── 左栏(flex:1 1 400px):参数表单(目标 chips + 周期 chips + 周数/天数下拉 + 日期 + 备注 textarea)+ 编号步骤列表(可点击运行)
└── 右栏(flex:0.82 1 300px):浮起白卡(使用说明 ✓ 列表 + 当前参数回显 + 状态通知)+ 产出文件卡(点击打开)
编号步骤区(工作台主操作区,四个步骤):同步分析 01 / 周期计划 02 / 推送模版 03 / 执行追踪 04。
重要 React 规则:所有 hooks(useStore、useState、useEffect)必须在 if (!s.open) return null 早退语句之前声明,否则打开面板瞬间 hook 数量变化会抛错。
2.9 产出文件读取(producedPathsOf + openProduced)—— 两个经典坑,务必按新版写
面板右下角展示本会话登记的交付文件(present 工具登记的 deliverables),点击用系统默认应用打开。
坑 1:数据源不是 session 快照,而是事件窗口。 当前 DSH 的 SessionSnapshot 没有 chat
字段(只有 running/queue/pendingSubmissions 等),所以 session.getSnapshot().chat.nodes
恒为 undefined,卡片永远空。正确数据源是:
const binding = sessions.binding(sessionId); // SessionBinding
const entries = binding.eventSource.getSnapshot().entries; // 事件窗口
而且要摊平包装层——窗口 entry 是 { type: "transient"|…, event: {type,seq,time,data} },
真实事件在 entry.event 里(官方 conversation/deliverables 装配读的也是 entry.event.type):
const ev = raw.event ? raw.event : raw;
坑 2:键有两个,produced 不够。 最终交付文件由 present 工具登记为
deliverables/presented 事件(data.files[].path);produced 只覆盖 write/edit 类工具
(bash 跑出来的 docx/html 不在其中)。所以扫描要同时吃:
deliverables/presented→ev.data.files[].pathtool/call(write/edit/str_replace_editor)配对成功的tool/result→ 该路径
坑 3:workspaces.openPath 不存在。 workspaces 服务面只有 create/rename/delete/
archiveSession 这类注册方法。打开文件必须走 Remote:
await store.remote.session.openWorkspacePath({ path: absolutePath });
因此 apply() 的注入面是 ["slots", "sessions", "remote", "remote.session"],
并且 refresh 里要把 cwd 写回 store(store.sessionCwd = cwd),否则相对路径拼不出绝对路径。
坑 4:只订 session 不够。 present 登记只出现在事件流上,必须双订阅:
session.subscribe(refresh); // running / 队列
binding.eventSource.subscribe(refresh); // 事件窗口(新回复、present 登记)
顺带两个体验优化:工作区内绝对路径压成相对路径再去重(同一文件会以两种写法重复),
并把「输出/」下的成品排到列表最前。
坑 5(v1.0 实测踩坑):流程状态机的 kind 键必须与 composePrompt 分支严格一致。
本工作台的动作键是 sync / generate / push / track——生成计划的键是 generate 不是 plan。
曾把状态机写成 plan(想当然),后果:提交「生成计划」后管线全程不亮、横幅显示通用文案、
⌘↵ 提交报「参数不完整」。防回归:写一个一致性断言脚本,检查
①s.flow.kind === "X" 引用的 X 都在 FLOW_ORD;②FLOW_ORD / FLOW_LABEL 的键都在
composePrompt 分支里;③run("…") 字面量同上。三个方向任意一处漂移立刻报错。
产出栏 v1.0 行为变更:列表改为直接列工作区「输出/」目录(remote.workspaceFiles.list),
跨会话、跨对话做过的成品都会出现,不再只显示当前会话事件流里的登记;且只保留文档类
交付物(docx/html/md/txt/pdf),图片/视频/脑图不进栏。需要 package.json 注入
@deepseek-ai/dsh-api-workspace-files(激活期扫描,改后重启一次 dsh web)。
2.10 入口按钮(HeaderButton,玻璃质感 + SVG 图标)
- 绑定提交:
sessions.scope(sessionId)→sessionOf(actx)→sess.prompt([{type:"text",text}], "queue") - 玻璃拟态:半透明渐变 +
backdropFilter: blur(12px)毛玻璃 + 内高光描边 + 柔和投影;打开状态染主题色、文字变"关闭工作台" - 图标:玻璃小方块容器 + SVG 线性图标(哑铃),不用 emoji
- 交互:点击切换
store.open,Esc 或点遮罩空白也可关闭
2.11 apply 与 slot 注入
function apply(ctx) {
ctx.inject(["slots", "sessions", "remote", "remote.session"], (scope) => {
const sessions = scope.sessions;
if (sessions === void 0) return;
store.remote = scope.remote === void 0 ? null : scope.remote;
store.sessions = sessions;
scope.slots.inject("conversation.session.header.actions", () => scope.slots.register({
name: "conversation.session.header.actions",
id: "training-analysis-workbench-toggle",
order: 50 // 保险工作台 40 在左,本按钮 50 在其右
}, HeaderButton));
scope.slots.inject("shell.overlay", () => scope.slots.register({
name: "shell.overlay",
id: "training-analysis-workbench-page",
order: 100
}, WorkbenchPage));
});
}
slot 速查:conversation.session.header.actions=会话级头部按钮区(order 越小越靠左);shell.overlay=根级全屏浮层。
3. 功能与四步工作流
- 01 同步并分析训练数据:agent 用训练数据 Open API 逐日读取最近 90 天记录 → 汇总训练频率 / 每周组数吨位 / 主要动作最高重量与估算 1RM(Epley: w×(1+r/30))趋势 → 输出中文报告 + 瓶颈改进点
- 02 生成周期化训练计划:先读最近 30 天数据了解水平 → 选周期化结构(线性/波动/DUP)→ 完整展示按天拆分计划(动作/组/次/RPE/RIR/重量参考)→ 每周标注阶段(积累/强化/减载)→ 等审核
- 03 推送计划到训记 Agent 模版:增量同步读 folder/limits → 一天一个模版转 upserts(官方动作名 + progression 双重递增:先加次数、达标后加重并重置次数)→ 展示清单等确认 →
confirmed: true+ 唯一 mutation_id 写入 - 04 执行追踪与调整:对比计划 vs 实际 → 完成率看板 → 自适应调整建议
4. 训记 Open API 对接细节
> ⚠️ Key 安全红线(必读):本 skill 不附带、不展示任何训记 API Key(包括真实 Key 与示例前缀)。所有 Key 必须由使用者本人在训记 App 内申请并填写自己的(每套服务一个 Key,格式以训记 App 提供的为准)。严禁把任何他人/示例/本机已有的 Key 复制进本 skill 或随包分发。
| 服务 | Base URL | 接口 | 用途 |
|---|---|---|---|
| 训练数据 | trains.xunjiapp.cn |
/api_trains_for_llm_v2、/api_upsert_trains_for_llm_v2 |
读/写训练记录、RPE、心率 |
| Agent 模版 | trains.xunjiapp.cn |
/api_agent_templates_sync_for_llm_v1、/api_agent_templates_mutate_for_llm_v1 |
增量同步/写入模版文件夹 |
| 身体数据 | api.xunjiapp.cn |
/open/body/query_gzip、/open/body/upsert_gzip |
体重/体脂/围度 |
| 饮食数据 | eatings.xunjiapp.cn |
/open/food/query_gzip、/open/food/upsert_gzip |
饮食记录/自定义食物 |
| 食物搜索 | api.xunjiapp.cn |
/open_agent/food/search_gzip |
官方食物库 |
通用规则:
- 响应统一 gzip;curl 用 --compressed,python 手动判 0x1f8b 魔数解压
- Key 由使用者自行申请,放 Header Authorization: Bearer,绝不进 body/query/日志
- 写操作前先展示摘要 + 等明确确认;写训练/身体/饮食都要求 confirmed
- 限频:同 key 同 endpoint 15s 一次;批量逐日读取用脚本自适应退避 + 按日期落盘续跑
- 动作名必须用官方中文名(GitHub Foveluy/Xunji-movements 1187 词表),详见踩坑 6.3
5. 安装注册与热更新(桌面端 0.2.x 校订)
> 2026-09-30 校订:DSH 已从 ~/.dsh-desktop(web profile、端口 3080)迁移到 ~/.dsh/profiles/desktop,
> 插件改走 bundle 通道,不再需要在 profile 里手写 insert;桌面端 GUI 端口是 19387。
- 源码位置:插件包在
<workspace>/dsh-training-workbench/,包名是training-analysis-workbench。 - 注册(bundle 通道,当前方式):在
~/.dsh/profiles/desktop/package.json里同时声明依赖与 bundle:
json "dependencies": { "training-analysis-workbench": "link:../../../Documents/harness/dsh-training-workbench" }, "dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "training-analysis-workbench" ] } }
包自带的cordis.patch.yml提供那条 insert,loader 由 profile 负责解析模块路径。
⚠️ 不要再在 profile 的cordis.patch.yml里手写同名 insert —— 早期 web profile 就是这么挂的,
结果是Cannot find package(loader 只从内核 node_modules 解析)叠加重复挂载的双重故障。 - 热更新:
cordis.patch.yml/ client bundle 变更 → HMR 重挂载 →clientModules并入__DSH_BOOT__manifest → 刷新页面生效。 - 验证:
bash # ① profile 里确实挂上了这个 bundle grep -n "training-analysis-workbench" ~/.dsh/profiles/desktop/package.json # ② GUI 在跑(19387 需会话鉴权;未鉴权时根路径返回 401 属正常,不是故障) curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:19387/
6. 制作复盘与踩坑记录
6.1 制作时间线
- 需求澄清(ask_user_question 两轮:形态/数据源/运动类型/功能 + 平台/框架/范围)
- 先做了独立 Next.js + FastAPI 应用(
training-workbench/)→ 用户嫌启动麻烦 - 改为 DSH client-plugin(
dsh-training-workbench/)→ 按钮进 GUI 会话头部 - 对接训记 Open API 四套 Skill
- 注册插件 + HMR 自动生效
- harness 升级 0.1.0 → 0.1.1(全局 CLI)
- 安装 dshmarket 插件市场 + 本地镜像 + launchd 常驻
- 完成四步业务闭环(同步 90 天数据分析 → 8 周周期计划 → 推送 4 个模版成功)
6.2 踩坑记录
- gzip:urllib 不自动解压,响应
0x8b开头需手动解压 - 限频:
too frequent, retry after Ns,脚本解析 sleep + 4.5s 间隔 + 落盘续跑 - 动作名词表(最大坑):模版 mutate 校验的是 GitHub 1187 词表,不是 catalog 259 词表;
杠铃硬拉不存在(用「硬拉」)、裸深蹲不存在(用「杠铃深蹲」)、保加利亚分腿蹲→「保加利亚蹲(负重)」;但自重臀冲/肩绕圈/壶铃单臂侧式军事推举反而是官方名,别想当然改 - 模版名特殊字符:
下肢·深蹲的·报name special characters not allowed,改纯中文 - weight_increment:
0报 invalid(平板支撑给 1) - 写前确认 + confirmed:必须
confirmed: true+ 唯一 mutation_id(重试幂等) - harness 升级要 root:
/usr/local/lib/node_modules属 root:wheel,bundle 强制从全局 CLI 解析,需用户授权 sudo - TCC 权限:launchd 常驻服务的工作目录别放
~/Documents(os.getcwd()都 EPERM),放主目录根如~/.xxx-mirror - 境外源慢:GitHub Pages 数据源 27KB/s → 本地镜像 + 环境变量覆盖源地址(
DSHM_REGISTRY_URL,dsh 启动加载cwd/.env)+ launchd 开机自启(RunAtLoad+KeepAlive) - 缺训记 skill 会静默失败(2026-09-30 发现,最隐蔽):工作台面板只拼指令、不校验依赖是否存在。4 个训记 Open API skill 没装进会话时,面板照常打开、指令照常提交,但 agent 既不知道 API 地址也不知道调用格式 → 工作流卡住且没有任何报错。排障第一步:确认技能表里有
xunji-training/xunji-diet/xunji-body/xunji-agent-template - 多套独立 Key(极易混淆):训记不是一把通用 Key —— 训练数据与 Agent 模版各一把(
trains.xunjiapp.cn)、身体数据一把(api.xunjiapp.cn)、饮食记录一把(eatings.xunjiapp.cn)、食物搜索还另有一把(api.xunjiapp.cn)。弄错 Key 一律报apikey invalid
6.3 训记模版写入的标准动作名对照(训练工作台常用)
| 需求 | 官方名(GitHub 词表) |
|---|---|
| 深蹲 | 杠铃深蹲 / 自重深蹲 / 哑铃深蹲 |
| 硬拉 | 硬拉 / 罗马尼亚硬拉 / 杠铃罗马尼亚硬拉 |
| 卧推 | 杠铃卧推 / 哑铃卧推 |
| 划船 | 杠铃划船 / 哑铃划船 / 坐姿划船 |
| 推举 | 站姿哑铃推举 / 站姿杠铃推举 / 坐姿杠铃推举 |
| 臀冲 | 自重臀冲 / 哑铃臀冲 / 杠铃臀冲 |
| 保加利亚 | 保加利亚蹲(负重)/ 哑铃保加利亚蹲 / 自重保加利亚蹲 |
| 核心 | 卷腹 / 平板支撑 / 平板支撑_计时 |
| 自重 | 俯卧撑 / 引体向上 / 自重臀冲 |
| 热身/专项 | 肩绕圈 / 壶铃单臂侧式军事推举 |
7. 维护与扩展清单
修改后需同步三处并验证:
1. 改源码 <workspace>/dsh-training-workbench/lib/client.js(或 package.json 版本号)
2. 确认 ~/.dsh/profiles/desktop/node_modules/training-analysis-workbench 仍是指向该源码的 link(走 bundle 通道时直连源码,通常无需再手动同步副本)
3. 验证 grep -n training-analysis-workbench ~/.dsh/profiles/desktop/package.json,并刷新 GUI(http://127.0.0.1:19387)确认入口按钮与面板可用
扩展方向:新增训练日历热力图、身体数据趋势图表、饮食营养汇总面板、计划导出 PDF、执行追踪的周报生成等——每个都是面板里加一个编号步骤 + 对应 composePrompt 指令。