43 Commits

Author SHA1 Message Date
huangjianwu
2e69d1179b chore(release): v2.2.1
补 v2.2.0 ghcr.io 镜像构建失败(pnpm 版本不兼容 Node 20)。
2026-05-09 14:47:11 +08:00
huangjianwu
7e5be46cda fix(docker): pin pnpm 到 9.15.0,修复 v2.2.0 ghcr.io 镜像构建失败
v2.2.0 tag 触发的 ghcr.io 推送挂在 frontend-builder 第 5/7 步
'pnpm install --frozen-lockfile',错误:

  code: 'ERR_UNKNOWN_BUILTIN_MODULE'
  Node.js v20.20.2

根因:'corepack prepare pnpm@latest' 拉到 pnpm 11.0.9,pnpm 11+ 要求 Node 22+,
跟我们 node:20-alpine 不兼容。lockfile 本身是 lockfileVersion '9.0' 由 pnpm 9
生成,理应跟 pnpm 9 配。

修:Dockerfile.complete + BillNote_frontend/Dockerfile 都 pin 到 pnpm@9.15.0;
不再用 @latest,避免上游再次升级悄悄破坏 CI。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:47:06 +08:00
huangjianwu
604cdefa15 chore(release): v2.2.0
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
详见 CHANGELOG.md。
2026-05-09 14:43:38 +08:00
huangjianwu
ff91f74bef docs: v2.2.0 CHANGELOG + README 版本
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
详见 CHANGELOG.md。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:43:25 +08:00
huangjianwu
9bbae2c0c4 fix(backend): 把 deploy-resilience 合入 release/2.2.0
P0 修:whisper 半成品目录死循环 + /deploy_status 硬依赖 torch。
原 PR fix/backend-deploy-resilience 未走 develop,直接随本次发版上 master,
回灌时 develop 也拿到。
2026-05-09 14:42:11 +08:00
Jianwu Huang
5b5bf802af Merge pull request #363 from JefferyHcool/feat/desktop-onboarding
feat(desktop): 桌面端首启 4 步引导
2026-05-09 14:37:53 +08:00
Jianwu Huang
ecc2e56246 Merge pull request #362 from JefferyHcool/feat/desktop-backend-health
feat(desktop): Sidecar 健康度面板 + 重启后端能力
2026-05-09 14:36:50 +08:00
Jianwu Huang
d8470bacbc Merge pull request #360 from JefferyHcool/feat/desktop-startup-diagnostics
feat(desktop): 启动期路径诊断 + 顶端横幅,主动暴露已知失败因素
2026-05-09 14:35:51 +08:00
Jianwu Huang
0af2efb4de Merge pull request #359 from JefferyHcool/feat/desktop-transcriber-defaults
feat(transcriber): 默认 size 改 tiny + 切本地引擎前 confirm 模型下载
2026-05-09 14:35:08 +08:00
huangjianwu
721bda5280 feat(transcriber): 默认 size 改 tiny + 切本地引擎前 confirm 模型下载
桌面端用户首次跑视频时挂在 fast-whisper 模型下载(默认 medium ~1.5GB),
两处改动:

1. backend/app/services/transcriber_config_manager.py:
   默认 whisper_model_size 从 'medium' (~1.5GB) → 'tiny' (~75MB)。
   新装用户没主动设置时不再被首次下载卡住;想要更高精度的用户去配置页主动切。

2. BillNote_frontend/src/pages/SettingPage/transcriber.tsx:
   handleSave 在保存前判断:选了 fast-whisper / mlx-whisper 且当前 size 在
   modelStatuses 里既未下载也不在下载中 → window.confirm 弹一个体积提示,
   推荐改用 Groq / 必剪 / 快手 等在线引擎;用户取消则不保存。

不改业务逻辑;零回归风险(已有用户 transcriber.json 里写了什么就还是什么)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:32:41 +08:00
huangjianwu
a928e0e38f feat(desktop): 桌面端首启 4 步引导
桌面端用户安装后空白进 web 主页面,提示填模型但不知道从哪填、转写引擎从哪选、
为什么 fast-whisper 在下东西。新增首启 onboarding wizard,把四件事拉成一条线:

1. 后端连通性自检(启动后调 /api/get_all_providers,OK 才能进下一步)
2. LLM 供应商 + 模型:填 OpenAI 兼容 base_url + key + model_name,调
   /add_provider 创建并 addModel 默认 model,附带 testConnection
3. 转写引擎:四选一,**默认推荐 Groq**(在线、免下载本地模型);
   选 fast-whisper 时显式提示"将下载模型"
4. Cookie 同步说明:桌面端无 chrome.cookies API,引导手动配;并指向插件版

实现:
- 新页 src/pages/Onboarding/index.tsx,单文件 stateful wizard
- App.tsx 加 /onboarding 路由 + OnboardingGuard 路由守卫:
  · 仅 Tauri 桌面端(__TAURI_INTERNALS__)拦截,纯 web 端透传,不打扰
  · localStorage('bilinote-onboarded') 不为 '1' 时强制跳 /onboarding
- 完成第 4 步 markOnboarded() 写 localStorage 后 navigate('/')

回归风险:纯 web 用户无感知;旧桌面端用户的 localStorage 没这个 key,
首次升级到含此 PR 的版本时会跳一次 onboarding(建议在升级 release notes 里
说明,避免老用户疑惑)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:30:05 +08:00
huangjianwu
1329390f98 feat(desktop): Sidecar 健康度面板 + 重启后端能力
P1 已经把 backend-warning / backend-terminated 横幅做出来了;P2 把
lib.rs 那条 stdout/stderr/terminated 信息流真正落到一个常驻 UI 上:
- 右下角浮动状态点(绿/黄/红),轮询 /api/sys_health 决定颜色
- 点开抽屉看最近 200 行日志(ring buffer),含「重启后端」「复制日志」按钮

Rust:
- src-tauri/src/lib.rs:把 sidecar 启动抽出 spawn_backend_sidecar(),
  CommandChild 存进 SidecarHandle(Mutex<Option<CommandChild>>) 这个 state
- 新增 #[tauri::command] restart_backend_sidecar:kill 旧 child + 重新 spawn +
  emit 'backend-restarted' 给前端
- 监听任务 stdout/stderr emit 时不再用 format!("'{}'", ...) 包引号,原文直传;
  前端 hook 同时兼容旧形式(兜底剥引号)

前端:
- components/BackendHealth/useBackendEvents.ts:listen 四个事件 +
  ring buffer (MAX 200 行) + invoke restart + clipboard 复制日志
- BackendHealthIndicator.tsx:右下角浮动状态点,5s 轮询 /api/sys_health;
  连续 3 次失败或 backend-terminated 触发 → 红
- BackendLogPanel.tsx:右侧抽屉,深色 monospace 日志区 + 操作按钮
- 纯 web 环境(无 __TAURI_INTERNALS__)下静默不挂载

P3 / P4 还在路上。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:27:02 +08:00
Jianwu Huang
b117ab9f71 Merge pull request #358 from JefferyHcool/revert-357-fix/backend-deploy-resilience
Revert "Fix/backend deploy resilience"
2026-05-09 14:25:49 +08:00
Jianwu Huang
c4abaf4e60 Revert "Fix/backend deploy resilience" 2026-05-09 14:25:37 +08:00
Jianwu Huang
50f0816dab Merge pull request #357 from JefferyHcool/fix/backend-deploy-resilience
Fix/backend deploy resilience
2026-05-09 14:25:29 +08:00
huangjianwu
9a64a2da8e feat(desktop): 启动期路径诊断 + 顶端横幅,主动暴露已知失败因素
桌面端历史痛点:PyInstaller sidecar 在含非 ASCII / 含空格 / 不可写的安装路径下
直接炸(README:36 仅文字警告"不要中文路径",无主动防御)。Sidecar 退出事件 lib.rs
已 emit 但前端没 listener 消化,用户看到的是空白主页。

- src-tauri/src/lib.rs:
  · setup 中 env::current_exe() 之后做 InstallPathDiagnostics(is_ascii / 空格 /
    父目录 write_probe),命中任一异常就 emit 'backend-warning' 给前端
  · 用 std::thread::spawn + 1500ms sleep 等首屏 listener 挂上再 emit,避免事件丢失
  · 新增 Tauri command get_install_path_diagnostics,前端可主动重查(用户卸载到
    新目录后首次启动)
- BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx(新建):
  · 监听 backend-warning(路径警告,可关闭)+ backend-terminated(致命,常驻)
  · 纯 web 环境(无 __TAURI_INTERNALS__)下静默不挂载,对桌面端定向起效
  · backend-error(stderr 噪音)暂不展示,留给后续 P2 日志面板
- App.tsx:StartupBanner 在 BackendInitDialog 之前就挂载,让"后端还在初始化时"
  也能看见路径警告(正是该场景容易炸)

不改任何业务逻辑;纯桌面端 UX 加固。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:22:41 +08:00
huangjianwu
2bb69d1581 fix(backend): 部署友好性——whisper 半成品目录与 deploy_status 硬依赖 torch
两处部署反馈来的问题:

1. WhisperTranscriber 反复抛 'Unable to open file model.bin in
   model whisper-base'
   · 原因:__init__ 只看目录是否存在判定模型已下载(Path(model_path).exists()),
     但首次下载若中断 / 网络异常会留下空 / 半成品目录,下次启动绕过下载分支直接
     进 WhisperModel 加载,于是死循环报错
   · 修:判定条件换成 'model.bin' 落盘存在;目录在但 model.bin 缺失时打 warn
     并触发重新下载
   · routers/config.py 的 _check_whisper_model_exists 同步改用 model.bin 判定,
     避免「已下载」状态在监控页误报

2. /api/deploy_status 在没装 torch 的部署上 500
     ModuleNotFoundError: No module named 'torch'
   · 原因:endpoint 顶部直接 import torch,仅 fast-whisper 才用得到的依赖被强制为
     全局必需。轻量部署 / 用户切到 Groq / 必剪 / 快手 在线引擎时无 torch 也合理
   · 修:torch 改为 try/except,未装或 cuda 检测异常时返回
     {available: false, torch_installed: false};同时把 transcriber 配置 +
     ffmpeg 都包在 try 里,保证整个监控 endpoint 不会被任一子项打死

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:57:34 +08:00
Jianwu Huang
e89090bed0 Merge pull request #356 from JefferyHcool/feature/extension-video-understanding
feat(extension): 多模态视频理解开关 + 抽帧/拼图参数(对齐 web NoteForm)
2026-05-09 13:53:34 +08:00
Jianwu Huang
edf2083d71 Merge pull request #355 from JefferyHcool/feature/extension-form-parity
feat(extension): NoteForm 字段对齐 web 端(style 预设 + format 完整 + extras)
2026-05-09 13:53:11 +08:00
Jianwu Huang
f6d299ce48 Merge pull request #353 from JefferyHcool/feature/extension-video-understanding
feat(extension): 多模态视频理解开关 + 抽帧/拼图参数(对齐 web NoteForm)
2026-05-07 17:28:00 +08:00
Jianwu Huang
ed1ee0a151 Merge pull request #352 from JefferyHcool/feature/extension-form-parity
Feature/extension form parity
2026-05-07 17:27:35 +08:00
huangjianwu
a7c717abbd feat(extension): 多模态视频理解开关 + 抽帧/拼图参数(对齐 web NoteForm)
web 端 NoteForm 早就有 video_understanding / video_interval / grid_size 三件套,
插件之前没有,导致用户在视觉模型上想用「画面理解」时只能去 web 端发任务。

