版本更新日志

unobox 所有版本的发布说明均在此归档,每一次迭代都为你带来更安全、更优异的体验。

未发布 · 🎙️ 新增 Microsoft Edge 朗读引擎

2026年9月13日

新增

  • Microsoft Edge 朗读引擎(edge-tts) — 复用 Edge 浏览器「大声朗读」所用的微软在线语音服务,无需 API Key、无需安装 Edge、免费无配额,中文音质接近 Azure Neural TTS。定位为可选引擎,不改动默认引擎(仍为本地 sherpa-onnx)
  • 自研 WebSocket 客户端,零新增依赖 —— 复用项目已有的 ws。未采用 msedge-tts 等现成包,因其会引入 39 个传递依赖(含 axios / buffer / stream-browserify 等浏览器 shim,在 Electron 主进程中是纯冗余),且与 RemoteProviderBase 的「无需额外 npm 依赖」约定冲突
  • 未继承 RemoteProviderBase —— 该基类面向 HTTPS REST(子类只实现 buildRequest()),而本引擎走 WebSocket;且它在无凭据时 synthesize() 会直接抛错,而本引擎免鉴权(requiresAuth: false),继承反而要覆盖掉基类逻辑
  • 内置 21 个精选音色:普通话 6 + 方言 2 + 粤语 3 + 台湾国语 3 + 英文 5 + 日韩 2
  • 协议要点:Sec-MS-GEC 反滥用令牌(Windows 纪元秒 → 5 分钟取整 → 100ns ticks → SHA-256 大写十六进制)+ Edge UA / Origin 握手头
  • 边界时间戳(逐字/逐句)能力 —— ITTSProvider 新增 TTSBoundary 类型与 TTSSynthesizeResult.boundaries 字段,为朗读时的逐字高亮提供对齐数据
  • Edge 朗读返回 WordBoundary / SentenceBoundary,偏移单位为 100ns,中文分词正确(实测 1640 字长文本得到 865 个边界,词级覆盖 93.3%)
  • TTSCache 一并缓存边界 —— 边界是 (文本, 音色, 语速) 的纯函数,与音频同缓存;否则命中缓存时 boundaries 会静默丢失,重播即失去高亮数据
  • 其余引擎不支持该能力,不返回该字段,上层需按可选处理

修复

  • 长文本单次请求会被服务端断连 — 实测单次请求约 2000 字可用、4000 字失败(服务端不发 turn.end 直接断连),且 2000 字单次要 26 秒。改为按 1200 字分段、复用同一条 WebSocket 连接顺序合成(一次只有一个未完成请求,消息归属无歧义),最后拼接音频并换算跨段偏移
  • 边界数组并非按时间有序 — 服务端先发完一句内各词级边界、再补发指向句首的句级边界,故原始顺序不是时间序。初期实现仅在注释中声称「按时间升序」而实际不成立。现统一按偏移排序,且同偏移时显式让词级排在句级之前——若依赖 Array#sort 的稳定性,结果会取决于服务端下发顺序,而句级边界若在词级之后处理,会用粗粒度高亮覆盖掉更细的词级高亮
  • 主进程崩溃风险:ws 无 error 监听器时抛未捕获异常 — 分段完成后 cleanup() 会摘掉错误监听器,此后 close() / terminate() 期间若触发 error,ws 会将其抛成未捕获异常直接终止主进程。改为在连接建立后始终保留一个兜底错误监听器
  • TTS 设置页看不到免鉴权的远程引擎 — TTSSettings.tsx 的可见性过滤为「本地全部 + 已配置凭据的远程」,而 Edge 朗读 requiresAuth: false、永远进不了 configuredRemoteIds,于是两个列表都进不去、被静默过滤。改为免鉴权的远程引擎直接可见
  • 听书抽屉的引擎选择弹窗列表为空 — 两个成因叠加:
    ① AudiobookVoiceSelector 的引擎列表取自 useTTSStore.engines,而该数组只在打开 TTS 设置面板时才会被 fetchEngines() 填充,直接进入听书抽屉时它仍是 [];现改为挂载时若为空则按需补拉一次。
    ② 原有 if (engine.type !== "local") continue 的「有声书默认仅本地引擎」限制,叠加本地模型未安装时 if (!voice.downloaded) continue 会把本地音色全部滤掉,两者一起导致列表全空。现按需求取消该限制,本地与远程引擎均可选
  • 缓存命中时 format 被硬编码为 "wav" — handlers/tts.ts 的缓存分支无视缓存文件的实际扩展名,返回固定 wav。改为按缓存文件后缀推断(远端引擎缓存的是 mp3)

v0.4.0 · 📖 阅读器排版设置 + 有声书高亮重构 + 电子书标注 + 查词/翻译 + 朗读支持扩展到 TXT/MD/PDF

2026年9月13日

新增

  • 有声书起始位置弹窗 — AudiobookPositionDialog 组件,点击朗读按钮且存在播放记录时,三选一:继续上次进度 / 从当前阅读位置开始 / 从头开始。替代此前的原生 confirm()(其风格与整体 UI 不一致)。不弹窗仅在无历史记录时出现,此时直接从当前阅读位置开播
  • 阅读器排版设置(Aa 弹层) — 新增 ReaderSettingsPanel 组件与 readerTypography.ts 模块,8 项设置:
  • 主题(跟随应用 / 明亮 / 羊皮纸 / 夜间)—— 阅读器配色与应用主题解耦
  • 字体族(默认 / 无衬线 / 衬线 / 等宽)、字重(300–700)
  • 字间距(0–0.2em)、左右边距(0–40px)、对齐(默认 / 左对齐 / 两端对齐)
  • 亮度(30%–100%,遮罩层 z-index: 4,不拦截点击)
  • 原独立的「行距」下拉并入 Aa 面板,避免同一设置两个入口
  • 收听历史面板入口 — AudiobookHistoryPanel 此前数据层(表 / IPC / preload / Store)已就绪但无任何挂载点,现通过播放器抽屉的「历史」按钮进入
  • 电子书标注(高亮) — 内文选中文字后浮现工具条,可将选区存为高亮;位置以 EPUB CFI 存储,与渲染引擎无关
  • 主进程新增 ebook_highlights 表与 4 个 IPC(新增 / 查询 / 删除 / 更新),新 handler 文件 handlers/ebook.ts
  • bookReader 新增 getCurrentSectionIndex() / makeCfi(range) / rangeFromCfi(cfi),基于 foliate-js 的 getCFI / resolveCFI
  • 高亮使用独立的 CSS Custom Highlight 注册表,与朗读高亮(蓝/灰)互不干扰 —— 停止朗读不会误删标注。标注支持 4 色,每色一个注册键(unobox-ebook-highlight-{color}),改色即按色重新分组注册;每次注册前先清空所有色键,否则改色后旧色的键仍持有原 Range,两层底纹会叠加
  • 章节切换后按 CFI 重新解析 Range 渲染;同一章节内翻页不重算(带 (doc, sectionIdx) 缓存)
  • 选区浮层 SelectionToolbar:高亮 / 复制 / 发送。用 onMouseDown 阻止默认行为,避免点击时清掉正文选区
  • 标注管理面板 — 新增 previews/EbookAnnotationPanel.tsx,阅读器工具栏「标注」按钮进入(不依赖有声书状态,与书签按钮的 audiobookActive 门控不同)
  • 列出本书全部标注:色条 + 摘录原文(4 行截断),支持跳转原文 / 改色(4 色)/ 编辑笔记(失焦即存)/ 发送到会话 / 删除
  • 改色与删除后经 loadHighlights() 重载并强制重绘当前章节高亮
  • 笔记编辑用 lastCommittedRef 兜住「提交后父组件刷新 highlights 的延迟窗口」—— 否则在此期间重开编辑框会从旧的 h.note 回填,再失焦就把旧值写回去、覆盖刚编辑的内容
  • 书摘发送到会话 — 从标注面板或选区浮层发起,文本格式 「摘录」\n\n—— 《书名》(书名取 EPUB 元数据,取不到则只发摘录)
  • 跨窗口中继:阅读器跑在独立 BrowserWindow 里(主进程 window:open-preview 新建),那个窗口没有聊天 store、也没有 ProviderManager(都在主窗口渲染进程),既拿不到房间清单也无法发送。因此新增 digest:request(预览窗口 → 主进程)与 digest:request-forwarded(主进程 → 主窗口)两个通道,由主窗口弹出会话选择并真正发送,复用现成的 ForwardModal + useChatStore.sendMessage(含乐观消息与指数退避重试)
  • 主窗口会被置顶(restore / show / focus)—— 会话选择弹窗开在主窗口里,不置顶用户看不到
  • 阅读器查词 / 翻译(AI) — 内文选中文字后,选区浮层提供「翻译」「查词」两个入口,结果流式显示在右侧面板(与目录、搜索面板同一套容器样式)
  • 新增 hooks/useReaderAI.ts(配置选取 / prompt 组装 / 流式状态机 / 卸载取消)与 previews/ReaderAIPanel.tsx(纯展示,状态由 hook 持有)
  • 配置获取不需要跨窗口中继 —— AI 配置经 zustand persist 存在主进程 SQLite 的 app_kv_store(键 unobox-ai),而阅读器所在的预览窗口用的是同一份 preload(含 api.db),因此导入 useAIStore 即水合出同一份配置。这与书摘发送(走 digest:request 跨窗口)不同,是本次唯一「不需要新通道」的跨窗口能力
  • AI 调用复用聊天页的流式链路:streamAIAPI → window.api.net.streamRequest → 主进程 net.fetch
  • prompt 要求「只输出结果本身」,避免模型寒暄污染结果区;翻译目标语言自动判断(译成中文,原文已是中文则译成英文)
  • 结果可复制、可发送到会话(复用上面那条跨窗口发送链路,但原文样发出、不套书摘的《书名》出处)
  • 面板底部显示 配置名 · 模型,保持用哪个模型透明(暂不支持切换模型或目标语言)
  • 朗读支持扩展到 TXT / Markdown / PDF — 此前朗读链路只在 FoliatePreview.tsx 里接好,三种目标格式的正文虽同在 DOM 中却无任何入口。现将编排抽出共享层后一并接入,四种格式都能点右下角浮动按钮起播
  • 新增 hooks/domBookReader.ts(纯 DOM 的 IBookReader)与 hooks/pdfBookReader.ts(PDF 版)
  • 新增 hooks/domTtsAdapter.ts:正文提取根由调用方给定,供三种主文档渲染的格式复用
  • TXT 用 preRef(
     同时是滚动容器与正文根)、Markdown 用 bodyRef、PDF 用 react-pdf 的页容器 div.react-pdf__Page
  • PDF 把「一页」当作一个 section(href/cfi 均为 page-N):换页即被上层判为「跨章节」,自动替换句子列表并续读下一页,直接复用现成的翻章逻辑
  • PDF 的 next() 必须等目标页文本层渲染完成才 resolve。playLoop 读完后只等 600ms 就判定「无下一章」并写历史、停止播放,而文本层重建加 pdf.js 异步渲染远超该窗口 —— 若 next() 是同步翻页,会变成每读一页结束一次会话
  • 单 section 的格式(TXT / Markdown)无章节概念,抽屉里的章节箭头不渲染(AudiobookDrawer 的 onPrevChapter/onNextChapter 改为可选)
  • 新增 utils/readerLocation.ts 统一 DOM 版预览的阅读位置持久化(dom-reader- 键,与 foliate 的 foliate- 键互不影响)

