Skip to content
dsh-market Browse plugins GitHub 中文

YUNmengyuan/Herta-dsh

Installs Herta as a DeepSeek Harness agent: a preset carrying her persona and memory prompt sections, six memory and voice tools, a narrative layer that separates her thinking from what she says, 80 bundled voice clips, offline and MiniMax speech synthesis, and two conversation views that render the session with her own components.

Stars ★ 4 Category Identity & Communication Listed 2026-09-29

Install

Inside DeepSeek Harness, with dsh-market

dsh plugin --profile web add dshmarket

Or from the command line

dsh plugin --profile web add github:YUNmengyuan/Herta-dsh

Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.

README

This plugin publishes its README in Chinese only.

把 Herta(黑塔) 作为一个插件装进 DeepSeek Harness: 她的人格、她的记忆、她的声音、她的界面。

📌 来源声明

本项目是基于原作者项目所做的第三方改造,不是原创作品。

  • 原作者项目:Herta —— THE SELF THAT USES THE AGENT
  • 原作者官网:https://www.herta-ai.com/#research
  • 本项目把 Herta 作为一个插件接进 DeepSeek Harness —— 人格、记忆、语音、界面四层 都来自 Herta 原作;插件侧的代码(src/、scripts/、Cordis 配置)为本次改造新写。

本项目由第三方独立开发,与 Herta 原作者无任何隶属、合作或背书关系。 如原作者认为本改造有不妥之处,请联系我,我会立即调整或撤下。

另:「Herta / 黑塔」是 HoYoverse《崩坏:星穹铁道》中的角色。Herta 原作者项目本身 即为非官方同人作品,本项目同样是非官方同人作品,与 HoYoverse / miHoYo / Cognosphere 无关联、未获其背书或赞助。

素材授权细节见 THIRD-PARTY.md。

四层,各自独立可验:

层 内容 落在哪
A 身份 HertaBio.txt 逐字作人格前缀 + 为 DSH 改写的处境/纪律 agent preset
B 记忆 记忆货架(.herta/narrative/)作动态提示词段;四个记忆/做梦工具 preset 行
C 语音 80 条她本人的 .opus(开场白 / 语气词 / 自我收回 / 彩蛋)+ 发声工具 静态路由 + client
D 界面 乙:用她的展示组件渲染 DSH 会话;甲:iframe 装下她的整机 conversation.view ×2

⚙️ 兼容性(先看这一节)

面向 DSH 0.1.7-rc.2。 本版本修的正是 0.1.5 → 0.1.7 之间三处破坏性 API 变更 (都是实测出来的,不是猜的):