新增字段(types.ts Settings 与 GenerateRequest 同步):
- video_understanding: boolean,默认 false(关)
- video_interval: number,1-30 秒,默认 6(与 web NoteForm 默认一致)
- grid_size: [number, number],1-10,默认 [2,2]

UI 落地:
- popup 「高级」折叠区:开关 + interval + grid_size 行/列三栏,启用时才显示后两个,
  并提示需要选视觉模型
- options General 页:单独一节「视频理解(多模态)」展开同样字段
- popup start() 与 background startTask() 在 generate_note 请求里带上这三个字段;
  关闭时不传(避免覆盖 backend 默认)

回归风险:默认 false,对现有用户行为不变。

依赖:feature/extension-form-parity(叠加在它之上,因为 Settings 是同一片字段域)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:22:57 +08:00
huangjianwu
799ab64a28 feat(extension): NoteForm 字段对齐 web 端(style 预设 + format 完整 + extras)
之前插件 popup / options 的笔记选项跟 web 端 NoteForm 不齐,存在三处差距:

1. style 字段实质 broken
   · backend prompt_builder.get_style_format 是 enum 映射(minimal/detailed/
     academic/tutorial/xiaohongshu/life_journal/task_oriented/business/
     meeting_minutes 共 9 个),不命中直接 return ''
   · 插件原来给的是自由文本框,用户填什么都对不上 enum,等于没传
   · 改:popup + options 都换成 9 个预设的下拉框,与 backend 严格对齐
2. format 字段缺一半
   · backend 支持 toc / link / screenshot / summary 四个
   · 插件只暴露了 screenshot / link 两个 checkbox
   · 改:types.ts 新增 NOTE_FORMATS 常量,UI 渲染完整 4 个 checkbox。
     生成请求时 format 数组、screenshot/link 单布尔由 settings.formats 派生,单一真相源
3. 缺 extras 字段
   · backend VideoRequest.extras 直接拼到 prompt 末尾给 LLM
   · 改:popup 折叠的"高级"区 + options 默认生成选项区都加 textarea

Settings 默认值:style='minimal'、formats=['toc','summary']、extras=''。
旧 settings 里若 style 是无效字符串,下拉会显示空白,用户重选一次即可。

logic/types.ts:
- 新增 NoteStyle / NoteFormat type alias 与 NOTE_STYLES / NOTE_FORMATS 常量
- Settings 接口加 formats: NoteFormat[] / extras: string,style 改为 NoteStyle
- 老的 screenshot / link 布尔保留(向后兼容旧 storage),但 UI 不再绑定,submit 时也由 formats 派生

popup / background / options 三处提交 generate_note 的逻辑同步收口。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:18:28 +08:00
huangjianwu
c0837e0132 chore(release): merge release/2.1.4 back into develop 2026-05-07 16:45:11 +08:00
huangjianwu
c9497b502c chore(release): v2.1.4
CI 工程化修复,无运行时行为变化。详见 CHANGELOG.md。
2026-05-07 16:44:59 +08:00
huangjianwu
1aea86a8d6 docs: v2.1.4 CHANGELOG + README 版本
CI 工程化修复,无运行时行为变化。详见 CHANGELOG.md。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:44:49 +08:00
Jianwu Huang
9237cac9c3 Merge pull request #351 from JefferyHcool/fix/ci-commitlint
fix(ci): commitlint workflow 去掉伪 input + 规范 release merge commit 格式
2026-05-07 14:47:03 +08:00
Jianwu Huang
f97ab0b7bc Merge pull request #350 from JefferyHcool/fix/ci-drop-linux-tauri-build
ci(tauri): 桌面端构建去掉 Linux,只保留 macOS + Windows
2026-05-07 14:42:30 +08:00
huangjianwu
ac72cc6d6e ci(tauri): 桌面端构建去掉 Linux,只保留 macOS + Windows
Tauri Linux 构建 (ubuntu-22.04, x86_64-unknown-linux-gnu) 在 v2.1.x 这几次发版上
持续 17m+ 才完成,相比 macOS / Windows 更慢,且没有面向 Linux 桌面端用户的实际分发渠道。
直接从 matrix 里去掉。

清理:
- matrix 删除 ubuntu-22.04 条目
- 'Install Linux Dependencies' step(仅 ubuntu 触发)整段移除
- artifact 收集步里的 .deb / .AppImage 两条 find 命令移除

Linux 用户继续可以走 Docker 镜像 (ghcr.io/jefferyhcool/bilinote),那条线没变。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:30:07 +08:00
huangjianwu
7358cd0123 fix(ci): commitlint workflow 去掉伪 input + 规范 release merge commit 格式
v2.1.3 push master 时 Lint commit messages job 红了,根因两条:

1. workflow 里写了 'firstParent: false',但 wagoid/commitlint-github-action@v6
   的合法 input 列表里没这个字段,被 ignore 同时打 warn

2. release merge commit 标题 'Release v2.1.3' 不符合 type(scope): subject 格式,
   commitlint 报 subject-empty + type-empty
   · @commitlint/config-conventional 默认 ignore 'Merge ' 前缀的 commit,
     但我们手动 -m 把标题写成 'Release vX.Y.Z' 跳过了豁免

修:
- 去掉 .github/workflows/commitlint.yml 里那条 firstParent 假 input
- RELEASING.md §3 加入 merge commit 标题模板:
  · 合 master 用 'chore(release): vX.Y.Z'
  · 回灌 develop 用 'chore(release): merge release/X.Y.Z back into develop'
- CONTRIBUTING.md §6.3 同步加这条提醒

历史上 master / develop 的 'Release v2.1.x' 那几个 merge commit 已经在 history
里,没法回头改(不能强推 master)。但 commitlint 在 push 时只 lint 推送范围里的
新 commit,旧 commit 不会重新校验,所以不会持续报错。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:19:39 +08:00
huangjianwu
80f081613b Merge branch 'release/2.1.3' back into develop 2026-05-07 14:14:43 +08:00
huangjianwu
26e23d0f2c Release v2.1.3
修 issue #282 (DeepSeek 等非多模态供应商被 400 拒绝)。详见 CHANGELOG.md。
2026-05-07 14:14:33 +08:00
huangjianwu
234e3b9d2a docs: v2.1.3 CHANGELOG + README 版本
修 issue #282(DeepSeek 等非多模态供应商被 400 拒绝)。详见 CHANGELOG.md。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:14:22 +08:00
Jianwu Huang
1d93d1c5f5 Merge pull request #345 from voidborne-d/fix/backend-deepseek-content-format
fix(backend): UniversalGPT.create_messages emit string content when no images
2026-05-07 14:12:34 +08:00
huangjianwu
c19d462505 Merge branch 'release/2.1.2' back into develop 2026-05-07 14:06:35 +08:00
huangjianwu
64882e6a77 Release v2.1.2
补 v2.1.1 ghcr.io 镜像构建失败。详见 CHANGELOG.md。
2026-05-07 14:06:26 +08:00
huangjianwu
f32a6944d1 docs: v2.1.2 CHANGELOG + README 版本
补 v2.1.1 ghcr.io 镜像构建失败。详见 CHANGELOG.md。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:06:16 +08:00
Jianwu Huang
c5c84a8ec7 Merge pull request #349 from JefferyHcool/fix/docker-frontend-build
fix(docker): 修复 Tag push 触发的镜像构建失败
2026-05-07 14:04:38 +08:00
huangjianwu
c4413c66a1 fix(docker): 修复 Tag push 触发的镜像构建失败
ghcr.io 镜像推送在 v2.1.1 tag 上失败,停在 frontend-builder 第 7/7 步
'pnpm run build':vite loadConfigFromBundledFile 1.5s 内挂掉,没具体行号——
典型现象是 vite.config.ts 顶部 import 的某个 plugin(@tailwindcss/vite)的
native binding 在容器里 require 失败。

三处修复:
1. Dockerfile.complete + BillNote_frontend/Dockerfile:node:18-alpine → node:20-alpine
   · Tailwind v4 已不再支持 Node 18(package 现实需要 20+)
   · Vite 6 也建议 Node 20+
2. Dockerfile.complete 的 frontend 阶段:复制 pnpm-lock.yaml + 改用 --frozen-lockfile
   · 之前没传 lockfile,每次 pnpm install 重解析 semver,有可能拉到比本地更新的 native dep
3. BillNote_frontend/pnpm-lock.yaml 强制入库(git add -f)
   · 之前根 .gitignore 有条诡异的 'BiliNote/pnpm-lock.yaml'(拼错的路径),
     虽然没真匹配上这个文件,但 lockfile 历史上一直没被提交,导致 CI 与本地依赖图持续漂移
   · lockfile 里 @tailwindcss/oxide 同时锁了 musl 与 gnu 变体,alpine 跑没问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:01:26 +08:00
Jianwu Huang
26ee15ce28 Merge pull request #347 from JefferyHcool/docs/readme-wechat-qr
docs(readme): 联系和加入社区段落补上微信群二维码
2026-05-07 13:59:47 +08:00
huangjianwu
29fa3d9540 docs(readme): 联系和加入社区段落补上微信群二维码
之前只写"年会恢复更新以后放出最新社区地址",现在直接挂 doc/wechat.png 上去。
GitHub 渲染相对路径图片时按 raw.githubusercontent.com 自动转,无需 CDN。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:57:40 +08:00
huangjianwu
61cb4ec9fa Merge branch 'release/2.1.1' back into develop 2026-05-07 13:55:01 +08:00
voidborne-d
3ff7086491 fix(backend): UniversalGPT.create_messages emit string content when no images
DeepSeek deepseek-chat 等非多模态模型只接受 ``content`` 为字符串。旧实现在
没有 ``video_img_urls`` 输入时也把 ``content`` 拼成
``[{"type":"text","text":...}]`` 多模态数组,导致 DeepSeek API 返回
``Failed to deserialize the JSON body into the target type: messages[0]:
unknown variant `image_url`, expected `text```,整个笔记生成流程随之崩溃。

修复方式:``create_messages`` 在没有截图时退回 string content;有截图时维持
原多模态数组形态,多模态模型功能不退化。同时把 ``_build_merge_messages`` 也
改为 string content —— 合并阶段从不带图片,旧的数组形态会让长视频 chunk
之后的合并阶段同样命中 DeepSeek 400。

新增 ``backend/tests/test_universal_gpt_content_format.py`` (6 cases):

- 无图片 / 显式空 image 列表都走 string content
- 有图片仍输出多模态数组(含 ``image_url`` + ``detail: auto``)
- 纯文本响应里完全不含 ``image_url`` 字段
- ``_build_merge_messages`` 用 string content + 仍带入 partials 文本