修复

  • 收听时长把暂停时间也算进去 — 旧实现用「会话开始 → 章节结束」的墙钟差值计算 durationSeconds。改为仅在 isPlaying 为真时累加。实测:听 2 分钟、暂停 1 小时、再听完一章,旧实现记约 62 分钟
  • 分句偏移对不上导致整句静默不高亮 — 句子文本取自归一化后的块文本(\s+→单空格 + trim),却拿它去原始文本里 indexOf 定位,空白不一致时返回 -1 并 continue,该句永远不高亮。现偏移直接由文本节点算出,不经过字符串匹配
  • 嵌套块重复计入导致同一句被读多遍 — 旧实现收集块级元素后取 textContent,

    … 结构下 div/section/p 均命中 BLOCK_TAGS,同一句被计入 3 次。改用 foliate-js tts.js 的「按块起始位置顺序分段」算法

  • findFirstVisibleSentenceIdx 混用两套坐标系 — 拿 iframe 内的 Range.getBoundingClientRect() 与宿主窗口的 viewport 比较。现统一在文档自身的视口(documentElement.clientWidth/Height)内比较
  • textSplitter.ts 两个零消费者死字段 — SentenceItem.paragraphIndex / sentenceInParagraph 全仓库无引用,已移除(该文件随后于同日整体删除,见「重构」末条)
  • 停止朗读后高亮残留 — 根因不在高亮机制,而在 foliate 代为选中:renderer.scrollToAnchor(range, true) 的第二个参数为 true 时,foliate 以 reason='selection' 触发 relocate,进而执行 setSelectionTo(anchor, 0) —— 不折叠的整段选中,在页面上画出一层 ::selection 底纹。它不受 CSS.highlights 清除影响、也扛得过重绘,因此长期被误判为「高亮残留」。改为传 false(走 reason='navigation',只折叠成光标且不可见;两者在 paginator #afterScroll 中分支完全相同,滚动行为不受影响),并在 clear() 中一并清掉选区作为兜底
  • 翻页热区盖住正文 — 左右热区原为各 35% 宽、整高,会把正文显示区域一并覆盖。改为 48px(取 foliate paginator 的 --_margin 页边距),热区只落在页边距内,点击正文不再触发翻页
  • 朗读起始位置三个选项均从章节开头起播 — 两个成因叠加:
    ① findFirstVisibleUnit() 参照系错误 —— foliate 分页模式把 iframe 撑到整个内容宽度、翻页靠滚动父文档中的容器,因此在 iframe 内部所有单元相对自身视口都「可见」,用 documentElement.clientWidth 判断会永远命中第 0 个(日志表现为 首可见句=0)。改为把 range 矩形叠加 iframe 元素偏移换算到父坐标系再比较。
    ② 跨章 relocate 处理器抢跑 —— 会话开始时 sectionRef.current 仍为旧值,首次 relocate 必被判为跨章,该分支会 setCurrentSentenceIdx(0) 并在 200ms 后 playLoop(0),与 500ms 后才执行的 startReading(所选句) 形成两个并发的播放循环。改为仅当「relocate 当下 isPlaying 为真」(即播完本章自然翻章)才自动起播;用户主动开播时 startSession 已将 isPlaying 置为 false,不会再抢跑
  • 主窗口引用拿到永久 null 快照,导致标题栏三个按钮与两处 IPC 推送全部失效 — main/index.ts 中 IPC handler 注册刻意排在 createWindow() 之前(窗口一加载就会发 IPC,此时 handler 必须已就绪,顺序不能调换),而 handler 里 const { mainWindow } = deps 是值快照 —— 注册那刻主窗口尚未创建,拿到的永远是 null。受影响的不止标题栏:
  • handlers/ui.ts 的 window:minimize / maximize / close → 三个按钮全为空操作(该 UI 有 platform !== "darwin" 门控,macOS 上不渲染,故长期未被发现)
  • handlers/media.ts → media:status 推送从不发出,App.tsx 的 ffmpeg 状态与「缺失引导弹窗」不会随保存路径更新
  • handlers/fileops.ts → showSaveDialog(mainWindow!) 传入 null,另存为对话框没有父窗口(不模态)
  • scheduler.ts → 构造时存下 null,定时消息「已派发」通知从不推送,ChatView 不会在到期消息自动发布后刷新
  • 另有 7 个 handler 声明了 mainWindow 却从不使用
  • 修法:新增 main/mainWindow.ts 提供 setMainWindow() / getMainWindow(),主窗口一律在调用时取,由 createWindow() 注册引用。同时从全部 9 个 handler 的 Deps 中删除 mainWindow 参数 —— 拆掉这个「传值快照」的口子本身,避免再次踩坑(删字段后任何残留用法都会编译报错,由类型系统保证无遗漏)
  • 附带收益:消掉 3 条 ESLint 未使用变量 warning(89 → 86)
  • endSession() 从死代码变为有调用点(防御性改动,当前场景不可达 —— 曾误判为可复现缺陷) — useAudiobookStore.endSession() 此前全仓库零调用,现在由 useAudiobookSession 的 cleanup 调用(pauseReading() + clearHighlight() + endSession()),键取 bookPath。
  • ⚠️ 更正:本条最初被记为「跨格式会话残留」缺陷(在 TXT 里读到第 500 句 → 关闭 → 打开 PDF → 拿 TXT 的句子列表朗读 PDF 正文),并称其为可见行为变更。该症状不可达,两个前提都不成立:
  • 每个预览都是独立的 BrowserWindow,src 在 main/handlers/ui.ts:60,76 生成 URL 时写死、由 main.tsx:19-26 渲染时读取一次,之后没有任何路径改写它(window:open-preview 每次新建窗口,不复用、不切 src)。独立窗口即独立渲染进程,zustand store 是模块级单例、按渲染进程隔离,因此 isActive / sentences 根本不会跨文件/跨格式传递
  • 预览窗口关闭是真销毁(无 close → hide 拦截),teardown 只在那一刻运行,store 随之丢弃 —— 所以这段 cleanup 在当前窗口模型下是空操作
  • 连带更正:「重开同一本书会直接续播」也不成立 —— 重开即新窗口、store 全新,isActive 本来就是 false,起始位置弹窗在改动前后都会出现,不是行为变更
  • 保留它的价值:① endSession 不再是无调用点的死代码;② 若将来 window:open-preview 改为复用窗口并在窗口内切换 src,这一残留会立刻变成真问题(那时 bookPath 为键的 teardown 正是需要的)
  • 教训:这条缺陷是子代理调查 + 代码审查给出的,双方都基于「组件会在同一窗口内换 src」这一未经核实的假设推演出了完整症状链。教训是协议/生命周期类结论必须回到底层代码核实(窗口创建处),而不是从组件内部的 effect deps 反推
  • startReading 未裁剪恢复索引 — 恢复进度传入的 restoreIdx 直接进了 playLoop,越界时 for 循环一次都不执行、直接落到「本章播完」分支;而它前面已 setPlaying(true),会被 relocate 处理器当成「自然翻章」接着从下一节第 0 句起播(表现为静默跳读)。seekTo 早有同样的裁剪,startReading 漏了。PDF 的句索引是页内的,换页后长度必然不同,这个缺口由此变成必现路径
  • 对 EPUB 是恒等变换(restoreIdx 恒小于句数),不改变现有行为
  • 点「上一章 / 下一章」后停播 — 抽屉上两个章节按钮在播放中点击会停掉音频,抽屉显示 ▶,需手动再点播放。根因是跨章 relocate 处理器的 wasPlaying 闸门:两个按钮在 await reader.next()/prev() 之前就会 player.stop() + setPlaying(false),于是 relocate 到达时 isPlaying 已是 false,与「用户刚点开播」无法区分 —— 而后者正是该闸门当初要挡住的「抢跑」(见上方重构段),所以不能简单放宽它
  • 也不能简单地「按钮不置 isPlaying=false」:末章点「下一章」时 reader.next() 直接返回、不会起播,isPlaying 会永远停在 true(抽屉显示播放中却无声)
  • 修法:新增一次性标志 resumeAfterChapterChangeRef,由按钮在换章前正在播放时置位,跨章 relocate 处理器读到它即继续起播。三个读写点各有必要:
  • 消费:relocate 处理器分支之前读取并清零(不分跨章与否)。放在分支前是刻意的 —— 若某次 relocate 不跨章,标志也必须失效,否则会泄漏到之后某次真正跨章的 relocate 上,让已暂停的会话被意外起播
  • 作废:会话层发起导航前调 clearPendingChapterResume()。末章点「下一章」不会产生 relocate,标志会留存,若此后用户走「继续上次进度」触发跨章 relocate,陈旧标志会让处理器抢先 playLoop(0)、与 startReading 形成两个并发循环 —— 正是当初修掉的抢占
  • 兜底:playLoop 入口再清一次,覆盖「末章点下一章 → 无 relocate → 之后直接开播」这条边角
  • 为什么不用计时器过期:PDF 的换章要等目标页文本层渲染完成,耗时无上限,任何固定时限都可能先于 relocate 到期
  • 顺带修掉一处不一致:prevChapter 的 catch 原先无条件 playLoop(0),导致暂停状态下点「上一章」会自行开始朗读,而「下一章」是静默换章。现改为两者都只在原本播放时续播
  • TXT 以空行开头时整本读不出(新接入格式的路径) — getBlockSegments 的回退分支(根内没有任何块级标签时)把段起点落在 root 的第一个子节点上,而 extractReadableText 只对段起点元素做可读性判定,isReadable 又会剔除「文本不足 2 字符」的元素。TXT 正是这种形态:
     里全是扁平 ,span 不在 BLOCK_TAGS 里、
     自身又不会被 TreeWalker 访问到,于是必然走回退分支。文件以空行开头时 displayParagraphs[0] === "",首段就是空串,整篇被判为不可读而整本丢掉 —— fullText 为空、0 句,点浮动按钮毫无反应(日志只打一行「无可读文本」)
  • 修法:回退分支的起点改用 root 自身,判定的就是一整篇的长度
  • EPUB 不受影响:章节 body 内通常有块级标签,回退分支不会被走到;即便走到(body 内只有一个裸文本节点),起点元素仍是 body,与旧行为等价
  • PDF 并发翻页等待时 Promise 永久挂起 — waitForTextLayer 原先把待命 resolver 存在单个槽位里。4 秒窗口内并发两次等待(playLoop 收尾的 await reader.next() 与用户点抽屉「下一章」重叠,或连点两次章节箭头)时,后一次会覆盖槽位,而前一个 Promise 的超时判据是「拿自己与槽位比对」,覆盖后同样失效 —— 于是它永不 settle:await goToPage() 永久挂起,其后的写收听历史与 setPlaying(false) 都不再执行;走 navigateThenStart 的「书首 / 继续上次进度」则永不起播
  • 修法:槽位改为 Set,超时用 Set.delete 的返回值当「是否仍在等待」的判据(已完成的等待者会先被 delete,不会重复 resolve)
  • DomBookReader.emitRelocate() 是死代码,且注释声称了一个不存在的契约 — 该方法在生产代码里零调用点(PDF 版阅读器有自己的 emitRelocate,因为页码就是它的 section),而 onRelocate 上方注释却写着「其余由宿主在内容变化时显式调用 emitRelocate()」。这会让维护者以为存在一条并不存在的通知链路
  • 修法:删掉该方法与其 2 条测试;注释改为说明「只在订阅时补发一次」以及为什么 TXT / Markdown 不需要主动通知(href 恒定,正文被改写时变的是 DOM 节点身份而非句子文本,那是 TTS 适配器的职责,由宿主重建适配器解决)
  • 本地 pnpm test 在 @unobox/ws-server 硬失败(原生模块 ABI 冲突) — 排查 pnpm test 时发现 Test Files 2 failed | 2 passed (4)、Tests 1 failed | 39 passed | 53 skipped。根因是构建目标冲突,与业务代码无关:
  • apps/desktop 的 postinstall 执行 electron-rebuild -v 32.3.3 -f,把 better-sqlite3 编成 Electron 的 ABI 128
  • 原生模块在 pnpm 的共享 store(node_modules/.pnpm/…)里只有一份、并非每包一份,于是这份 Electron ABI 的产物被全局解析
  • pnpm test 跑在纯 Node(24 → ABI 137)下,new Database() 即抛 NODE_MODULE_VERSION 不匹配
  • 表现:beforeAll 抛错 → 整份套件被跳过。auth.test.ts 16 个全 skip、db-sqlite.test.ts 38 个变成 1 failed + 37 skipped;config(8) 与 policy(31) 不碰 DB 所以全过
  • 仅本地现象:postinstall 有 if (!process.env.CI) 门控,CI 下原生模块保持 Node ABI,这些测试不受影响 —— 大概也是它一直没被发现的原因
  • 修法:移植 packages/core/src/db/__tests__/IDatabaseProvider.test.ts 已有的探测护栏到 backend/ws-server 的两个 DB 测试文件,原生绑定不可用时 describe.skip 整份套件而非硬失败
  • 一个易错点:import Database from "better-sqlite3" 本身不会抛 —— 原生模块在首次 new Database() 时才加载,所以探测必须真的构造一个实例(new Database(":memory:").close()),只 try 住 import 是探测不到的
  • 与 core 的写法有细微差异:core 用 require,这里改用静态 import + 构造探测,避免在 "type": "module" 的包里用 CJS require
  • 代价:Electron 重编后这 54 个用例会被静默跳过(已加 console.warn 使跳过可见),不再报错但也不再验证

重构

  • 新增 utils/speechPlan.ts(统一正文源) — 朗读文本与高亮映射消费同一份 canonical 全文,每个朗读单元保留绝对偏移(start/end 与 rawStart/rawEnd 区分空白),不再用「trim 后累计长度」这种会漂移的方式
  • 分句主路径改用 Intl.Segmenter(分段自带 index,偏移天然精确,且能正确处理英文句号与缩写);环境不支持时回退标点扫描
  • 注意:未采用纯正则扫描方案 —— 其句末符集合不含英文句号 .,会导致英文书被当成 400 字符一段的超长句
  • 超长句按软断点(逗号等)回退切分,上限 400 字符
  • 新增 utils/ttsHighlighter.ts(Range + CSS Custom Highlight) — 高亮不再 surroundContents 包裹 ,正文 DOM 保持原样
  • 两个注册表:当前句(蓝底纹)与已读句(灰底纹),已读窗口上限 200 句
  • 按文档分组注册,跨 realm 分别注册;charMap 按偏移有序,区间切分走二分定位
  • clear() 按 key 清除所有可达的同源文档(递归下探 open shadow DOM 与嵌套 iframe)—— 两个 highlight 键是模块级常量、所有实例共用
  • hooks/foliateTtsAdapter.ts 重写 — 内部改用上述两个模块,对外 ITTSAdapter 接口保持不变,useAudiobook 无需感知实现变化。随之取消「已读标记超 200 个清理一半」的补丁
  • 排版设置改为合并持久化 — 由分散的 foliate-fontsize-{src} / foliate-linespacing-{src} 合并为单个 foliate-typography-{src},并保留读旧键的回退,已有用户的字号/行距不丢
  • 默认状态下注入的 CSS 与改动前逐字节一致 — 所有样式项只在偏离默认值时才输出声明,全默认时不产生任何覆盖,不影响任何现有书籍的渲染(已用脚本对 git show HEAD 旧实现逐字节比对验证)
  • 删除 legacy 朗读路径(textSplitter.ts + domMarker.ts,-344 行) — 适配器接管后这两个模块仅作 getAdapters() 在适配器缺失时的回退。经核实该回退不可达:createFoliateTTSAdapter() 无 early return、必然返回有效适配器,且 useAudiobook 中所有 extractSentences / markSentences 调用点都位于朗读会话内(会话只能由阅读器加载后开始),setTTSAdapter(null) 只发生在组件卸载、此时 hook 已随之销毁
  • SentenceItem 类型迁至 hooks/foliateTtsAdapter.ts(ITTSAdapter 所在处)
  • 门面 getAdapters() 保留原形状、改用可选链,12 个调用点零改动 —— 该文件是全项目最脆弱、刚稳定的一处,刻意避免大范围改动引入回归
  • 一并删除 playLoop 中依赖 legacy DOM 标记 [data-tts-idx] 的跟随分支:适配器路径下该元素从不存在、el 恒为 null,故行为等价
  • 抽出 utils/aiStream.ts(AI 流式调用) — streamAIAPI 与两个 SSE 解析器原先私有在 AIChatView.tsx 里,虽已无状态(不碰 AISession 与 store)但未导出。原样搬出供阅读器复用,AIChatView 改为导入;204 行逐字节未改,聊天页零行为变化
  • 注意:Provider 适配仍是 if/else 硬编码(gemini / claude / OpenAI 兼容),packages/core 不参与对话 —— 这是既有状况,本次搬家未改变
  • 泛化跨窗口发送入口 — requestSendDigest → requestSendText(text, label),书摘与 AI 结果共用同一条跨窗口链路,只是送入文本不同(书摘带《书名》出处,AI 结果原样发出)
  • 正文提取改为「按容器作用域」 — extractReadableText / getBlockSegments 的入参由 Document 改为正文根元素(内部从 root.ownerDocument 取文档)。原先硬编码遍历 doc.body,只有「内容就在 iframe body 里」的 EPUB 成立;TXT / Markdown / PDF 都渲染在主文档中,直接传 document 会把侧边栏、工具栏、搜索面板整个应用 UI 当正文读进去。EPUB 侧传 doc.body,与旧实现内部所用节点完全一致,行为逐字节等价(TtsHighlighter 不引用 doc.body,只从 charMap 的 Range 工作,无需改动)
  • 抽出共享朗读编排(hooks/useAudiobookSession.ts + components/AudiobookOverlay.tsx) — 8 个朗读 UI 组件本身早已是独立文件,但把它们串起来的编排全部内联在 FoliatePreview.tsx 里。现按「引擎无关 / 引擎专属」切开:
  • useAudiobookSession(host) 持有全部朗读编排与 UI 状态并返回一个 controller; 只做渲染,自身零状态
  • 宿主接口刻意只留 6 个成员(bookPath / reader / containerRef / log / restoreLocation / persistLocation),后两个把「位置存哪两个键、存什么值」挡在共享层外
  • 导航归 IBookReader,持久化归宿主:新增 IBookReader.goToStart() 与 hasChapters()。前者不能用 navigateToLocation({fraction:0}) 表达 —— 该方法的 fraction > 0 守卫必须保留,因为 lastLocation 初值就是 fraction:0,0 同时表示「书首」与「未设置」,放行 0 会引入回归。navigateToLocation() 此前零调用,现用于书签跳转与「继续上次进度」的定位,因此不必新增宿主成员
  • 页码信息(起始位置弹窗的文案)走 这个普通 prop 而非 hook 成员:它需要渲染时新鲜的值,做成 reader getter 会形成「靠宿主 re-render 才正确」的隐式约定,加一层 memo 就静默失效。非分页阅读器不传,AudiobookPositionDialog 的 currentPage / totalPages 随之改为可选并降级为不带页码的文案(新增 audiobookFromCurrentDescPlain)
  • 新增 ready prop 是必需的 —— 原先浮动按钮的 visible 混着宿主的加载态(!loading && !error)。缺了它会出现「卡死的活跃会话」:DOM 三种格式正文异步到达,若正文就绪前可点,startReading 取到 0 句后 return,但 startSession 已把 isActive 置真,抽屉点播放无反应
  • FAB 的裁切层没有新增:FilePreview 的内容区本就是 overflow: hidden,预览根节点(FAB 的包含块)在其内部,折叠成半圆的效果四种格式今天都已成立
  • FoliatePreview.tsx 1733 → 1517 行(净减约 216 行)
  • utils/ttsHighlighter.ts 的 findFirstVisibleUnit 去掉对 iframe 的硬依赖 — 原先 if (!frameEl) return 0,而 TXT / Markdown / PDF 的正文都在主文档里,frameElement 恒为 null,于是「从当前阅读位置开始」永远命中第 0 句。改为按「是否同一文档」分流:同文档时偏移恒为 0(此时也不能再看 frameElement —— 万一整个应用被嵌在 iframe 里,frameElement 非空但两边坐标已同源,再叠加偏移就是重复计数);跨文档且有 frameElement 时保持原有换算。EPUB 分支的代码路径、比较顺序与容错逐字节未变
  • domTtsAdapter 的失效判据是两层,且主判据在宿主侧 — 只靠「节点是否 isConnected」不够:TXT 的 在换文件时会被 React 复用并原地改写 nodeValue,节点始终连通,于是会拿着上一本书的偏移去朗读新 DOM(静默错读)。因此主判据是宿主在 src / content 变化时重建适配器实例(新实例即空缓存),连通性检查只作兜底,覆盖 PDF 换页/缩放(文本层 key 含 pageIndex 与 scale,必然整棵重挂)与 TXT / Markdown 全文搜索(replaceChild 替换文本节点)这两种宿主不知情的失效
  • 适配器内的 highlighter 一律复用而非重建:TtsHighlighter.destroy() 会摘掉文档里的高亮样式表,而用户标注与朗读高亮共用同一份样式,重建会把标注的底纹一并干掉
  • 未与 foliateTtsAdapter 合并:差异不止「正文根从哪来」一处(正文根、失效判据、滚动方式、可见性坐标系、isSentenceInViewport),可共享的仅约 60 行;那条链路刚稳定且渲染层无任何测试设施,用少量重复换零回归风险

待办

PDF / TXT / MD 朗读支持 —— 三段均已完成(见上方「新增」「重构」)。当时预判的两个 PDF 坑,实际情况记录于此,供后续接新格式时参考:

  • 「高亮矩形要与 pdf.js 自己算的 span 几何对齐」——此坑不存在。采用 Range + CSS Custom Highlight 后,Range 的容器就是 pdf.js 自己创建的 .textLayer > span,其盒子本就与 canvas 字形一一对应,::highlight() 画在 span 自身的盒子里,几何自动一致。另需三点同时成立,已核对:.textLayer 是 z-index:2 且在 canvas 之后渲染(高亮在 canvas 之上);span 是 color: transparent,而 ::highlight() 画在文字背景层、与 color 无关(可见);水平缩放用的 transform: scaleX() 被 Range 的 rect 包含。唯一「不对齐」的现实现象是背景条比字形略高(span 盒高≈字号),与 pdf.js 原生选择框一致,不算缺陷
  • 「翻页 DOM 重建、游标需重挂」——存在,但比预想的多一处:react-pdf 内部文本层的 key 是 ${pageIndex}@${scale}/${rotate},缩放变化同样整棵重挂。因此 emitRelocate() 挂在每一次文本层渲染完成上,而非只挂页码变化
  • 另一处当时未预见、实现时才发现的关键点:next() 若同步翻页会让 playLoop 的「600ms 判定」误判为无下一章(详见「新增」段)

其他待办

  • handleHideDrawer 有既有的重复写库:同一个 saveProgress 载荷被构建并写入两次(s 与 store 两次 getState(),中间无状态变更,内容完全相同)。搬到共享层时两个 saveProgress 调用原样保留,只把它中间那段 foliate 专属的 localStorage 写入换成了 persistLocation();重复本身未改(重构要求行为零变化)
  • 新接入的三种格式尚未具备的能力(本次刻意不做,避免范围蔓延):
  • 标注 / 书摘 / 查词翻译 —— 依赖 EPUB CFI,DomBookReader / PdfBookReader 的 makeCfi 返回 null,属正确降级
  • 工具栏书签按钮 —— TXT / Markdown / PDF 用的是各自的极简工具栏(非 useSharedReaderToolbar),加按钮要动它们的结构。朗读抽屉与浮动按钮不受影响
  • PDF 的导出只覆盖当前页 —— AudiobookExportDialog 导出的是当前 sentences,而 PDF 的句子列表是按页提取的
  • AudiobookSleepTimer 的「本章结束」(-1)模式不工作(既有缺陷):remaining 由 sleepTimerMinutes = -1 立刻算出 0 并触发 onExpire,且 useAudiobook 全文没有任何 sleepTimerMinutes 消费点 —— 定时器只在弹窗打开期间生效。本次未动
  • isSentenceInViewport 是死代码(useAudiobook.ts 定义、ITTSAdapter 声明,但全仓库无调用点)。两个 DOM 适配器按接口要求返回 false,未清理
  • Step 3 剩余:「最近阅读」视图、阅读数据导出
  • 收尾类:useAudiobook.ts:401 多余依赖、三处 any、en.json 对有声书/阅读器子系统零覆盖(本次新增的 audiobookFromCurrentDescPlain 只进了 zh-CN,与既有 15 个朗读相关 key 的现状一致)、zh-CN.json 英文值残留(本次顺手修了 copyContent 一条)、docs/未完成工作清单.md 与 docs/有声书功能检视报告.md 两份过时文档
  • 轮代码审查发现、但本次未修的既有问题(已核实存在,改动前就有,本次只是搬动或扩大适用面。记录以免丢失):
  • 睡眠定时器「一闪而过」(既有,本次未修) —— AudiobookSleepTimer 用 sleepTimerStartedAt 算剩余。可达路径(全程在同一个窗口内):设一个定时器 → 关掉弹窗 → 等超过该时长 → 再打开弹窗 → remaining 算出 0 → 立刻 onExpire()(暂停播放)并自关。
  • 本次曾在 endSession 里清 sleepTimerMinutes / sleepTimerStartedAt,但对该路径无效:endSession 只在窗口销毁时被调用(见上方 endSession 条目),而这条路径里会话从未结束。那次清理属顺手为之,不构成修复。
  • 真正的修法应在弹窗打开时判定「计时已过期且早已触发过」,直接清空而非再次 onExpire;或把定时器状态收敛到播放会话的生命周期上。
  • 跨章 relocate 的延迟回调从不取消(useAudiobook.ts 跨章分支的 setTimeout(…,200) + setTimeout(playLoop,200),全文无 clearTimeout,effect cleanup 只有 return unsub)。在这个约 400ms 窗口内点「停止/暂停」,回调仍会触发,而 playLoop 开头会无条件把 abortedRef 与 highlightSuppressedRef 都置回 false —— 停止被静默撤销,音频从新章第 0 句重新起播、刚清掉的底纹重新出现。机制在 HEAD 就有,本次新增的 highlightSuppressedRef 让「撤销」变得可见
  • 书签跳转不 await 异步导航(useAudiobookSession.jumpToBookmark):navigateToLocation 内部 await viewer.goTo(href),但代码立刻调 seekTo,而 seekTo 只等 100ms 就重新提取句子。EPUB 播放中跨章跳书签时,会从旧章节文档提取并把 bookmark.sentenceIndex 夹进旧列表 → 一段错章音频/高亮,随后 relocate 替换列表并强制索引 0,书签句被丢弃。HEAD 的 FoliatePreview.tsx 是完全相同的写法,本次只是搬进新模块并换成 navigateToLocation
  • highlightUnit 每句都重建整份「已读」高亮(utils/ttsHighlighter.ts):MAX_PLAYED_UNITS = 200 个旧单元全部重新解析 Range 并重新注册,而实际每句只进入/离开两个单元。每句一次、贯穿整个收听过程,foliate 每次翻页的 relocate 重高亮还会再来一次
  • extractReadableText 的 O(块数 × 文本节点数) 扫描:每个 block segment 都全量遍历 textNodes 做 intersectsNode,且 getBlockSegments 对每段做整段 toString() 序列化。大型 Markdown(无大小上限)或大 EPUB section 首次提取会有秒级卡顿。有缓存,所以是一次性代价
  • PDF 文本层「过期渲染回调」的残余竞态(本次引入,未完全消除) — onRenderTextLayerSuccess 不带页码,而判断「这一页渲染好了」只能靠 pageNumberRef。react-pdf 的文本层渲染 effect 虽返回了 cancelRunningTask(runningTask),但若 render() 的 promise 已经 fulfill、其 .then 微任务已入队,cancel() 撤不掉已入队的微任务,回调仍可能在新页 commit 之后到达;此时它会唤醒新页的等待者,让 goToPage 提前返回、从尚未就绪(甚至已被 layer.innerHTML = '' 清空)的文本层提取正文,进而写出一条 (page-N+1, 指向第 N 页列表的索引) 的错配进度。
  • 窗口很窄(需要 commit 恰好落在那个微任务 drain 之前),且页容器上的 data-page-number 在 commit 时就会更新,反而会把迟到回调误判成当前页,所以不能用它当判据。
  • 彻底消除需要让等待者校验文本层内容确实属于目标页。当前无可靠判据,故保留现状并记录。
  • 渲染层测试基础设施已补,但覆盖面仍很小 —— apps/desktop 增加了 test script 与 vitest.config.ts(配置与 packages/core 一致:vitest run --passWithNoTests)。默认环境 node(纯逻辑跑得最快,需要 DOM 的传桩对象);正文提取用例在文件头用 @vitest-environment jsdom 单独切换到 jsdom。当前覆盖:
  • hooks/pdfBookReader.test.ts —— page-N 严格解析(不能把 fraction:… 或 page-0 当页码)、翻页委派、next() 必须等宿主 goToPage resolve 才 resolve(「每读一页结束一次会话」的回归护栏)
  • hooks/domBookReader.test.ts —— href 恒定、滚动比例换算(含不足一屏与越界)、navigateToLocation、「订阅时只补发一次、后续靠显式 emitRelocate」
  • utils/readerLocation.test.ts —— 键名隔离(不与 foliate-* 冲突)、JSON 损坏与 localStorage 不可用时的降级
  • utils/ttsHighlighter.test.ts(jsdom)—— 正文提取。这是朗读链路的地基:fullText 决定分句、charMap 决定高亮落在哪个文本节点上,而它出错几乎都是静默的(不抛异常、不发警告,只是没声音或没底纹)。覆盖:TXT 形态(
     内扁平 )必然走到的回退分支 —— 含「以空行开头 / 首段 1 字符 / 首段是 1~4 位数字」三条整本丢正文的回归;Markdown 形态的块级分段与换行句边界;嵌套块不重复计入;非正文剔除(页码 / 脚注 / script / style / 纯数字段);以及 charMap 偏移不变量(每条记录必须精确等于它在 fullText 里的那一段)
  • 一个必须注意的桩:jsdom 不实现布局,offsetParent 恒为 null,而 isReadable 正是用它判断「元素是否被渲染」。不补这个桩,每个段都会被判为不可读,于是「提取正确」与「整篇被丢掉」两种结果无法区分 —— 恰好让上面那三条回归测试失去意义。测试文件里用 Object.defineProperty 把已挂载元素的 offsetParent 补成非 null
  • 另一条易错点:isReadable 会剔除文本不足 2 字符的段。用单字(
  • 甲
  • )做夹具时整段会被合法丢掉,断言得到空串;更隐蔽的是断言「charMap 里每个节点都在正文根内」这类遍历式断言会空转通过。夹具一律用 2 字符以上并显式断言集合非空
  • 仍未覆盖:useAudiobookSession(依赖 React 渲染)、domTtsAdapter / TtsHighlighter 的高亮注册(依赖 CSS Custom Highlight,jsdom 未实现)、三个 preview 组件。这些靠手测清单验证
  • 执行情况:本沙箱装不了 jsdom(pnpm install 会从头重装 node_modules,且 store 里没有该包),且沙箱内只有 darwin 原生绑定(node_modules 是在 macOS 上装的),缺 @rollup/rollup-linux-arm64-gnu,因此在这个环境里任何 vitest 都跑不起来(packages/core 的既有测试同样如此)。上述用例已在本地 pnpm install && pnpm test 全部通过;vitest ^1.0.0 与 jsdom ^25.0.0 两条 devDependency 已随该次 install 进入 pnpm-lock.yaml
  • AI 对话链路不存在真正的 Provider 抽象 —— 适配是 utils/aiStream.ts 内的 if/else 硬编码(gemini / claude / OpenAI 兼容),packages/core/src/utils/ai.ts 只负责拉模型列表。CLAUDE.md 宣称的「Provider 热插拔」在对话链路上并不成立

v0.3.5 · 🌐 国际化 i18n + 语音文件管理 + 剩余组件文本替换

2026年7月13日

新增

  • 国际化 i18n 全面支持 — 基于 i18next + react-i18next
  • i18next + react-i18next + LanguageDetector + http-backend
  • Telegram 风格扁平键 + 点号嵌套混合模式,英文后备语言
  • 自动检测系统语言,localStorage 持久化偏好
  • zh-CN.json 累计 949 条翻译键,en.json 基础英文包
  • 语言设置页面 — LanguageSettings 组件,支持语言切换、远程下载、已安装包管理
  • 聊天文件安装语言包 — LangInstallModal 解析 .json/.i18n 文件后注入 i18next 并切换
  • IPC 语言包管理 — 5 个新通道:i18n:fetch-pack-index / download-pack / list-installed / save-pack / delete-pack

修复

  • App.tsx 变量冲突 — 修复 const { t } = useTranslation() 与 map((t) => 迭代变量名冲突导致 TypeScript 类型错误

重构

  • 57/57 组件全面国际化 — 全部组件导入 useTranslation,约 1600 处硬编码中文替换为 t() 调用
  • 基础设施新增:
  • main/handlers/i18n.ts — 国际化 IPC 处理器
  • renderer/src/i18n/ — 国际化模块(i18n.ts + locales + components)
  • preload/index.ts — 新增 api.i18n.* 桥接

新增

  • @成员提及 — 群聊输入框输入 @ 触发成员列表自动补全(InputBar)
  • 基于光标位置检测 @ 后文本过滤房间成员(username/displayName)
  • 选择成员后插入 @username 并正确定位光标
  • 发送时从文本提取 @username 匹配成员 ID,写入 MessageContent.mentionedUserIds
  • 消息渲染时 @username 以 accent 蓝色高亮显示
  • 被提及消息在聊天列表中行背景淡蓝色高亮
  • 仅群组/超群触发,私聊/频道不显示,自己不会出现在列表
  • 与 / Bot 命令补全互斥共存
  • 消息多选 / 批量操作 — 右键菜单新增「选择」选项进入多选模式(MessageBubble)
  • 选中消息显示圆形复选框 + 行背景高亮,再次点击取消选择
  • 底部操作栏显示已选数量 + 「转发」「删除」「取消」按钮
  • 批量转发:逐个转发到目标房间
  • 批量删除:二次确认后逐条删除
  • 多选模式与正常输入模式互斥
  • 会话内搜索 — ChatView 顶部工具栏新增搜索按钮,展开搜索栏后调用后端 searchMessages 限定当前房间检索,支持上一条/下一条命中导航(ChevronUp/ChevronDown)、结果计数(3/8)、Enter 跳转、Esc 关闭,复用现有 handleJumpToMessage 滚动定位 + 高亮
  • 全局消息搜索 — ChatList 搜索框输入时,除按房间名过滤外,并发调用 searchMessages 跨所有房间检索消息,结果按「聊天 / 消息」分组展示;点击消息结果切换到对应房间并经 jump-to-message 自定义事件定位到该消息
  • 跨房间跳转事件 — 新增 jump-to-message window 事件,ChatView 监听后带重试(最多 10 次 × 200ms)等待消息加载完成再定位
  • 搜索越界提示 — 命中消息不在已加载范围(更早历史)时,会话内搜索显示「需加载」提示,引导用户向上滚动

修复

  • 有声书自动翻章 — playLoop 播完当前章节后自动调用 reader.next() 推进到下一章
  • 有声书高亮残留 — adapter 新增 markSentences 预包裹句子,highlightSentence 仅切换颜色,消除 surroundContents 导致的 DOM 损坏
  • 有声书收起播放器后自动翻页失效 — 新增 autoScrollingRef 区分引擎自动滚动与用户手动翻页,relocate handler 不再误关 autoFollow
  • 有声书 prebuffer 超时导致~10句自动停 — PREBUFFER 5→10,CHUNK_TIMEOUT 15s→60s,超时退出加日志明确原因
  • 有声书 book_title 未填充 — Store 新增 bookTitle / setBookTitle,FoliatePreview 提取 EPUB 元数据后写入
  • 有声书 sleep timer 停止不优雅 — 新增 onExpire 回调,倒计时归零时触发 pauseReading 而非仅 setPlaying(false)
  • 有声书导出拼接 Broken — 新建 audio:concat-wav IPC handler 调用 concatWavFiles 真正拼接 WAV 文件,替换废弃的 media.transcode + file.upload concat-list 方案

重构

  • 统一 Modal 组件基座 — packages/ui-web 新增 组件,统一遮罩/ESC/Portal/X 按钮/标题栏/footer
  • 17 个 *Modal.tsx 全部迁移完成,净减约 440 行样板代码
  • 最后 4 个手搓 Modal(AddContact/FFmpegMissing/OSSNotices/Update)一次性迁移
  • 拆分 main/index.ts — 从 2917 行按域拆出 8 个 handler 文件(handlers/{storage,fileops,media,network,ui,tts,audiobook,misc}.ts)
  • index.ts 仅保留编排逻辑(初始化 + 注册调用 + createWindow),精简至 333 行
  • 约 85 个 IPC 通道名不变,renderer 端零感知
  • 纯机械搬运,业务逻辑零改动

移除(死代码清理,净减约 1957 行)

  • 删除 SupertonicProvider.ts(728 行)、PiperCliProvider.ts(293 行)—— 已注释禁用、从不实例化的 TTS Provider
  • 删除 AudiobookPlayer.tsx(320 行)、UserProfileModal.tsx(482 行)、hooks/useTTSPlayer.ts(134 行)—— 全树无引用
  • 清理 main/index.ts 中指向已删文件的注释 import / register 行;修正 TTS 引擎数日志「3 本地」→「1 本地」(与实际注册一致)
  • 保留 VoiceManager.ts/TTSManager.ts 中 supertonic/piper 的模型下载配置(标记"待接入",待确认是否永久移除)

测试

  • LocalProvider.test.ts 新增 searchMessages 单元测试:跨房间/限定房间搜索、排除已删除消息、无命中返回空数组

新增

  • 移动端功能全面增强 — 移动端功能从 Phase 1 推进至完整闭环
  • 全面增强聊天功能 Phase 1-7:消息收发、群组/频道管理、成员管理完整实现
  • 频道统计 / 文件管理 / 全局搜索 / Bot 管理
  • 投票 / 评论区 / 定时消息列表 / 慢速模式 / 已读状态
  • UI redesign + Paper 主题迁移
  • UI 全面优化 + 文件夹管理 + 新建功能
  • 语言设置「系统跟随」选项 — 语言选择新增跟随系统语言选项,自动检测系统语言并切换
  • 翻译质量批量提升 — 修复 536 条劣质自动翻译,补充 688 条缺失翻译键,zh-CN 累计 1669 条

修复

  • 系统跟随模式语言检测 — 修复系统跟随模式下语言检测优先级逻辑
  • ESLint 清理 — 修复 4 个错误和 27 个警告
  • ssh-tunnel-manager.test.ts — 修复无效端口断言逻辑
  • pnpm-lock 同步 — 修复 pnpm-lock.yaml 与 apps/mobile/package.json 不同步 + pnpm 9.0.0 @electron/node-gyp 解析 bug

重构

  • 代码结构 — 提升整体可读性和可维护性

改进

  • 语言偏好持久化 — localStorage 检测优先级高于系统语言
  • 设置页国际化 — 空状态 / loading 文本国际化
  • ForwardModal — 类型修复 + emoji 替换
  • 语言图标 — 改用 Globe lucide icon
  • 联系人模块 — 翻译键补充
  • 设置页中文 — 批量替换为 t() + 语法修复

v0.3.3 · 🤖 AI 功能增强:火山引擎接入 + 多媒体显示 + 会话管理

2026年6月28日

新增

  • 火山引擎(Coding Plan)接入 — 新增 volc Provider,支持 ark-code-latest 接入点,OpenAI 兼容协议(/api/coding/v1)
  • AI 回复图片显示 — ReactMarkdown 支持 img 标签渲染,点击可全屏预览(复用 ImageViewer);消息结构新增 imageUrls 字段
  • AI 会话右键菜单 — 支持重命名和删除会话操作,右键 → 菜单 → 确认对话框

修复

  • AI 会话切换消息串扰 — AIChatView 添加 key={activeSessionId},切换会话时组件重建,state 自动重置
  • Google Fonts 连接失败 — 移除 fonts.googleapis.com 外部引用,降级到系统字体,移除 CSP 中相关声明
  • AI 流式结束后内容消失 — onDone 回调改为函数式更新 setMessages(prev => ...),修复闭包陷阱

变更

  • AISessionMessage / AIMessage 接口新增 imageUrls?: string[]
  • AIManageModal 火山引擎走手动输入接入点 ID 模式

变更

  • 彻底移除 epubjs 渲染引擎(-1598 行 EpubPreview + -243 行 epubIndexer),所有电子书格式(EPUB/MOBI/AZW/FB2/CBZ)统一使用 @xincmm/foliate-js
  • EPUB 全文搜索 (FTS5) 合并:EPUB 索引从独立的 book_sections 表迁移到通用 file_sections 表,使用统一 file:index-file IPC 通道
  • 移除 epub: IPC 通道:删除全部 11 个 epub: IPC 处理器及其在 preload + env.d.ts 中的声明,代码净减 466 行
  • FoliatePreview 增强:新增 EPUB 文件类型检测、点击翻页区域(左 35%/右 35%)、键盘方向键翻页、有声书书签列表、导出对话框
  • 样式注入加固:getReaderCSS 全部可调属性加 !important,body 补充 background-color,防止 EPUB 自身样式表覆盖用户设置
  • 阅读位置持久化修复:取消 fraction > 0.001 恢复阈值,消除 requestAnimationFrame 级联触发 relocate,加 Number.EPSILON 补偿浮点偏差

新增

  • foliate-js TTS 适配器:新建 foliateTtsAdapter.ts,用 Intl.Segmenter(同 foliate-js tts.js 方案)替代正则分句,用 Range 替代 包裹做 DOM 高亮
  • setTTSAdapter 模块级注册:useAudiobook 通过适配器优先的包装函数切换文本提取/高亮路径,textSplitter.ts/domMarker.ts 保留为 fallback
  • 有声书 auto-follow 重构:改用 paginator.scrollToAnchor(range) 实现页面跟随,解决 iframe 坐标系不可比导致的自动翻页失效
  • TTS 引擎空检测:startReading 检查 voiceEngineId,未配置时输出警告并跳过;合成失败添加零时长 chunk 避免播放循环卡死
  • seekTo 自适应:拖动进度条时重新提取当前文档句子列表并 clamp 索引到有效范围
  • 有声书书签按钮:AudiobookDrawer 新增 onShowBookmarkList prop + 书签按钮

修复

  • ESC 关闭整个阅读器:键盘事件处理器加 { capture: true },搜索打开时 Esc 仅关闭搜索面板
  • 搜索结果区域高度自适应:ResizeObserver 动态测量可用高度传递给 VirtualSearchList
  • 主题切换背景色不跟随:getReaderCSS 在 body 上补充 background-color: ... !important
  • 搜索 placeholder 显示错误:索引完成后显示「输入关键词搜索全书……」
  • 搜索输入文字颜色太淡:#fff → #e0e0e0

移除

  • 删除 EpubPreview.tsx(1598 行)
  • 删除 epubIndexer.ts(243 行)
  • 删除 EpubjsReader 类 / EpubjsRendition 接口
  • 从 package.json 移除 epubjs 依赖

v0.3.1 · 🔒 E2EE 二次方案:补齐+安全增强+部署策略

2026年6月27日

新增

  • 部署级 E2EE 加密策略开关:支持 none(明文版)/ optional(自由选择)/ mandatory(强制加密)三种分发模式,通过构建时环境变量注入,死代码消除
  • 群聊 E2EE 全员检查接口:内置 WSS 服务端新增 GET /api/group/check-e2ee?groupId=xxx 端点
  • THIRD_PARTY_LICENSE.md:第三方加密依赖合规声明文件
  • E2EE 单元测试增强:新增 e2ee-round2.test.ts,覆盖策略模块/Sender Key/Double Ratchet/X3DH/InBandKeyExchange/会话管理/密钥销毁 38 项测试

修复

  • InBandKeyExchange G1:接收方 SPK 私钥使用真实密钥对(修复随机私钥导致解密失败)
  • InBandKeyExchange G2:nonce 从 payload 恢复而非全零数组
  • LocalCryptoProvider G3/G4:实现 encryptGroupMessage/decryptGroupMessage 接入 Sender Key
  • LocalCryptoProvider G5/G6:实现 encryptMessage/decryptMessage 接入 Double Ratchet + keyStore 持久化

安全增强

  • sodium_memzero 密钥销毁 (G7):destroy() 方法安全清零主密钥/身份私钥/Ratchet 会话密钥
  • 30 分钟会话超时 (G8):InBandKeyExchange 无活动会话自动过期销毁
  • 重连自动清理 (G9):WebSocket 重连后清理临时缓存,保留持久化 Ratchet 状态

改进

  • 解密失败 UI 统一 (G10):MessageBubble 支持五态显示(encrypted/decrypt-failed/encrypted-locked/session-expired/plaintext)
  • Bot 管理改为独立设置子页面:重构 BotManageModal 为 BotManageContent+BtManageModal 架构
  • oss-notices.json 更新:移除 ffmpeg/onnx/sherpa 等运行时下载组件,1227 个包 22 种许可证
  • Turbo 环境变量传递:turbo.json 新增 globalPassThroughEnv 配置
  • 清理旧版 release 构建产物(838MB,含 ffmpeg 二进制)

工程

  • Vite 虚拟模块 virtual:e2ee-policy:绕过 define 对跨包别名模块失效的问题
  • TypeScript 严格模式零错误(所有包)
  • 手动功能测试:3 种策略 × UI 元素完整验证通过

v0.3.0 · 📱 移动端 SDK 54 + 本地聊天闭环

2026年6月20日

新增

  • 移动端升级到 Expo SDK 54 / React Native 0.81 / Expo Router 6,兼容新版 Expo Go。
  • 建立移动端本地聊天闭环:本地服务添加、群组/频道创建、聊天列表、聊天详情、消息发送、历史消息恢复均可用。
  • 新增 MobileProviderManager,移动端 Phase 2A 仅加载 LocalProvider,避免 Expo Go + Hermes 启动阶段触发 libsodium WebAssembly 依赖。
  • 移动端接入 ExpoSqliteDatabaseProvider,本地服务数据写入 expo-sqlite,冷启动后服务、房间与消息可恢复。
  • 移动端聊天页补齐 Telegram 风格基础体验:群头像显示、群名称标题、消息方向、本地发送状态、自动滚动到底部、键盘避让与深浅色主题。

修复

  • 修复 Expo 缺失 assets/icon.png / adaptive-icon.png 导致启动失败;移动端图标同步桌面端主图标。
  • 修复 Expo Router v6 tab 路由识别问题,为 contacts / settings / more 补充布局文件。
  • 修复 libsodium-wrappers 在 Hermes 下依赖 import.meta / crypto.getRandomValues / WebAssembly 导致的启动崩溃:移动端启动路径不再加载 E2EE/WASM 链。
  • 修复本地发送消息沙漏长期不消失:用临时消息 ID 正确替换 Provider 返回的真实消息。
  • 修复消息顺序倒置:消息按时间正序显示,新消息位于底部并自动滚动。
  • 修复 iPhone 安全区问题:聊天列表顶部按钮避开状态栏,聊天输入栏避开虚拟键盘与 Home Indicator。
  • 修复 Expo Go 中动态状态栏配置触发 UIViewControllerBasedStatusBarAppearance 原生错误;保留原生配置给后续 Dev Client / EAS Build 使用。

已知限制

  • 移动端当前仅支持本地存储服务;WebSocket / Matrix 移动端接入将在后续版本迁移。
  • Expo Go + Hermes 不支持 libsodium WebAssembly,移动端 E2EE 暂在原生 Dev Client 阶段启用。
  • 移动端超群/话题 UI 暂未实现,创建入口暂只提供群组与频道。

未发布 · Matrix 连接稳定性

2026年6月19日

修复

  • MatrixProvider 不再在 Homeserver URL 缺失时静默回退到 https://matrix.org,避免离线或 DNS 不可达环境启动即请求公共服务器。
  • Access Token 校验仅在明确认证失败时触发密码重登;DNS/网络失败会直接提示检查 Homeserver 地址、DNS/网络或 CORS 配置。
  • 启动时跳过非活跃 Matrix 服务器的自动连接,减少历史配置导致的无意义外网请求。
  • Matrix 添加服务器表单恢复默认 https://matrix.org,同时连接层保留网络/DNS/CORS 错误的明确提示。
  • Matrix 实时事件增加连接去重、初始同步历史事件过滤和 eventId 去重,避免启动同步时同一消息重复进入 UI。
  • Matrix 创建会话调整为房间级 E2EE 模式:支持普通对话、普通房间、加密对话、加密房间;加密会话创建时写入 m.room.encryption,消息继续由 matrix-js-sdk 自动加解密。
  • Matrix E2EE 设备身份修复:优先使用 /account/whoami 返回的 token 绑定 device_id,不再通过 /devices 猜测设备,避免 /keys/upload 出现 device_id mismatch。

v0.2.6 · 🔒 端到端加密 Phase 3-5:全协议 E2EE + 密钥管理

2026年6月18日

三种 Provider 模式 E2EE 全部实现

WebSocket 模式(X3DH + Double Ratchet):

  • InBandKeyExchange:三步握手协议(key_request→key_bundle→pre_key_message)
  • 双路径:带内协商(PASSIVE, 2 RTT)+ 服务端 Pre-Key 辅助(ASSISTED, 1 RTT)
  • WS 服务端新增 wss_pre_keys 表 + HTTP Pre-Key 端点 + 6 种 e2ee.\* 透传
  • 群聊 Sender Key 加密(sendEncryptedGroupMessage)+ 成员变更自动轮换
  • 降级 UI:ChatView E2EE 状态横幅(🔒/⚠️ 三种配色 + 服务端能力标签)
  • CSP 修复:wasm-unsafe-eval 支持 libsodium WebAssembly

Matrix 模式(Olm + Megolm):

  • MatrixProvider 集成 initRustCrypto(matrix-sdk-crypto-wasm)
  • MatrixCryptoProvider 适配器:bindCryptoApi + exportRoomKeys/importRoomKeys
  • Web Crypto SHA-256 派生 storageKey + deviceId 交给 SDK 自动管理
  • 修复 Buffer is not defined / device_id mismatch 等渲染进程兼容问题

E2EE 状态管理重构

  • 按 serverId 独立隔离:不同协议 E2EE 配置互不干扰
  • settings 页全面重构:Toggle 开关 + Modal 弹窗 + 多服务器列表
  • 持久化:zustand persist(enabled mark per-server → localStorage)
  • 旧数据自动迁移:v0 {enabled:true} → v1 {servers:{}}

密钥备份与恢复

  • 导出密钥备份:口令验证 → cryptoProvider.exportState → JSON Blob 下载
  • 恢复密钥备份:选择 .json 文件 + 口令 → cryptoProvider.importState
  • 聊天记录导出:verifyE2EEPassphrase + exportData → 明文 JSON 下载

Phase 5 其他完成项

  • 5.4 群聊 Sender Keys 完善:rotate/distribute/getOrCreate + 缓存
  • 5.6 通知隐私:隐藏加密消息通知内容开关(localStorage 持久化)
  • 5.7 密文搜索优化:全量读取→逐条解密→内存匹配
  • 5.8 离线队列检查:关闭 E2EE 前检查 encrypted=1 的待发消息
  • 5.10 协议升级框架:E2EE_VERSION_TABLE + canDecrypt + getDecryptVersionLabel

🔬 验证状态

  • TypeScript 严格模式类型检查(6 包):零错误
  • ESLint:零错误(仅项目级 any 类型 warning)
  • X3DH 4-DH 协商:DH1-DH4 全部匹配
  • Double Ratchet 100 条消息往返:全部正确解密
  • BLAKE2b KDF 单向性(前向安全):通过

#### 涉及文件

| 文件 | 说明 |

| --------------------------------------------------------------- | --------------------------------- |

| packages/core/src/crypto/InBandKeyExchange.ts | 新建 — Phase 3a 带内密钥交换 |

| packages/core/src/crypto/MatrixCryptoProvider.ts | 新建 — Phase 4 Matrix 适配器 |

| packages/core/src/providers/WebSocketProvider.ts | 修改 — Phase 3 完整 E2EE 集成 |

| packages/core/src/providers/MatrixProvider.ts | 修改 — Phase 4 initRustCrypto |

| apps/desktop/src/main/ws-server.ts | 修改 — Phase 3b Pre-Key 端点 |

| apps/desktop/src/renderer/src/store/useE2EEStore.ts | 新建 — E2EE 状态管理 |

| apps/desktop/src/renderer/src/store/useE2EEState.ts | 新建 — 房间级 E2EE 状态 hook |

| apps/desktop/src/renderer/src/components/E2eeSettingsView.tsx | 新建 — 隐私与安全设置页 |

| apps/desktop/src/renderer/src/components/ChatView.tsx | 修改 — E2EE 状态横幅 |

| apps/desktop/src/renderer/src/components/MessageBubble.tsx | 修改 — E2EE 加密图标 |

v0.2.5 · 🔒 端到端加密 Phase 2 完整交付:UI 层 + 附件加密 + Bot 豁免

2026年6月17日

🖥️ UI 层 E2EE 状态管理

useE2EEStore(新建):

  • Zustand store 管理 E2EE 启用/禁用/口令状态,桥接 LocalProvider E2EE 方法到渲染层
  • 动态导入 useServerStore 避免循环依赖
  • 提供 enableE2EE / disableE2EE / changePassphrase / verifyPassphrase / refreshStatus 方法

E2eeSettingsView(新建)— 设置 → 隐私与安全:

  • 当前状态卡片:🛡 已启用 / ⚪ 未启用 + 身份指纹显示
  • 启用端到端加密:口令输入 + 👁 显示切换 + 强度指示器(6 项检测、4 级颜色条)
  • 关闭确认弹窗:警告新消息将明文 + 历史密文不自动解密
  • 更改口令:旧口令验证 + 新口令强度检测 + 不触发消息重加密提示
  • 安全说明区域:加密算法说明 + WS/Matrix 模式待开发提示

MessageBubble E2EE 加密图标:

  • 新增 E2EEIndicator 组件:根据 msg.__e2eeStatus 渲染图标
  • encrypted → 🔒(端到端加密)
  • decrypt-failed → ❌(解密失败)
  • encrypted-locked → 🔒(锁定,需输入口令)
  • plaintext → 不显示
  • 集成在时间戳区域,紧跟置顶图标 📌

📎 附件文件级加密

  • LocalProvider.uploadEncryptedFile() 私有方法:E2EE 启用时自动加密上传
  • 加密后文件名:UUID.enc(无意义随机名)
  • MIME 类型:统一隐藏为 application/octet-stream
  • 真实文件名/MIME/nonce 通过 UploadResult._realFileName/_realMimeType/_encNonce 扩展字段保存
  • uploadFile 自动检测 E2EE 状态并分流加密/明文路径

🤖 Bot/AI 房间 E2EE 豁免

  • createRoom 中 botConfig 或 type === 'bot' 时自动 e2eeExemptRoomIds.add(roomId)
  • 控制台输出日志提示:[LocalProvider] 房间 xxx 已排除 E2EE(Bot/AI 房间)

📋 设计文档

  • E2EE 方案文档更新至 v1.3:附录 D Phase 2 标记全部完成,P0 清零
  • PRD.md 同步更新:版本号 0.2.4,E2EE 状态描述更新

#### 涉及文件(新建 2 个,修改 5 个)

| 文件 | 说明 |

| --------------------------------------------------------------- | ------------------------------------------------------- |

| apps/desktop/src/renderer/src/store/useE2EEStore.ts | 新建 — E2EE 状态管理 store |

| apps/desktop/src/renderer/src/components/E2eeSettingsView.tsx | 新建 — 隐私与安全设置页 |

| apps/desktop/src/renderer/src/App.tsx | 修改 — 集成隐私页 + Shield 入口 + E2EE 状态自动刷新 |

| apps/desktop/src/renderer/src/components/MessageBubble.tsx | 修改 — E2EEIndicator 组件 |

| packages/core/src/providers/LocalProvider.ts | 修改 — 附件加密 + Bot 豁免 |

| packages/core/src/providers/IServerProvider.ts | 修改 — UploadResult 扩展字段 |

| docs/requirements/unobox-端到端加密方案.md | 修改 — v1.3 附录 D 更新 |

v0.2.4 · 端到端加密 Phase 1-2:基础库 + 本地模式加密存储

2026年6月16日

🔒 加密基础库(packages/core/src/crypto/,~3,200 行)

完整实现 Signal Protocol 核心:

  • identity.ts:Ed25519 身份密钥生成、X25519 预密钥、Ed25519↔X25519 转换、设备 ID 派生
  • x3dh.ts:X3DH 异步密钥协商(4-DH,含 Ed25519→X25519 转换修正)
  • doubleRatchet.ts:Double Ratchet 会话加密(对称棘轮 + DH 棘轮),前向安全
  • senderKeys.ts:群聊 Sender Keys 加密/解密/轮换
  • sessionManager.ts:会话生命周期管理(序列化/反序列化)
  • messageEncrypt.ts:本地加密/解密(BLAKE2b KDF)、附件加解密、消息密钥派生
  • 4 个单元测试文件(identity / x3dh / doubleRatchet / messageEncrypt)

加密原语选型: libsodium-wrappers@^0.8.4(X25519 / Ed25519 / XSalsa20-Poly1305 / BLAKE2b)

🛡️ 本地模式加密存储

LocalProvider 集成加密路径:

  • LocalCryptoProvider.ts:ICryptoProvider 实现,口令→主密钥→消息密钥完整链路
  • SQLiteKeyStore.ts:IKeyStore 实现(e2ee_keys / e2ee_pre_keys / e2ee_sessions / e2ee_sender_keys 四表)
  • 数据库 migration:messages 表新增 encrypted / e2ee_version 列
  • sendMessage 加密分支:E2EE 启用时自动加密,Bot/AI 房间自动排除
  • getMessages 解密分支:逐条检查 encrypted 标志,明文直返 / 密文解密
  • 口令管理:启用 / 关闭 / 更改口令 / 验证口令(口令变更不触发消息重加密)

IServerProvider E2EE 扩展: 新增 supportsE2EE / e2eeEnabled / e2eeExemptRoomIds 等 12 项 E2EE 属性

📋 设计文档

  • 端到端加密方案文档更新至 v1.1:新增附录 C 实施记录(数据库 Schema、技术差异、验证状态)
  • PRD.md 同步更新:竞品对标表、短板表、现状表、实施阶段状态

🔬 验证状态

  • TypeScript 严格模式类型检查(6 包):零错误
  • ESLint 新增模块:零警告
  • X3DH 4-DH 协商:DH1-DH4 全部匹配
  • Double Ratchet 100 条消息往返:全部正确解密
  • BLAKE2b KDF 单向性(前向安全):通过

#### 涉及文件(新增 16 个,修改 7 个)

| 文件 | 说明 |

| ------------------------------------------------- | -------------------------------------- |

| packages/core/src/crypto/identity.ts | 新建 — 身份密钥生成与管理 |

| packages/core/src/crypto/x3dh.ts | 新建 — X3DH 密钥协商 |

| packages/core/src/crypto/doubleRatchet.ts | 新建 — Double Ratchet 会话加密 |

| packages/core/src/crypto/senderKeys.ts | 新建 — Sender Keys 群组加密 |

| packages/core/src/crypto/sessionManager.ts | 新建 — 会话生命周期管理 |

| packages/core/src/crypto/messageEncrypt.ts | 新建 — 消息加密/解密封装 |

| packages/core/src/crypto/LocalCryptoProvider.ts | 新建 — 本地加密提供者 |

| packages/core/src/crypto/SQLiteKeyStore.ts | 新建 — SQLite 密钥存储 |

| packages/core/src/crypto/__tests__/ | 新建 — 4 个单元测试 |

| packages/core/src/providers/LocalProvider.ts | 修改 — 集成加密路径 + DB migration |

| packages/core/src/providers/IServerProvider.ts | 修改 — E2EE 接口扩展 |

| docs/requirements/unobox-端到端加密方案.md | 修改 — 更新至 v1.1 |

v0.2.3 · 官网增强:赞赏页优化 + 交流社区页 + 品牌导航栏

2026年6月16日

赞赏页(donate.html)优化

新增三段式金额提示,提升支付转化率:

  • 二维码上方增加静默金额参考:☕ 一杯咖啡 ¥6 · 🍱 一顿快餐 ¥25 · 🎁 给个大的 ¥88
  • 附提示文案:"扫码后按心意输入任意金额即可 ❤️"
  • 纯视觉参考,无选中/输入交互,不干扰用户体验
  • 法律声明第 3 点同步更新:从"不设金额引导"改为"金额仅为温馨参考"
  • 设计依据:中国大陆个人开发者合规指南——预设金额 + 双码并行 + 不承诺回报

交流社区页(community.html)

新建独立页面,集中展示 Telegram 官方群组和频道:

  • 群组卡片:二维码 + "加入群组"按钮 → t.me/+3KH9Cn8-yIQ1ZDI1
  • 频道卡片:二维码 + "关注频道"按钮 → t.me/+KN45JemRvb5kMmNl
  • 统一 "交流" 导航入口,替换原来导航栏独立的两个外部链接
  • 新增二维码图片资源:web/assets/telegram_group_unobox_qr.jpeg / web/assets/telegram_channel_unobox_qr.jpeg

品牌导航栏

  • 导航栏品牌图标:用 web/icon/icon.png 替换 💬 emoji(36×36px)
  • 品牌文字改为大写 UNOBOX,保留原有蓝白渐变色样式

#### 涉及文件

| 文件 | 说明 |

| -------------------------------------------------- | ------------------------------------ |

| web/donate.html | 三段式金额提示 + 法律声明更新 |

| web/community.html | 新建 — 交流社区独立页 |

| web/assets/telegram_group_unobox_qr.jpeg | 新建 — Telegram 群组二维码 |

| web/assets/telegram_channel_unobox_qr.jpeg | 新建 — Telegram 频道二维码 |

| web/index.html / usage.html / changelog.html | 导航栏更新:图标 + 大写 + "交流"入口 |

| web/assets/style.css | 新增 .navbar-icon 规则 |

贴纸系统

完整的贴纸功能:内置贴纸包 + 选择器 + 消息管线。

  • MessageContent 接口新增 stickerId / stickerPackId / stickerEmoji 三个贴纸专用字段
  • 内置 2 套贴纸包、14 款手绘 SVG 贴纸(表情心情 x12 + 可爱动物 x8,跨包复用)
  • StickerPicker.tsx — 贴纸选择面板:4 列网格、包切换标签(表情心情 / 可爱动物)、hover 高亮、点击即发
  • MessageBubble.tsx — 贴纸渲染:200px 大尺寸、无气泡背景浮动显示(Telegram 风格)、带头像 + 时间戳 + 表情反应
  • InputBar.tsx — ⭐ 贴纸按钮(Sticker lucide 图标),紧邻 Emoji 按钮,点击弹出选择器
  • ChatView.tsx — handleSendSticker 完整管线:dataURI→Blob→uploadFile→sendMessage

定时消息调度器

主进程持久化调度器,确保持久化保证。

  • scheduler.ts — MessageScheduler 单例:每 15 秒轮询 messages + wss_messages 表,到期消息自动清除 scheduled_at 并更新 created_at/sent_at/房间 last_message,通过 IPC scheduler:messages-dispatched 通知渲染端刷新
  • ScheduledMessagesModal.tsx — 定时消息管理面板:查看当前房间待发送列表、格式化倒计时(天/小时/分钟/秒)、单条取消、自动刷新
  • 集成到 ChatView.tsx 更多菜单(Clock 图标入口)+ IPC 监听自动刷新消息列表
  • preload/index.ts 新增 api.scheduler 接口(getStatus/start/stop/pollNow/onMessagesDispatched)
  • useChatStore.ts — scheduleMessage/cancelScheduledMessage/loadScheduledMessages 正确维护 scheduledMessages store 状态
  • 应用退出时自动停止调度器;启动时自动开始轮询
  • 与渲染端 armTimer(setTimeout)互补:近程精确 + 持久化兜底,双重保障

#### 涉及文件

| 文件 | 说明 |

| --------------------------------------------------------------------- | ---------------------------------- |

| packages/core/src/providers/IServerProvider.ts | MessageContent 新增贴纸字段 |

| apps/desktop/src/renderer/src/stickers/stickerData.ts | 新建 — 2 套贴纸包、14 款 SVG |

| apps/desktop/src/renderer/src/components/StickerPicker.tsx | 新建 — 贴纸选择面板 |

| apps/desktop/src/renderer/src/components/MessageBubble.tsx | 贴纸渲染(200px 无气泡浮动) |

| apps/desktop/src/renderer/src/components/ChatList.tsx | 贴纸预览带 emoji 标识 |

| apps/desktop/src/renderer/src/components/InputBar.tsx | 贴纸按钮 + 选择器集成 |

| apps/desktop/src/renderer/src/components/ChatView.tsx | handleSendSticker 发送管线 |

| apps/desktop/src/main/scheduler.ts | 新建 — MessageScheduler 单例 |

| apps/desktop/src/renderer/src/components/ScheduledMessagesModal.tsx | 新建 — 定时消息管理面板 |

| apps/desktop/src/main/index.ts | 调度器 IPC + 启停 |

| apps/desktop/src/preload/index.ts | api.scheduler 接口 |

| apps/desktop/src/renderer/src/env.d.ts | scheduler 类型声明 |

| apps/desktop/src/renderer/src/store/useChatStore.ts | store 状态管理 |

v0.2.1 · 反馈问题 + 赞赏支持独立菜单

2026年6月13日

反馈问题

设置界面新增反馈问题入口:

  • 设置主菜单新增"反馈问题"入口(Bug 图标)
  • 支持三种反馈类型:Bug 报告 / 功能建议 / 使用体验(三选一卡片切换)
  • 表单字段:标题(必填)、详细描述(必填)、联系邮箱(选填)
  • 自动附带环境信息:应用版本、操作系统、架构、Electron/Node.js 版本
  • 通过 GitHub Issues REST API 提交,自动标记 bug / enhancement / feedback label
  • Token 通过 .env.local 运行时读取(GITHUB_FEEDBACK_TOKEN),不提交到代码仓库
  • 成功后展示 Issue 链接,失败展示详细错误原因(含调试路径信息)

#### 涉及文件

| 文件 | 说明 |

| --------------------------------------- | ---------------------------------------------------- |

| apps/desktop/src/renderer/src/App.tsx | "feedback" 子页面(表单 UI + 提交逻辑) |

| apps/desktop/src/preload/index.ts | api.feedback.submit() IPC 桥接 |

| apps/desktop/src/main/index.ts | feedback:submit handler(Token 读取 + GitHub API) |

| .env.example | GITHUB_FEEDBACK_TOKEN 配置模板 |

赞赏支持菜单提升

  • ☕ 赞赏支持从"关于与自动更新"子页面提升为设置主菜单独立项
  • 使用 Coffee 图标(橙色),点击直接打开外部捐赠页面

v0.2.0 · TTS 语音合成 + EPUB 有声书 + 音频导出 + 播放器 UI 重设计

2026年6月7日

TTS 语音合成增强

偏好持久化重构:

  • useTTSStore 全面重构:引擎/语音/语速/音调偏好 100ms 防抖自动保存到 SQLite tts_preferences 表
  • beforeunload 事件同步 flush 兜底,确保刷新/重启不丢配置
  • 全局偏好与有声书独立偏好解耦(speed 用于合成,playbackRate 用于播控)

内联 TTS 播放器(InlineTTSPlayer):

  • 文本消息气泡内嵌 TTS 播放按钮(Volume2 图标)
  • 合成中显示脉冲动画,播放中显示暂停图标 + 时间进度
  • 远程引擎隐私声明首次确认后缓存

合成缓存(TTSCache):

  • engineId + voiceId + speed + SHA256(text) 为 key,写入 tts-cache/ 目录
  • 重复文本请求直接复用缓存文件,省 API 调用/本地计算
  • 索引文件 .index.json,getStats() 查询缓存统计,clearAll() 一键清空

语音列表改进:

  • 引擎动态语音列表(移除 TTSManager 中下载状态过滤)
  • Sherpa-ONNX Kokoro 模型配置修正(根据官方文档)
  • 语音模型 tar.bz2 压缩包下载 + 完整日志

修复:

  • TTS 设置页文字颜色统一为 CSS 变量适配主题
  • 推荐徽章颜色 #4caf50 → #81c784 提亮
  • 语音模型下载断点续传兼容性

有声书播放器 UI 重设计

浮动耳机按钮(AudiobookFloatingBtn):

  • EPUB 内容区右下角 48×48px 圆形蓝色悬浮按钮,白色耳机图标
  • 20 秒无点击自动折叠:缩小 1/2、透明度 65%,折叠至右侧边缘呈半圆贴边
  • 0.5s cubic-bezier 过渡动画(transform + opacity),transform-origin: right center
  • 点击半圆按钮恢复完整态,重新开始倒计时
  • 有声书激活后自动隐藏

底部抽屉播放器(AudiobookDrawer,替代旧 AudiobookPlayer):

  • 从底部滑入 35vh(最大 420px),translateY 0.35s 过渡动画
  • 遮罩层点击隐藏抽屉,不改变播放状态
  • 停止按钮暂停 + 隐藏,不销毁会话
  • 再点击浮动按钮恢复抽屉 + 继续播放(resumeReading)

播控调速(playbackRate):

  • 合成 speed 与播控 playbackRate 彻底解耦
  • 合成时使用全局 TTS 设置的 speed(音质由引擎保证)
  • 播放时 audio.playbackRate 由抽屉 UI 独立控制(0.5x-3.0x)
  • playbackRate 持久化到 audiobook_progress.playback_rate
  • 调整语速时 audio.playbackRate 即时生效(store subscribe 同步)

字幕横滚动画:

  • 当前句文本在抽屉内 90% 宽容器中显示
  • 超宽时按句子播放时长匀速左移(~4 字/秒估算)
  • CSS @keyframes audiobook-subtitle-scroll,forwards 停在末尾
  • key={globalIndex} 确保换句时动画重置

全局 TTS 偏好兜底:

  • 开始朗读时自动从 useTTSStore 同步引擎/语音/语速/音调到有声书 Store
  • 已有进度时恢复进度中的配置(优先级更高)
  • 移除抽屉内音调控件(pitch 仅影响合成,不在播放时调整)

修复:

  • textSplitter.ts:CSS 选择器 [epub|type] 修复为 [epub\\:type] + matches() try-catch 防御
  • 工具栏耳机按钮已移除(入口统一为浮动按钮)

工程

  • Pre-commit/pre-push lint-staged hook 配置

代码统计(相对 v0.1.1)

| 类别 | 新增/修改文件 | 代码量 |

| ---------------------------------- | :-----------: | :-----------: |

| TTS 偏好重构 + 缓存 + 内联播放器 | ~8 | ~500 行 |

| 有声书浮动按钮 + 抽屉播放器 | 2 新建 | ~500 行 |

| 有声书 Store/Hook/集成重构 | ~5 | ~300 行 |

| 数据库迁移(pitch, playback_rate) | 1 | ~30 行 |

| 修复(textSplitter, 颜色等) | ~6 | ~50 行 |

| 合计 | ~22 | ~1,380 行 |

TTS 多引擎系统(2026-06-06 基础建设)

基于 ITTSProvider 统一接口的多引擎 TTS 系统,覆盖 13 个引擎。

本地免费引擎(4 个):

  • Sherpa-ONNX + Kokoro:中英文双语主力引擎(TTS Arena #1, 8 种中文语音 + 10+ 英文)
  • Supertonic TTS:极速英文引擎(CPU 167x 实时, M4 Pro 性能)
  • PaddleSpeech:百度飞桨开源中文引擎(Python CLI 子进程)
  • Piper CLI:40+ 语言兜底引擎(编译失败备用)

远程云端引擎(9 个):

  • 国内:阿里云 Qwen3-TTS(中文最佳)、火山引擎(基础音色免费)、腾讯云 TTS(¥0.75/万字最低)、百度智能云 TTS(企业 1 亿次免费)
  • 国际:OpenAI TTS、Google Cloud TTS(75+ 语言)、Azure Speech(国内节点 ¥0.95/万字)、Amazon Polly(首年 500 万字符/月免费)、ElevenLabs(语音克隆天花板)

安全:

  • API Key AES-256-GCM 加密存储(CredentialStore)+ PBKDF2(deviceId) 派生密钥 + 0o600 权限
  • 配置远程引擎前强制隐私声明弹窗(两重确认勾选)
  • 远程引擎失败自动降级到本地引擎

集成入口:

  • 消息右键菜单 "朗读"(MessageBubble.tsx)
  • AI 对话自动语音回复开关(AIChatView.tsx)
  • ChatView 头部菜单 "朗读最新消息"(ChatView.tsx)
  • 设置 → 语音合成 (TTS)(TTSSettings.tsx)

TTS 设置页功能:

  • 引擎状态展示(本地/云端分区,已就绪/未安装/错误状态)
  • 语音浏览 + 下载 + 删除 + 试听
  • 语速 0.25x-4.0x 滑块 + 音调 0.5x-2.0x 滑块
  • 隐私模式切换(仅本地 / 允许云端)
  • 语音模型静默下载(首次启动 30s 后后台下载默认语音 Kokoro ~160MB)

基础设施:

  • ITTSProvider 统一接口(packages/core/src/providers/ITTSProvider.ts)
  • TTSManager 多引擎管理器(注册/切换/降级链)
  • VoiceManager 语音模型注册表 + HTTP 下载 + 清单管理
  • CredentialStore AES-256-GCM 加密存储
  • 9 个 IPC 通道(tts:get-engines / speak / speak-stream / cancel-stream / download-voice / set-credentials 等)

有声书 (EPUB Audiobook)

核心功能:

  • 逐句朗读 + 高亮(当前句黄色背景 + 已读句灰色)
  • 自动翻页(高亮句超出视口触发 epubjs rendition.next())
  • 跨章节连续播放(relocated 事件自动追加新页句子)
  • 内嵌播放条(字幕滚动 + 进度拖拽 + 快进快退 + 章节导航 + 书签 + 导出 + 定时关闭)

播放体验:

  • 断点续听(CFI + 句子索引 + TTS 配置持久化到 SQLite,重新打开提示恢复)
  • 多语音选择(AudiobookVoiceSelector)
  • 语速 0.5x-4.0x 实时调节
  • 定时关闭(15/30/60/90 分钟 / 当前章结束,倒计时显示)
  • 快进快退(按累计音频时长跳过 ~10s / ~30s)
  • 文本跟随模式(高亮句 scrollIntoView({ block: 'center' }))
  • 句间停顿(200ms 静默间隔)

进度与存储:

  • 3 张 SQLite 表(audiobook_progress / audiobook_bookmarks / audiobook_history)
  • 每 5 秒 + 暂停时 + 翻页时 + 关闭时自动保存
  • 书签列表管理(命名 + 增删 + 跳转 + 恢复语音配置)
  • 播放统计(每次听书时长/句子数/引擎记录)

导出音频(AudiobookExportDialog):

  • 批量合成当前章所有句子 → FFmpeg concat 拼接 → 通过系统保存对话框导出 WAV/MP3
  • 导出进度条实时反馈,支持中途取消
  • 导出完成自动打开文件所在目录

技术核心:

  • textSplitter.ts:段落提取 + 中英文分句 + 非正文过滤(页码/脚注/引用)
  • domMarker.ts:TreeWalker 文本节点定位 + Range.surroundContents 逐句标记
  • useAudiobook.ts:提取→分句→TTS 合成→播放→高亮→翻页 全流程调度

UI 增强

  • 联系人列表(ContactsView.tsx):群组/频道成员汇总,在线绿点 + Bot 标签 + 搜索过滤 + 点击创建私聊
  • Emoji 选择器(EmojiPicker.tsx):7 分类(表情/手势/爱心/物品/自然/食物/符号)~300 emoji,8 列网格,光标位置插入

修复

  • LocalProvider.ts MessageStatus 类型错误(as string → as MessageStatus)

文档

| 文档 | 说明 |

| ------------------------------------- | ----------------------------------------- |

| docs/Piper TTS 集成评估报告.md | 14 种 TTS 方案横向对比 + 完整引擎架构设计 |

| docs/EPUB 电子书转有声书方案设计.md | 有声书完整方案 + 架构 + 实施计划 |

| docs/未完成工作清单.md | 代码库逐项验证的剩余工作清单 |

代码统计

| 类别 | 新增文件 | 代码量 |

| ----------------------- | :------: | :-----------: |

| TTS 引擎 + 管理器 | 16 | ~3,700 行 |

| 有声书 | 12 | ~1,800 行 |

| UI 增强(联系人/Emoji) | 2 | ~350 行 |

| IPC/Preload/类型 | ~10 修改 | ~500 行 |

| 合计 | ~40 | ~6,350 行 |

| TTS 引擎总计 | 4 本地 + 9 云端 = 13 引擎 |

| 所有 commits typecheck 状态 | 8/8 ✅ |

v0.1.1 · 消息状态系统 等

2026年6月5日

概述

从单一 isRead: boolean 全面升级为 Telegram 风格的五态消息指示器,覆盖数据层(IServerProvider + SQLite 迁移 + 所有 Provider)、UI 层(StatusIndicator / MessageDetailModal / MediaUploadPlaceholder)和服务端(WSS message_delivered / message_read 推送)。消息发送流程重构为乐观更新 + 自动重试。

数据层

  • MessageStatus 类型:'sending' | 'sent' | 'delivered' | 'read' | 'failed'
  • Message 接口新增 status、sentAt、deliveredAt、readAt 字段
  • LocalProvider:SQLite messages 表 ALTER TABLE 新增 4 列,sendMessage() 设置 status: 'sent' + sentAt,markAsRead() 同步设置 status: 'read' + readAt
  • WebSocketProvider:新增 message_delivered / message_read 事件类型,handlePacket() 分发状态变更
  • MatrixProvider:matrixEventToMessage() 默认 status: 'sent',注册 Room.receipt 事件 → status: 'read' 映射

UI 层

  • StatusIndicator:Telegram 风格五态图标
  • 🕐 sending(发送中)→ ✓ 单灰勾(已发送)→ ✓✓ 双灰勾(已送达)→ ✓✓ 蓝色(已读)→ ❌ 红色(失败)
  • 频道特殊处理:roomType === 'channel' 时仅显示 👁 + 已查看人数,不显示勾选标记
  • MessageDetailModal:右键菜单 → "查看详情" → 显示创建/发送/送达/已读时间线 + 状态徽章
  • MediaUploadPlaceholder:上传中尚未获得 mediaUrl 的媒体消息显示上传占位(类型图标 + 文件名 + 进度条 + 百分比),而非"无法找到"错误

useChatStore 重构

  • 乐观更新:sendMessage() 立即创建 status: 'sending' 消息显示在 UI → 成功后替换为服务端返回的消息
  • 自动重试:发送失败后指数退避重试(1s / 2s / 4s),最多 3 次,全部失败标记 status: 'failed'
  • upsertMessage:智能合并 WSS 推送的状态更新,保留最高优先级状态(read > delivered > sent > sending > failed)
  • 防重复气泡:sendFile / sendMessage 成功后先检查消息是否已被 onMessage 回调添加,避免重复

WSS 服务端

  • message_delivered:消息广播后检测是否有其他在线客户端,有则向发送者推送送达状态
  • message_read:mark_read 处理后推送已读状态给消息发送者 + 广播 readCount 给房间所有人
  • WSS schema 迁移:wss_messages 表新增 status/sent_at/delivered_at/read_at 列
  • 所有消息响应(get_messages/send_message/schedule_message/sendSystemMessage)添加 status 字段

涉及文件

| 文件 | 变更 |

| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------- |

| packages/core/src/providers/IServerProvider.ts | 新增 MessageStatus 类型,Message 新增 4 字段 |

| packages/core/src/providers/LocalProvider.ts | ALTER TABLE + sendMessage/markAsRead/rowToMessage 适配 |

| packages/core/src/providers/WebSocketProvider.ts | message_delivered/read 事件分发 |

| packages/core/src/providers/MatrixProvider.ts | Room.receipt 已读回执映射 |

| apps/desktop/src/main/ws-server.ts | 状态推送 + readCount 广播 + WSS schema 迁移 |

| apps/desktop/src/renderer/src/store/useChatStore.ts | 乐观更新/重试/upsertMessage/防重复 |

| apps/desktop/src/renderer/src/components/MessageBubble.tsx | StatusIndicator / roomType 差异化 / 频道 👁 / MediaUploadPlaceholder / 右键详情入口 |

| apps/desktop/src/renderer/src/components/MessageDetailModal.tsx | 新建 — 消息详情弹窗 |

| apps/desktop/src/renderer/src/App.tsx | addMessage → upsertMessage |

| apps/desktop/src/renderer/src/components/ChatView.tsx | 透传 roomType + onViewDetail |

内置浏览器

2026年6月2日

内置浏览器窗口

聊天消息中的 URL 自动识别为可点击链接,点击弹出独立 BrowserWindow 加载目标网站,自动嗅探页面中的视频资源并支持在线播放。

  • 链接识别:TextContent 组件自动解析消息文本中的 HTTP/HTTPS URL,渲染为蓝色可点击链接,派发 open-browser 自定义事件
  • 独立窗口:BrowserWindow 直接加载目标 URL,原生窗口框架,使用独立 persist:browser session 持久化 Cookie/Storage
  • 工具栏:前进/后退/刷新、地址栏(输入新 URL 跳转)、关闭窗口、系统浏览器打开(shell.openExternal)、Ctrl+L 聚焦地址栏
  • 持久登录态:persist:browser 分区,所有浏览器窗口共享 Cookie,重启应用后登录态保持

三层视频嗅探

| 嗅探层 | 位置 | 方式 |

| ----------------------- | -------------------------- | ---------------------------------------------------------------------------------------------- |

| webRequest URL | 主进程 onBeforeRequest | 正则匹配 .mp4/.m3u8/.mpd/.flv/.webm/.mkv/.mov/.avi |

| webRequest Content-Type | 主进程 onHeadersReceived | 匹配 video/、audio/、application/x-mpegURL、application/dash+xml |

| 主世界 JS 注入 | executeJavaScript | 劫持 fetch/XMLHttpRequest + 扫描 / 标签,通过 window.postMessage 回传 |

M3U8 校验与结果展示

  • M3U8 异步校验:嗅探到 .m3u8 链接后 fetch() 验证,检查响应内容含 #EXTM3U/#EXTINF → 标记 valid/invalid
  • TS/M4S 分片过滤:.ts/.m4s 流媒体分片直接过滤,不显示在嗅探列表
  • 页面导航刷新:URL 跳转时自动清空旧结果,重新嗅探新页面

| 链接类型 | 状态 | 图标 | 点击行为 |

| --------------- | -------- | :---: | --------------------- |

| MP4/MKV/WebM 等 | — | ▶ 蓝 | 弹出播放器 + 复制链接 |

| M3U8 校验通过 | valid | ▶ 蓝 | 弹出播放器 + 复制链接 |

| M3U8 校验失败 | invalid | 🚫 灰 | 仅复制链接 |

| M3U8 校验中 | checking | ⏳ 黄 | 仅复制链接 |

视频播放集成

  • 弹出播放器:点击可播放条目 → 暂停页面中视频 → 新建 BrowserWindow 加载 VideoPlayer 组件(?player=1&src=...)
  • HLS/M3U8 支持:VideoPlayer 新增 HLS 动态解码(import('hls.js') → hls.loadSource() + hls.attachMedia()),hls.js 未安装时回退 Safari 原生
  • standalone 模式: 解除内联尺寸限制(maxWidth 280→100%、maxHeight 200→100vh),自动播放
  • 不可播放条目:仍可点击复制链接到剪贴板,蓝色高亮反馈 600ms

请求安全与反检测

  • 请求头伪装:UA 替换为标准 Chrome 131,自动补齐 Referer
  • 弹窗拦截:window.open()/target="_blank" 全部在当前窗口内导航
  • 独立 Session:session.fromPartition('persist:browser'),主窗口 CSP 不影响浏览器窗口
  • 反检测脚本(主世界注入):覆盖 11 项特征(navigator.webdriver→false、补充 window.chrome、补全 navigator.plugins 等)

架构演进

| 尝试 | 问题 | 结论 |

| ------------------------- | ---------------------------------------- | ------------------------------ |

| + React | DOM 对账冲突→ERR_ABORTED、焦点抢占 | 废弃 |

| BrowserView + 透明窗口 | transparent:true→macOS 窗口消失 | 废弃 |

| preload 中 XHR/Fetch 劫持 | contextIsolation:true 下只影响隔离世界 | 改为主世界 executeJavaScript |

| all:initial CSS | SVG 图标放大、继承混乱 | 改为精确属性重置 |

涉及文件

| 文件 | 说明 |

| ------------------------------------------------------------ | --------------------------------------------------------------------- |

| apps/desktop/src/preload/browser.ts | 新增 — 浏览器窗口 preload(工具栏 + 嗅探面板 + postMessage 监听) |

| apps/desktop/src/main/index.ts | 7 个 browser IPC;主世界嗅探脚本注入;persist:browser session |

| apps/desktop/src/preload/index.ts | api.browser.{open, openExternally, openPlayer, onPlayVideoInChat} |

| apps/desktop/src/renderer/src/components/MessageBubble.tsx | TextContent:URL 识别 + open-browser 事件 |

| apps/desktop/src/renderer/src/components/VideoPlayer.tsx | standalone 模式 + HLS 动态解码 |

| apps/desktop/src/renderer/src/App.tsx | open-browser 监听 + ?player=1 播放器窗口 |

| apps/desktop/electron.vite.config.ts | preload 多入口(index.ts + browser.ts) |

| apps/desktop/package.json | 新增 hls.js 依赖 |

视频播放与媒体处理

2026年5月31日

FFmpeg/FFprobe 集成

  • 捆绑分发:@ffmpeg-installer/ffmpeg + @ffprobe-installer/ffprobe 随应用分发预编译二进制
  • 视频编码探测:上传时自动检测编码器(FFprobe → VideoMeta)
  • HEVC/Dolby Vision 转码:hevc/hvc1/dvh1/dvhe → 后台异步 H.264 SDR 转码
  • 封面缩略图:自动提取视频首帧作为消息气泡封面()
  • 视频笔记生成:圆形裁剪 + 静音 + 60s 限制,对标 Telegram video_note

自定义视频播放器

  • 替换原生 为自建 VideoPlayer 组件,inline/全屏控件一致
  • 控件栏:播放/暂停、可拖拽进度条(缓冲+播放双图层)、倍速、音量、全屏
  • 全屏:3s 自动隐藏控件,ESC 退出,键盘快捷键(Space/←→/↑↓/F)
  • 播放进度记忆:localStorage 持久化,重开视频从上次位置继续
  • 附件丢失 UI:文件被清理后显示图标+文件名+"无法找到"占位,停止重试循环
  • 视频播放方式设置:内置播放器 / 系统播放器(IINA 等),设置→数据与存储

字幕系统

  • 内嵌字幕提取:FFprobe 探测 → FFmpeg 提取文本字幕为 WebVTT → 渲染
  • 外挂字幕上传:右键菜单/⋮ 菜单 → .srt/.vtt/.ass/.ssa → FFmpeg 转 VTT
  • PGS 位图字幕:可探测列出,标记"待支持"(Chromium 无法渲染位图)
  • 字幕管理:单选模式,来源标记(内嵌/外挂),活跃轨道高亮 ✓

unbox-file:// 协议增强

  • Range 请求支持:206 Partial Content + bytes=start-end,分段读取,支持视频流播和 seek
  • 大文件保护:单次响应上限 200MB,超大文件自动流式传输避免 OOM
  • 路径编码修复:pathToUrl() 逐段 percent-encode,兼容路径中 @ 等特殊字符
  • 附件同步清理:删除聊天消息时自动清理 mediaUrl/thumbnailUrl/subtitleTracks 文件
  • 附件丢失检测:图片/GIF/视频笔记加载失败时显示 fallback UI

设置页调整

  • 移除 FFmpeg 开关:FFmpeg 为默认且唯一方案,不再需要用户手动选择
  • 新增视频播放方式:设置→数据与存储→内置播放器/系统播放器

文件预览系统增强

2026年5月28日

Office 文档在线预览

  • Word (.docx) 预览:新增 DocPreview 组件,基于 mammoth 将 .docx 转为 HTML 渲染,跟随主题适配深浅色
  • Excel (.xlsx/.csv) 预览:新增 SheetPreview 组件,基于 xlsx 解析为 HTML 表格,固定浅色主题保证可读性
  • 支持多 sheet 切换(Tab 标签栏)
  • 奇数行/偶数行底色交替,灰色边框
  • PPT (.pptx/.ppt):降级为"用系统应用打开"
  • 旧格式(.doc/.xls/.mobi):不支持预览,给出转换建议提示

Office 预览方式设置

  • 设置 → 数据与存储新增"Office 文档预览"选项:内置预览(默认)/ 系统应用
  • 设置持久化到 localStorage,切换即时生效

EPUB 电子书阅读增强

  • 主题跟随:深色/浅色主题自动切换 iframe 内配色
  • 字号缩放:60%~200%,步进 10%,通过 epubjs themes.fontSize() 实现
  • 行距调节:紧密(1.3) / 标准(1.6) / 宽松(2.0),通过 themes.override() 实现
  • 阅读位置记忆:CFI 位置持久化到 localStorage,重新打开自动跳转
  • 字号/行距持久化:关闭后重新打开保持设置
  • 分阶段加载动画:加载电子书… → 正在排版… → 正在分页… → 恢复阅读位置…,带旋转圆环动画
  • 页码追踪:rendition.on('relocated') + locations.locationFromCfi() 精确映射
  • DOM 结构修复:viewer div 始终渲染(visibility: hidden 替代 DOM 移除),解决 ref 为 null 的问题

代码质量提升

  • 抽取 useFileContent Hook:useFileText / useFileBuffer,消除四组件重复代码,内置 HTTP 缓存
  • file-type 内容检测兜底:扩展名 + MIME 无法识别时读取文件头 magic bytes
  • 大文本保护:TextPreview >500KB 截断 + 警告条

端口自动查找

  • portUtils.ts:findAvailablePort(startPort) 探测可用端口
  • 内置 WSS + 独立 WS 启动前先查找,被占用自动递增
  • Store 接收实际端口并更新 serverConfigs 持久化
  • 默认端口 8080 → 8090,避免与 Expo Metro (8081) 冲突

独立预览窗口

  • IPC window:open-preview 创建独立 BrowserWindow(960×720)
  • main.tsx URL 参数 ?preview=1 分流渲染
  • FilePreview 新增 embedded prop 适配独立窗口模式

其他

  • CSP style-src 新增 blob: 来源
  • db:run / db:exec 对"duplicate column name"不抛错
  • 移除废弃的 @types/react-native@0.73.0

v0.1.0

2026年5月7日

概述

unobox是作者一个人在空闲时间开发的项目,目前仅支持桌面端的即时通讯应用。本项目从构思到第一个版本发布,耗时近20天,是用多个AI Agent复合在一起的自动开发项目,按照构思时所整理的需求,通过AI整理思路、功能等,再通过多个AI全程自动实现、测试、调试等开发工作。

使用到的AI有(按照使用比重排序):

  • 1、Claude
  • 2、Antigravity
  • 3、Claude Code
  • 4、Gemini
  • 5、豆包
  • 6、DeepSeek
  • 7、Grok
  • 8、Copilot

产品简介

unobox 是一款仿照 Telegram 功能设计的跨平台私有即时通讯应用,支持用户自主选择和管理通讯服务后端。首个版本涵盖桌面端(Electron + React),移动端尚处于脚手架阶段。

核心价值主张:

  • 隐私优先:数据由用户自主控制,支持纯本地存储模式
  • 灵活部署:支持 WebSocket、Matrix 等多种后端,可随时切换
  • 功能完整:复刻 Telegram 核心功能,包括 Bot、Channel、群组等
  • 跨平台:Windows / macOS / Linux 全平台支持(桌面端)

平台与技术

| 组件 | 技术方案 |

| -------- | ---------------------------------------- |

| 桌面端 | Electron 32+ + React 18+ + TypeScript 5+ |

| 移动端 | Expo 52+ + React Native 0.76+(脚手架) |

| 共享核心 | TypeScript(packages/core) |

| 状态管理 | Zustand |

| 构建工具 | Turborepo + pnpm |

| 数据库 | better-sqlite3(桌面端) |

服务端架构

  • IServerProvider 统一接口:所有服务端通信通过统一接口实现,用户可在 UI 中增减服务器。
  • ProviderManager:多服务器管理核心,支持热插拔。
  • 启动引导:首次启动时强制进入 OnboardingView 配置向导,至少配置一个服务器后方可进入主界面。
  • 单服务器激活模式:从用户头像菜单切换当前激活的服务器,切换后界面内容自动刷新。

已实现的 Provider:

  • WebSocketProvider:自建 WebSocket 服务端连接器,支持多房间、实时消息广播、文件传输(Base64)、Token 鉴权。
  • LocalProvider:纯本地存储模式,基于 SQLite,数据保存在设备本地,完全离线可用。
  • MatrixProvider:Matrix(Synapse)协议适配,支持房间管理、消息收发、成员管理,端到端加密待实现。
  • UnsupportedProvider:Rocket.Chat / Nextcloud Talk 占位适配器,待 Phase 2 实现。

内置 WebSocket 服务端(backend/ws-server):

  • 多房间消息广播
  • 局域网 HTTP 文件直链共享
  • Channel 发言权限控制(owner/admin 可发言)
  • Channel 消息可见性控制(非成员不可读)
  • 系统消息生成(加入/创建频道通知)
  • Token 鉴权 + UUID 文件防冲突

消息功能

基础消息类型:

  • 文本消息(支持 Markdown 渲染:粗体、斜体、代码块、代码行)
  • 图片消息(发送原图或压缩图)
  • 视频消息(内置播放器)
  • 语音消息(录制、播放、波形显示)
  • 视频圆形气泡(Video Note,仿 Telegram 圆形视频,muted/autoPlay/loop)
  • 文件发送(任意格式,显示文件名、大小、类型图标)
  • GIF 动图(图片渲染 + 左上角 GIF 标签,附件菜单支持 image/gif)
  • Emoji 表情

消息操作:

  • 引用回复(显示被引用消息预览)
  • 消息转发(ForwardModal 选择目标房间,支持跨房间转发)
  • 编辑已发送消息(显示"已编辑"标记)
  • 删除消息(仅自己 / 双端删除)
  • 固定消息(Pin)
  • 消息已读状态(✓ 已发送 / ✓✓ 已读)
  • 消息表情反应(Reactions)
  • 消息定时发送(InputBar 时钟按钮 + datetime-local 选择器 + 客户端 setTimeout 调度)
  • 消息搜索(聊天内 / 全局,客户端过滤)
  • 消息跳转(点击引用 scrollIntoView + 2s 蓝色高亮闪烁)
  • 右键 / 长按消息菜单

媒体处理:

  • 图片查看器(全屏浮层,滚轮缩放 25%-500%,拖拽平移,旋转,下载)
  • 发送前预览媒体文件
  • 多图 / 多文件批量发送
  • 拖拽发送文件(拖拽到聊天窗口直接发送,拖拽到会话列表项自动切换会话,蓝色高亮视觉反馈)

聊天列表

  • 显示所有聊天类型(私聊、群组、Channel)
  • 未读消息计数徽章
  • 最后一条消息预览
  • 最后活跃时间
  • 置顶聊天
  • 聊天静音
  • 聊天文件夹(Folders):
  • 系统默认文件夹:所有、频道、群组、未读
  • 自定义文件夹(自定义名称、包含的会话类型/特定会话)
  • 聊天搜索

群组(Group)

  • 创建群组(名称、头像、描述)
  • 邀请成员
  • 成员管理(踢出、设置管理员、修改角色)
  • 管理员权限配置(6 项权限:修改群信息、删除消息、封禁成员、邀请成员、置顶消息、管理管理员)
  • 群公告(群主/管理员可编辑,聊天区顶部持久展示,支持编辑/移除/折叠)
  • 群内搜索成员(RoomInfoModal 实时搜索框,按 displayName/username 过滤)
  • 离开 / 解散群组
  • 话题模式(Topics,仿 Telegram Supergroup):超群包含子话题房间,支持创建/列表/进入/返回
  • 群投票(Poll):创建投票(单选/多选/定时开始/定时结束),实时百分比进度条,关闭投票
  • 发言冷却(Slow Mode):可配置 10s~1h 冷却时长,InputBar 倒计时锁定

Channel(频道)

创建 Channel 时必须选择存储模式:

模式 A:与服务器同步(📡)

  • 内容发布到已选择的服务器
  • 支持多端实时订阅
  • 支持他人订阅(公开 Channel)
  • 支持 Bot 集成

模式 B:仅保存在本地(💾)

  • 数据保存在设备本地(SQLite)
  • 无需网络连接即可使用
  • 适合个人日志、笔记、草稿
  • 支持导出(JSON/Markdown)
  • 支持 Bot 本地处理

已实现的 Channel 功能:

  • 发布帖子(文章、图文混排)
  • 定时发布
  • 编辑 / 删除已发布内容
  • 查看阅读量(Message.readCount + 消息气泡显示"已阅读 N")
  • 评论区(关联讨论群 + 右侧 40% 评论面板)
  • 订阅者管理(RoomInfoModal 订阅者标签页 + 移除按钮 + joinedAt 显示)
  • Channel 统计(ChannelStatsModal:总览卡片 + 频率柱状图 + 活跃热力图 + 发言者排行)
  • 多管理员协作(updateMemberRole 接口 + RoomInfoModal 角色下拉选择器)

Bot 框架

Bot 注册与管理:

  • Bot 注册(名称、头像、命令前缀、Webhook URL、命令列表)
  • Bot 删除(unregisterBot 接口,LocalProvider/WebSocketProvider 完整实现)
  • BotManageModal UI(注册、命令管理、删除、内联键盘配置)
  • InputBar 命令提示(输入 / 时实时过滤匹配命令)
  • 本地存储 Channel 可接入本地处理 Bot(无需网络)

Bot 命令处理:

  • Provider 层 onBotCommand 事件监听
  • App.tsx 订阅 onBotCommand,实现全自动 Bot 回复
  • 内置命令支持:/help(列出命令)、/ping(连通性)、/echo(回显)、/time(时间)
  • Webhook 模式:配置 webhookUrl 的 Bot 委托给外部 HTTP 服务处理
  • 防递归:Bot 回复消息标记 botId,不会再次触发命令处理

Bot 内联键盘(新增):

WS 服务端 Bot 存储(从占位升级为完整实现):

  • 新增 bots Map(StoredBot 接口)
  • register_bot:解析 payload → 写入 Map → 返回 { success, botId }
  • get_bots:按 roomId 过滤返回 Bot 列表
  • unregister_bot:从 Map 中删除
  • send_message 中 Bot 命令检测:文本以 / 开头时遍历 bots Map 匹配命令,广播 bot_command 事件

待实现:Bot 权限控制、Bot 定时发布、MatrixProvider Bot 完整实现

AI 智能聊天(计划外已实现)

  • 8 种 AI 接口支持:OpenAI / Gemini / Claude / DeepSeek / OpenRouter / Qwen / MiniMax / 自定义
  • 三种 API 协议适配:OpenAI 兼容格式、Gemini generateContent、Claude Messages
  • AI 接口配置管理(添加/编辑/删除/测试连接/获取模型列表)
  • 流式输出:Electron 主进程 ReadableStream + IPC 事件逐块推送,逐 token 追加到气泡,支持中途取消
  • AI 会话持久化:会话和消息存储至 SQLite,刷新/重启后完整恢复历史消息和活跃会话
  • 思考过程展示:DeepSeek R1 reasoning_content / Claude Extended Thinking,可折叠面板实时显示推理过程
  • 跨接口会话独立:每个会话绑定特定 AI 接口 + 模型,切换会话即切换上下文

服务器管理

  • 服务器列表(显示类型、状态、连接延迟)
  • 添加服务器向导(选择类型 → 填写配置 → 测试连接 → 保存)
  • 编辑服务器配置
  • 删除服务器(确认对话框)
  • 连接 / 断开切换
  • 服务器类型标识(Matrix 图标、WS 图标等)
  • 连接状态指示(绿色/黄色/红色)

UI/UX

  • 布局:三栏式桌面端布局(功能导航栏 60px + 聊天列表 320px + 聊天区域)
  • 主题:深色主题(默认)/ 浅色主题,字体大小可调节,Zustand 持久化
  • 侧边栏:文件夹图标列表,点击快速筛选会话;顶部头像弹出菜单(个人信息、服务器切换、设置入口)
  • 自定义无边框标题栏
  • 消息气泡:两种颜色区分自己/他人消息

工程与基础设施

  • Monorepo 结构:apps/desktop + apps/mobile + packages/core + packages/ui-web + packages/ui-mobile + backend/
  • CI/CD:GitHub Actions 自动构建(Linux AppImage/deb/rpm),跨仓库 Release 自动发布
  • 自动更新:Electron autoUpdater 集成 + UpdateModal 更新提示弹窗
  • 静态官网:web/ 目录,含跨平台下载页、使用说明、更新日志,响应式适配
  • 安全增强:Content-Security-Policy 配置(HTML meta + session.webRequest 响应头注入),WS 服务端用户身份兜底匹配

已知限制

以下功能尚未实现或仅有 Provider 层接口而缺 UI:

  • 联系人列表与用户资料页(占位页面)
  • 贴纸(Sticker)
  • 图片编辑器(裁剪、文字、涂鸦)
  • 语音/视频通话(Phase 2)
  • 移动端完整功能(仅 Expo 脚手架)
  • Bot 权限控制
  • MatrixProvider Bot 完整实现(当前为占位)
  • 端到端加密(本地模式)