版本更新日志
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-jstts.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.ts16 个全 skip、db-sqlite.test.ts38 个变成 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"的包里用 CJSrequire- 代价: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) - 新增
readyprop 是必需的 —— 原先浮动按钮的visible混着宿主的加载态(!loading && !error)。缺了它会出现「卡死的活跃会话」:DOM 三种格式正文异步到达,若正文就绪前可点,startReading取到 0 句后 return,但startSession已把isActive置真,抽屉点播放无反应 - FAB 的裁切层没有新增:
FilePreview的内容区本就是overflow: hidden,预览根节点(FAB 的包含块)在其内部,折叠成半圆的效果四种格式今天都已成立 FoliatePreview.tsx1733 → 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增加了testscript 与vitest.config.ts(配置与packages/core一致:vitest run --passWithNoTests)。默认环境node(纯逻辑跑得最快,需要 DOM 的传桩对象);正文提取用例在文件头用@vitest-environment jsdom单独切换到 jsdom。当前覆盖: hooks/pdfBookReader.test.ts——page-N严格解析(不能把fraction:…或page-0当页码)、翻页委派、next()必须等宿主goToPageresolve 才 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-messagewindow 事件,ChatView监听后带重试(最多 10 次 × 200ms)等待消息加载完成再定位 - 搜索越界提示 — 命中消息不在已加载范围(更早历史)时,会话内搜索显示「需加载」提示,引导用户向上滚动
修复
- 有声书自动翻章 —
playLoop播完当前章节后自动调用reader.next()推进到下一章 - 有声书高亮残留 — adapter 新增
markSentences预包裹句子,highlightSentence仅切换颜色,消除surroundContents导致的 DOM 损坏 - 有声书收起播放器后自动翻页失效 — 新增
autoScrollingRef区分引擎自动滚动与用户手动翻页,relocatehandler 不再误关autoFollow - 有声书 prebuffer 超时导致~10句自动停 —
PREBUFFER5→10,CHUNK_TIMEOUT15s→60s,超时退出加日志明确原因 - 有声书
book_title未填充 — Store 新增bookTitle/setBookTitle,FoliatePreview 提取 EPUB 元数据后写入 - 有声书 sleep timer 停止不优雅 — 新增
onExpire回调,倒计时归零时触发pauseReading而非仅setPlaying(false) - 有声书导出拼接 Broken — 新建
audio:concat-wavIPC handler 调用concatWavFiles真正拼接 WAV 文件,替换废弃的media.transcode+file.uploadconcat-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)接入 — 新增
volcProvider,支持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-fileIPC 通道 - 移除
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新增onShowBookmarkListprop + 书签按钮
修复
- 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 启动阶段触发libsodiumWebAssembly 依赖。 - 移动端接入
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— ⭐ 贴纸按钮(Stickerlucide 图标),紧邻 Emoji 按钮,点击弹出选择器ChatView.tsx—handleSendSticker完整管线:dataURI→Blob→uploadFile→sendMessage
定时消息调度器
主进程持久化调度器,确保持久化保证。
scheduler.ts—MessageScheduler单例:每 15 秒轮询messages+wss_messages表,到期消息自动清除scheduled_at并更新created_at/sent_at/房间last_message,通过 IPCscheduler:messages-dispatched通知渲染端刷新ScheduledMessagesModal.tsx— 定时消息管理面板:查看当前房间待发送列表、格式化倒计时(天/小时/分钟/秒)、单条取消、自动刷新- 集成到
ChatView.tsx更多菜单(Clock图标入口)+ IPC 监听自动刷新消息列表 preload/index.ts新增api.scheduler接口(getStatus/start/stop/pollNow/onMessagesDispatched)useChatStore.ts—scheduleMessage/cancelScheduledMessage/loadScheduledMessages正确维护scheduledMessagesstore 状态- 应用退出时自动停止调度器;启动时自动开始轮询
- 与渲染端
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/feedbacklabel - 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 防抖自动保存到 SQLitetts_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),
translateY0.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 下载 + 清单管理CredentialStoreAES-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.tsMessageStatus 类型错误(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:browsersession 持久化 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 的问题
代码质量提升
- 抽取
useFileContentHook: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.tsxURL 参数?preview=1分流渲染FilePreview新增embeddedprop 适配独立窗口模式
其他
- 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 内联键盘(新增):
- 类型系统:
InlineKeyboardButton、InlineKeyboardMarkup、CallbackQuery MessageContent.replyMarkup字段,支持在消息下方渲染可点击按钮- MessageBubble 内联键盘渲染:URL 按钮(
新标签打开)、回调按钮(点击触发回调) - ChatView 内联回调处理:
handleInlineCallback→answerCallbackQuery→ 刷新消息 - App.tsx
onCallbackQuery订阅:点击按钮后自动编辑消息(更新文本 + 移除键盘) - LocalProvider 完整实现:
answerCallbackQuery+onCallbackQuery - WebSocketProvider 代理:
answer_callback_query/callback_query消息类型 - BotManageModal 内联键盘配置 UI:展开面板、添加行、添加按钮(文字 + 回调数据)
- BotConfig 扩展
inlineKeyboard字段,注册时保存,回复时自动附带
WS 服务端 Bot 存储(从占位升级为完整实现):
- 新增
botsMap(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 完整实现(当前为占位)
- 端到端加密(本地模式)