dsh-olivia-bridge
Verifieddsh-olivia-bridge · v0.2.1 · MIT
BSide: Olivia Lin bridge — serves the game client's /toy/letter/* contract locally and answers each letter through a DSH agent session.
Install
dsh plugin add dsh-olivia-bridge Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-olivia-bridge
把《BSide: Olivia Lin》(米哈游「林离」)的客户端接到 DSH 上——游戏里写的信,由 DSH 的一个 agent 会话来回。
它做什么
官方服务器 2026-08-31 关停后,客户端只剩本地演奏和壁纸。这个插件在回环地址上起一个 HTTP 服务,冒充客户端原本要连的后端,然后把每封来信投进一个持久的 DSH agent 会话;她的回信写回本地信件库,客户端照常显示。
Olivia.exe 0.0.9.627
│ POST /toy/letter/send · GET /toy/letter/list|detail|unread_count
▼
dsh-olivia-bridge 127.0.0.1:8791
│ 信件存储 <DSH_HOME>\olivia-bridge\letters.json
▼
DSH agent 会话(preset: 林离)
│ ctx.agents.create → followup → whenIdle
▼
会话历史即她的记忆:每封信都进同一个 session,不需要额外的记忆库
会话 id 固定为 olivia-letterbox,插件启动时 agents.resume() 接回同一个会话,所以 DSH 重启后她仍然记得之前每一封信——这是这个方案区别于其他本地实现的地方。
安装
需要 DSH 桌面端 0.2.x(Windows)与 0.0.9.627 版游戏客户端。
安装是多步骤的:挂载插件只是其中一步,此外还要给游戏前端打补丁、往 profile 追加人格预设。所以下面的脚本必须跑——dsh plugin --profile <profile> add dsh-olivia-bridge 只是把同一份代码从 npm 拿下来(脚本就在包里 node_modules\dsh-olivia-bridge\tools\),单独一条命令装不完整。
# 1. 把插件挂进 desktop profile(改动前自动备份成 *.bak-olivia-<时间戳>)
.\tools\install-desktop.ps1
# 2. 给游戏打前端补丁(原版先备份成 feapp.dat.orig-backup)
.\tools\patch-feapp.ps1 -GameDir 'X:\SteamLibrary\steamapps\common\BSide Olivia Lin Test'
# 3. 重启 DSH
install-desktop.ps1 做三件事:profile 的 package.json 加 file: 依赖与 bundle 条目、
建 node_modules\dsh-olivia-bridge junction 指向本仓库、cordis.patch.yml 末尾追加
preset-olivia(林离人格,complete: true,不挂工具)。
游戏目录也可以设一次环境变量,之后游戏侧的两个脚本都不用再传参:
[Environment]::SetEnvironmentVariable('BSIDE_GAME_DIR', 'X:\SteamLibrary\steamapps\common\BSide Olivia Lin Test', 'User')
补丁锚点是按 0.0.9.627 的前端包写的,每一步都校验锚点唯一性,换版本会直接报错中止,不会写坏文件。
游戏侧补丁
tools/patch-feapp.ps1 改前端包里的十五处(每次都先从 feapp.dat.orig-backup 重打,幂等):
| # | 补丁点 | 目的 |
|---|---|---|
| 1 | jl() 里的 Te.defaults.baseURL |
axios 基址钉死到本地 |
| 2 | N=j(()=>d.value.offlineMode===!0) → !1 |
关掉离线门禁(为 true 时请求拦截器首行直接 throw) |
| 3 | setClientConfig 覆盖 |
offlineMode=false + toyApiUrl 指向本地 + appInfo.Channel="demo",绕开已停服的米哈游账号登录 |
| 4 | 实例创建时的 baseURL:"" |
离线版 conf.app.dat 没有 appConfig 段,1/3 两条路都会短路 |
| 5 | index.html 注入 tools/inject.js |
探针 + XHR/fetch 层请求改写(必须插在 </head> 之前) |
| 6 | m() 函数 |
主动调 toggleLetterEntry 通知原生层显示入口(原生层不认,保留作双保险) |
| 7 | N3=!1,Ss=!1,wa=({onComplete → N3=!0,Ss=!0 |
关键:解除离线门控 —— N3 管「写信」按钮,Ss 管 MIDI 上传入口;只开前者会出现「信能写、曲子传不了」 |
| 8 | App 模板里的空 tour 锚点 | 保持原样:导航已挪到各页标题栏(补丁 14),这里不再动 |
| 9 | disabled:a.remainingCount<=0 + 额度文案 |
写信按钮不受额度影响,文案换成「今天还可寄 ∞ 封」 |
| 10 | handleToyLoginSuccess 末尾挂 Yn().load() |
触发离线曲库加载并上报结果(原生桥的返回不经 HTTP,探针看不到) |
| 11 | N=b(0),$=b(3) → b(9999) + 文案 |
解除前端硬编码的「每日定制 3 首」上限,文案显示 ∞ |
| 12 | syncLocalStatus / startDownload 那段 |
绕开原生下载状态机(原生不回应),自己上传的曲子直接标成已下载(只改「我的上传」处,官方曲库那处保留) |
| 13 | 信箱头部组件里的「分享信件」按钮 | 换成「删除」,调 window.__oliviaDeleteMail(mail.id) → 桥的 /toy/letter/delete。前端 store 只 splice 自己的缓存,不走服务端的话刷新就回来 |
| 14 | 曲库 / 信箱页的标题栏 h1 |
换成「曲库」「信件」两个固定顺序的平行入口(曲库恒在左),当前页全亮、另一页 opacity:.5,走 window.__oliviaNav() |
| 15 | 右上角用户菜单里的「测试版」标签 | 从容器子节点里摘掉 |
.\tools\patch-feapp.ps1 # 打补丁(幂等,反复运行结果一致)
.\tools\patch-feapp.ps1 -Port 8800 # 换端口
.\tools\patch-feapp.ps1 -Restore # 一键还原原版
.\tools\verify-patches.ps1 # 只读校验全部补丁是否落地(全 PASS 才算到位)
补丁是盲替换,改坏语法会让游戏黑屏且窗口无响应。所以脚本在写回之前会把改好的 bundle 交给
node --check 解析一次,失败就中止 —— 此刻 feapp.dat 还停在从备份复制过来的原版状态,游戏照常能开。
定制演奏(MIDI 上传 → 生成 → 播放)
游戏原本要把 MIDI 上传到官方服务器换演奏,桥在本地实现了整条链路:
genObjectUploadUrl → PUT /toy/midi/upload/<key> → midi/generate → 轮询 getGenerateResult
↓
WAV(试听) + MP4(演奏,需要 ffmpeg)
- MIDI 解析与 WAV 合成是纯 JS、零依赖(移植自 Comma0103/Linli-Nocturne 的
midi-manifest.js/audio-renderer.js,MIT)。 - 演奏侧的原生 WebPlayer 是
<video>,播不了 WAV,所以有 ffmpeg 时会再封一个音频-only MP4。 没装 ffmpeg 也能用:任务正常完成、试听照常,只是演奏不出声,日志里会写明。 ffmpeg 按配置 → OLIVIA_FFMPEG → <DSH_HOME>\olivia-bridge\ffmpeg\ → PATH的顺序发现,不硬编码任何路径。 - 上传是
PUT,属于非简单请求,浏览器会先发OPTIONS预检。Access-Control-Allow-Methods里少一个PUT,请求就在离开浏览器之前被拒掉了 —— 而命令行curl/Invoke-WebRequest不走 CORS, 会把这类问题完全掩盖。测试里专门留了一条预检断言盯住它。 - 存储:
<DSH_HOME>\olivia-bridge\midi\(jobs.json+<key>.mid+<jobId>.wav|.mp4)。 - 歌单(「加播单」/「音乐桌面」)走本地
<DSH_HOME>\olivia-bridge\playlist.json。 - 删除也是真删:「我的上传」里删歌走
POST /toy/deleteUserSong(userSongId),会连 WAV/MP4 一起清掉; 信件走POST /toy/letter/delete(letter_id),连她的回信一起删、幂等(重复删返回deleted:false)。 这两个端点以前落到lib/index.js末尾的兜底分支,返回code:0+ 空信封 —— 界面看着删掉了、刷新又回来,是典型的静默失败。另留了一个POST /toy/letter/clear清空全部往来(前端暂未接入)。
配置
cordis.patch.yml 里 olivia-bridge 那一行的 config:
| 字段 | 默认 | 说明 |
|---|---|---|
port |
8791 |
监听端口,改了要同步重打游戏补丁 |
presetId |
olivia |
回信用的 agent preset(行 id 为 preset-olivia) |
provider / model |
空 | 留空则跟随 DSH 默认模型 |
maxDailyLetters |
1000000 |
每日寄信上限,客户端从 list 的 remainingToday 读 |
llmFallback |
true |
agent 路径跑不通时退回直接用 LLM 服务生成回信 |
ffmpegPath |
空 | 留空自动发现(见上):OLIVIA_FFMPEG → <DSH_HOME>\olivia-bridge\ffmpeg\ffmpeg.exe → PATH |
观测与排错
# 桥的日志(每次请求、每封信的生成耗时都在这里)
$dsh = if ($env:DSH_HOME) { $env:DSH_HOME } else { "$env:USERPROFILE\.dsh" }
Get-Content "$dsh\olivia-bridge\bridge.log" -Tail 40
# 信件库
Get-Content "$dsh\olivia-bridge\letters.json" -Raw
# 手动确认服务活着
Invoke-RestMethod http://127.0.0.1:8791/toy/letter/unread_count
# 静态诊断:preset 是否解析、会话建到哪一步、inbox / phase / 消息数
Invoke-RestMethod http://127.0.0.1:8791/olivia/diag
# 让 agent 真跑一轮,回报前后快照(定位「会话建了但没人跑」)
Invoke-RestMethod -Method Post http://127.0.0.1:8791/olivia/agent-test
# 直接打一次 LLM,拿底层真实错误。默认按 agent 路线复现:tools / reasoningEffort /
# maxTokens 取自会话最近一次请求头 —— 这三样正是 agent 路径与裸调用唯一的差别
# (实测同一 provider/model 下裸调成功、agent 路径必失败)。返回里的 errorChain
# 是沿 cause 展开的(≤6 层),TRANSPORT 那句的真因永远在第 2 层往后。
Invoke-RestMethod -Method Post http://127.0.0.1:8791/olivia/llm-test -ContentType 'application/json' -Body '{"text":"ping"}'
# 对照 / 单变量:{"plain":true} 退回只发 provider/model;也可单独覆盖
# {"plain":true,"reasoningEffort":"high"} 或直接给 {"tools":[],"maxTokens":4096}
# 重投某封失败的信
Invoke-RestMethod -Method Post http://127.0.0.1:8791/olivia/retry -Body '{"letterId":"3"}' -ContentType 'application/json'
# 端到端:投一封信等回信
node tools\verify.mjs
# 四组自测:契约层 / 响应形状 / MIDI 全链路 / profile YAML(各自用临时 DSH_HOME,不碰真实信件)
node test\contract.test.mjs
node test\response-shape.test.mjs
node test\midi-flow.test.mjs
node test\validate-profile-yaml.mjs
# 解压看她那个会话的事件(zstd JSONL)
node tools\dump-session.mjs
客户端每 60 秒轮询一次 letter/list + unread_count,所以回信生成完最多一分钟内在游戏里出现。
agent 路径的四个硬要求
这几条都是实测踩出来的,写在这里免得改代码时再踩(dsh-agent 的 README 示例有部分是过时的 v3 写法):
- 消息必须带
id。UserMessage.id是 readonly 必需字段,官方createMessage用brandString(randomUUID())生成;缺了它 inbox 会静默丢弃。 source.kind不能是笼统的"plugin"。v4 会话格式硬拒(dsh-session-format-v3-to-v4的source()校验),合法形状是plugin:<插件名>。agentOptions必须给 provider/model。空着的话会话建得出来、presetActive也是 true,但 turn 立刻空转、会话日志零事件。没配置时用ctx.agentDefaultModel.currentSelection()兜底。followup()之后不能立刻whenIdle()。它只负责排队 + 异步唤醒驱动器,紧接着调用会在 agent 还没醒时返回(实测 146~174ms)。要先轮询等 assistant 消息落地。
另外两条:preset 按 config.id 索引(行 id 惯例是 preset-<config.id>);sessionId 固定为 olivia-letterbox 并在启动时 agents.resume(),否则每次重启 DSH 她都会失忆。
已知边界
- 客户端与后端的契约来自对 0.0.9.627 前端包(
assets/main-*.js)的逆向,以及社区实现的交叉验证。没实现的端点走lib/index.js末尾的兜底分支,返回字段完备的空信封 —— 不是{}:前端有 33 个调用点会直接对data里的数组调方法,缺字段就抛 TypeError、Promise 被拒、骨架屏永远转圈。 - 只实现文字回信。视频/语音回信需要客户端从
detail.replyVideoUrl拿绝对 URL,并以带音轨 MP4 下发,当前没有做。 - 官方离线曲库(「自带曲目」)出不来:它不走 HTTP,而是走原生桥
getOfflineSongList,该 action 在停服后的客户端上压根不回应;songlist.dat又是密文,曲目媒体文件本地也没有。想听曲子只能自己上传 MIDI。 - MIDI 分享码是官方服务端能力,本地不伪造成功(返回 409)。
- 插件只监听
127.0.0.1,不对外网开放;服务端不校验 token,任何本机进程都能读写这些信件。 - 只动前端包,不碰任何原生 DLL。
卸载
.\tools\install-desktop.ps1 -Uninstall # 摘掉 junction
.\tools\patch-feapp.ps1 -Restore # 还原游戏文件
# 再按 <DSH_HOME>\profiles\desktop\*.bak-olivia-<时间戳> 恢复 profile 的
# package.json / cordis.patch.yml,重启 DSH
参考
同一件事的社区实现,可交叉验证补丁点:
- Comma0103/Linli-Nocturne —— 补丁表最完整,前端与原生 DLL 都覆盖
- AETAVK/linli-local-mail —— 本地信件服务
- 2962152120/oliviaproxy —— 代理式实现
许可
MIT