红基线:在不打补丁的 ``universal_gpt.py`` 上跑这 6 个 case,3 个 string-
content 断言会失败(命中 issue #282 的同一根因),打补丁后 6/6 通过。

Closes #282
2026-05-07 13:50:59 +08:00
25 changed files with 12306 additions and 137 deletions

View File

@@ -26,7 +26,5 @@ jobs:
uses: wagoid/commitlint-github-action@v6
with:
configFile: .commitlintrc.json
# PR 上检查 base..head 之间所有 commitpush 上只校验最新 commit
firstParent: false
failOnWarnings: false
helpURL: https://github.com/JefferyHcool/BiliNote/blob/develop/CONTRIBUTING.md#5-提交规范

View File

@@ -13,8 +13,6 @@ jobs:
include:
- platform: macos-latest
target: universal-apple-darwin
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- platform: windows-latest
target: x86_64-pc-windows-msvc
@@ -24,13 +22,6 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
# Linux 系统依赖Tauri 需要)
- name: Install Linux Dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
# 设置 Python 环境(带 pip 缓存)
- name: Set up Python
uses: actions/setup-python@v5
@@ -103,9 +94,6 @@ jobs:
# Windows: .msi, .exe (NSIS)
find "$BUNDLE_DIR" -name "*.msi" -exec cp {} release-artifacts/ \; 2>/dev/null || true
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
# Linux: .deb, .AppImage
find "$BUNDLE_DIR" -name "*.deb" -exec cp {} release-artifacts/ \; 2>/dev/null || true
find "$BUNDLE_DIR" -name "*.AppImage" -exec cp {} release-artifacts/ \; 2>/dev/null || true
echo "=== Collected artifacts ==="
ls -lh release-artifacts/

View File

@@ -70,6 +70,7 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
// B 站:先在浏览器里抓字幕(带本地登录态 cookie随提交带过去
const prefetched = platform === 'bilibili' ? await fetchBilibiliSubtitle(url) : null
const formats = settings.formats || []
try {
const res = await fetch(`${backend}/api/generate_note`, {
method: 'POST',
@@ -80,13 +81,15 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
quality: settings.quality,
provider_id: settings.providerId,
model_name: settings.modelName,
screenshot: settings.screenshot,
link: settings.link,
// backend 同时接受 format 数组与 screenshot/link 单独布尔;从 formats 派生保持单一真相源
format: [...formats],
screenshot: formats.includes('screenshot'),
link: formats.includes('link'),
style: settings.style || undefined,
format: [
...(settings.screenshot ? ['screenshot'] : []),
...(settings.link ? ['link'] : []),
],
extras: settings.extras || undefined,
video_understanding: settings.video_understanding || undefined,
video_interval: settings.video_understanding ? settings.video_interval : undefined,
grid_size: settings.video_understanding ? settings.grid_size : undefined,
prefetched_transcript: prefetched ?? undefined,
}),
})

View File

@@ -7,9 +7,14 @@ export const DEFAULT_SETTINGS: Settings = {
providerId: '',
modelName: '',
quality: 'medium',
formats: ['toc', 'summary'],
screenshot: false,
link: false,
style: '',
style: 'minimal',
extras: '',
video_understanding: false,
video_interval: 6,
grid_size: [2, 2],
}
export const MAX_TASKS = 30

View File