变了什么 0.1.5 时的写法 0.1.7 的现状 本仓库怎么办
agent preset 的载体 $DSH_HOME/.agent-presets/<name>/agent.cordis.yml(一整棵 cordis 树)+ preset.yml 该目录机制整个移除(运行时里已无任何代码引用 .agent-presets);preset 变成一条 @deepseek-ai/dsh-agent-preset loader 行,官方写成 dsh-web-app/presets/*.patch.yml 生成 preset/herta.patch.yml,作为第二条 bundle patch 随插件一起装(dsh.bundle.patch 现在可以是数组)
用户偏好的存放(设置域) 宿主 ctx.settings.register(ns, schema) + 客户端 settingsScope.bind({namespace}) 两者一起消失:SettingsProvider/SettingsScope/SettingsRegisterOptions 不再导出,客户端 settingsScope 服务不存在,事件 settings/updated、settings/document-updated 也没了;换成基于插件 Config 的 SettingsForms/configForms,没有第三方命名空间入口 用后者:插件自己的 volatile Config(落 profile 的 cordis.patch.yml),客户端挂 settings.section 一页。密钥另走凭据缝 ctx.remote.credentials(值进 $DSH_HOME/.credentials.yaml,不进明文配置)。2026-09-26 起黑塔的设置只有这一处
消息来源的 kind(会话格式 v4) { kind: "plugin", plugin: "dsh-herta" } v4 只认「生产者自有 kind」,kind: "plugin" 在写入会话时就被 assertV4SourceRowAdmission 拒绝(format v4 message requires a producer-owned source kind);第三方插件的合法形状是 { kind: "plugin:<包名>" } —— 这正是 DSH 自己的 v3→v4 迁移为旧行推导出的形状,两代读回同一个 kind 全部改成 { kind: "plugin:dsh-herta" }(agent.steer 两处 + 两个 PLUGIN_SOURCE);新增 scripts/test-source-kind.mjs 钉住这条不变量

⚠️ 第三处症状最难定位:被拒的事件根本没进会话日志(写入前就抛了), 事后翻 session.v4.jsonl.zstd 是干净的,只有 GUI 上显示「本轮运行失败」。 实测就是这么踩的 —— 详见 v0.1.3 版本历史。

其余用到的 API 在 0.1.7 上没变,实测可用:ctx.systemPrompt.section({name,order,text})、 ctx.tools.register(defineTool(...))、ctx.inject([...])、ctx.slots.inject/register、 ctx.uiConversation.binding(id).target('chat')、 ctx.on('agent/turn-stopping' | 'agent/error' | 'tools/result')、 @deepseek-ai/dsh-llm 的 BlockAssembler / createUserMessage、 webServer.register({ kind: 'prefix' })。

升级 DSH 后先跑这三条:

  1. dsh --profile <p> --dump-config → 应有 - id: preset-herta,且其 config.plugins 末尾有 plane: preset
  2. 启动日志 → 应有 plane=host、plane=preset、叙述层依赖就绪、 设置命名空间已就绪:herta(自带页面,不自动生成)、整机页面已挂:/herta-ui, 不应出现 settings.register is not a function,也不应再出现 从整机迁移设置:…(种子已删除)或 设置写回已挂(写回已删除)
  3. npm test → 1059 项(20 组);npm run test:integration → 28 项

📌 素材权利声明(clone 后先看这一节)

本仓库包含《崩坏:星穹铁道》的角色素材,这些素材的权利不属于本仓库作者:

© 米哈游版权所有

【《崩坏:星穹铁道》素材的权利归米哈游所有,其他内容的相关权利、利益均归各自所有者享有】

素材 干什么用的
assets/voice/(80 条 .opus,2.55 MB) C 层语音:开场白 / 语气词 / 自我收回 / 彩蛋
preset/herta.patch.yml(42 KB) A 层人格正本(内含 HertaBio.txt 逐字,含引用台词)
icon.png(384×384,173 KB) 插件在 DSH 插件管理页里的图标(取自 Herta 上游桌面应用,缩放重编码以符合 DSH 的 ≤256 KiB 约束)

上述声明依据米哈游官方 《崩坏:星穹铁道》同人衍生作品创作指引 V2.0(2024-04-18 生效)第三条放置。 素材权利未转让,收录不构成授权,使用仅限非商业用途, 且不得作为独立素材包再分发(官方指引将「纯搬运」排除在二创许可之外)。

完整声明、依据、使用者义务与移除方式见 NOTICE.md; Herta 上游项目自身的授权范围见 THIRD-PARTY.md。

clone 后开箱即用,无需自备素材。


装

插件市场 / npm

已收录:条目在上游精选目录 awesome-dsh-plugin(PR #5946,2026-09-29 合并)。 所以 DSH 内置的**插件市场(dshmarket)**里搜 herta 就能找到「Herta-dsh」并一键安装 —— 2026-10-01 实测:本机市场的目录(/dsh-market/registry)里已经有这一条,added: 2026-09-29。

📌 目录与版本是两条链:目录(简介、分类、下载量、能力扫描)由上游 CI 每日重建, 而装到哪个版本以 npm 的 latest 为准 —— 市场里「有更新」的判据也是 npm 的 latest dist-tag(dshmarket/lib/updates.js)。所以新版本发到 npm 之后用户立刻能装到, 目录卡片上的版本号要等下一次重建才跟上。

⚠️ 市场搜索的候选集只有那份上游目录(2026-10-01 实测 4400 条),不含本机已装 的插件,也没有「自定义目录源」这个用户入口。所以 file: 装进来的插件不会出现在搜索结果 里 —— 要能被搜到,就必须先被收录。

条目里那条 install: dsh plugin --profile web add dsh-herta 是上游 CI 按模板生成的通用值 (条目文件里作者只写 url / name / category / description 四项);桌面应用要把 profile 换成 desktop。

也可以直接装 npm 包(dsh-herta,0.1.4 起有):

dsh plugin --profile desktop add dsh-herta

桌面应用(0.1.7-rc.2)

桌面应用的 DSH 运行时打包在 resources/app.asar 里 —— 那是 Electron 的归档格式, 普通 Node 读不到,所以 install-web.mjs 驱动不了它。走应用内置的插件管理器:

plugin_manager  install_bundle   file:<本仓库绝对路径>
# 然后关掉桌面应用窗口,重新打开

或者在有独立 DSH 安装(不是 asar 版)时用命令行:

dsh plugin --profile desktop add file:<本仓库绝对路径>

独立 DSH 安装 / lab(脚本可全程驱动)

$env:DSH_BIN   = "<…>\node_modules\@deepseek-ai\dsh\lib\bin.js"   # 必须能被普通 Node 启动
$env:DSH_HOME  = "<目标 home>"                                    # 可选
node scripts\install-web.mjs --dry-run    # 只读:先看它会做什么
node scripts\install-web.mjs --profile desktop
# 然后关掉桌面应用窗口,重新打开

脚本做四件事:前置检查 → 备份 → 装(dsh plugin add,失败则走确定性兜底) → --dump-config 自校验(herta 行必须恰好一条、preset-herta 必须进合成结果、 其他已装插件不能消失)。回滚:--rollback。

preset 不再需要单独安装。 0.1.7 起它是本包自带的第二条 bundle patch (preset/herta.patch.yml,见 package.json 的 dsh.bundle.patch 数组), plugin add 会一并生效 —— 所以这个脚本里没有「拷 preset 到 $DSH_HOME」那一步了, 也不再有「preset 属于整个 home」那个副作用。

必须关窗重开:dsh.profile.bundles 的变更是启动期合成;桌面应用没有子进程守护, 直接杀掉 dsh web 不会自动拉起。

出问题回滚:node scripts\install-web.mjs --rollback(之后同样关窗重开)。

回滚按 home + profile 过滤备份(每份备份带 TARGET.json),不是取"全局最新"—— 同一台机器上装过多个 profile 时,取最新会把别人的配置还原到这边来。 该路径已实测:还原后 bundles 与 dependencies 回到安装前、找不到匹配备份时 明确报错而不是乱还原。

⚠️ 桌面应用内置的插件管理器没有回滚。要改它真正在用的那个 profile 之前, 先手工备份 profiles/<name>/package.json 与 cordis.patch.yml。

装完之后,怎么让她出声

装好 ≠ 会说话。 默认引擎是「本地模型」,而模型要另行下载;不做下面这几步,她会 一个字都不念。以前这里连原因都不显示(2026-09-30 体检后已修:失败原因会写在 设置页那一行上)。五步:

  1. 打开设置:DSH 设置 ▸ 黑塔 ▸ 「语音」组。
  2. 二选一(要么下模型、要么填密钥):
    • 本地模型(不花钱、不联网):到「整机动作」组点「本地语音模型」的下载, 约 72.7 MiB,装到 $DSH_HOME/tts/herta-best-e72;然后在「语音引擎」里选 本地模型。只有 GitHub 这一个源(被墙时那个「安装包网盘」里没有模型)。
    • 云端:「密钥」组里填 MiniMax 或 Fish 的密钥,再把「语音引擎」切到那一档。 ⚠️ MiniMax 只「认领」你账号上已有的克隆音色,不做上传/克隆 —— 新账号上还没有 那个克隆时,填了密钥也不会出声。
  3. 点「试听」那一行:有声就说明这条链通了。没声就看同一行的状态行 —— 它现在会说 原因(没有密钥 / 模型没装 / 连不上且没配代理 / 认领失败…)。
  4. Fish 引擎要能连上接口:插件按 fishaudio.org(官方现在用的域名,国内可直连)→ api.fish.audio(旧域名,国内会被按 SNI 重置,只作海外兜底)的顺序试。 两条都不通时才需要在「Fish 语音」组填 「Fish 代理」(例如 http://127.0.0.1:7897), 或者设 HTTPS_PROXY 环境变量;留空 = 直连。都失败时状态行会直说原因。 ⚠️ 音色 id 跟域名绑定:默认的大黑塔属于 fishaudio.org,id 是 36e4d5f5-7654-43d4-b160-a1f15398116f(旧域名那个 f9ede038… 在新域名下会返回 ERR_VOICE_NOT_FOUND)。手动换域名时,「Fish 音色」那一行要跟着换。
  5. 不想花钱 / 不想被吵到:「语音」组里
    • 实时语音关掉 = 不再自动念回复(省钱;herta_say 与试听不受它管);
    • 静音只决定听不听得见,不决定要不要花钱合成(合成照旧发生)。

架构

这个包同时挂在两个平面上,两件事不同

profile bundle 行(宿主面) preset 行(agent 面)
干什么 让 client 半侧进浏览器启动图 + 挂四条路由(语音 / 偏好 / 模型 / 整机) 注册她的提示词段与五个工具
为什么不换 dsh-client-modules 只扫 loader.entries(),preset 子树不在其中 放宿主面会把工具泄漏给所有会话
怎么区分 行上没有 config 行上带 config: { plane: preset }
住在哪 包自带的 cordis.patch.yml 包自带的 preset/herta.patch.yml(0.1.7 起 preset 就是一条普通 loader 行;旧版的 $DSH_HOME/.agent-presets/ 目录机制已被 DSH 移除)

两个实例的 apply 都会跑(模块只求值一次,fiber 是两个),所以模块级可变状态 必须与平面无关或按 key 索引 —— 缓存按 cwd 索引就是这个原因。

preset 行现在启动期就挂(0.1.7 实测:冷启动日志里 plane=host 与 plane=preset 一起出现)—— 旧版 0.1.5 是懒挂载,要等第一个属于该 preset 的会话才挂。 两种都正常,但别拿「没看到 plane=preset」当插件没装上的证据,以 --dump-config 为准。

两条界面路线

乙(conversation.view id herta)—— 用她的展示组件渲染 DSH 会话。数据走 ctx.uiConversation.binding(sessionId).target('chat'),组件拿到的是 DSH 的 ConversationNode[],映射成她的气泡。样式挂进 shadow root:她原版样式表的 :root 变量在 shadow 里不匹配任何元素,所以构建期把 :root 转成 :host、 删掉整页 chrome(见 scripts/build.mjs)。

甲(conversation.view id herta-full,页签显示「黑塔·整机」)—— iframe 装下她的整机(/herta-ui/)。 iframe 是独立文档,所以她的原版样式原样使用(:root / body 整页规则在这里 正好是对的)。数据经 postMessage bridge 从父窗口取;bridge 会把每个调用的最后一条 载荷缓存下来补给新订阅者 —— 不这么做,早期那次 reset 会在渲染层订阅之前丢掉, 现象是「界面起来了但会话是空的,而且不会恢复」。

整机页要去掉两处「宿主已经有的 chrome」(都落在薄层 src/herta-ui/herta-ui.css, 两条规则,上游 Herta-src/ 一行没动):

去掉的 为什么
.composer(她自己的输入框) DSH 底部本来就有一整套输入区,两套并存;且她那套走 bridge.submitText,而 bridge 没实现这个方法,发出去的话只会丢在地上
.window-controls(右上角最小化/最大化/关闭) 三个都是死按钮 —— bridge 把它们全实现成空函数(iframe 里也没有「窗口」可最小化,该最小化的是外层 DSH 窗口)

两处都只圈元素自己的 class、不碰父级,所以布局自动让位:.workspace 的 grid-template-rows:1fr auto 里那个 auto 行会跟着子项自己塌,对话区直接长满 (实测 452px → 512px),不留空档也不出双重滚动条。

叙述调度层(她的「自我叙述」)

她的原版里有一层演员调度:说什么、怎么分拍、什么时候自我收回、开口之前先想什么。 这一层在上游骑在她自己的编码后端上(packages/herta/src/narrative/,46 个模块 1.29 MB)。本插件不移植那个后端(DSH 就是后端),只把调度逻辑接到 DSH 的 agent loop 上。

完整设计(已核实的 DSH API、机制映射、踩过的坑)见 docs/叙述调度层设计.md。要点:

她的叙述语法

(我 想)……(/我 想)      思考 —— 内心判断,不进用户视野
(我 说)……(/我 说)      说话 —— 真正说给开拓者的话

四组围栏在 zh/en 两种语言下都是中文(上游 thought-hint.ts:16-18:它们是语法 记号,不是指导语)。src/host/narrative-hints.js 把这套语法与上游的提示词资产 逐字移植进来(含 supervisor 否决模板、rethink/respeak 两阶段、三种分拍提示)。

四种行为,各自落在 DSH 的哪个钩子上

行为 落点 要 LLM 调用?
分拍(干活中途补一句点评) tools/result → 判据 → agent.steer 否
自我收回 / supervisor 复核 agent/turn-stopping(turn 关闭前被 await)→ 独立复核 → steer 让她重说 是
thought tag 输出里的围栏,由渲染层(乙 / 甲)呈现 否
做梦蒸馏 herta_dream 的 distill: true → 宿主另起调用蒸馏候选 → 过门 → 落账 是

三条安全底线(缺一条都会出真问题):任何失败一律放行(复核坏了不该让她说不出话)/ 配额到顶一律放行(steer 会让 turn 继续,持续否决她将永远说不完)/ 拿不到模型路由就跳过(provider/model 是必填,不猜)。

一个必须知道的语义差距:DSH 的 agent.steer 在下一个 step 边界生效, 不像上游能在后端事件发生当拍插话。所以分拍是「事件后一个 step 补评」—— 效果等价,时机晚一拍。

六条路由

前两条是白名单静态资源:启动时扫出文件索引,请求路径必须命中,否则 404。 不存在路径穿越的可能,也就不需要 ../ 过滤这类容易写错的代码。

  • /herta-voice —— 80 条语音 + 代码生成的 index.json
  • /herta-ui —— 整机页面(html / js / css / 开场段 / pdf worker)

后三条不是静态资源,是宿主自持的读写端点:

  • /herta-voice-model —— 本地语音模型(离线 TTS)的下载。 GET 状态、POST {"action":"download"|"cancel"|"remove"} 点火即返回, 进度靠轮询 GET 拿。状态码与 phase 都用上游那套 (absent / downloading / ready / failed,失败带 error 键)。 详见下一节。
  • /herta-minimax-events —— SSE:MiniMax 云端语音的 PCM 推给浏览器半侧。 三种帧:tts(一个单元的 Int16 PCM,base64)/ ttsStop / state。 浏览器半侧转成 push("voice", …) 交给整机 iframe(iframe 缺席时自己在父窗口用 WebAudio 放)。走自建 SSE 而不是 DSH 的事件通道,理由见下一条注释。
  • /herta-minimax-state —— 认领状态 + 手动动作: GET 快照,POST {"action":"adopt"|"reset"}(设置页「重新认领」按钮)。

为什么 PCM 不走 DSH 的宿主→客户端事件:那条路是 ctx.remote.$on,事件名在 @deepseek-ai/dsh-api-remotes 里是硬编码白名单(第三方插件没有扩展点), 而且过线前要过 isJsonValue —— Int16Array 一律被拒(不是真数组、原型也不是 Object.prototype)。所以 PCM 只能走插件自持的 HTTP 路由,SSE 是 dsh-client-hmr 已经在用、本仓库已验过的形状。

/herta-settings 已删除(0.1.7 那版曾用它自持语音偏好)。现在客户端直接走 ctx.configForms,再留一条 HTTP 写入口就是第二个真相来源。密钥也不走 HTTP —— 它们走 DSH 官方的凭据缝 ctx.remote.credentials。


本地语音模型(离线 TTS 的模型那半)

DSH 设置 ▸ 黑塔 ▸ 整机动作 里的「下载」现在是真的:宿主会去上游发布的地址取那一个归档。

固定参数从上游扒来,钉在代码里(src/host/tts-release.js)——不是运行时问服务端:

项 值 出处
归档 herta-best-e72.tar.gz Herta-src/…/tts/tts-release.ts
地址 https://github.com/PersonaCLI/Herta/releases/download/voice-herta-best-e72/herta-best-e72.tar.gz 同上
体积 76,255,506 B(72.7 MiB) 同上
SHA-256 ce993a6fab911e9a86328facc952120c21de54303652f648e96e9d14ee4f172e 同上
解包后 115,897,197 B(110.5 MiB) 同上
装到哪 $DSH_HOME/tts/herta-best-e72 上游是 <userData>/tts,这里跟 DSH 的 home

上游那段注释说明了为什么要钉:「A retrain is a new bundle id …, a new archive, new pins, and therefore an app release: the download never trusts the host, only this file.」 本插件不重打包也不转发这个归档,只是按上游发布的地址去取。

四段,任何一段失败都不会留下半个可用的 bundle(src/host/voice-model.js):

  1. 边下边算 SHA-256;先比字节数、再比哈希,任一不符即中止
  2. 解到最终目录旁边的 .installing/,带解压炸弹上限(只看普通文件与目录,拒绝路径穿越)
  3. 拿 bundle 自带的 manifest.json 逐个文件比 size + SHA-256,还要比 release 是不是这一版期望的
  4. 只有前三段全过,才 rename 就位 —— 读到的要么是旧的完整版,要么是新的完整版

已经装好的旧 bundle 在任何失败下都活着(只在第 4 步被替换)。

✅ 运行时已随包分发(assets/tts-runtime/,22 MB,sherpa-onnx 1.13.6 + onnxruntime 1.27.1)。宿主会真探测它(拉子进程把 addon 加载起来拿版本号, 结果进程内缓存),探测通过才报 runtime: true —— DSH 设置页里「本地语音模型」 那行按它如实报「运行时未就绪」,写死 true 就是仓库别处修过的假绿。

合成本身也实测过:scripts/test-tts-runtime.mjs 用真实模型合成 「你好。我是黑塔,天才俱乐部第八十三号。」→ 24 kHz / 5.08 s / 非静音 (峰值 25209、平均 2319),4.3 s 出结果。合成跑在子进程里 (src/host/tts-worker.cjs):sherpa 的 espeak 构建在 Windows 上处理不了 非 ASCII 绝对路径,而本机路径里就有中文 —— worker chdir 到模型根再传相对 路径,与上游同一招;顺带也避免了同步阻塞宿主、原生件崩溃拖死宿主。

⚠️ 还差最后一段:把她的回复自动念出来(触发 + 播放)还没接。 也就是说 runtime 与 bundle 都是真的、引擎确实能合成,但「实时语音」开关 打开后她暂时不会自己开口。要接的是:回复文本 → 合成 → 推给 iframe 播放。

⚠️ 本机 TLS 提示。 这台机器对 GitHub 有中间拦截,普通 Node 的 fetch 会直接 fetch failed,加 --use-system-ca 才通。DSH 宿主是 Electron 拉起的普通 Node 子进程,所以要给宿主加 NODE_OPTIONS=--use-system-ca 再启动;下载失败时插件 会把这条提示写进日志(而不是只回一个 network)。


六个工具

工具 作用
herta_narrative_list 列出记忆货架,并如实报告哪份进了当前提示词、哪份被门拦下
herta_narrative_read 读某一份的完整正文
herta_memory_save 随手记一笔(过格式门 + 标题新颖性)
herta_dream 做梦:门槛更高(另加篇幅下限),并记进做梦账本
herta_speak 让模型能主动发声;片段信息经 presentationMeta 落到工具结果的 meta 上,浏览器侧读取后播放。放的是她的录音片段(.opus),不经过任何合成
herta_say 用她自己的克隆声音说一句话(MiniMax 云端合成,走 SSE 推给界面)。既是调试入口,也是语音链路的验收通道 —— 端到端哑掉时,先用它把「合成」与「推给界面」两段分开看

三道门

记忆内容会进她的系统提示词,所以写入必须过滤:

  1. 格式门 —— 与读取端同一道(逐字移植 Herta 的 few-shot-guard):对话栅栏必须 平衡、不嵌套、到 EOF 全关;首行必须是 ### 废案 / ### 记录 头;单个文件不超过 1 万估算 token。检查的是结构不是词汇 —— 上游第一版用词表判断,把全部 18 份 活样本静默丢了 25 天。
  2. 标题新颖性 —— 逐字移植 novelty.ts(含「文件名消毒后同名」那个坑与连载篇豁免)。
  3. token 预算 —— 实测她真实货架折合 47445 tokens,全部注入不可接受。默认预算 12000,按「最新优先」挑选,但拼装时排回升序(拼接顺序与挑选顺序无关,否则 同样内容会产出不同前缀、打散 KV 缓存)。

开发

# 构建脚本需要一份「解开目录」的 DSH 安装(桌面应用的包在 app.asar 里,读不到)
$env:DSH_PACKAGES = "<…>\node_modules\@deepseek-ai"

node scripts\build.mjs           # client 半侧(esbuild + 模块加载器包装)
node scripts\build-preset.mjs    # agent preset 补丁层(以随附 standard 为底,只换 persona 行)
node scripts\build-herta-ui.mjs  # 整机页面
node scripts\deploy.mjs          # 三样都构建 + 镜像进 lab profile

node scripts\test-narrative.mjs        # 货架逻辑(31 项)
node scripts\test-dream.mjs            # 做梦逻辑(28 项)
node scripts\test-mapping.mjs          # DSH↔Herta 映射(41 项)
node scripts\test-narrative-hints.mjs  # 叙述语法与提示词资产(54 项)
node scripts\test-supervisor.mjs       # 复核:判决解析 / 路由 / 否决配额(81 项)
node scripts\test-session-surface.mjs  # 会话表面提取:候选回话 / 摘要 / turn(32 项)
node scripts\test-beat-policy.mjs      # 分拍判据与配额(61 项)
node scripts\test-dream-distill.mjs    # 蒸馏提示构造与解析(55 项)
node scripts\test-mimo-tts.mjs         # MiMo 合成请求构造(40 项)
node scripts\test-voice-settings.mjs   # 语音偏好的清洗 / 合并(58 项)
node scripts\test-voice-model.mjs      # 模型下载:tar 解析 / 校验 / 状态机(50 项)

可选:用真实数据再跑一遍管线(需要本机已经有一份真的 bundle —— Herta 桌面应用 在「设置 → 语音」里下过模型的话就有):

$env:HERTA_TTS_REAL_BUNDLE = "$env:APPDATA\Herta\tts\herta-best-e72"
node scripts\test-voice-model-real.mjs

它把那份真实的 367 个文件 / 116 MB 自己打成 tar.gz、起一个本机 HTTP 服务、 再走完整的下载 → 校验 → 解包 → 再校验 → 换入,最后逐文件对账。 小归档验不了的东西(真实 manifest.json 的 366 个真 SHA-256、真实的 espeak-ng-data 目录树、真实长路径)都在这里过一遍。默认不跑,因为它依赖机器上 已有的那份模型。

可选:验证本地 TTS 运行时真能出声(同样需要一份真实模型):

$env:HERTA_TTS_MODEL_ROOT = "$env:APPDATA\Herta\tts\herta-best-e72"
node scripts\test-tts-runtime.mjs

22 项:运行时能被加载(拿到 sherpa-onnx 版本号)→ 真合成出 24 kHz 非静音音频 → WAV 头/采样数/字节数自洽 → 波形有起伏(不是一条平线)。

npm test 跑十三组纯逻辑测试(共 608 项);另有 LLM 路径的集成测试 (28 项,用 mock 的 ctx.llm 把管道整条跑通):

npm run test:integration
# 等价于 node --import ./scripts/test-resolve-hook.mjs scripts/test-llm-integration.mjs

集成测试为什么需要那个 hook:本仓库刻意不带 node_modules,而 supervisor-llm.js / dream-distill-llm.js 静态 import @deepseek-ai/dsh-llm, 在仓库里直接 import 会 ERR_MODULE_NOT_FOUND(在测试目录放软链接也没用 —— ESM 从被导入文件的位置解析)。hook 把它指向本机 DSH 运行时那份。

src/shared/mapping.js 是纯函数、不 import 任何东西,所以 Node 能直接测、 esbuild 也能原样打进 client bundle —— 一份代码两个消费者,不需要额外构建步骤。 之所以把它从客户端里抽出来:那是客户端逻辑最密的一段(11 种节点 → 3 种块), 埋在 .tsx 里就只能靠浏览器验证。

改动落在 dsh.profile.bundles 或 preset 上的,必须重启实例才生效。

环境变量:

变量 给谁用 含义
HERTA_SRC build*.mjs、test-narrative.mjs Herta 源码树(身份正本与渲染层从这里取)
DSH_PACKAGES build-preset.mjs DSH 安装里的 …/node_modules/@deepseek-ai(preset 底本)
DSH_MODULES test-resolve-hook.mjs DSH 安装里的 …/node_modules(借 @deepseek-ai/*)
DSH_PROFILE_DIR deploy.mjs、test-dream.mjs 目标 lab profile
DSH_BIN / DSH_HOME install-web.mjs 可被普通 Node 启动的 dsh bin、目标 home
NODE_OPTIONS DSH 宿主进程 本机对 GitHub 有 TLS 拦截时需要 --use-system-ca,否则模型下载会失败
HERTA_TTS_ARCHIVE_URL tts-release.js 开发用:覆盖模型归档地址(哈希 pin 照旧生效,内容不能换)

已知缺口

⚠️ 叙述调度层:哪些验过、哪些没验

如实分开写,别把「代码写完了」当成「验过了」:

部分 验到了什么 没验到
提示词资产、语法解析、判决解析、配额闸 314 项纯逻辑单测(Node 里直接跑) —
挂载与依赖 lab 冷启动日志:plane=preset + dsh-llm 可用 + llm 服务就绪 —
模型路由可读性 lab 实测读出 {"provider":"deepseek-official","model":"deepseek-flash"} —
LLM 路径的管道 28 项集成测试(mock ctx.llm):请求组装 / 流消费 / 判决解析 / 失败一律放行(无 llm、无路由、流抛错、finish 被截断) —
supervisor 复核的真实闭环 ✅ lab 里用假模型服务跑通了整条链(见下):她说没凭据的话 → 复核 veto → steer → 她重想重说 → 复核 pass → turn 才结束。日志:supervisor 否决 turn 1(第 1 次):她宣称写过笔记,但记录里没有任何写入工具调用 真实 DeepSeek 的判决质量(它到底会不会正确 veto)。假模型验的是链路与行为,不是判断力。
thought tag 的真实渲染 ✅ 同一次验证里,她重说的回复带 (我 想)…(/我 想) 与 (我 说)…(/我 说),页面正确呈现 上游那种「逐字揭示」的动画节奏(reveal-driver)没移植。
做梦蒸馏的真实行为 ✅ lab 实测跑通整条链:她调 herta_dream {distill:true} → 两阶段(worthiness max_tokens=300 → generation 1200)→ 过 promoteFeian 的格式门 → 落盘 .herta/narrative/### 废案_01:….txt → 记账 manifest.json 记 promoted / tokens: 151 真实 DeepSeek 蒸馏出的候选质量(像不像她的语气)没验。
分拍的真实行为 ✅ lab 实测:mock 发起一次失败的工具调用 → tools/result 判 tool-failed → 分拍注入。日志:tools/result #1 name=read isError=true → tool-failed / 分拍候选 turn=1 / 分拍 turn 1(tool-failed):cannot read …: not found 验证类工具的成功分拍(verification-passed)没单独验。
空轮护栏(2026-09-30 新增) ✅ 拿真实会话日志回放验过:session-96f68201 的 70 轮里判出 13 轮「用户在界面上什么都看不到」(54、58、60–70);而 52 / 56 / 57 这些「最后一步只出思考、前面有工具调用」的轮次正确地没判成空轮(那些轮次用户看得见卡片)。另加 40 项纯逻辑单测。✅ 且在生产里真触发过一次(2026-09-30):信标记到 silence: { "n": 1, "turn": 28, "attempt": 1 },那一刻她的正文没落地;下一轮她重说了 —— 通知 → 重说这条闭环实测走通。 模型持续把回话写进思考通道时,护栏只能让用户看见,治不了模型 —— 见下。
叙述层对子代理让过路(2026-09-30 新增) ✅ 判据 11 项单测(用本机实测的 header 形状);外加 12 项源码级接线检查(三处钩子都在、且都问在动作之前、跳过都留痕)。 运行时行为没验 —— 「接线检查」证明的是源码里没漏、没挪位,不是子代理真的不再被打断。要真验得跑一次长子任务看它能不能自己交完。

一句话:调度逻辑、失败路径、两条 LLM 路径的管道都测了; 分拍 / thought tag / 自我收回 / supervisor 复核 / 做梦蒸馏的行为都在 lab 里 用假模型服务实测过;只剩「真实 DeepSeek 的判断力与语气质量」没验 —— 那需要真实凭据,且不属于代码正确性的范畴。

⚠️ 两种「她突然不会说话了」(2026-09-30 真事故)

session-96f68201 从第 58 轮起完全静默:用户只能一轮轮问「还在吗」, 每一轮再喂进去一条「只出思考」的先例。事后拆会话日志,静默有两条独立的路径:

路径 现象 谁的问题
A. 只出思考 助手消息里只有 reasoning 块,没有 text 块也没有工具调用,turn/end 照样报 completed。模型在思考里循环「动手。/(做。)/(写。)」几十遍,最后把要说的那句写在思考末尾。DSH 只渲染 text 块 → 屏幕上什么都没有。 模型侧(DSH 的 EMPTY_RESPONSE 只拦「一个块都没有」,有思考就不算空)。插件管不了,但能让它看得见。
B. 说了但被吞 回话确实在 text 块里,但整段被解析成思考 → speech 为空 → 界面上不显示。成因是 splitSurfaces 的形状 B(只有 (我 想) 没有说话围栏)把整段都当思考,连围栏之外那句正常发言一起吞掉。 插件的 bug,2026-09-30 修:splitThoughtFences —— 围栏之内是思考,围栏之外仍是说话。

处置:A 由 src/host/silence-guard.js(空轮护栏)兜住 —— 一轮结束前清点「用户到底能 看见什么」,什么都没有就注入一条看得见的通知并要求她重说;每轮最多 2 次, 且复核刚否决过的那一轮跳过(那条静默是 rethink 阶段故意要的)。 B 修在解析器里,半侧产物 lib/client.js 用 scripts/reapply-surface-fix.mjs 补。 护栏不会让模型停止犯错,它只是把「她不理我」变成「终端告诉你她这一轮只出了思考」。

叙述层该对谁说话:不当着工人的面喊她(2026-09-30)

叙述层那三样(复核 / 空轮护栏 / 分拍)都是人格行为 —— 它们对着「跟你说话的那个她」 说话。而 subagent 不是她:它是她在会话里派出去的工人,产出通过父级回话交回。

本机实测的形状(会话头,四个子代理会话逐个看过):

{ "id": "…", "parentSession": "session-…", "origin": "subagent",
  "delegationDepth": 1, "agentPreset": "herta" }   // 人机只有 delegationDepth: 0,没有 origin

注意最后那个字段:子代理继承人设,所以人格机制会一视同仁地打进它们。那天的代价是 两次收尾被吃掉 —— 一次子代理把一句「分拍点评」当成交付物(几千字的摘要没交出来, 只能靠 send_message 追回),一次报告交完了、最后一轮被复核拉去做「重想」。

判据是 isSubagentAgent(agent)(src/host/session-surface.js,纯函数):origin 是语义 字段、delegationDepth 是结构字段,任一说是子代理就认;两个都读不到才当人机 —— 默认站在「照常服务她」这一边,不误伤。三处钩子都问它,都问在动作之前,跳过都留痕 (marks + 信标 subagentSkip + 一行日志;分拍那处只在第一次打日志,工具结果太频繁)。

怎么确认叙述层真的在跑(启动信标)

叙述层在放行的时候不留痕迹:复核判 pass 就不注入、不写会话记录。所以在正式 环境里,光看会话记录区分不出「复核跑了并放行」与「复核压根没跑」。

src/host/narrative-beacon.js 把「跑到哪一步」写成一份可读证据,落在 $DSH_HOME/dsh-herta-narrative.json(lab 与正式环境各一份,互不覆盖):

{
  "verdict": "叙述层在跑:复核执行过",
  "phases": {
    "install":  { "pid": 123, "depsOk": true },      // 钩子挂上了
    "llm":      { "ok": true },                      // llm 服务拿到了
    "turnStop": { "n": 3, "turn": 3 },               // turn 边界钩子触发过
    "review":   { "n": 3, "verdict": "pass" },       // 复核真的执行过(pass 也记)
    "veto":     { "n": 1, "turn": 3, "stage": 1 },   // 复核否决过(rethink / respeak)
    "beat":     { "n": 1, "kind": "tool-failed" },   // 分拍判据执行过
    "silence":  { "n": 1, "turn": 60, "attempt": 1 }, // 空轮提醒注入过(只出思考的那一轮)
    "subagentSkip": { "n": 3, "hook": "beat" }      // 叙述层对子代理让过路(诊断,不是推进)
  }
}

verdict 可直接读作结论。只放阶段名、计数、时间与 pid,不放任何对话内容。

# 正式环境:桌面应用的 DSH_HOME 是 %USERPROFILE%\.dsh
Get-Content "$env:USERPROFILE\.dsh\dsh-herta-narrative.json"
# lab:$env:DSH_HOME 指到哪就在哪
Get-Content "$env:DSH_HOME\dsh-herta-narrative.json"

同一目录下曾经还有语音偏好 dsh-herta-voice.json —— 那条自持路线已废弃 (语音偏好现在是 DSH 设置里的字段)。旧文件若还在,只是一份没人读的历史残留; 插件启动时那次「读→改名 .imported」的一次性迁移也已删除。

turnStop 那一条是「它活着」最硬的信号 —— 比「有没有人 veto」硬得多: 放行不留痕迹,而钩子被触发过就说明链路接上了。

怎么在没有 API Key 的环境里验这些

scripts/mock-llm-server.mjs —— 一个 OpenAI 兼容的 SSE 假模型服务, 按 system 提示的词判定阶段(复核 / 蒸馏 / 对话 / 第三方辅助调用), 并让第一次复核 veto、之后 pass,于是一次会话就能看到完整的自我收回往返。

它不碰任何真实凭据:DeepSeek provider 的配置里只放凭据的「名字」 (apiKeyEnv),密钥不进配置 —— 所以 lab 指向它时用的是假的环境变量名。

node scripts\mock-llm-server.mjs --port 8791
# 然后在**只属于 lab** 的 profile patch 里把 llm-deepseek 指向它:
#   - id: llm-deepseek
#     config: { baseURL: http://127.0.0.1:8791, apiKeyEnv: DEEPSEEK_API_KEY, models: [{id: deepseek-flash, …}] }
# 并用一个假值启动 lab:$env:DEEPSEEK_API_KEY='mock-key-for-lab-verification'
# 验完记得还原 patch(别让它进正式配置)。

这一条路是踩出来的:此前几轮都以为「没有 Key 就验不了真实链路」。 其实要分开看 —— 真实模型回什么验不了,但行为链路能验。

其余缺口

  • 五个工具从未在真实会话里被调用过 —— 只验证到「进了工具表」与纯逻辑单测。 lab 里没有 API Key,所以一次真实调用的闭环还没走通。
  • 构建脚本需要指向一份 DSH 安装,因为 preset 的底本是官方随附的那份。 0.1.7 起:build-preset.mjs 读 dsh-web-app/presets/standard.patch.yml, 通过 DSH_PACKAGES(指到 …/node_modules/@deepseek-ai)定位;集成测试与 test-dream.mjs 通过 DSH_MODULES(指到 …/node_modules)借 @deepseek-ai/*。 两者都不再写死本机绝对路径,探测不到会明确报错并告诉你设哪个变量。 HERTA_SRC(Herta 源码树)仍有一个本机默认值,可用环境变量覆盖。 ⚠️ 桌面应用的包在 app.asar 里,构建脚本读不到 —— 构建请指向一份 解开目录的 DSH 安装(npm 安装或便携版)。
  • 她的设置页已删除,全部设置由 DSH 设置 ▸ 黑塔 统一管理(2026-09-26)。 Herta-src 里的 SettingsModal + 7 个分页 + 侧栏入口按钮整块移除,插件也不再往 她自己的 settings.json 写任何字节(写回、种子、↺ 跟随整机 一并删除)。 仍在「暂未接线」组里的 9 项整机当前不读,页面逐行标注了原因 —— 详见 docs/设置搬迁.md。(voiceEngine 与 realtimeVoice 已在 2026-09-27 移出这一组,见下。)
  • 整机页的 submitText 未实现 —— 她那套输入框既然已隐藏,这条路径日常碰不到; 但若要恢复输入框,必须同时把 bridge 的 submitText 补上,否则还是发不出消息。
  • C 层客户端消费 cue 的那一半未端到端验证 —— cue 的抽取已有单测覆盖 (test-mapping.mjs),但从一个真实的 herta_speak 工具结果触发播放,需要 一次模型调用才能走通。
  • 整机的 listSessions 返回空 —— 她自己的会话列表 / 开场白 / 设备卡还没接; 整机目前只服务「当前这一个 DSH 会话」。
  • 云端语音(MiniMax)已端到端接通(2026-09-27):她的回复正文 → 宿主分段 → MiniMax 云端合成(认领她账号上已有的克隆,不做上传/克隆)→ SSE /herta-minimax-events → 浏览器半侧 → push("voice", …) → 整机 iframe 播放 (iframe 缺席时父窗口用 WebAudio 自己放)。边写边念;复核否决/要求重说时把还在流的 那一段掐掉;每轮 800 字上限;云端不可用(没密钥/没认领到克隆/被拒/克隆被删)时 显式回落本地模型,并在设置页写明"现在是谁在说话"。仍未接的: voiceEngine = local / mimo 这两条(本地模型目前只服务 herta_speak 与这条回落, mimo-tts.js 仍未实例化)—— 这是决定不是遗漏。 单测:npm run test:minimax(324 项;test-minimax-segment 还与上游做了 16164 次差分比对,0 处不一致)。
  • C2(全量本地 TTS)已能合成。现在:模型能按上游地址 下载 / 校验 / 解包 / 装好,运行时(22 MB sherpa-onnx)随包分发且被真探测, 合成本身用真实模型实测过(24 kHz / 非静音,scripts/test-tts-runtime.mjs 22 项)。 它现在是回落的音源:MiniMax 不可用时由同一条 SSE 通道推给界面。 注意回落需要先把模型下下来($DSH_HOME/tts/herta-best-e72);没装时回落会 明确报「模型还没装」,不会假装出声。 静音与音量走通了(2026-09-26):它们是 DSH 设置里的 voiceMuted / voiceVolume, 经 bridge 下发给整机的 voice-prefs.ts(hydrateVoicePrefs),播放路径真的读它们。 换成独立版 / 官网 demo(没有那个 bridge 成员)时该模块退回自己的 localStorage。
  • 整机页面占 20.1 MB(16 MB JS + 3 MB 开场段)。开场段可以改成首次运行下载。
  • B 层的「知识」部分按计划推迟了。现在实现的是记忆(货架 + 做梦账本 + 四个工具);Herta 上游 @herta/knowledge 里还有一套 sqlite 知识库 (canon / 自省提取 / 剧情摄入,数百行)。当初的分期决定是「先把 .herta/narrative/ 的文本货架打通,sqlite 留到 B2」。 她的身份不需要它(那由 A 层的人格正本负责),所以不影响她现在的可用性; 但如果你想要「她记得设定集里的细节」这类能力,那就是这块。

⚠️ 授权

插件代码(src/、lib/、scripts/、Cordis 配置)为本次改造新写,采用 MIT。

语音资产(assets/voice/)与人设语料(preset/herta.patch.yml)不在 Herta 的 MIT 范围内,权利归米哈游及各自所有者。本仓库已按《崩坏:星穹铁道》同人衍生作品 创作指引 V2.0 第三条放置法律声明后收录:仅限非商业使用,且不得作为独立素材包再分发。

法律声明原文见 NOTICE.md;上游授权范围与收录依据见 THIRD-PARTY.md。


与 Herta 上游的关系

人格正本、格式门、新颖性判定、语音资产都来自 Herta(逐字或逐字移植,出处写在 各处注释里)。做梦的蒸馏环节被换掉了:原版是离线自主 pass(约 6700 行,用它 自己的 LLM 蒸馏候选),这里改由她在会话里写候选 —— 在 DSH 里模型就是她本人、 上下文就在眼前,再让宿主另起一次 LLM 调用去「蒸馏她自己」既贵又绕。 保留的是晋升门与做梦账本。


版本历史

v0.1.5

让她真的出声,并且每一处「没声」都说得清原因 —— Fish 引擎补齐成可用的一档, 两条静默路径修掉,叙述层不再对子代理说话,设置页按体检结果收口。

  • Fish 引擎(voiceEngine: fish):与 MiniMax 并存的一档。
    • 「Fish 密钥」走 DSH 凭据缝(FISH_API_KEY),C:/herta-ai/fish_key.txt 退化成兜底;
    • 「Fish 代理」是可留空的一行:接口按可达性排序 —— fishaudio.org(官方现用域名, 国内可直连)优先,api.fish.audio(旧域名,国内会被按 SNI 重置)兜底。 ⚠️ 音色 id 跟域名绑定:默认大黑塔用的是新域名下的 id,旧 id 在新域名上会返回 ERR_VOICE_NOT_FOUND;
    • 计费字符数收下并显示到语音状态行(「这一档花了多少」有答案了);
    • 代理不再写死本机端口,空串也不会冲掉 fish_config.json 里的值 (那正是「填了密钥也不出声」的根因)。
  • 两条静默路径修掉:围栏之外的字算说话(「正常发言 + 内心话」不再被整段吞掉); 空轮护栏 —— 一轮什么都没说时注入一条看得见的通知(生产上已实测到它唯一的一次触发)。
  • 叙述层对子代理让过路:分拍 / 复核 / 空轮护栏一律不拦子代理 —— 三处钩子都问在动作之前, 每次跳过都留痕。
  • 设置页体检两批:记忆能取下、失败说人话、做梦账本不丢、模型下载失败说出原因、 计费字符上状态行;theme 与 deviceScene 经核实在 DSH 侧没有消费方, 从活字段降为「暂未接线」并各写清原因 —— 此前这两行会渲染两遍(既在普通分组里, 又在「暂未接线」里),现在只活在后者。
  • 产物一致性上了测试:test-artifact-sync.mjs 守着「src/host 的每个文件与 lib/ 逐字节相同」, 也守着「发布目录里不许留补丁脚本的 .bak 备份」(那东西会跟着 npm 包一起发出去); 另加每个工具的「形状声明必须覆盖它的返回值」。
  • 兼容性收口:engines.dsh 放宽上界,preset 底本改用自带的官方 0.2.0。
  • 许可证:GPL 分发义务写清,THIRD-PARTY.md 给出对应源码的获得方式。
  • 测试:核心 23 组 906 项 + MiniMax 6 组 390 项 = 1296 项全过、0 失败。

v0.1.4

把 Herta 的 MiniMax 语音「拉过来」 —— 从"设置里存得下密钥"到"她真的用那个声音说话"。

  • 新增 src/host/minimax/(TS 移植件,单独一条编译缝 scripts/build-minimax.mjs, 用 Node 自带的 stripTypeScriptTypes,不引 esbuild/tsc):
    • api.ts —— HTTP 层,忠实移植上游 minimax-api.ts;不含上传/克隆 (用户决策:只认领已有克隆,不把 8.36 MB 参考音频带进插件)。
    • segment.ts —— segmentSpeechUnits 移植;与上游真身做了 16164 次差分比对,0 处不一致。
    • voice.ts —— 只认领:按 LEGACY_REFERENCE_TAG = "b1a43133" 找她自己那个克隆 (那个 tag 正是 herta-reference.wav 的 SHA-256 前 8 位),失败冷却 10 分钟。
    • synthesizer.ts —— refusal 三态 doom 整个 utterance、取消静默、超时算 network、 并发上限 2、voice_missing 交给宿主决定回落。
    • pipeline.ts —— 纯状态机:边写边念、turn-stopping 时掐掉还在流的那段、 每轮字符上限、子代理的文字不念。
    • state.ts —— 克隆记录落 $DSH_HOME/dsh-herta-minimax.json(机器状态,不进设置表单)。
  • src/host/minimax-voice.js —— 宿主接线:凭据缝读密钥、启动认领 + 凭据变更重认领、 SSE /herta-minimax-events + 状态端点 /herta-minimax-state、按 voiceEngine 分发、 显式回落本地模型并在状态里写明原因。
  • 新工具 herta_say(云端合成说一句,调试 + 验收通道);voiceEngine 与 realtimeVoice 从 wired: false 翻成 wired: true(那 11 项「暂未接线」因此变成 9 项)。 realtimeVoice 是自动念回复的总开关:关掉就不再自动送去合成(省钱), 而 herta_say 是明确要求说一句,不受它管。
  • 密钥走凭据缝:MINIMAX_API_KEY / MINIMAX_PLAN_API_KEY。没填就回落本地, 状态里说清是哪一种 unavailable。
  • 测试:npm run test:minimax = 388 项 (api 98 / segment 112 / voice+state 43 / synthesizer 37 / pipeline 43 / pcm 55)。 全量 npm test = 20 组 1059 项全过、0 失败(14 组逻辑用例 671 项 + 上面 6 组 388 项)。

仍未接:voiceEngine = local / mimo 这两条(本地模型目前只服务 herta_speak 与 上面那条回落;mimo-tts.js 仍未实例化)—— 这是决定,不是遗漏。

同时按 DSH 插件清单规范收口(package.json,这一版才补齐):

  • dsh.manifestVersion: 1 —— 清单格式标识,与包版本、会话格式版本都无关。
  • engines.dsh: ">=0.1.7-rc.1 <0.2.0-0" —— 作者声明的兼容 DSH 版本区间。 要带显式下界:写成 >=0.1.0-rc.1 这种宽区间时,0.1.7-rc.2 一类预发布版会被 semver 静默排除(预发布版只有在同 major.minor.patch 的比较子上才被认)。 插件市场的「按宿主发现」读的就是这个字段(也接受同版本线的 @deepseek-ai/dsh-* peer 声明)。
  • locale/en.json + locale/zh.json(形状 { "meta": { title, description } })—— 插件管理页与市场卡片不激活插件也能读到本地化的标题与简介; 配套 exports 补 ./locale/*.json、files 收 locale。
  • files 补 NOTICE.md / THIRD-PARTY.md。npm 包会带上 assets/voice/(80 条语音) 与 preset/herta.patch.yml 这些第三方同人素材,法律声明必须随包一起走; TTS 运行时的许可原文本来就在 assets/tts-runtime/LICENSES/(含 espeak-ng 的 GPL-3.0 全文),随 assets 一起发,这一项此前已满足。
  • description 改中英双语、补 keywords、补 publishConfig.access: "public"。

刻意没加 peerDependencies。 规范允许「engines.dsh」与「同版本线 @deepseek-ai/dsh-* peer 声明」二选一,市场两者都读。这里只声明 engines.dsh: 本包当前的安装形态是 file: 本地路径,加 peer 会让 pnpm 在安装时去解析一批宿主包, 收益(市场兼容性展示)与 engines.dsh 重合,风险却不重合。等有真实 npm 安装反馈再说。

首次发到 npm:dsh-herta@0.1.4 —— 此前只在 GitHub 上,npm 上这个包名还是空的。

v0.1.3

首个把「0.1.7 兼容修复」真正发出去的版本。 v0.1.2 那批改动(preset 载体迁移、 语音偏好自持)此前只存在于仓库里、没有单独发版;这一版连同下面几项一起发布, tag 为 v0.1.3:

  • 会话 v4 的消息来源 kind(第三处破坏性变更,2026-09-26 实测补上):注入消息 原先写 0.1.5 时代的 { kind: "plugin", plugin: "dsh-herta" },v4 在写入会话时 就拒(format v4 message requires a producer-owned source kind),症状是每一轮 都报「本轮运行失败」,而被拒的事件根本没进日志 —— 翻会话文件是干净的,极难定位。 三处 agent.steer / PLUGIN_SOURCE 全部改成 { kind: "plugin:dsh-herta" } (与 DSH 自己的 v3→v4 迁移为旧行推导出的形状一致),并新增 scripts/test-source-kind.mjs 把这条不变量钉住(已确认注入旧写法会变红)。 同一形状的坑 dsh-mimo-coder 也有,一并改了它的 PLUGIN_SOURCE。
  • 本地 TTS 运行时随包分发(assets/tts-runtime/,22 MB):sherpa-onnx 1.13.6 + onnxruntime 1.27.1 + espeak-ng / piper-phonemize 的 fork,许可原文在 LICENSES/。 宿主真探测它(子进程把 addon 加载起来拿版本号)之后才报 runtime: true —— DSH 设置页里「本地语音模型」那行会如实报「运行时未就绪」而不是把按钮点亮,两个标志 都不是写死的。合成跑在子进程里(src/host/tts-worker.cjs):sherpa 的 espeak 构建在 Windows 上处理不了非 ASCII 绝对路径,而本机路径里就有中文。 ⚠️ espeak-ng 是 GPL-3.0-or-later 且静态链接,分发前需自行拍板 —— 见 THIRD-PARTY.md。
  • 本地语音模型的下载(/herta-voice-model):归档参数钉死在 src/host/tts-release.js(herta-best-e72 / 76,255,506 B / SHA-256),四段流程 —— 边下边算哈希(先比字节数、再比哈希)→ 解到最终目录旁边的 .installing/(带解压 炸弹上限、拒绝路径穿越)→ 拿 bundle 自带的 manifest.json 逐文件比 size + SHA-256 → 前三段全过才 rename 就位。任何一段失败都不留半个可用的 bundle,已装好的旧 bundle 在任何失败下都还活着。
  • 插件图标 icon.png(384×384 / 173 KB):满足清单对图标的两条硬约束 (≤256 KiB、必须位于 manifest 所在目录之内),生成过程留在 scripts/make-icon.py。
  • 构建与测试脚本去掉全部写死的本机绝对路径:preset 底本改从 dsh-web-app/presets/standard.patch.yml 取,探测不到会明确报错并告诉你设哪个变量。

兼容性细节(三处破坏性 API 变更、修之前各自的症状)见上面「兼容性」一节与 下面 v0.1.2 一节 —— v0.1.2 的正文是前两处真正发出去的内容。

验证:npm test 13 组纯逻辑用例 622 项全过(test-narrative 31 / dream 28 / mapping 41 / narrative-hints 54 / supervisor 81 / session-surface 32 / beat-policy 61 / dream-distill 55 / mimo-tts 40 / voice-settings 50 / voice-model 50 / herta-settings 92 / source-kind 7),另有 28 项 LLM 路径集成测试 (npm run test:integration)。

2026-09-26 收口:herta-settings 那一组被重写过。它原来测的是「把值写回 整机自己读的两份 settings.json」(读-改-写、原子落盘、旧文件迁移、只写用户覆盖过 的字段)—— 那条链路连同 src/host/settings-sync.js 已整体删除,所以那些断言消失, 换成了字段表/Wired 注解/Config 同源 + 五组防回归(写回不得复活、语音偏好必须 真接 bridge、整机设置页组件必须不存在、Select 必须存在、凭据缝必须用 ctx.get 而不是 inject 取)。数字从 78 变 92 不是「测多了」,是测的东西换了。

仍未接:回复 → 合成 → 整机 iframe 播放那一跳 —— 「实时语音」开关会亮, 但她暂时不会自己开口。

v0.1.2

兼容 DSH 0.1.7-rc.2。 这一版全部是兼容性修复 —— 0.1.5 → 0.1.7 之间有两处 破坏性 API 变更,旧版在 0.1.7 上「界面能开、功能静默失效」:

  • 修 preset:DSH 移除了 $DSH_HOME/.agent-presets/ 目录机制,preset 改成一条 @deepseek-ai/dsh-agent-preset loader 行。build-preset.mjs 重写为生成 preset/herta.patch.yml,并通过 dsh.bundle.patch 数组随包一起装。 修之前的表现是:--dump-config 一切正常,但「黑塔」这个 preset 根本不存在 —— A 层人设、B 层提示词段与五个工具、叙述调度层全部静默缺席。

  • 修语音偏好:DSH 移除了 SettingsProvider.register 与客户端 settingsScope (换成基于插件 Config 的 SettingsForms/configForms,无第三方命名空间入口)。 改为自持:$DSH_HOME/dsh-herta-voice.json + 白名单端点 GET/PUT /herta-settings。 修之前的表现是:宿主打一行 settings.register is not a function,客户端那个 ctx.inject(["settingsScope"]) 永不回调 —— 面板能开能点,值永远是默认值。

    ⚠️ 这一条后来又被推翻了(2026-09-26):SettingsForms 已经够用(插件自己的 volatile Config + 客户端 settings.section),所以那条自持路线与 /herta-settings 端点一并删除。读上面的历史时别照它去做 —— 现在语音偏好就是 DSH 设置里的字段。

  • 修构建/测试脚本:去掉全部写死的本机绝对路径;preset 底本从 dsh-agent-presets/presets/standard/*(已不存在)改为 dsh-web-app/presets/standard.patch.yml;test-dream.mjs 不再依赖某个 已部署的 lab profile(改用 lib/ + resolve hook),test-narrative.mjs 的临时目录改到系统 temp。

  • 插件图标:加 icon.png(取自 Herta 上游桌面应用的 resources/herta-icon.png, 由 1024×1024 / 1.16 MB 缩放重编码为 384×384 / 173 KB),并在 package.json 里声明 "icon": "./icon.png" —— DSH 插件管理页按这个字段显示图标。 两条硬约束(见 @deepseek-ai/dsh-package-manifest 与 dsh-app-boot 的 iconOf): ≤256 KiB、必须位于 manifest 所在目录之内;原图两条都不满足。 生成过程留在 scripts/make-icon.py,便于换图时复现。

  • 本地语音模型的下载:从上游扒来并钉死在 src/host/tts-release.js 的 归档参数(voice-herta-best-e72 / 76,255,506 B / SHA-256),加上完整四段流程 (下 → 校验 → 解包 → 再校验 → 原子换入)与 /herta-voice-model 端点。 同时修了整机 bridge 里三个坏成员:downloadVoiceModel 的兜底值曾是 phase:"ready"(假绿 —— 盘上什么都没有却显示已就绪)、onVoiceModel 从不订阅、 cancelVoiceModelDownload 是空函数。模型能下、能校验、能装;还发不出声(缺运行时)。

  • 修「语音模型显示约 0 MB」(用户报,看截图发现的):iframe 那边的 onVoiceModel 只登记本地监听、从不通知父窗口,于是父窗口那个 onVoiceModel 应答器永远不被触发 —— 它既不去读宿主的模型状态、也不推 voiceModel 事件。现在订阅会补一次 call(与真实 preload 的语义一致), 父窗口另外主动推一次。实测:iframe 收到的 unpackedBytes 从 0 变成 115,897,197,即面板显示「约 116 MB」。

    ⚠️ 下载按钮仍然点不了:上游把它 gate 在 disabled={!runtime} 上 (VoiceSettings.tsx:489 / :506),而 runtime 指 sherpa-onnx 原生运行时 在不在。这是上游有意为之:没有运行时,116 MB 的模型下下来也用不了。 要让它亮起来,得先把那 22 MB 原生件接进来。

  • 随包分发本地 TTS 运行时(应要求,22 MB):assets/tts-runtime/ (sherpa-onnx 1.13.6 + onnxruntime 1.27.1 + espeak-ng/piper-phonemize 的 fork, 许可原文见 LICENSES/,espeak-ng 是 GPL-3.0-or-later 且静态链接,分发前需自行拍板 —— 见 THIRD-PARTY.md)。宿主侧新增 src/host/tts-runtime.js(真探测 + 合成入口) 与 src/host/tts-worker.cjs(子进程执行体:非 ASCII 路径 / 同步阻塞 / 原生崩溃 三重隔离)。runtime 从恒为 false 变成探测结果,于是「下载模型」按钮 与「实时语音」开关都会点亮 —— 而这两个标志这次都不是写死的。 实测:真实模型合成「你好。我是黑塔…」→ 24 kHz / 5.08 s / 非静音, test-tts-runtime.mjs 22 项全过。仍未接:回复 → 合成 → iframe 播放那一跳。

  • 验证:531 项单测 + 28 项集成全过(test-mapping 的实际项数从 31 更正为 41; 新增 test-voice-model.mjs 50 项,用现造的小归档覆盖哈希/体积/穿越/炸弹/取消/ 「失败不留半个 bundle」/「旧 bundle 在失败下活着」); 在真实 0.1.7-rc.2 运行时冷启动实测 plane=host / plane=preset / 叙述层依赖就绪 / 四条路由 200;无头 Edge 实测 client 半侧两个视图注册成功、 voiceSettingsLoaded: true、控制台无错误; 模型下载走真链接实测(HTTP 进度按字节推进,content-length 与 pin 逐字节一致)。

v0.1.1

叙述调度层(方案 B)落地,并修掉一个用户报的真 bug:

  • 叙述调度层:把她的演员调度接到 DSH 的 agent loop 上(不移植上游 1.6 MB 后端)—— (我 想)/(我 说) 叙述语法、分拍、thought tag、 自我收回、supervisor 复核、做梦蒸馏走 LLM。设计见 docs/叙述调度层设计.md
  • 修 bug(用户报):她的思考围栏被当成发言显示出来了 —— mapping.js 原来 把 assistant 的整段文本无条件当 speech,没解析围栏
  • 可观测性:加启动信标 $DSH_HOME/dsh-herta-narrative.json,让「叙述层在不在 跑」变成可读证据(放行是不留痕迹的,光看会话记录分不出来)
  • 测试:314 项单元 + 28 项集成(mock ctx.llm);另有假模型服务 scripts/mock-llm-server.mjs 可在无 API Key 的环境里验真实链路
  • 验证边界:五种行为都在 lab 用假模型服务实测过;真实 DeepSeek 的判断力 与语气质量没验(需要真实凭据)。详见 README 的「验过 / 没验」表

v0.1

首个公开版本:四层(人格 / 记忆 / 语音 / 界面)+ 五个工具 + 两条静态路由。

Content from the project README on GitHub ↗

Comments

Comments live in GitHub Discussions. Sign in with GitHub to post or react.