@@ -40,6 +40,9 @@ export interface GenerateRequest {
format?: string[]
style?: string
extras?: string
video_understanding?: boolean
video_interval?: number
grid_size?: [number, number]
// 客户端在浏览器里直接抓到的字幕,跳过后端的 download_subtitles + 音频转写
prefetched_transcript?: {
language: string
@@ -78,14 +81,52 @@ export interface TaskRecord {
result?: NoteResult
}
// 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐
export type NoteStyle =
| 'minimal' | 'detailed' | 'academic' | 'tutorial'
| 'xiaohongshu' | 'life_journal' | 'task_oriented'
| 'business' | 'meeting_minutes'
// 与 backend/app/gpt/prompt_builder.py note_formats 一一对齐
export type NoteFormat = 'toc' | 'link' | 'screenshot' | 'summary'
export const NOTE_STYLES: Array<{ value: NoteStyle, label: string }> = [
{ value: 'minimal', label: '精简' },
{ value: 'detailed', label: '详细' },
{ value: 'tutorial', label: '教程' },
{ value: 'academic', label: '学术' },
{ value: 'xiaohongshu', label: '小红书' },
{ value: 'life_journal', label: '生活向' },
{ value: 'task_oriented', label: '任务导向' },
{ value: 'business', label: '商业风格' },
{ value: 'meeting_minutes', label: '会议纪要' },
]
export const NOTE_FORMATS: Array<{ value: NoteFormat, label: string }> = [
{ value: 'toc', label: '目录' },
{ value: 'summary', label: 'AI 总结' },
{ value: 'screenshot', label: '原片截图' },
{ value: 'link', label: '原片跳转' },
]
export interface Settings {
backendUrl: string
providerId: string
modelName: string
quality: Quality
// 输出 format 的 toggle 集合screenshot / link 与下方两个布尔保持联动)
formats: NoteFormat[]
screenshot: boolean
link: boolean
style: string
style: NoteStyle
extras: string
// 多模态视频理解:抽帧拼图喂给视觉模型,提升画面相关问题的回答质量
// 要求所选 model 是视觉模型(如 gpt-4o / gemini / claude-opus 系列),文字模型会忽略图片
video_understanding: boolean
// 抽帧间隔(秒),范围 1-30默认 6
video_interval: number
// 拼图网格 [rows, cols],每张拼图最多 rows*cols 帧。默认 [2,2]
grid_size: [number, number]
}
export interface ProviderUpdatePayload {

View File

@@ -3,9 +3,16 @@ import { onMounted, ref } from 'vue'
import { getProviders, ping } from '~/logic/api'
import { settings, settingsReady } from '~/logic/storage'
import { getModelsByProvider } from '~/logic/api'
import type { Model, Provider } from '~/logic/types'
import { NOTE_FORMATS, NOTE_STYLES, type Model, type NoteFormat, type Provider } from '~/logic/types'
import { watch } from 'vue'
function toggleFormat(value: NoteFormat, checked: boolean) {
const cur = settings.value.formats || []
settings.value.formats = checked
? Array.from(new Set([...cur, value]))
: cur.filter(v => v !== value)
}
const providers = ref<Provider[]>([])
const models = ref<Model[]>([])
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
@@ -128,13 +135,67 @@ onMounted(async () => {
</label>
<label class="flex flex-col gap-1">
<span class="text-gray-600">笔记风格</span>
<input v-model="settings.style" class="input" placeholder="留空使用默认">
<select v-model="settings.style" class="input">
<option v-for="s in NOTE_STYLES" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</label>
<label class="flex items-center gap-2">
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
</div>
<div class="flex flex-col gap-1 text-sm">
<span class="text-gray-600">输出形式 web NoteForm 对齐</span>
<div class="flex flex-wrap gap-x-4 gap-y-2">
<label v-for="f in NOTE_FORMATS" :key="f.value" class="flex items-center gap-2">
<input
type="checkbox"
:checked="(settings.formats || []).includes(f.value)"
@change="toggleFormat(f.value, ($event.target as HTMLInputElement).checked)"
>
{{ f.label }}
</label>
</div>
</div>
<label class="flex flex-col gap-1 text-sm">
<span class="text-gray-600">额外提示词追加到 prompt 末尾</span>
<textarea
v-model="settings.extras"
class="input resize-y"
rows="3"
placeholder="例如:重点关注游戏开发部分;保留所有专业术语原文"
/>
</label>
</section>
<section class="section-card">
<h2 class="font-semibold">视频理解多模态</h2>
<p class="text-xs text-gray-500">
启用后会按抽帧间隔截取视频帧拼成网格图连同字幕一起喂给视觉模型提升画面相关问题的回答质量
<strong class="text-amber-700">需要选择视觉模型</strong>GPT-4o / Gemini / Claude 文字模型会忽略图片
</p>
<label class="flex items-center gap-2 text-sm">
<input v-model="settings.video_understanding" type="checkbox">
启用视频理解
</label>
<div v-if="settings.video_understanding" class="grid grid-cols-3 gap-3 text-sm">
<label class="flex flex-col gap-1">
<span class="text-gray-600">抽帧间隔(, 1-30)</span>
<input v-model.number="settings.video_interval" type="number" min="1" max="30" class="input">
</label>
<label class="flex items-center gap-2">
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
<label class="flex flex-col gap-1">
<span class="text-gray-600">拼图行 (1-10)</span>
<input
:value="settings.grid_size?.[0] ?? 2"
type="number" min="1" max="10" class="input"
@input="settings.grid_size = [Number(($event.target as HTMLInputElement).value) || 2, settings.grid_size?.[1] ?? 2]"
>
</label>
<label class="flex flex-col gap-1">
<span class="text-gray-600">拼图列 (1-10)</span>
<input
:value="settings.grid_size?.[1] ?? 2"
type="number" min="1" max="10" class="input"
@input="settings.grid_size = [settings.grid_size?.[0] ?? 2, Number(($event.target as HTMLInputElement).value) || 2]"
>
</label>
</div>
</section>

View File

@@ -4,7 +4,7 @@ import { detectPlatform } from '~/logic/platform'
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
import type { TaskRecord } from '~/logic/types'
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
const tabUrl = ref<string>('')
const tabTitle = ref<string>('')
@@ -67,19 +67,22 @@ async function start() {
try {
// B 站:在用户浏览器里直接抓字幕(带本地登录态 cookie跳过后端的 download_subtitles 与音频转写
const prefetched = platform.value === 'bilibili' ? await fetchBilibiliSubtitle(tabUrl.value) : null
const formats = settings.value.formats || []
const { task_id } = await generateNote({
video_url: tabUrl.value,
platform: platform.value!,
quality: settings.value.quality,
provider_id: settings.value.providerId,
model_name: settings.value.modelName,
screenshot: settings.value.screenshot,
link: settings.value.link,
// backend VideoRequest 同时接受 format 数组与 screenshot/link 单独布尔,从 formats 派生保持单一真相源
format: [...formats],
screenshot: formats.includes('screenshot'),
link: formats.includes('link'),
style: settings.value.style || undefined,
format: [
...(settings.value.screenshot ? ['screenshot'] : []),
...(settings.value.link ? ['link'] : []),
],
extras: settings.value.extras || undefined,
video_understanding: settings.value.video_understanding || undefined,
video_interval: settings.value.video_understanding ? settings.value.video_interval : undefined,
grid_size: settings.value.video_understanding ? settings.value.grid_size : undefined,
prefetched_transcript: prefetched ?? undefined,
})
activeTaskId.value = task_id
@@ -108,6 +111,13 @@ function openOptions() {
browser.runtime.openOptionsPage()
}
function toggleFormat(value: NoteFormat, checked: boolean) {
const cur = settings.value.formats || []
settings.value.formats = checked
? Array.from(new Set([...cur, value]))
: cur.filter(v => v !== value)
}
async function openSidePanel() {
// 只能在用户操作触发的同步上下文里调,且需要明确的 tabId
try {
@@ -176,7 +186,7 @@ onUnmounted(() => {
</div>
<fieldset class="border rounded p-2 flex flex-col gap-2" :disabled="!supported || submitting">
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="grid grid-cols-2 gap-2 text-xs">
<label class="flex flex-col gap-1">
<span class="text-gray-600">画质</span>
<select v-model="settings.quality" class="border rounded px-1 py-0.5">
@@ -185,14 +195,76 @@ onUnmounted(() => {
<option value="slow">高质</option>
</select>
</label>
<label class="flex items-center gap-1 mt-4">
<input v-model="settings.screenshot" type="checkbox"> 截图
</label>
<label class="flex items-center gap-1 mt-4">
<input v-model="settings.link" type="checkbox"> 跳转
<label class="flex flex-col gap-1">
<span class="text-gray-600">笔记风格</span>
<select v-model="settings.style" class="border rounded px-1 py-0.5">
<option v-for="s in NOTE_STYLES" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</label>
</div>
<div class="flex flex-col gap-1 text-xs">
<span class="text-gray-600">输出形式</span>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<label v-for="f in NOTE_FORMATS" :key="f.value" class="flex items-center gap-1">
<input
type="checkbox"
:checked="(settings.formats || []).includes(f.value)"
@change="toggleFormat(f.value, ($event.target as HTMLInputElement).checked)"
>
{{ f.label }}
</label>
</div>
</div>
<details class="text-xs">
<summary class="cursor-pointer text-gray-500">高级</summary>
<label class="flex flex-col gap-1 mt-2">
<span class="text-gray-600">额外提示词追加到 prompt 末尾</span>
<textarea
v-model="settings.extras"
class="border rounded px-1 py-1 resize-y"
rows="2"
placeholder="例如:重点关注游戏开发部分;保留所有专业术语原文"
/>
</label>
<label class="flex items-center gap-2 mt-2">
<input v-model="settings.video_understanding" type="checkbox">
<span class="text-gray-600">启用视频理解抽帧拼图喂视觉模型</span>
</label>
<div v-if="settings.video_understanding" class="grid grid-cols-3 gap-2 mt-2">
<label class="flex flex-col gap-1">
<span class="text-gray-600">抽帧间隔()</span>
<input
v-model.number="settings.video_interval"
type="number" min="1" max="30"
class="border rounded px-1 py-0.5"
>
</label>
<label class="flex flex-col gap-1">
<span class="text-gray-600">拼图行</span>
<input
:value="settings.grid_size?.[0] ?? 2"
type="number" min="1" max="10"
class="border rounded px-1 py-0.5"
@input="settings.grid_size = [Number(($event.target as HTMLInputElement).value) || 2, settings.grid_size?.[1] ?? 2]"
>
</label>
<label class="flex flex-col gap-1">
<span class="text-gray-600">拼图列</span>
<input
:value="settings.grid_size?.[1] ?? 2"
type="number" min="1" max="10"
class="border rounded px-1 py-0.5"
@input="settings.grid_size = [settings.grid_size?.[0] ?? 2, Number(($event.target as HTMLInputElement).value) || 2]"
>
</label>
</div>
<p v-if="settings.video_understanding" class="text-amber-700 mt-1">
需要选择视觉模型GPT-4o / Gemini / Claude 文字模型会忽略图片
</p>
</details>
<div class="text-xs text-gray-600">
<span v-if="settings.providerId && settings.modelName">
模型{{ settings.modelName }}

View File

@@ -1,7 +1,9 @@
# === 前端构建阶段 ===
FROM node:18-alpine AS builder
# Tailwind v4 / Vite 6 需要 Node 20+alpine + pnpm 会按 lockfile 拉 musl native binary。
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
# pnpm pin 到 9.xlockfile 是 v9 生成pnpm 11 要求 Node 22+ 与 node:20 不兼容
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app

10810
BillNote_frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,14 @@
use tauri::{Manager, Emitter};
use tauri::{Manager, Emitter, State};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::CommandEvent;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use std::env;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use serde::Serialize;
// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程
struct SidecarHandle(Mutex<Option<CommandChild>>);
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -18,77 +24,31 @@ pub fn run() {
}
let exe_path = env::current_exe().expect("无法获取当前可执行文件路径");
let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录");
// 收集所有系统环境变量
let mut all_env_vars = HashMap::new();
for (key, value) in env::vars() {
all_env_vars.insert(key, value);
// 安装路径诊断PyInstaller sidecar 在含非 ASCII / 空格的路径下经常炸README 已警告但缺主动防御)
// 命中时把诊断信息 emit 给前端,由顶端横幅展示,不阻断启动
let diag = analyze_install_path(&exe_path);
if diag.path_has_non_ascii || diag.path_has_space || !diag.parent_writable {
let app_handle = app.handle().clone();
// 等前端首屏挂载好 listenersetup 阶段 window 已存在但 React 还没 render
// 用独立线程 + 标准 sleep不引入 tokio 依赖
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1500));
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("backend-warning", &diag);
}
});
}
// 增强 PATH 环境变量,添加常见的二进制路径
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
let additional_paths = get_additional_binary_paths();
let enhanced_path = enhance_path_variable(&current_path, &additional_paths);
all_env_vars.insert("PATH".to_string(), enhanced_path);
// 打印一些关键环境变量用于调试
println!("Enhanced PATH: {}", all_env_vars.get("PATH").unwrap_or(&"Not found".to_string()));
println!("Total environment variables: {}", all_env_vars.len());
// 检查 ffmpeg 是否在 PATH 中可用
check_ffmpeg_availability();
// 启动 Python 后端侧车
let mut sidecar_command = app.shell().sidecar("BiliNoteBackend").unwrap();
// 设置所有环境变量到 sidecar
for (key, value) in &all_env_vars {
sidecar_command = sidecar_command.env(key, value);
}
let (mut rx, _child) = sidecar_command
.current_dir(sidecar_dir)
.spawn()
.expect("Failed to spawn sidecar");
// 获取主窗口句柄用于发送事件
let window = app.get_webview_window("main").unwrap();
tauri::async_runtime::spawn(async move {
// 读取诸如 stdout 之类的事件
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
let output = String::from_utf8_lossy(&line);
println!("Backend stdout: {}", output);
// 发送到前端
window
.emit("backend-message", Some(format!("'{}'", output)))
.expect("failed to emit event");
}
CommandEvent::Stderr(line) => {
let error = String::from_utf8_lossy(&line);
eprintln!("Backend stderr: {}", error);
window
.emit("backend-error", Some(format!("'{}'", error)))
.expect("failed to emit event");
}
CommandEvent::Terminated(payload) => {
println!("Backend terminated with code: {:?}", payload.code);
window
.emit("backend-terminated", Some(payload.code))
.expect("failed to emit event");
break;
}
_ => {
println!("Backend event: {:?}", event);
}
}
}
});
// 启动 Sidecar 并把 child handle 存到 state方便后续 restart_backend_sidecar 使用
let child = spawn_backend_sidecar(app.handle()).map_err(|e| {
eprintln!("Sidecar 启动失败: {}", e);
e
})?;
app.manage(SidecarHandle(Mutex::new(Some(child))));
Ok(())
})
@@ -96,7 +56,9 @@ pub fn run() {
get_system_env_vars,
find_executable_path,
run_command_with_env,
test_ffmpeg_access
test_ffmpeg_access,
get_install_path_diagnostics,
restart_backend_sidecar
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -268,6 +230,150 @@ async fn test_ffmpeg_access() -> Result<String, String> {
run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await
}
// 启动后端 Sidecar负责装环境变量、spawn、挂 stdout/stderr/terminated 监听并 emit 给前端。
// 第一次启动 + restart_backend_sidecar 都走这里,保持单一启动路径。
fn spawn_backend_sidecar(app_handle: &tauri::AppHandle) -> Result<CommandChild, String> {
let exe_path = env::current_exe().map_err(|e| format!("无法获取可执行文件路径: {}", e))?;
let sidecar_dir = exe_path
.parent()
.ok_or("无法获取可执行文件父目录")?
.to_path_buf();
// 收集所有系统环境变量并增强 PATH含 ffmpeg 常见安装位置)
let mut all_env_vars = HashMap::new();
for (key, value) in env::vars() {
all_env_vars.insert(key, value);
}
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
let additional_paths = get_additional_binary_paths();
let enhanced_path = enhance_path_variable(&current_path, &additional_paths);
all_env_vars.insert("PATH".to_string(), enhanced_path);
let mut sidecar_command = app_handle
.shell()
.sidecar("BiliNoteBackend")
.map_err(|e| format!("找不到 BiliNoteBackend sidecar: {}", e))?;
for (key, value) in &all_env_vars {
sidecar_command = sidecar_command.env(key, value);
}
let (mut rx, child) = sidecar_command
.current_dir(sidecar_dir)
.spawn()
.map_err(|e| format!("spawn sidecar 失败: {}", e))?;
// 异步监听 stdout / stderr / terminated 事件,转发到前端 webview
let app_handle_for_listener = app_handle.clone();
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
// window 句柄每次重新取,允许窗口关闭重开
let window = app_handle_for_listener.get_webview_window("main");
match event {
CommandEvent::Stdout(line) => {
let output = String::from_utf8_lossy(&line).to_string();
println!("Backend stdout: {}", output);
if let Some(w) = window {
let _ = w.emit("backend-message", Some(output));
}
}
CommandEvent::Stderr(line) => {
let error = String::from_utf8_lossy(&line).to_string();
eprintln!("Backend stderr: {}", error);
if let Some(w) = window {
let _ = w.emit("backend-error", Some(error));
}
}
CommandEvent::Terminated(payload) => {
println!("Backend terminated with code: {:?}", payload.code);
if let Some(w) = window {
let _ = w.emit("backend-terminated", Some(payload.code));
}
break;
}
_ => {
println!("Backend event: {:?}", event);
}
}
}
});
Ok(child)
}
// 重启 sidecar杀旧 childspawn 新 child回写到 state。
#[tauri::command]
fn restart_backend_sidecar(
state: State<'_, SidecarHandle>,
app: tauri::AppHandle,
) -> Result<(), String> {
// 1. 拿出旧 child 并 killkill 失败也继续,可能进程已经退了)
{
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
if let Some(child) = guard.take() {
let _ = child.kill();
}
}
// 2. 重新 spawn
let new_child = spawn_backend_sidecar(&app)?;
{
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
*guard = Some(new_child);
}
// 3. emit 一个事件让前端知道已重启
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("backend-restarted", ());
}
Ok(())
}
// 安装路径诊断PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸;
// 父目录不可写时模型 / 配置 / 日志也无法落盘
#[derive(Serialize, Clone)]
struct InstallPathDiagnostics {
exe_path: String,
path_has_non_ascii: bool,
path_has_space: bool,
parent_writable: bool,
platform: String,
}
fn analyze_install_path(exe_path: &Path) -> InstallPathDiagnostics {
let path_str = exe_path.to_string_lossy().to_string();
// 不在 ASCII 范围内的字符(中文 / 日文 / 西里尔等都会命中 PyInstaller 路径解析坑)
let has_non_ascii = path_str.chars().any(|c| !c.is_ascii());
// 空格本身在 Windows shell 引号场景偶尔出问题,且 macOS path 里也偶尔触发 sidecar 启动失败
let has_space = path_str.contains(' ');
// 父目录可写PyInstaller 解压 _internal/、写日志、写配置都需要这个
let parent = exe_path.parent();
let parent_writable = parent
.and_then(|p| {
let probe = p.join(".bilinote_write_probe");
match std::fs::write(&probe, b"x") {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
Some(true)
}
Err(_) => Some(false),
}
})
.unwrap_or(false);
InstallPathDiagnostics {
exe_path: path_str,
path_has_non_ascii: has_non_ascii,
path_has_space: has_space,
parent_writable,
platform: std::env::consts::OS.to_string(),
}
}
// Tauri 命令:让前端按需重新查询诊断结果(比如用户卸载到新目录后重启)
#[tauri::command]
fn get_install_path_diagnostics() -> InstallPathDiagnostics {
let exe_path = env::current_exe().unwrap_or_default();
analyze_install_path(&exe_path)
}
// 可选:添加一个函数来动态更新 sidecar 的环境变量
#[tauri::command]
async fn update_sidecar_environment(

View File

@@ -5,11 +5,23 @@ import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
import { systemCheck } from '@/services/system.ts'
import BackendInitDialog from '@/components/BackendInitDialog'
import StartupBanner from '@/components/SystemDiagnostic/StartupBanner'
import BackendHealthIndicator from '@/components/BackendHealth/BackendHealthIndicator'
import Index from '@/pages/Index.tsx'
import { HomePage } from './pages/HomePage/Home.tsx'
// 非首屏页面使用 React.lazy 按需加载
const Onboarding = lazy(() => import('@/pages/Onboarding'))
const SettingPage = lazy(() => import('./pages/SettingPage/index.tsx'))
// 桌面端首启引导守卫:未完成 onboarding 时强制跳到 /onboarding
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// 仅在 Tauri 桌面端拦截;纯 web 端不打扰用户
if (!isTauri) return <>{children}</>
if (localStorage.getItem('bilinote-onboarded') !== '1') return <Navigate to="/onboarding" replace />
return <>{children}</>
}
const Model = lazy(() => import('@/pages/SettingPage/Model.tsx'))
const ProviderForm = lazy(() => import('@/components/Form/modelForm/Form.tsx'))
const AboutPage = lazy(() => import('@/pages/SettingPage/about.tsx'))
@@ -34,6 +46,7 @@ function App() {
if (!initialized) {
return (
<>
<StartupBanner />
<BackendInitDialog open={loading} />
</>
)
@@ -42,10 +55,13 @@ function App() {
// 后端已初始化,渲染主应用
return (
<>
<StartupBanner />
<BackendHealthIndicator />
<BrowserRouter>
<Suspense fallback={<div className="flex h-screen items-center justify-center"></div>}>
<Routes>
<Route path="/" element={<Index />}>
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/" element={<OnboardingGuard><Index /></OnboardingGuard>}>
<Route index element={<HomePage />} />
<Route path="settings" element={<SettingPage />}>
<Route index element={<Navigate to="model" replace />} />

View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { useBackendEvents } from './useBackendEvents'
import BackendLogPanel from './BackendLogPanel'
// 健康度判定:
// - 绿sidecar running 且 /sys_health 通
// - 黄sidecar running 但 /sys_health 失败 (ffmpeg 缺等)
// - 红sidecar terminated 或 /sys_health 连续 3 次失败
type Health = 'green' | 'yellow' | 'red' | 'unknown'
const HEALTH_POLL_MS = 5000
const SYS_HEALTH_PATH = '/api/sys_health'
function backendBase(): string {
// 与 services/request.ts 用的一致
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
return (fromEnv ?? '').replace(/\/$/, '')
}
const BackendHealthIndicator = () => {
const { status, isTauri, exitCode, logs, restart, copyLogs } = useBackendEvents()
const [open, setOpen] = useState(false)
const [healthCheckFailures, setHealthCheckFailures] = useState(0)
const [lastHealthOk, setLastHealthOk] = useState<boolean | null>(null)
// 仅在 Tauri 环境挂指示器;纯 web 用户由 useCheckBackend 接管
useEffect(() => {
if (!isTauri) return
let mounted = true
async function ping() {
try {
const res = await fetch(`${backendBase()}${SYS_HEALTH_PATH}`)
const ok = res.ok
if (!mounted) return
if (ok) {
setHealthCheckFailures(0)
setLastHealthOk(true)
}
else {
setHealthCheckFailures(c => c + 1)
setLastHealthOk(false)
}
}
catch {
if (!mounted) return
setHealthCheckFailures(c => c + 1)
setLastHealthOk(false)
}
}
ping()
const t = setInterval(ping, HEALTH_POLL_MS)
return () => {
mounted = false
clearInterval(t)
}
}, [isTauri])
if (!isTauri) return null
const health: Health = (() => {
if (status === 'terminated') return 'red'
if (healthCheckFailures >= 3) return 'red'
if (lastHealthOk === false) return 'yellow'
if (lastHealthOk === true) return 'green'
return 'unknown'
})()
const colorMap: Record<Health, string> = {
green: 'bg-green-500',
yellow: 'bg-amber-500',
red: 'bg-red-500',
unknown: 'bg-gray-400',
}
const labelMap: Record<Health, string> = {
green: '后端运行正常',
yellow: '后端运行中(部分检查未通过)',
red: status === 'terminated' ? `后端已退出 (code=${exitCode ?? 'unknown'})` : '后端无响应',
unknown: '后端状态未知',
}
return (
<>
<button
className="fixed right-3 bottom-3 z-[9998] flex items-center gap-2 rounded-full border bg-white px-3 py-1.5 text-xs shadow hover:shadow-md"
title={labelMap[health]}
onClick={() => setOpen(true)}
>
<span className={`inline-block h-2 w-2 rounded-full ${colorMap[health]}${health === 'red' || health === 'yellow' ? ' animate-pulse' : ''}`} />
<span className="text-gray-700"></span>
</button>
{open && (
<BackendLogPanel
status={status}
exitCode={exitCode}
logs={logs}
health={health}
onRestart={restart}
onCopyLogs={copyLogs}
onClose={() => setOpen(false)}
/>
)}
</>
)
}
export default BackendHealthIndicator

View File

@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react'
import type { LogEntry, BackendStatus } from './useBackendEvents'
interface Props {
status: BackendStatus
exitCode: number | null
logs: LogEntry[]
health: 'green' | 'yellow' | 'red' | 'unknown'
onRestart: () => Promise<void>
onCopyLogs: () => Promise<boolean>
onClose: () => void
}
const BackendLogPanel = ({ status, exitCode, logs, health, onRestart, onCopyLogs, onClose }: Props) => {
const [restarting, setRestarting] = useState(false)
const [copied, setCopied] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
// 新日志进来自动滚到底
useEffect(() => {
if (scrollRef.current)
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}, [logs])
async function handleRestart() {
setRestarting(true)
try { await onRestart() }
catch { /* errors already in log via useBackendEvents */ }
finally { setRestarting(false) }
}
async function handleCopy() {
const ok = await onCopyLogs()
setCopied(ok)
setTimeout(() => setCopied(false), 1500)
}
return (
<>
{/* 半透明遮罩 */}
<div className="fixed inset-0 z-[9998] bg-black/20" onClick={onClose} />
<aside className="fixed right-0 bottom-0 top-0 z-[9999] flex w-[480px] max-w-[90vw] flex-col border-l bg-white shadow-2xl">
<header className="flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-base font-semibold"></h2>
<div className="mt-0.5 text-xs text-gray-500">
{status === 'terminated'
? `已退出(退出码 ${exitCode ?? 'unknown'}`
: health === 'red'
? '运行中但无响应'
: health === 'yellow'
? '运行中,部分系统检查未通过'
: '运行正常'}
</div>
</div>
<button className="rounded p-1 text-gray-500 hover:bg-gray-100" onClick={onClose}></button>
</header>
<div className="flex items-center gap-2 border-b px-4 py-2">
<button
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
disabled={restarting}
onClick={handleRestart}
>
{restarting ? '重启中…' : '重启后端'}
</button>
<button
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
onClick={handleCopy}
>
{copied ? '已复制 ✓' : '复制日志'}
</button>
<span className="ml-auto text-xs text-gray-400">
{logs.length}
</span>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-auto bg-gray-900 p-3 font-mono text-xs text-gray-100"
>
{logs.length === 0 ? (
<div className="text-gray-500 italic"></div>
) : (
logs.map((l, i) => (
<div
key={`${l.ts}-${i}`}
className={`whitespace-pre-wrap break-all leading-snug ${l.level === 'error' ? 'text-red-300' : 'text-gray-100'}`}
>
<span className="mr-2 text-gray-500">
{new Date(l.ts).toISOString().slice(11, 19)}
</span>
{l.text}
</div>
))
)}
</div>
<footer className="border-t px-4 py-2 text-xs text-gray-500">
退 / issue
</footer>
</aside>
</>
)
}
export default BackendLogPanel

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react'
// 桌面端 Sidecar 健康度。监听 Tauri 侧的 backend-message / backend-error /
// backend-terminated / backend-restarted 事件,把 stdout/stderr 缓冲成 ring buffer
// 同时维护进程运行状态。
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
export type LogLevel = 'info' | 'error'
export interface LogEntry {
level: LogLevel
text: string
ts: number
}
export type BackendStatus = 'running' | 'terminated'
const MAX_LOG_LINES = 200
interface BackendEvents {
status: BackendStatus
exitCode: number | null
logs: LogEntry[]
/** 调 Tauri 命令重启 sidecar */
restart: () => Promise<void>
/** 复制全部日志到剪贴板 */
copyLogs: () => Promise<boolean>
isTauri: boolean
}
export function useBackendEvents(): BackendEvents {
const [status, setStatus] = useState<BackendStatus>('running')
const [exitCode, setExitCode] = useState<number | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
// 用 ref 持有最新 logs 数组append 时不被闭包陷阱卡到旧值
const logsRef = useRef<LogEntry[]>([])
function append(entry: LogEntry) {
const next = logsRef.current.concat(entry)
if (next.length > MAX_LOG_LINES)
next.splice(0, next.length - MAX_LOG_LINES)
logsRef.current = next
setLogs(next)
}
useEffect(() => {
if (!isTauri) return
let unlisteners: Array<() => void> = []
;(async () => {
const { listen } = await import('@tauri-apps/api/event')
const offMsg = await listen<string>('backend-message', event => {
append({ level: 'info', text: stripQuotes(event.payload), ts: Date.now() })
})
const offErr = await listen<string>('backend-error', event => {
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() })
})
const offTerm = await listen<number | null>('backend-terminated', event => {
setStatus('terminated')
setExitCode(event.payload ?? null)
append({
level: 'error',
text: `[Backend terminated] code=${event.payload ?? 'unknown'}`,
ts: Date.now(),
})
})
const offRestart = await listen('backend-restarted', () => {
setStatus('running')
setExitCode(null)
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
})
unlisteners = [offMsg, offErr, offTerm, offRestart]
})()
return () => {
unlisteners.forEach(fn => fn())
}
}, [])
async function restart() {
if (!isTauri) return
const { invoke } = await import('@tauri-apps/api/core')
try {
await invoke('restart_backend_sidecar')
}
catch (e) {
append({ level: 'error', text: `[Restart failed] ${(e as Error).message ?? e}`, ts: Date.now() })
throw e
}
}
async function copyLogs() {
const text = logsRef.current
.map(l => `${new Date(l.ts).toISOString().slice(11, 19)} ${l.level === 'error' ? 'E' : 'I'} ${l.text}`)
.join('\n')
try {
await navigator.clipboard.writeText(text)
return true
}
catch {
return false
}
}
return { status, exitCode, logs, restart, copyLogs, isTauri }
}
// Rust 早期版本 emit 时把 stdout 包了一层 '...',新版本已经直接 emit 原文。
// 这里做兼容:去掉外层单引号(如果有的话)。
function stripQuotes(s: string): string {
if (typeof s !== 'string') return String(s)
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'"))
return s.slice(1, -1)
return s
}

View File

@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
// 桌面端启动诊断横幅。监听 Tauri 侧 emit 的 backend-warning / backend-error / backend-terminated。
// 只在 Tauri 环境生效;纯 web 环境(无 window.__TAURI_INTERNALS__下静默不挂载。
type Severity = 'info' | 'warning' | 'error'
interface DiagnosticPayload {
exe_path?: string
path_has_non_ascii?: boolean
path_has_space?: boolean
parent_writable?: boolean
platform?: string
}
interface BannerState {
severity: Severity
title: string
detail: string
payload?: DiagnosticPayload
dismissible: boolean
}
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
function describeWarning(payload: DiagnosticPayload): { title: string; detail: string } {
const parts: string[] = []
if (payload.path_has_non_ascii) {
parts.push('安装路径包含非 ASCII 字符(中文 / 日文等)')
}
if (payload.path_has_space) {
parts.push('安装路径包含空格')
}
if (payload.parent_writable === false) {
parts.push('安装目录不可写(缺少权限或只读)')
}
return {
title: '检测到可能导致后端启动失败的安装路径',
detail:
`${parts.join('')}\n` +
'建议把 BiliNote 重新安装到一个纯英文、无空格、可写的路径下(如 C:\\BiliNote\\ 或 /Applications/)。\n' +
`当前路径:${payload.exe_path || '未知'}`,
}
}
const StartupBanner = () => {
const [banner, setBanner] = useState<BannerState | null>(null)
useEffect(() => {
if (!isTauri) return
let unlisteners: Array<() => void> = []
;(async () => {
const { listen } = await import('@tauri-apps/api/event')
const offWarning = await listen<DiagnosticPayload>('backend-warning', event => {
const { title, detail } = describeWarning(event.payload || {})
setBanner({
severity: 'warning',
title,
detail,
payload: event.payload,
dismissible: true,
})
})
const offTerminated = await listen<number | null>('backend-terminated', event => {
setBanner({
severity: 'error',
title: '后端进程已退出',
detail: `退出码:${event.payload ?? '未知'}。打开「部署监控」或重启应用以恢复。`,
dismissible: false,
})
})
// backend-error 是 sidecar stderr量大噪音多这里不直接展示留给 P2 的日志面板。
unlisteners = [offWarning, offTerminated]
})()
return () => {
unlisteners.forEach(fn => fn())
}
}, [])
if (!banner) return null
const colorByLevel: Record<Severity, string> = {
info: 'bg-blue-50 border-blue-300 text-blue-900',
warning: 'bg-amber-50 border-amber-300 text-amber-900',
error: 'bg-red-50 border-red-300 text-red-900',
}
const iconByLevel: Record<Severity, string> = {
info: '',
warning: '⚠️',
error: '✕',
}
return (
<div
className={`fixed left-0 right-0 top-0 z-[9999] flex items-start gap-3 border-b px-4 py-2 text-sm shadow-sm ${colorByLevel[banner.severity]}`}
>
<span className="text-lg">{iconByLevel[banner.severity]}</span>
<div className="flex-1 min-w-0">
<div className="font-medium">{banner.title}</div>
<pre className="mt-0.5 whitespace-pre-wrap break-words font-sans text-xs opacity-90">
{banner.detail}
</pre>
</div>
{banner.dismissible && (
<button
className="shrink-0 rounded px-2 py-0.5 text-xs hover:bg-black/10"
onClick={() => setBanner(null)}
>
</button>
)}
</div>
)
}
export default StartupBanner

View File

@@ -0,0 +1,265 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { addProvider, addModel, getProviderList, testConnection } from '@/services/model'
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
import logo from '@/assets/icon.svg'
// 桌面端首启 4 步引导。完成后写 localStorage('bilinote-onboarded') = '1',路由守卫不再拦。
//
// 1. 后端连通性自检
// 2. LLM 供应商 + 模型(最简:只引导填一个 OpenAI-兼容供应商 + 一个 model 名)
// 3. 转写引擎选择(推荐 Groq 在线,避开本地模型下载坑)
// 4. 可选Cookie 同步说明(仅当用户关注 B 站等需要登录态的平台时)
const ONBOARD_KEY = 'bilinote-onboarded'
export function isOnboarded(): boolean {
return localStorage.getItem(ONBOARD_KEY) === '1'
}
function markOnboarded() {
localStorage.setItem(ONBOARD_KEY, '1')
}
const Onboarding = () => {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [error, setError] = useState('')
// step 1
const [pinging, setPinging] = useState(false)
const [backendOk, setBackendOk] = useState<boolean | null>(null)
// step 2
const [providerName, setProviderName] = useState('OpenAI')
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1')
const [modelName, setModelName] = useState('gpt-4o-mini')
const [providerId, setProviderId] = useState<string | null>(null)
const [savingProvider, setSavingProvider] = useState(false)
// step 3
const [transcriberType, setTranscriberType] = useState<string>('groq')
const [savingTranscriber, setSavingTranscriber] = useState(false)
function next() {
setError('')
setStep(s => s + 1)
}
function prev() {
setError('')
setStep(s => Math.max(1, s - 1))
}
// step 1: ping 后端
useEffect(() => {
if (step !== 1) return
let cancelled = false
;(async () => {
setPinging(true)
try {
await getProviderList()
if (!cancelled) setBackendOk(true)
}
catch {
if (!cancelled) setBackendOk(false)
}
finally {
if (!cancelled) setPinging(false)
}
})()
return () => { cancelled = true }
}, [step])
async function saveProvider() {
setError('')
if (!apiKey.trim()) { setError('请填 API Key'); return }
if (!baseUrl.trim()) { setError('请填 API 地址'); return }
if (!providerName.trim()) { setError('请填供应商名'); return }
if (!modelName.trim()) { setError('请填模型名'); return }
setSavingProvider(true)
try {
// 复用桌面 web 端的 add_providertype 必须是 'custom'backend 强制)
const res: any = await addProvider({
name: providerName.trim(),
api_key: apiKey.trim(),
base_url: baseUrl.trim(),
type: 'custom',
logo: 'custom',
})
const newId = (res?.data ?? res) as string | undefined
if (!newId) throw new Error('后端未返回 provider id')
setProviderId(newId)
// 加一个默认 model
await addModel({ provider_id: newId, model_name: modelName.trim() })
// 测试连通
try { await testConnection({ id: newId }) }
catch (e: any) {
// 测试失败不阻断流程,让用户自己决定继续
console.warn('测试连接失败:', e?.message ?? e)
}
next()
}
catch (e: any) {
setError(`保存失败:${e?.message ?? e}`)
}
finally {
setSavingProvider(false)
}
}
async function saveTranscriber() {
setError('')
setSavingTranscriber(true)
try {
// fast-whisper / mlx-whisper 需指定 model size在线 (groq/bcut/kuaishou) 不用
const needsSize = transcriberType === 'fast-whisper' || transcriberType === 'mlx-whisper'
await updateTranscriberConfig({
transcriber_type: transcriberType,
...(needsSize ? { whisper_model_size: 'tiny' } : {}),
} as any)
next()
}
catch (e: any) {
setError(`保存失败:${e?.message ?? e}`)
}
finally {
setSavingTranscriber(false)
}
}
function finish() {
markOnboarded()
navigate('/', { replace: true })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-pink-50 p-6">
<div className="w-full max-w-xl rounded-xl border bg-white p-6 shadow-lg">
<div className="flex items-center gap-3 mb-4">
<img src={logo} alt="logo" className="h-10 w-10" />
<div>
<h1 className="text-xl font-bold">使 BiliNote</h1>
<p className="text-xs text-gray-500"></p>
</div>
</div>
{/* Stepper */}
<div className="mb-5 flex items-center gap-2 text-xs text-gray-500">
{[1, 2, 3, 4].map(s => (
<div key={s} className="flex items-center gap-2">
<div
className={`flex h-6 w-6 items-center justify-center rounded-full border ${step >= s ? 'border-blue-600 bg-blue-600 text-white' : 'border-gray-300 bg-white text-gray-400'}`}
>{s}</div>
{s < 4 && <div className={`h-px w-8 ${step > s ? 'bg-blue-600' : 'bg-gray-300'}`} />}
</div>
))}
</div>
{step === 1 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 1 · </h2>
<p className="text-sm text-gray-600"> Python </p>
{pinging && <div className="text-sm text-gray-500"></div>}
{backendOk === true && <div className="rounded bg-green-50 p-2 text-sm text-green-700"> </div>}
{backendOk === false && (
<div className="rounded bg-red-50 p-2 text-sm text-red-700">
1-2
</div>
)}
<div className="flex gap-2 justify-end">
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={!backendOk} onClick={next}>
</button>
</div>
</section>
)}
{step === 2 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 2 · </h2>
<p className="text-sm text-gray-600"> OpenAI DeepSeek / Qwen / Claude / / OpenAI </p>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600"></span>
<input className="input border rounded px-2 py-1" value={providerName} onChange={e => setProviderName(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600">API </span>
<input className="input border rounded px-2 py-1" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600">API Key</span>
<input type="password" className="input border rounded px-2 py-1" value={apiKey} onChange={e => setApiKey(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-gray-600"> gpt-4o-mini / deepseek-chat / qwen-turbo</span>
<input className="input border rounded px-2 py-1" value={modelName} onChange={e => setModelName(e.target.value)} />
</label>
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={savingProvider} onClick={saveProvider}>
{savingProvider ? '保存中…' : '保存并下一步'}
</button>
</div>
</section>
)}
{step === 3 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 3 · </h2>
<p className="text-sm text-gray-600"><strong>线</strong> ~600MB </p>
<div className="grid gap-2">
{[
{ value: 'groq', title: 'Groq在线推荐', desc: '注册 https://groq.com/ 拿免费 key速度快、英文语料佳。无需本地模型。' },
{ value: 'bcut', title: '必剪(在线,免登)', desc: '免登,中文表现好;偶尔限流。' },
{ value: 'kuaishou', title: '快手(在线,免登)', desc: '与必剪类似,备选。' },
{ value: 'fast-whisper', title: 'Faster Whisper本地', desc: '完全离线但首次需下载 ~75MBtiny至 ~3GBlarge-v3的模型。CPU 慢。' },
].map(opt => (
<label key={opt.value} className={`flex gap-3 p-3 rounded border cursor-pointer ${transcriberType === opt.value ? 'border-blue-600 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="radio" name="transcriber" value={opt.value} checked={transcriberType === opt.value} onChange={e => setTranscriberType(e.target.value)} />
<div>
<div className="text-sm font-medium">{opt.title}</div>
<div className="text-xs text-gray-500 mt-0.5">{opt.desc}</div>
</div>
</label>
))}
</div>
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={savingTranscriber} onClick={saveTranscriber}>
{savingTranscriber ? '保存中…' : '保存并下一步'}
</button>
</div>
</section>
)}
{step === 4 && (
<section className="flex flex-col gap-3">
<h2 className="font-semibold"> 4 · Cookie </h2>
<p className="text-sm text-gray-600">
<strong>B / / </strong> cookie
<br />
YouTube cookie
</p>
<div className="rounded bg-gray-50 p-3 text-xs text-gray-600">
<a className="text-blue-600 underline" href="https://github.com/JefferyHcool/BiliNote/tree/develop/BillNote_extension" target="_blank" rel="noreferrer">BillNote_extension</a> cookie
</div>
<div className="flex gap-2 justify-between">
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}></button>
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={finish}>
BiliNote
</button>
</div>
</section>
)}
</div>
</div>
)
}
export default Onboarding

View File

@@ -73,6 +73,28 @@ export default function Transcriber() {
}, [modelStatuses, mlxModelStatuses, fetchModelsStatus])
const handleSave = async () => {
// 切到本地 whisper 引擎且选了未下载的模型时,提前 confirm避免用户保存后到首次任务才发现要下 GB 级模型
if (isWhisperType(selectedType)) {
const pool = selectedType === 'mlx-whisper' ? mlxModelStatuses : modelStatuses
const target = pool.find(m => m.model_size === selectedModelSize)
if (target && !target.downloaded && !target.downloading) {
const sizeHint: Record<string, string> = {
'tiny': '~75MB',
'base': '~150MB',
'small': '~500MB',
'medium': '~1.5GB',
'large-v3': '~3GB',
'large-v3-turbo': '~1.6GB',
}
const ok = window.confirm(
`选择 ${selectedType} / ${selectedModelSize} 后,首次转写时会下载该模型(${sizeHint[selectedModelSize] || '体积未知'})。\n` +
`网络较差时容易中断;推荐改用 Groq / 必剪 / 快手 等在线引擎。\n\n` +
'继续保存吗?',
)
if (!ok) return
}
}
setSaving(true)
try {
const payload: { transcriber_type: string; whisper_model_size?: string } = {

View File

@@ -2,6 +2,79 @@
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [2.2.1] - 2026-05-09
补 v2.2.0 ghcr.io 镜像构建失败。
### Fixed
- Docker 镜像构建失败:`v2.2.0` tag 触发的 ghcr.io 推送在 frontend-builder 第 5/7 步 `pnpm install --frozen-lockfile``ERR_UNKNOWN_BUILTIN_MODULE`。根因:`corepack prepare pnpm@latest` 拉到了 pnpm 11.0.9,而 pnpm 11 要求 Node 22+,跟我们的 `node:20-alpine` 不兼容。
- `Dockerfile.complete``BillNote_frontend/Dockerfile` 的 pnpm 版本 pin 到 `9.15.0`lockfile 由 pnpm 9 生成,匹配 Node 20
## [2.2.0] - 2026-05-09
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
### Added — 浏览器插件
- 笔记选项与 web 端 NoteForm 完整对齐:
- `style` 由自由文本改成 9 个预设下拉minimal / detailed / academic / tutorial / xiaohongshu / life_journal / task_oriented / business / meeting_minutes与 backend `prompt_builder.note_styles` 严格匹配(之前自由文本不命中 enum 等于没传——隐性 bug
- `format` 完整 4 个 checkboxtoc / link / screenshot / summary原来只有 screenshot/link
- `extras` 文本框:拼接到 prompt 末尾的 ad-hoc 提示
- 多模态视频理解:`video_understanding` 开关 + `video_interval`1-30 秒)+ `grid_size`[r,c]1-10抽帧拼图喂视觉模型提示需选视觉模型才生效
### Added — 桌面客户端
- **首启 4 步引导**`/onboarding`):后端连通性自检 → LLM 供应商 + 模型 → 转写引擎选择(默认推荐 Groq→ Cookie 同步说明。完成后 `localStorage('bilinote-onboarded')` 标记,纯 web 端不打扰
- **Sidecar 健康度面板**:右下角浮动状态点(绿/黄/红5s 轮询 `/sys_health`),点开抽屉看最近 200 行后端日志、一键重启后端(新增 Tauri command `restart_backend_sidecar`)、复制日志
- **启动期路径诊断**Tauri `setup` 中检测安装路径含非 ASCII / 含空格 / 父目录不可写时emit `backend-warning` 让前端顶端横幅显式告警,主动暴露 README 长期文字警告但无防御的"中文路径"等坑
### Changed
- Whisper 默认模型 size 从 `medium`~1.5GB)改为 `tiny`~75MB新装用户没主动设置时不再卡在首次大模型下载高精度可在「音频转写配置」页主动切
- 切到 `fast-whisper` / `mlx-whisper` 且当前 size 未下载时,「音频转写配置」页保存前 confirm 体积提示,并推荐改用在线引擎
- Tauri sidecar 启动逻辑抽出 `spawn_backend_sidecar()`child handle 存进 `SidecarHandle` state 以支持后续 restart
- sidecar stdout/stderr emit 时不再用 `format!("'{}'", ...)` 包引号,原文直传(前端 hook 兼容旧格式兜底剥引号)
### Fixed
- WhisperTranscriber 在半成品模型目录上死循环报 `Unable to open file 'model.bin'`:判定从「目录存在」改为「`model.bin` 落盘」半成品目录会被识别并重新下载PR `fix/backend-deploy-resilience`
- `/api/deploy_status` 在没装 torch 的部署上 `ModuleNotFoundError: No module named 'torch'` 500torch 改 try/except未装时返回 `{available: false, torch_installed: false}`transcriber 配置 + ffmpeg 也都裹 try单项失败不再打死整个监控页同上 PR
- `routers/config._check_whisper_model_exists` 同步改用 `model.bin` 判定,避免「已下载」状态在监控页误报
## [2.1.4] - 2026-05-07
CI 工程化修复,无运行时行为变化。
### Internal
- 桌面端 Tauri 构建矩阵去掉 Linux`ubuntu-22.04 / x86_64-unknown-linux-gnu`。Linux 桌面端构建持续 17m+且无对应分发渠道Linux 用户继续可以走 Docker 镜像 (`ghcr.io/jefferyhcool/bilinote`)
- commitlint workflow 去掉无效的 `firstParent` inputwagoid/commitlint-github-action@v6 不支持,被忽略并打 warn
- 规范 release merge commit 标题:`chore(release): vX.Y.Z`(合 master/ `chore(release): merge release/X.Y.Z back into develop`(回灌 develop让 commitlint 能正确识别。`RELEASING.md` §3 与 `CONTRIBUTING.md` §6.3 同步更新
## [2.1.3] - 2026-05-07
### Fixed
- DeepSeek 等非多模态供应商被 400 拒绝issue #282`UniversalGPT.create_messages``_build_merge_messages` 此前**无条件**把 content 拼成 OpenAI 多模态数组 `[{"type":"text",...}]`DeepSeek `deepseek-chat` 等模型不识别 `image_url` 变体直接报 `invalid_request_error``GPTFactory.from_config` 一律实例化 `UniversalGPT`,所以问题覆盖**所有**通过模型设置页接入的非多模态供应商,不止 DeepSeek。
- 现按 `video_img_urls` 是否非空切换 content 形态:有图保留多模态数组(视觉模型不退化),无图退回 string。合并阶段历来不带图统一改 string。
- 与同包内 `deepseek_gpt.py` / `openai_gpt.py` / `qwen_gpt.py` 的 message builder 行为对齐。
- 新增 `backend/tests/test_universal_gpt_content_format.py` 6 个 case 回归覆盖(含 `image_url` 字面 not-in JSON 断言)。
感谢 @voidborne-d 的修复(#345)。
## [2.1.2] - 2026-05-07
补 v2.1.1 上 ghcr.io 镜像构建失败的坑。
### Fixed
- Docker 镜像构建失败v2.1.1 tag 触发的 ghcr.io 推送在 frontend-builder 第 7/7 步 `pnpm run build` 挂掉vite `loadConfigFromBundledFile` 加载 `@tailwindcss/vite` plugin 时 1.5s 内异常退出)。
- `Dockerfile.complete``BillNote_frontend/Dockerfile``node:18-alpine``node:20-alpine`Tailwind v4 已不再支持 Node 18Vite 6 也推荐 Node 20+
- `Dockerfile.complete` 的 frontend 阶段同时复制 `pnpm-lock.yaml` 并改用 `--frozen-lockfile`,杜绝每次构建重解析 semver 拉到比本地新的 native dep
- `BillNote_frontend/pnpm-lock.yaml` 强制入库(之前一直未提交,导致 CI / 本地依赖图持续漂移)
- README 联系社区段补上微信群二维码(之前只写"年会恢复更新以后放出最新社区地址"
## [2.1.1] - 2026-05-07
工程化与文档收尾,无运行时行为变化。

View File

@@ -250,6 +250,7 @@ chore(ci): 优化 docker 构建缓存
- `feature/*` / `fix/*` 合入 `develop`:推荐 **Squash and merge**,保持 develop 历史线性。
- `release/*` 合入 `master` 与回灌 `develop`:使用 **Merge commit (--no-ff)**,保留发版结构。
· merge commit 标题用 `chore(release): vX.Y.Z`(合 master`chore(release): merge release/X.Y.Z back into develop`(回灌 develop保证 commitlint 通过。
- `hotfix/*` 同上 release。
### 6.4 合并后

View File

@@ -26,15 +26,22 @@ RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
COPY ./backend /tmp/backend
# === 阶段2构建 Frontend ===
FROM node:18-alpine AS frontend-builder
# Node 18-alpine 跑不动 Tailwind v4 / Vite 6前者要求 Node 20+,后者推荐 Node 20+
# 升到 node:20-alpine。alpine 走 muslpnpm 会按 lockfile 拉 *-linux-x64-musl native binary。
FROM node:20-alpine AS frontend-builder
RUN corepack enable && corepack prepare pnpm@latest --activate
# pnpm 版本 pin 到 9 系列:
# - lockfile (BillNote_frontend/pnpm-lock.yaml) 是 lockfileVersion '9.0',由 pnpm 9 生成
# - pnpm 11+ 要求 Node 22+,与 node:20 不兼容ERR_UNKNOWN_BUILTIN_MODULE
# - 不用 @latest 避免上游 pnpm 升级悄悄破坏 CI
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /tmp/frontend
# 先复制 package.json 利用依赖层缓存
COPY ./BillNote_frontend/package.json ./
RUN pnpm install
# 先复制 package.json + lockfile 利用依赖层缓存
# --frozen-lockfile 保证 CI 与本地开发依赖版本一致,杜绝 semver 漂移引入的破坏性升级
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY ./BillNote_frontend /tmp/frontend

View File

@@ -3,7 +3,7 @@
<p align="center">
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
</p>
<h1 align="center" > BiliNote v2.1.1</h1>
<h1 align="center" > BiliNote v2.2.1</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
@@ -53,6 +53,35 @@ BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、Y
- 笔记顶部视频封面 Banner 展示
- 工作区和生成历史面板支持折叠/展开
### v2.2.1 修订
- 修复 v2.2.0 ghcr.io 镜像构建失败pnpm@latest 拉到 11与 Node 20 不兼容pin 到 pnpm 9.15.0
### v2.2.0 新增
- **浏览器插件**笔记选项与 web 端完整对齐style 9 个预设下拉、format 4 个 checkbox、extras 文本框、多模态视频理解开关
- **桌面客户端**首启 4 步引导(连通自检 → 供应商/模型 → 转写引擎 → Cookie 提示)
- **桌面客户端**右下角后端运行状态指示,点开看日志、一键重启
- **桌面客户端**启动期主动检测中文 / 空格 / 不可写安装路径,弹横幅告警
- Whisper 默认 size 从 medium~1.5GB)改为 tiny~75MB切大模型时显式 confirm
-whisper 半成品模型目录死循环;`/deploy_status` 在没装 torch 的部署 500
- 详见 [CHANGELOG.md](./CHANGELOG.md)
### v2.1.4 修订
- CI桌面端 Tauri 构建去掉 Linux17m+ 慢线退役Linux 用户继续走 Docker 镜像)
- CIcommitlint workflow 修复 + 规范 release merge commit 标题约定
### v2.1.3 修订
- 修复 DeepSeek 等非多模态供应商被 400 拒绝的问题issue #282`UniversalGPT` 的 message builder 按是否带图切换 string / 多模态数组形态
- 感谢 @voidborne-d (#345)
### v2.1.2 修订
- 修复 v2.1.1 触发的 ghcr.io Docker 镜像构建失败Node 18 + Tailwind v4 不兼容、缺 lockfile
- README 补上微信群二维码
### v2.1.1 修订
- 工程化与文档收尾CONTRIBUTING.md / RELEASING.md / issue + PR 模板 / commitlint CI / 插件发版工作流
@@ -205,7 +234,12 @@ docker-compose -f docker-compose.gpu.yml up -d
- [ ] 笔记导出为 PDF / Word / Notion
### Contact and Join-联系和加入社区
年会恢复更新以后放出最新社区地址
扫码加入 BiliNote 交流微信群(如二维码失效,请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈):
<p align="center">
<img src="./doc/wechat.png" alt="BiliNote 交流微信群" width="240" />
</p>

View File

@@ -42,10 +42,13 @@ git push -u origin release/X.Y.Z
在 GitHub 上发起两个 PR
| PR | base | 合并方式 |
|---|---|---|
| `release/X.Y.Z``master` | `master` | **Merge commit (--no-ff)** |
| `release/X.Y.Z``develop` | `develop` | **Merge commit (--no-ff)** |
| PR | base | 合并方式 | 合并后 commit 标题 |
|---|---|---|---|
| `release/X.Y.Z``master` | `master` | **Merge commit (--no-ff)** | `chore(release): vX.Y.Z` |
| `release/X.Y.Z``develop` | `develop` | **Merge commit (--no-ff)** | `chore(release): merge release/X.Y.Z back into develop` |
> ⚠️ Merge commit 的标题**必须**符合 `type(scope): subject` 格式commitlint 在 push 到 master/develop 时会校验)。
> 历史上用过 `Release vX.Y.Z` 这种形式,会被 commitlint 报 `type-empty` / `subject-empty`。
`master` 分支保护要求 review 通过。回灌 `develop` 是为了把发版冻结期内的小修同步回来。

View File

@@ -53,20 +53,26 @@ class UniversalGPT(GPT):
extras=kwargs.get('extras'),
)
# ⛳ 组装 content 数组,支持 text + image_url 混合
content: List[dict] = [{"type": "text", "text": content_text}]
video_img_urls = kwargs.get('video_img_urls', [])
for url in video_img_urls:
content.append({
"type": "image_url",
"image_url": {
"url": url,
"detail": "auto"
}
})
content: list[dict] | str
if video_img_urls:
# 有截图时走 OpenAI 多模态 content 数组text + image_url
content = [{"type": "text", "text": content_text}]
for url in video_img_urls:
content.append({
"type": "image_url",
"image_url": {
"url": url,
"detail": "auto"
}
})
else:
# 纯文本场景退回 string contentDeepSeek deepseek-chat 等非多模态模型
# 不识别 [{"type":"text",...}] 数组形态,会返回 invalid_request_error
# issue #282。OpenAI 规范本身也允许 content 为 string。
content = content_text
# 正确格式:整体包在一个 message 里role + content array
messages = [{
"role": "user",
"content": content
@@ -83,9 +89,10 @@ class UniversalGPT(GPT):
def _build_merge_messages(self, partials: list) -> list:
merge_text = MERGE_PROMPT + "\n\n" + "\n\n---\n\n".join(partials)
# 合并阶段没有图片,直接用 string content 兼容非多模态模型issue #282
return [{
"role": "user",
"content": [{"type": "text", "text": merge_text}]
"content": merge_text
}]
def _checkpoint_path(self, checkpoint_key: str) -> Path:

View File

@@ -25,7 +25,12 @@ class TranscriberConfigManager:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_config(self) -> Dict[str, Any]:
"""获取当前转写器配置fallback 到环境变量默认值。"""
"""获取当前转写器配置fallback 到环境变量默认值。
whisper 默认 size 从 'medium' (~1.5GB) 改为 'tiny' (~75MB)
新装用户没主动设置时不应该被首次下载卡住。想要更高精度可在「音频转写配置」
页主动切换。
"""
data = self._read()
return {
"transcriber_type": data.get(
@@ -34,7 +39,7 @@ class TranscriberConfigManager:
),
"whisper_model_size": data.get(
"whisper_model_size",
os.getenv("WHISPER_MODEL_SIZE", "medium"),
os.getenv("WHISPER_MODEL_SIZE", "tiny"),
),
}

View File

@@ -0,0 +1,189 @@
"""issue #282 回归测试UniversalGPT 拼装 content 时按是否有图片切换 string / array 形态。
DeepSeek deepseek-chat 等非多模态模型只接受 ``content`` 为字符串,旧实现无条件
emit ``[{"type":"text","text":...}]`` 导致 ``invalid_request_error``。
"""
import importlib.util
import pathlib
import sys
import types
import unittest
def _install_stubs():
app_mod = types.ModuleType("app")
gpt_pkg = types.ModuleType("app.gpt")
models_pkg = types.ModuleType("app.models")
base_mod = types.ModuleType("app.gpt.base")
class _GPT:
pass
base_mod.GPT = _GPT
prompt_builder_mod = types.ModuleType("app.gpt.prompt_builder")
def _generate_base_prompt(**_kwargs):
return "PROMPT_BODY"
prompt_builder_mod.generate_base_prompt = _generate_base_prompt
prompt_mod = types.ModuleType("app.gpt.prompt")
prompt_mod.BASE_PROMPT = ""
prompt_mod.AI_SUM = ""
prompt_mod.SCREENSHOT = ""
prompt_mod.LINK = ""
prompt_mod.MERGE_PROMPT = "MERGE_HEAD"
utils_mod = types.ModuleType("app.gpt.utils")
def _fix_markdown(text):
return text
utils_mod.fix_markdown = _fix_markdown
request_chunker_mod = types.ModuleType("app.gpt.request_chunker")
class _RequestChunker:
def __init__(self, *_args, **_kwargs):
pass
def group_texts_by_budget(self, texts, _builder, **_kwargs):
return [texts]
request_chunker_mod.RequestChunker = _RequestChunker
gpt_model_mod = types.ModuleType("app.models.gpt_model")
class _GPTSource:
pass
gpt_model_mod.GPTSource = _GPTSource
transcriber_model_mod = types.ModuleType("app.models.transcriber_model")
class _TranscriptSegment:
def __init__(self, **kwargs):
self.start = kwargs.get("start", 0)
self.end = kwargs.get("end", 0)
self.text = kwargs.get("text", "")
transcriber_model_mod.TranscriptSegment = _TranscriptSegment
sys.modules.setdefault("app", app_mod)
sys.modules.setdefault("app.gpt", gpt_pkg)
sys.modules.setdefault("app.models", models_pkg)
sys.modules["app.gpt.base"] = base_mod
sys.modules["app.gpt.prompt_builder"] = prompt_builder_mod
sys.modules["app.gpt.prompt"] = prompt_mod
sys.modules["app.gpt.utils"] = utils_mod
sys.modules["app.gpt.request_chunker"] = request_chunker_mod
sys.modules["app.models.gpt_model"] = gpt_model_mod
sys.modules["app.models.transcriber_model"] = transcriber_model_mod
def _load_universal_gpt_class():
_install_stubs()
root = pathlib.Path(__file__).resolve().parents[1]
module_path = root / "app" / "gpt" / "universal_gpt.py"
spec = importlib.util.spec_from_file_location(
"universal_gpt_content_format", module_path
)
if spec is None or spec.loader is None:
raise ImportError("universal_gpt module spec not found")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.UniversalGPT
UniversalGPT = _load_universal_gpt_class()
class _DummyClient:
"""create_messages 不会真的调用 client给个空壳即可。"""
def _make_gpt():
return UniversalGPT(_DummyClient(), model="deepseek-chat")
class TestCreateMessagesContentFormat(unittest.TestCase):
"""覆盖 create_messages 在不同 video_img_urls 输入下的输出形态。"""
def test_no_images_emits_string_content(self):
"""无图片时 content 为 strDeepSeek / 非多模态模型可解析)。"""
gpt = _make_gpt()
messages = gpt.create_messages(segments=[])
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["role"], "user")
self.assertIsInstance(messages[0]["content"], str)
self.assertEqual(messages[0]["content"], "PROMPT_BODY")
def test_empty_image_list_emits_string_content(self):
"""显式传入空列表也要走纯文本分支,避免图片字段误触发。"""
gpt = _make_gpt()
messages = gpt.create_messages(segments=[], video_img_urls=[])
self.assertIsInstance(messages[0]["content"], str)
def test_with_images_emits_multimodal_array(self):
"""有图片时保留多模态 array 形态,确保多模态模型功能不退化。"""
gpt = _make_gpt()
messages = gpt.create_messages(
segments=[],
video_img_urls=["https://example.com/a.jpg", "https://example.com/b.jpg"],
)
content = messages[0]["content"]
self.assertIsInstance(content, list)
self.assertEqual(len(content), 3) # 1 text + 2 images
self.assertEqual(content[0], {"type": "text", "text": "PROMPT_BODY"})
self.assertEqual(content[1]["type"], "image_url")
self.assertEqual(content[1]["image_url"]["url"], "https://example.com/a.jpg")
self.assertEqual(content[1]["image_url"]["detail"], "auto")
self.assertEqual(content[2]["image_url"]["url"], "https://example.com/b.jpg")
def test_no_image_url_field_when_no_images(self):
"""纯文本响应里不应该出现 image_url 关键字 —— 这是触发 DeepSeek 400 的根因。"""
gpt = _make_gpt()
messages = gpt.create_messages(segments=[])
import json
serialized = json.dumps(messages, ensure_ascii=False)
self.assertNotIn("image_url", serialized)
class TestBuildMergeMessagesContentFormat(unittest.TestCase):
"""合并阶段从不带图片,应该统一走 string content 路径。"""
def test_merge_messages_use_string_content(self):
"""否则长视频 chunk 后的合并阶段还会复现 issue #282 错误。"""
gpt = _make_gpt()
messages = gpt._build_merge_messages(["partial-A", "partial-B"])
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]["role"], "user")
self.assertIsInstance(messages[0]["content"], str)
self.assertIn("MERGE_HEAD", messages[0]["content"])
self.assertIn("partial-A", messages[0]["content"])
self.assertIn("partial-B", messages[0]["content"])
def test_merge_messages_no_image_url_field(self):
gpt = _make_gpt()
messages = gpt._build_merge_messages(["x"])
import json
serialized = json.dumps(messages, ensure_ascii=False)
self.assertNotIn("image_url", serialized)
if __name__ == "__main__":
unittest.main()