97 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
huangjianwu
a46880f169 Release v2.1.1
工程化与文档收尾,无运行时行为变化。详见 CHANGELOG.md。
2026-05-07 13:54:50 +08:00
huangjianwu
c187dce5cb docs: v2.1.1 CHANGELOG + README 版本
工程化与文档收尾,无运行时行为变化。详见 CHANGELOG.md。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:54:23 +08:00
Jianwu Huang
424c7f84e2 Merge pull request #346 from JefferyHcool/feature/repo-tooling
chore(repo): 仓库工具链——issue/PR 模板 + commitlint CI + 插件发版工作流
2026-05-07 13:53:04 +08:00
Jianwu Huang
f6a9af4658 Merge pull request #344 from JefferyHcool/chore/update-wechat-qr
chore(docs): 更新群聊微信二维码
2026-05-07 13:51:47 +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
huangjianwu
f583f3cc8c chore(frontend): about 页移除 QQ 群联系方式,仅保留微信群
QQ 群已不再活跃维护,关联展示也容易让用户搜不到。保留微信群入口即可。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:50:23 +08:00
huangjianwu
2e7fe8d3a8 chore(frontend): 关于页二维码改为 import 本地资源,不再依赖 CDN
之前 about 页直接拉腾讯云 COS 上的 wechat.png,每次换码都要手动重新上传 CDN
+ 刷缓存。改成 import @/assets/wechat.png,由 Vite 打包,更新时只需替换文件。

doc/wechat.png 仍保留作为源 / 备份。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:49:25 +08:00
huangjianwu
1e055c3068 chore(docs): 更新群聊微信二维码
doc/wechat.png 替换为最新版(396×396)。

⚠️ 前端 SettingPage/about.tsx:207 实际渲染的是腾讯云 COS 上的同名文件
   https://common-1304618721.cos.ap-chengdu.myqcloud.com/wechat.png
仓库这边只是源文件,CDN 那份需要项目维护者手动重新上传:
- 登录腾讯云 COS 控制台
- 找到 common-1304618721 桶
- 把根目录的 wechat.png 替换为 doc/wechat.png 这一份

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:47:23 +08:00
huangjianwu
b2af0e4e53 chore(repo): 仓库工具链——issue/PR 模板 + commitlint CI + 插件发版工作流
把 CONTRIBUTING.md 里写的规范落到 GitHub 工程化层。

Issue / PR 模板:
- .github/ISSUE_TEMPLATE/{config,bug_report,feature_request}.yml
  · yml 表单形式,跟随当前工作区分类(backend / frontend / extension / Tauri)
  · bug_report 强制选版本 + 部署方式 + 复现步骤;提交前自查不夹带 secrets
  · config.yml 禁用空白 issue,引导 Discussions
- .github/pull_request_template.md:把 CONTRIBUTING §5.2 的 PR 正文要求落成 checklist
- 删旧版 .md 模板(含中文文件名那条),避免新老两套并存

Commitlint:
- .commitlintrc.json:extend conventional + 自定义 type 白名单(feat/fix/docs/style/refactor/perf/test/build/ci/chore/ui/revert)
- .github/workflows/commitlint.yml:用 wagoid/commitlint-github-action@v6,PR + push develop/master 时校验
  · subject-case / subject-full-stop 关掉,兼容中文 subject
  · header-max-length 100 字符 warn 级别,不阻塞合并

插件发版工作流:
- .github/workflows/release-extension.yml:v* tag push 时
  · cd BillNote_extension && pnpm install + build
  · pack:zip / pack:xpi / pack:crx(crx 缺 key 自动跳过)
  · 产物重命名带版本后缀,挂到对应 GitHub Release
- 末尾保留 publish-chrome / publish-edge / publish-firefox 三段注释,配齐 secrets 即可启用商店自动发布
- RELEASING.md:发版执行手册,覆盖 release/* 流程 + 各商店人工上传步骤 + 自动发布所需 secrets

CONTRIBUTING.md 关联文档区指到新增的 RELEASING.md,commit 章节加 commitlint 落地说明。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:45:32 +08:00
Jianwu Huang
3d0838ba72 Merge pull request #343 from JefferyHcool/feature/contributing-doc
docs(contributing): 新增贡献指南,落地简化 Git Flow 分支管理
2026-05-07 13:26:23 +08:00
huangjianwu
ee58a65bcd docs(contributing): 新增贡献指南,落地简化 Git Flow 分支管理
落地分支模型:
- master:稳定主干,仅接受 release/hotfix 合入,禁止直接提交
- develop:长期开发集成分支,常规开发都从这里起
- feature/* / fix/*:短生命周期,基于 develop
- release/*:基于 develop,发版后合入 master + 回灌 develop
- hotfix/*:基于 master,处理线上紧急故障

同时收录:
- 多工作区命名 scope 约定(extension / frontend / backend / bilibili / ...)
- commit message 格式(type(scope): subject)+ PR 标题/正文要求
- 合并方式:feature/fix → develop 用 Squash;release/hotfix → master 用 --no-ff
- simple-git-hooks 残留 .git/hooks/pre-commit 的清理办法
- 语义化版本 + CHANGELOG.md 更新流程
- 禁止事项 + 历史分支迁移要求

适配自 emplai-ui doc/前端仓库分支管理办法.md,按本仓库实际情况调整:
master 不改名为 main、补充多工作区 scope、把发版与 tag 流程串联到 README/CHANGELOG。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:13:32 +08:00
huangjianwu
dbe7b89754 Release v2.1.0
详见 CHANGELOG.md。主线:
- 浏览器插件(Chrome/Edge/Firefox MV3)
- B 站字幕优先链路
- mlx-whisper 仓库 ID 修复
- 后端 CORS regex 兼容扩展源
2026-05-07 13:10:28 +08:00
huangjianwu
cfc3053be8 Merge upstream master (PR #341) into develop 2026-05-07 13:10:28 +08:00
huangjianwu
4dc5b97f0b Merge branch 'feat/browser-extension' into develop 2026-05-07 13:06:44 +08:00
huangjianwu
817bbd9807 docs: v2.1.0 CHANGELOG + README 版本更新
详细变更见 CHANGELOG.md。本次发布主线:
- 浏览器插件(Chrome/Edge/Firefox MV3)
- B 站字幕优先链路(插件 + 后端兜底)
- mlx-whisper 仓库 ID 修复
- 后端 CORS regex 兼容扩展源

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:06:31 +08:00
huangjianwu
406789f834 feat(extension+backend): 插件直接在浏览器里抓 B 站字幕,跳过后端 download_subtitles
之前 B 站字幕优先逻辑放在后端的 BilibiliSubtitleFetcher,需要后端通过 CookieConfigManager
管理 SESSDATA cookie 才能拿 AI 字幕。这次改为:插件在用户浏览器里直接抓字幕,
天然带着用户当前登录态的 cookie;后端只负责把传过来的字幕当作转写缓存。

extension:
- 新增 logic/bilibili-subtitle.ts,调 /x/web-interface/view → /x/player/wbi/v2 → 字幕 URL JSON
  · service worker fetch 走 credentials:'include',借 manifest host_permissions:'*://*/*'
    自动带 .bilibili.com 域 cookie,并绕过 CORS
  · 优先级:人工中文 > AI 中文 > 任意非空
- popup start() 与 background startTask() 在 platform === 'bilibili' 时先调一次抓取,
  结果作为 prefetched_transcript 字段塞到 /api/generate_note payload
- types.ts GenerateRequest 增加 prefetched_transcript 字段

backend:
- VideoRequest 增加可选 prefetched_transcript: dict
- generate_note endpoint 收到时调 _persist_prefetched_transcript() 写到
  NOTE_OUTPUT_DIR/<task_id>_transcript.json;NoteGenerator 的 cache-hit 逻辑天然命中,
  跳过 downloader.download_subtitles 和音频转写,直接走 GPT 总结
- 字幕清洗:去掉空 segment、必要时合成 full_text、language 默认 'zh'

效果:B 站登录用户的视频,从用户点击到 GPT 拿到全文,省掉一次后端 → B 站 API 的来回,
也彻底告别了 backend 那侧的 cookie 配置心智负担。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:23:16 +08:00
huangjianwu
d64741628b ui(extension): 渲染时剥掉 backend 注入的 '> 来源链接:URL' 行
backend 的 note_helper 会在笔记开头加一行 '> 来源链接:<url>'。侧边栏顶部已经有
封面 + 标题 + 跳原片链接的卡片,再在正文里出现一遍是冗余还吃高度。
MindMap 也会把它当作根节点的兄弟节点,影响导图结构。

加 stripSourceLink() helper(regex 直接复刻 web 端 MarkdownViewer.tsx:468 的处理),
在 MarkdownView 与 MindMap 渲染前剥掉。复制 / 下载导出的 .md 仍保留原行,便于溯源。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:18:55 +08:00
huangjianwu
bb9637f30a ui(extension): 侧边栏布局收紧,给笔记内容更多呼吸空间
之前侧边栏堆了:96×56 大封面 + 标题 + URL 链接 + 8 段进度条 + 'Markdown/思维导图/AI 问答' tab + MarkdownView 自带的复制/下载条 + 内容标题 …… 在窄侧栏里太挤,主内容被压到下半屏。

重做:
- 顶栏极简化:左边 'BiliNote',右边「历史 N」按钮 + 「设置」按钮
- 历史任务从底部 details 改成顶栏触发的下拉面板,用了再展开
- 标题区压成一行:12×7 小封面 + 单行标题(hover 显示完整 URL,点击跳原片)+ 行尾状态徽章
  · 进行中:蓝色阶段名 + 脉冲动画
  · 完成:绿色 ✓
  · 失败:红色徽章 + tooltip 显示原因
- 进度条只在 isRunning 时渲染;完成后整段消失
- 视图 tab 与「复制 / 下载」按钮合并到同一行(仅 markdown 视图显示)
- MarkdownView 加 hideActions prop,去掉它自带的按钮区,避免重复;同时去掉 max-h-[400px],撑满父容器

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:15:05 +08:00
huangjianwu
0793949516 feat(extension): popup 改紧凑视图,markdown 详情挪到侧边栏
之前 popup 直接在 400px 宽里渲染 markdown,看起来很挤。改成:
- popup:标题 + 封面缩略图 + 进度条 + 「在侧边栏查看」按钮,不再渲染 markdown
- 提交「生成笔记」后自动调 chrome.sidePanel.open 把侧边栏拉起来
- 最近任务列表显示标题(拿到时)而非 URL
- 新增 logic/api.resolveImageUrl:相对 /static 路径拼后端域名;hdslb / byteimg / kpcdn / ytimg 等带 referer 校验的封面走后端 /api/image_proxy 转发,避免 CORS / 防盗链问题
- 侧边栏顶部同样展示封面 + 标题 + 跳原片链接,方便用户辨识当前任务

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:11:48 +08:00
huangjianwu
e694b460e8 fix(extension): /task_status 拆 ResponseWrapper,进度条不再为空
backend /api/task_status/{id} 实际形状是 R.success({status, message, task_id, result?})
即外面再套一层 {code, msg, data}。原来 getTaskStatus 直接 fetch().json() 没拆包,
导致 res.status 一直是 undefined,TaskProgress 渲染不出阶段标签、进度条全灰,
"最近任务" 列表的状态字段也是空的。

同时把 backend 任务失败时的 R.error(message, code=500) 翻译成 {status:'FAILED', message},
让 UI 能正确显示失败终态、停止轮询,而不是被 request() 抛错卡在那里循环重试。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:07:45 +08:00
Jianwu Huang
e471054beb Merge pull request #341 from JefferyHcool/fix/fix-bugs
fix: 修复 AILogo 噪音、设置页滚动与供应商批量伪内置脏数据
2026-05-07 12:03:31 +08:00
huangjianwu
f37d2e95d1 feat(extension): 侧边栏接入思维导图(markmap)与 RAG 问答(P3 + P4)
任务完成(status === SUCCESS)后,侧边栏顶部出现 Markdown / 思维导图 / AI 问答 三个 tab:

- 思维导图:用 markmap-lib + markmap-view 把 markdown 转成可缩放思维导图
- AI 问答:
  · 进入 tab 自动调 /api/chat/index 触发后台索引,按 2s 间隔轮询 /api/chat/status
  · 索引完成后开放输入框;调 /api/chat/ask 时带上 settings 里的默认 provider/model + 完整 history
  · Cmd/Ctrl + Enter 发送
  · 回答用 markdown-it 渲染,user 气泡用纯文本
- 切换任务时清空对话历史并重新检查索引

logic/api.ts 补 indexChatTask / getChatStatus / askChat 三件套。

依赖新增:markmap-lib, markmap-view(生产依赖)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:02:12 +08:00
huangjianwu
be5e1637fa fix(mlx-whisper): 修正 huggingface 仓库 ID 命名
mlx-community 上 Whisper 仓库的命名实际是 'whisper-{size}-mlx'(large-v3-turbo 例外,无 -mlx 后缀)。
之前 hardcode 拼成 'mlx-community/whisper-{size}' 在 HF 上不存在,下载会 404:

  Repository Not Found for url:
    https://huggingface.co/api/models/mlx-community/whisper-small/revision/main.

修复:
- 在 mlx_whisper_transcriber.py 加 MLX_MODEL_MAP(已用 huggingface API 核对过命名)+ resolve_mlx_repo_id() 帮助函数
- routers/config.py 的 _do_download_mlx_whisper 与 _check ... 路径生成都改用同一份映射表
- 给 transcriber_models_status 的每条 mlx 状态加 available 字段,避免后续若有不支持的 size 时静默失败

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:59:02 +08:00
huangjianwu
702b57c165 feat(bilibili): 优先走官方 player API 直拉字幕
之前 BilibiliDownloader.download_subtitles 走的是 yt-dlp 的 writesubtitles 路径,对 B 站签名/Cookie 的兼容性差,常常空手而归,落到音频下载 + Whisper 转写的慢路径。

新增 bilibili_subtitle.BilibiliSubtitleFetcher:
- /x/web-interface/view?bvid=... → 拿 cid
- /x/player/wbi/v2?bvid=...&cid=... → 拿 subtitle 列表(subtitle_url 已带 auth_key)
- 优先级:人工中文 > AI 中文 > 任意中文 > 任意非空
- fetch JSON body 解析为 TranscriptResult
- 通过 CookieConfigManager 自动注入 SESSDATA cookie(AI 字幕必需)

bilibili_downloader.download_subtitles 顺序改为:先试新 fetcher,失败再回退到原 yt-dlp 路径。NoteGenerator 的字幕优先逻辑无需改动——它本来就调 download_subtitles。

效果:
- B 站视频如果有字幕(人工或 AI),直接秒拿,跳过音频下载 + 转写
- 完全绕开 MLX Whisper 不可用 / 模型未下载 等转写器问题
- 拿不到字幕时仍可走原音频转写路径

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:55:50 +08:00
huangjianwu
3bd8b670ca feat(extension): options 改为多 tab,搬入 web 端的全部设置项
把原来一长条的 options 拆成五个 tab,覆盖 web 端 SettingPage 的全部能力。今后新功能优先在插件里做,web 端逐步退役。

- 通用:后端地址 + 默认供应商/模型 + 默认生成选项(原 Options.vue 内容)
- 模型供应商:完整 CRUD —— 列表 / 启用切换 / 编辑 / 测试连接 / 添加 / 模型增删
- 音频转写配置:转写器引擎切换(fast-whisper / mlx-whisper / Groq / 必剪 / 快手)+ Whisper 模型大小切换 + 模型本地下载状态 + 触发下载
  · 直接修复 'MLX Whisper 不可用' 报错——非 Mac 用户现在能切到 fast-whisper / Groq
- 下载配置:每平台 cookie 显示 / 浏览器一键同步 / 手动粘贴保存
- 部署监控:后端、FFmpeg、CUDA、Whisper 模型 当前状态

logic/api.ts 补齐:provider CRUD / model CRUD / connect_test / transcriber_config / transcriber_models_status / transcriber_download / get_downloader_cookie / deploy_status / sys_health。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:53:08 +08:00
huangjianwu
880587f2db feat(extension): P2 视频页悬浮按钮 + 右键菜单 + cookie 直通;P3 侧边栏首版
- contentScripts: 仅在支持的视频平台(B 站 / YouTube / 抖音 / 快手)注入悬浮 BiliNote 按钮,点击通过 webext-bridge 发 'bilinote-start' 给 background
- background: 处理 bilinote-start 与右键菜单点击;调 /api/generate_note;写 chrome.storage;自动打开侧边栏。logic/storage 是 Vue 反应式版本,service worker 不能 import,因此把常量抽到 logic/constants.ts
- contextMenus: onInstalled 时注册"用 BiliNote 总结此视频",限定 4 个支持平台域名
- 浏览器 Cookie 同步:options 页加按钮,按平台读 chrome.cookies.getAll,序列化为 'name=value; ...' 后 POST 给后端 /api/update_downloader_cookie。chrome.cookies + contextMenus 权限补到 manifest
- 侧边栏(P3 首版):从 storage 读最近任务并轮询,复用 TaskProgress + MarkdownView。markmap 思维导图与 RAG 问答推到后续
- 修 P1 endpoint 拼错的 bug:/api/get_models_by_provider 实际是 /api/model_enable,404 来自这里

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:46:09 +08:00
huangjianwu
b8f359e7e7 feat(extension): 浏览器插件 P1 MVP
新建 BillNote_extension/ 工作空间(基于 vitesse-webext 骨架,Vue 3 + Vite + UnoCSS + MV3)。

P1 MVP 范围:
- popup:自动读当前 tab URL,识别 Bilibili / YouTube / 抖音 / 快手;提交 /generate_note 后轮询 /task_status;展示 markdown,复制 + 下载 .md
- options:后端地址输入与连通性测试;从 /get_all_providers + /get_models_by_provider 拉供应商/模型列表;默认画质、截图/跳转、笔记风格
- chrome.storage.local 持久化设置与最近 30 个任务,popup 重开恢复进行中任务
- markdown 里的 /static/screenshots 路径在渲染前重写为绝对地址

后端:CORS 改用 regex,新增允许 chrome-extension:// 与 moz-extension:// 源(同时保留 localhost / 127.0.0.1 / tauri.localhost)。无新增 backend endpoint。

P2-P4(content script 悬浮按钮、cookie 直通、side panel、思维导图、RAG 问答)保留 stub 文件,不在本次范围。

去掉 vitesse-webext 自带的 simple-git-hooks postinstall 配置——它会在仓库根装 pre-commit 钩子去跑 pnpm lint-staged,但仓库根没有 package.json,会破坏所有提交流。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:40:15 +08:00
huangjianwu
108ad270bf fix: 修复 AILogo 噪音、设置页滚动与供应商批量伪内置脏数据
- AILogo: `custom` 名称为合法兜底场景,不再以 console.error 上报;其余未匹配名称降级为 console.warn
- SettingPage/Model: 双栏加 `min-h-0 overflow-y-auto`,让供应商列表与右侧表单各自可滚动
- ProviderService.add_provider: API 创建一律落到 `type='custom'`,并对同名供应商抛 ValueError,避免再产生伪内置行
- CLAUDE.md: 补充 v2.0.0 子系统(RAG/Chat、可选 Nacos+RabbitMQ、i18n、cookie/transcriber 管理器)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:10:15 +08:00
Jianwu Huang
2cd43770eb Merge pull request #318 from linwinfan/feat/zustand-indexeddb 2026-05-05 14:34:18 +08:00
Jianwu Huang
e3134f2078 Merge pull request #333 from Lizhilin/fix/bilibili-cookie-injection 2026-05-05 14:32:32 +08:00
Jianwu Huang
118b7357c5 Merge pull request #336 from liang09255/fix-switch-0502 2026-05-05 14:32:22 +08:00
liang09255
c9ab763f1b fix(frontend): 修复供应商开关切换不能实时生效的问题 2026-05-02 21:51:33 +08:00
Lizhilin
c5e08e1ec6 fix: BilibiliDownloader 从 CookieConfigManager 读取 cookie 并注入 yt-dlp cookiefile 2026-04-28 23:20:34 +08:00
linwinfan
20fcf2c29c feat(frontend): migrate Zustand persist storage to IndexedDB
- Add idb-keyval dependency for IndexedDB support
- Configure persist middleware to use IndexedDB
- Improves persistence reliability in browser environments
2026-04-07 15:51:53 +08:00
Jianwu Huang
8fa3101f0f Merge pull request #311 from JefferyHcool/feature/optimize-build
fix(docker): 优化 Vite 配置以支持 Docker 构建环境
2026-03-23 18:55:35 +08:00
huangjianwu
499366da02 fix(docker): 优化 Vite 配置以支持 Docker 构建环境
- 修改 vite.config.ts 在 Docker 环境中使用当前目录加载 .env 文件
- 在 Dockerfile 中设置 DOCKER_BUILD 环境变量
- 移除不必要的 .env.example 复制步骤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:54:16 +08:00
Jianwu Huang
1f52185539 Merge pull request #310 from JefferyHcool/feature/optimize-build
fix(ci): 移除 pnpm install 的 --frozen-lockfile 标志
2026-03-23 18:52:56 +08:00
huangjianwu
cb5c11d41a fix(ci): 移除 pnpm install 的 --frozen-lockfile 标志
由于不提交 pnpm-lock.yaml 文件,移除 --frozen-lockfile 标志以修复桌面端构建失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:50:48 +08:00
Jianwu Huang
c9ee3e6957 Merge pull request #309 from JefferyHcool/feature/optimize-build
fix(frontend): 修复 ESM 模式下 __dirname 未定义的问题
2026-03-23 18:47:42 +08:00
huangjianwu
8e2f74c0f5 fix(frontend): 修复 ESM 模式下 __dirname 未定义的问题
在 vite.config.ts 中添加 ESM 兼容的 __dirname 定义,修复 Docker 构建时的配置加载错误

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:46:53 +08:00
Jianwu Huang
274e5d25a8 Merge pull request #308 from JefferyHcool/feature/optimize-build
fix(docker): 修复前端构建时缺少 .env 文件的问题
2026-03-23 18:43:58 +08:00
huangjianwu
c0f978bd77 fix(docker): 修复前端构建时缺少 .env 文件的问题
在构建前端之前复制 .env.example 到父目录,供 vite.config.ts 加载环境变量使用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:42:34 +08:00
Jianwu Huang
e7db5124ea Merge pull request #307 from JefferyHcool/feature/optimize-build
fix(docker): 修复 apt-get 安装失败问题
2026-03-23 18:39:15 +08:00
huangjianwu
4bff57c774 fix(docker): 修复 apt-get 安装失败问题
将清华镜像源从 https 改为 http 协议,避免 SSL 证书验证问题导致的 apt-get update 失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:36:22 +08:00
Jianwu Huang
198f01d079 Merge pull request #306 from JefferyHcool/feature/optimize-build
chore: 删除 ffmpeg 二进制文件并更新 .gitignore
2026-03-23 18:32:54 +08:00
huangjianwu
341d3ded06 chore: 删除 ffmpeg 二进制文件并更新 .gitignore
移除错误提交的 301MB ffmpeg 构建文件,并在 .gitignore 中添加 ffmpeg*/ 规则防止再次提交

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:31:32 +08:00
Jianwu Huang
7da4f9587b Merge pull request #305 from JefferyHcool/feature/optimize-build
Feature/optimize build
2026-03-23 18:26:34 +08:00
Jianwu Huang
0e10a3d906 Merge pull request #304 from JefferyHcool/master
修复打包错误
2026-03-23 18:13:24 +08:00
huangjianwu
6d5d1ad373 fix(ci): 修复 GitHub Actions 构建错误
移除 setup-node 中的 pnpm 缓存配置以修复 macOS 构建失败,修改 Dockerfile 不再依赖 pnpm-lock.yaml 以修复 Docker 构建失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:11:55 +08:00
Jianwu Huang
3010690d2e Merge pull request #303 from JefferyHcool/feature/update-readme-v2
docs: 更新 README 至 v2.0.0,补充新功能说明和 Docker 部署方式
2026-03-23 17:56:15 +08:00
Jianwu Huang
a40bb19743 Merge branch 'master' into feature/update-readme-v2 2026-03-23 17:56:07 +08:00
huangjianwu
6090982261 docs: 更新 README 至 v2.0.0,补充新功能说明和 Docker 部署方式
- 版本号升级为 2.0.0(README、tauri.conf.json、about 页面)
- 新增 v2.0.0 功能说明:RAG 问答、Function Calling、封面 Banner、面板折叠等
- Docker 部署改为推荐方式,支持 docker pull 预构建镜像
- 补充源码部署为方式二,修正前端访问端口为 3015
- 更新功能特性列表,补充快手、AI 问答等新功能
- TODO 标记 RAG 问答为已完成

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:52:30 +08:00
Jianwu Huang
90aeb22853 Merge pull request #302 from JefferyHcool/feature/optimize-build
feat(build): 全面优化打包流程,Docker 镜像自动发布到 GHCR
2026-03-23 17:49:25 +08:00
Jianwu Huang
c553fd898f Merge pull request #301 from JefferyHcool/feature/youtube-subtitle-innertube
feat(youtube): 使用 youtube-transcript-api 优先获取字幕,有字幕时跳过音频下载
2026-03-23 17:32:48 +08:00
huangjianwu
f4801d5be7 feat(youtube): 使用 youtube-transcript-api 优先获取字幕,有字幕时跳过音频下载
- 新增 YouTubeSubtitleFetcher 模块,通过 youtube-transcript-api 获取字幕
- 重构笔记生成流程:缓存 → 平台字幕 → 按需下载 → 转写 fallback
- 有字幕时仅提取视频元信息,不下载音视频文件
- 添加 youtube-transcript-api 依赖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:31:30 +08:00
172 changed files with 27525 additions and 259969 deletions

28
.commitlintrc.json Normal file
View File

@@ -0,0 +1,28 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"ui",
"revert"
]
],
"subject-case": [0],
"subject-full-stop": [0],
"header-max-length": [1, "always", 100],
"body-max-line-length": [0],
"footer-max-line-length": [0]
}
}

View File

@@ -1,49 +0,0 @@
---
name: Bug report
about: 上报一些bug
title: "[BUG]"
labels: bug
assignees: JefferyHcool
---
---
name: 🐛 Bug 反馈
about: 提交一个 Bug 报告,帮助我们改进
title: "[Bug] "
labels: bug
assignees: ''
---
**版本说明**
请说明的你的版本号
**部署方式**
使用的是什么方式部署代码环境部署docker部署桌面端在线预览
**描述问题**
清晰、简明地描述你遇到的问题是什么。
**复现步骤**
复现该问题的步骤:
1. 进入页面 '...'
2. 点击 '...'
3. 滚动到 '...'
4. 出现错误
**预期行为**
清晰、简明地描述你本来预期发生的行为。
**截图**
如果适用,请添加截图以帮助说明问题。
**桌面端(请补充以下信息)**
- 操作系统:例如 Windows / macOS / Ubuntu
- 浏览器:例如 Chrome、Safari
**其他补充信息**
请补充任何其他相关信息。

93
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: 🐛 Bug 报告
description: 报告一个可复现的问题
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢反馈。请尽量提供完整的复现路径与日志,便于排查。
⚠️ **不要**贴 API key、SESSDATA、密钥等敏感信息。
- type: dropdown
id: workspace
attributes:
label: 受影响的工作区
multiple: true
options:
- 后端 (backend/)
- Web 前端 (BillNote_frontend/)
- 浏览器插件 (BillNote_extension/)
- Tauri 桌面端
- 文档 / 其他
validations:
required: true
- type: input
id: version
attributes:
label: 版本
description: BiliNote 版本号README 顶部,例如 v2.1.0
placeholder: v2.1.0
validations:
required: true
- type: dropdown
id: deploy
attributes:
label: 部署方式
options:
- 源码运行
- Docker (docker-compose.yml)
- Docker GPU (docker-compose.gpu.yml)
- 桌面端安装包 (Tauri Release)
- 其他
validations:
required: true
- type: textarea
id: repro
attributes:
label: 复现步骤
description: 一步步说明如何触发问题
placeholder: |
1. 打开 ...
2. 点击 ...
3. 看到 ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望行为
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
description: 含错误信息、截图、录屏均可
validations:
required: true
- type: textarea
id: env
attributes:
label: 运行环境
description: 操作系统、Python 版本、Node 版本、浏览器(如适用)
placeholder: |
- OS: macOS 14.5 / Windows 11 / Ubuntu 22.04
- Python: 3.11.6
- Node: 20.18.0
- Browser: Chrome 124如涉及插件/前端)
- GPU: 无 / NVIDIA 4070如涉及 fast-whisper / video understanding
- type: textarea
id: logs
attributes:
label: 日志 / 堆栈
description: 后端 console、前端 DevTools、扩展 background 页都可以贴
render: text
- type: checkboxes
id: pre-checks
attributes:
label: 提交前自查
options:
- label: 我已搜索过 [Issues](https://github.com/JefferyHcool/BiliNote/issues?q=),确认不是重复问题
required: true
- label: 我提供的日志中**不**包含 API key、cookie、SESSDATA 等敏感信息
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 📖 文档与常见问题
url: https://docs.bilinote.app/
about: 安装与配置遇到问题,先看一下文档
- name: 💬 提问 / 讨论
url: https://github.com/JefferyHcool/BiliNote/discussions
about: 用法咨询、想法征集请发到 Discussions不是 bug 才用 Issues

View File

@@ -0,0 +1,40 @@
name: ✨ 功能建议
description: 提议新功能或改进
title: "[Feature] "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: 想解决什么问题?
description: 描述你遇到的实际场景或痛点。
validations:
required: true
- type: textarea
id: proposal
attributes:
label: 建议方案
description: 期望的功能或交互。可附草图 / 示例。
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 备选方案
description: 你考虑过哪些其他做法?为什么没采用?
- type: dropdown
id: workspace
attributes:
label: 涉及的工作区
multiple: true
options:
- 后端 (backend/)
- Web 前端 (BillNote_frontend/)
- 浏览器插件 (BillNote_extension/)
- Tauri 桌面端
- 不确定
- type: textarea
id: extra
attributes:
label: 其他补充
description: 关联 issue、参考资料、产品截图等

View File

@@ -1,29 +0,0 @@
---
name: 新增功能建议
about: 一些新的功能建议
title: "[FEATHURE]"
labels: enhancement
assignees: JefferyHcool
---
---
name: ✨ 功能请求
about: 提出一个新的功能建议
title: "[Feature] "
labels: enhancement
assignees: ''
---
**这个功能请求是否与某个问题相关?请描述**
清晰简要地描述问题是什么。例如:每次遇到 [...] 都让我感到很沮丧。
**描述你希望实现的解决方案**
清晰简要地描述你希望发生的事情。
**描述你考虑过的备选方案**
清晰简要地描述你考虑过的其他解决方案或功能。
**其他补充信息**
请在此添加关于功能请求的其他上下文或截图。

39
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,39 @@
<!--
PR 标题请遵循 type(scope): subject 格式,例如:
feat(extension): 侧边栏接入思维导图
fix(bilibili): 修正字幕优先链路在未登录态下的回退
分支命名 / 提交规范见 CONTRIBUTING.md。
-->
## 改动概述
<!-- 一句话说清这个 PR 做了什么 -->
## 为什么
<!-- 背景、关联 issueFixes #xxx / Refs #xxx)、用户场景 -->
## 做了什么
<!-- 关键文件、关键决策。可贴关键片段或截图 -->
## 测试方式
- [ ] `pnpm typecheck && pnpm build`(前端 / 插件)通过
- [ ] `python -m py_compile <文件>` 或本地 backend 启动验证(后端)通过
- [ ] 手动验证步骤:
<!-- 描述如何复现验证UI 改动请附截图 / 录屏 -->
## 回归风险
<!-- 影响面、可能受波及的功能、是否需要前后端 / 配置 同步部署 -->
## Checklist
- [ ] 分支命名遵循 [CONTRIBUTING.md §3](../CONTRIBUTING.md#3-分支命名)`feature/*` / `fix/*` / `release/*` / `hotfix/*`
- [ ] base 分支正确(常规改动 → `develop`;线上紧急 → `master`;发版 → 见 §4.3
- [ ] Commit message 遵循 `type(scope): subject` 格式([CONTRIBUTING.md §5.1](../CONTRIBUTING.md#51-commit-message-格式)
- [ ] 已自测核心流程
- [ ] 已更新相关文档(`README.md` / `CHANGELOG.md` / `CLAUDE.md` / 模块 README如适用
- [ ] 未夹带 secrets / `.env` / 大型二进制
- [ ] 单 PR 不跨多个工作区做无关改动

30
.github/workflows/commitlint.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Commit Lint
on:
pull_request:
types: [opened, synchronize, reopened, edited]
push:
branches:
- develop
- master
permissions:
contents: read
pull-requests: read
jobs:
commitlint:
name: Lint commit messages
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run commitlint
uses: wagoid/commitlint-github-action@v6
with:
configFile: .commitlintrc.json
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
@@ -59,17 +50,15 @@ jobs:
with:
version: 'latest'
# 设置 Node 环境(带 pnpm 缓存)
# 设置 Node 环境
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: BillNote_frontend/pnpm-lock.yaml
- name: Install frontend dependencies
working-directory: BillNote_frontend
run: pnpm install --frozen-lockfile
run: pnpm install
# 设置 Rust 环境
- name: Set up Rust
@@ -105,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/

115
.github/workflows/release-extension.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: Release Extension
# 在 v* tag push 时触发,构建插件并把产物挂到对应 GitHub Release。
# 商店上传仍走人工(详见 RELEASING.md如果将来配齐了商店 API secrets
# 把本文件末尾注释的 publish-* job 解开就是自动发布。
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
name: Build & attach to release
runs-on: ubuntu-latest
defaults:
run:
working-directory: BillNote_extension
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: BillNote_extension/pnpm-lock.yaml
- name: Install
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Pack zip (Chrome / Edge upload format)
run: pnpm pack:zip
- name: Pack xpi (Firefox Add-ons)
run: pnpm pack:xpi
- name: Pack crx (self-host sideload)
# crx 需要稳定 key.pem 才能保持插件 ID 不变CI 没有就跳过,不阻塞主流程。
# 想生成稳定 crx把 key 存到 secret EXTENSION_CRX_KEY下面解开几行。
run: |
# if [ -n "${{ secrets.EXTENSION_CRX_KEY }}" ]; then
# echo "${{ secrets.EXTENSION_CRX_KEY }}" > key.pem
# pnpm pack:crx
# else
pnpm pack:crx || true
# fi
continue-on-error: true
- name: Rename artifacts with version suffix
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
[ -f extension.zip ] && mv extension.zip "bilinote-extension-${VERSION}.zip"
[ -f extension.xpi ] && mv extension.xpi "bilinote-extension-${VERSION}.xpi"
[ -f extension.crx ] && mv extension.crx "bilinote-extension-${VERSION}.crx"
ls -la *.zip *.xpi *.crx 2>/dev/null || true
- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
BillNote_extension/bilinote-extension-*.zip
BillNote_extension/bilinote-extension-*.xpi
BillNote_extension/bilinote-extension-*.crx
fail_on_unmatched_files: false
generate_release_notes: false
# ---------- 商店自动发布(默认禁用,配齐 secrets 后可启用) ----------
#
# publish-chrome:
# needs: build
# runs-on: ubuntu-latest
# steps:
# - uses: actions/download-artifact@v4
# - uses: mnao305/chrome-extension-upload@v5
# with:
# file-path: BillNote_extension/bilinote-extension-${{ github.ref_name }}.zip
# extension-id: ${{ secrets.CHROME_EXTENSION_ID }}
# client-id: ${{ secrets.CHROME_CLIENT_ID }}
# client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
# refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
#
# publish-edge:
# needs: build
# runs-on: ubuntu-latest
# steps:
# - uses: wdzeng/edge-addon@v2
# with:
# product-id: ${{ secrets.EDGE_PRODUCT_ID }}
# zip-path: BillNote_extension/bilinote-extension-${{ github.ref_name }}.zip
# client-id: ${{ secrets.EDGE_CLIENT_ID }}
# api-key: ${{ secrets.EDGE_API_KEY }}
#
# publish-firefox:
# needs: build
# runs-on: ubuntu-latest
# steps:
# - uses: trmcnvn/firefox-addon@v3
# with:
# uuid: ${{ secrets.FIREFOX_ADDON_UUID }}
# xpi: BillNote_extension/bilinote-extension-${{ github.ref_name }}.xpi
# api-key: ${{ secrets.FIREFOX_API_KEY }}
# api-secret: ${{ secrets.FIREFOX_API_SECRET }}

6
.gitignore vendored
View File

@@ -322,4 +322,8 @@ cython_debug/
/backend/config/*
/backend/vector_db/
/BiliNote_frontend/.idea/*
/BiliNote_frontend/src-tauri/bin/
/BiliNote_frontend/src-tauri/bin/
# FFmpeg 构建文件(不应该提交到仓库)
ffmpeg*/
ffmpg*/

17
BillNote_extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
.DS_Store
.idea/
.vite-ssg-dist
.vite-ssg-temp
*.crx
*.local
*.log
*.pem
*.xpi
*.zip
dist
dist-ssr
extension/manifest.json
node_modules
src/auto-imports.d.ts
src/components.d.ts
.eslintcache

7
BillNote_extension/.gitpod.Dockerfile vendored Normal file
View File

@@ -0,0 +1,7 @@
FROM gitpod/workspace-full-vnc
USER root
# Install dependencies
RUN apt-get update \
&& apt-get install -y firefox

View File

@@ -0,0 +1,23 @@
image:
file: .gitpod.Dockerfile
tasks:
- init: pnpm install && pnpm run build
name: dev
command: |
gp sync-done ready
pnpm run dev
- name: pnpm start:chromium
command: |
gp sync-await ready
gp ports await 6080
gp preview $(gp url 6080)
sleep 5
pnpm start:chromium
openMode: split-right
ports:
- port: 5900
onOpen: ignore
- port: 6080
onOpen: ignore

View File

@@ -0,0 +1,2 @@
shamefully-hoist=true
auto-install-peers=true

View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"vue.volar",
"antfu.iconify",
"antfu.unocss",
"dbaeumer.vscode-eslint",
"csstools.postcss"
]
}

View File

@@ -0,0 +1,12 @@
{
"cSpell.words": ["Vitesse"],
"typescript.tsdk": "node_modules/typescript/lib",
"vite.autoStart": false,
"eslint.experimental.useFlatConfig": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"files.associations": {
"*.css": "postcss"
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Anthony Fu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,53 @@
# BiliNote 浏览器插件
把 BiliNote 的"视频链接 → Markdown 笔记"能力下沉到浏览器插件。当前为 P1 MVP仅工具栏 popup
## 当前状态P1 MVP
- ✅ 工具栏图标 popup自动读当前 tab URL识别支持平台触发笔记生成
- ✅ 设置页:后端地址、供应商/模型、画质、截图/跳转/风格默认值
- ✅ 任务进度可视化、Markdown 渲染、复制 / 下载 .md
- ✅ chrome.storage.local 持久化设置和最近 30 个任务
- ⏳ P2视频页悬浮按钮 + 右键菜单 + 浏览器 cookie 直通
- ⏳ P3side panel + 思维导图markmap
- ⏳ P4RAG 问答
## 开发
依赖node 20+ / pnpm 9+
```bash
cd BillNote_extension
pnpm install
pnpm dev # watch 模式,产物输出到 ./extension/
```
加载到 Chrome
1. `chrome://extensions/` → 打开右上"开发者模式"
2. 点"加载已解压的扩展程序",选 `BillNote_extension/extension/` 目录
3. 启动后端:`cd backend && python main.py`(默认 8483
4. 浏览器开任意支持的视频页B 站 / YouTube / 抖音 / 快手),点工具栏 BiliNote 图标
5. 首次使用先打开"设置",填后端地址 → 选供应商 + 模型
## 后端要求
后端 `backend/main.py` 的 CORS 白名单已通过 regex 兼容 `chrome-extension://``moz-extension://` 与本地 web。无需新增任何 backend endpoint。
## 构建发布
```bash
pnpm build # 产物 → ./extension/
pnpm pack:zip # 打包 → ./extension.zip (上传 Chrome Web Store
pnpm pack:crx # 打包 → ./extension.crx
pnpm pack:xpi # 打包 → ./extension.xpi Firefox
```
## 与桌面端的关系
桌面 web 端(`BillNote_frontend/`)继续负责:供应商/模型管理、转写器配置、笔记历史。
插件**不**复刻这些管理界面,仅消费已配置好的供应商。
## 致谢
骨架基于 [vitesse-webext](https://github.com/antfu-collective/vitesse-webext)Antfu

View File

@@ -0,0 +1,20 @@
import { expect, isDevArtifact, name, test } from './fixtures'
test('example test', async ({ page }, testInfo) => {
testInfo.skip(!isDevArtifact(), 'contentScript is in closed ShadowRoot mode')
await page.goto('https://example.com')
await page.locator(`#${name} button`).click()
await expect(page.locator(`#${name} h1`)).toHaveText('Vitesse WebExt')
})
test('popup page', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/dist/popup/index.html`)
await expect(page.locator('button')).toHaveText('Open Options')
})
test('options page', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/dist/options/index.html`)
await expect(page.locator('img')).toHaveAttribute('alt', 'extension icon')
})

View File

@@ -0,0 +1,48 @@
import path from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import fs from 'fs-extra'
import { type BrowserContext, test as base, chromium } from '@playwright/test'
import type { Manifest } from 'webextension-polyfill'
export { name } from '../package.json'
export const extensionPath = path.join(__dirname, '../extension')
export const test = base.extend<{
context: BrowserContext
extensionId: string
}>({
context: async ({ headless }, use) => {
// workaround for the Vite server has started but contentScript is not yet.
await sleep(1000)
const context = await chromium.launchPersistentContext('', {
headless,
args: [
...(headless ? ['--headless=new'] : []),
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
})
await use(context)
await context.close()
},
extensionId: async ({ context }, use) => {
// for manifest v3:
let [background] = context.serviceWorkers()
if (!background)
background = await context.waitForEvent('serviceworker')
const extensionId = background.url().split('/')[2]
await use(extensionId)
},
})
export const expect = test.expect
export function isDevArtifact() {
const manifest: Manifest.WebExtensionManifest = fs.readJsonSync(path.resolve(extensionPath, 'manifest.json'))
return Boolean(
typeof manifest.content_security_policy === 'object'
&& manifest.content_security_policy.extension_pages?.includes('localhost'),
)
}

View File

@@ -0,0 +1,5 @@
import antfu from '@antfu/eslint-config'
export default antfu(
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,12 @@
<svg width="415" height="412" viewBox="0 0 415 412" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 28C0 12.536 12.536 0 28 0H387C402.464 0 415 12.536 415 28V384C415 399.464 402.464 412 387 412H28C12.536 412 0 399.464 0 384V28Z" fill="#3C77FB"/>
<rect x="60" y="64" width="296" height="283" rx="37" fill="white"/>
<path d="M268.422 175.657C276.308 180.298 276.308 191.702 268.422 196.343L186.335 244.641C178.336 249.348 168.25 243.58 168.25 234.298V137.702C168.25 128.42 178.336 122.652 186.335 127.359L268.422 175.657Z" fill="#3C77FB"/>
<path d="M17 282C17 270.954 25.9543 262 37 262H83C94.0457 262 103 270.954 103 282V282C103 293.046 94.0457 302 83 302H37C25.9543 302 17 293.046 17 282V282Z" fill="#3C77FB"/>
<path d="M38 281.5C38 274.044 44.0442 268 51.5 268H82.5C89.9558 268 96 274.044 96 281.5V281.5C96 288.956 89.9558 295 82.5 295H51.5C44.0442 295 38 288.956 38 281.5V281.5Z" fill="white"/>
<path d="M17 206C17 194.954 25.9543 186 37 186H83C94.0457 186 103 194.954 103 206V206C103 217.046 94.0457 226 83 226H37C25.9543 226 17 217.046 17 206V206Z" fill="#3C77FB"/>
<path d="M38 205.5C38 198.044 44.0442 192 51.5 192H82.5C89.9558 192 96 198.044 96 205.5V205.5C96 212.956 89.9558 219 82.5 219H51.5C44.0442 219 38 212.956 38 205.5V205.5Z" fill="white"/>
<path d="M17 130C17 118.954 25.9543 110 37 110H83C94.0457 110 103 118.954 103 130V130C103 141.046 94.0457 150 83 150H37C25.9543 150 17 141.046 17 130V130Z" fill="#3C77FB"/>
<path d="M38 129.5C38 122.044 44.0442 116 51.5 116H82.5C89.9558 116 96 122.044 96 129.5V129.5C96 136.956 89.9558 143 82.5 143H51.5C44.0442 143 38 136.956 38 129.5V129.5Z" fill="white"/>
<path d="M145 290C145 285.582 148.582 282 153 282H284C288.418 282 292 285.582 292 290V299C292 303.418 288.418 307 284 307H153C148.582 307 145 303.418 145 299V290Z" fill="#3C77FB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

10
BillNote_extension/modules.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare module 'vue' {
interface ComponentCustomProperties {
$app: {
context: string
}
}
}
// https://stackoverflow.com/a/64189046/479957
export {}

View File

@@ -0,0 +1,77 @@
{
"name": "bilinote-extension",
"displayName": "BiliNote",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@9.7.1",
"description": "在浏览器里把视频链接一键变成 Markdown 笔记Bilibili / YouTube / Douyin / Kuaishou",
"scripts": {
"dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*",
"dev-firefox": "npm run clear && cross-env NODE_ENV=development EXTENSION=firefox run-p dev:*",
"dev:prepare": "esno scripts/prepare.ts",
"dev:background": "npm run build:background -- --mode development",
"dev:web": "vite",
"dev:js": "npm run build:js -- --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js",
"build:prepare": "esno scripts/prepare.ts",
"build:background": "vite build --config vite.config.background.mts",
"build:web": "vite build",
"build:js": "vite build --config vite.config.content.mts",
"pack": "cross-env NODE_ENV=production run-p pack:*",
"pack:zip": "rimraf extension.zip && jszip-cli add extension/* -o ./extension.zip",
"pack:crx": "crx pack extension -o ./extension.crx",
"pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename extension.xpi --overwrite-dest",
"start:chromium": "web-ext run --source-dir ./extension --target=chromium",
"start:firefox": "web-ext run --source-dir ./extension --target=firefox-desktop",
"clear": "rimraf --glob extension/dist extension/manifest.json extension.*",
"lint": "eslint --cache .",
"test": "vitest test",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@antfu/eslint-config": "^2.27.0",
"@ffflorian/jszip-cli": "^3.8.5",
"@iconify/json": "^2.2.239",
"@playwright/test": "^1.46.1",
"@types/fs-extra": "^11.0.4",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.5.0",
"@types/webextension-polyfill": "^0.12.0",
"@typescript-eslint/eslint-plugin": "^8.2.0",
"@unocss/reset": "^0.62.2",
"@vitejs/plugin-vue": "^5.1.2",
"@vue/compiler-sfc": "^3.4.38",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^11.0.1",
"chokidar": "^3.6.0",
"cross-env": "^7.0.3",
"crx": "^5.0.1",
"eslint": "^9.9.0",
"esno": "^4.7.0",
"fs-extra": "^11.2.0",
"jsdom": "^24.1.1",
"kolorist": "^1.8.0",
"lint-staged": "^15.2.9",
"npm-run-all": "^4.1.5",
"rimraf": "^6.0.1",
"simple-git-hooks": "^2.11.1",
"typescript": "^5.5.4",
"unocss": "^0.62.2",
"unplugin-auto-import": "^0.18.2",
"unplugin-icons": "^0.19.2",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.2",
"vitest": "^2.0.5",
"vue": "^3.4.38",
"vue-demi": "^0.14.10",
"web-ext": "^8.2.0",
"webext-bridge": "^6.0.1",
"webextension-polyfill": "^0.12.0"
},
"dependencies": {
"markdown-it": "^14.1.0",
"markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12"
}
}

View File

@@ -0,0 +1,15 @@
/**
* @see {@link https://playwright.dev/docs/chrome-extensions Chrome extensions | Playwright}
*/
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
retries: 2,
webServer: {
command: 'npm run dev',
// start e2e test after the Vite server is fully prepared
url: 'http://localhost:3303/popup/main.ts',
reuseExistingServer: true,
},
})

9983
BillNote_extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import fs from 'fs-extra'
import { getManifest } from '../src/manifest'
import { log, r } from './utils'
export async function writeManifest() {
await fs.writeJSON(r('extension/manifest.json'), await getManifest(), { spaces: 2 })
log('PRE', 'write manifest.json')
}
writeManifest()

View File

@@ -0,0 +1,40 @@
// generate stub index.html files for dev entry
import { execSync } from 'node:child_process'
import fs from 'fs-extra'
import chokidar from 'chokidar'
import { isDev, log, port, r } from './utils'
/**
* Stub index.html to use Vite in development
*/
async function stubIndexHtml() {
const views = ['options', 'popup', 'sidepanel']
for (const view of views) {
await fs.ensureDir(r(`extension/dist/${view}`))
let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8')
data = data
.replace('"./main.ts"', `"http://localhost:${port}/${view}/main.ts"`)
.replace('<div id="app"></div>', '<div id="app">Vite server did not start</div>')
await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8')
log('PRE', `stub ${view}`)
}
}
function writeManifest() {
execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' })
}
writeManifest()
if (isDev) {
stubIndexHtml()
chokidar.watch(r('src/**/*.html'))
.on('change', () => {
stubIndexHtml()
})
chokidar.watch([r('src/manifest.ts'), r('package.json')])
.on('change', () => {
writeManifest()
})
}

View File

@@ -0,0 +1,12 @@
import { resolve } from 'node:path'
import process from 'node:process'
import { bgCyan, black } from 'kolorist'
export const port = Number(process.env.PORT || '') || 3303
export const r = (...args: string[]) => resolve(__dirname, '..', ...args)
export const isDev = process.env.NODE_ENV !== 'production'
export const isFirefox = process.env.EXTENSION === 'firefox'
export function log(name: string, message: string) {
console.log(black(bgCyan(` ${name} `)), message)
}

10
BillNote_extension/shim.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import type { ProtocolWithReturn } from 'webext-bridge'
declare module 'webext-bridge' {
export interface ProtocolMap {
// define message protocol types
// see https://github.com/antfu/webext-bridge#type-safe-protocols
'tab-prev': { title: string | undefined }
'get-current-tab': ProtocolWithReturn<{ tabId: number }, { title?: string }>
}
}

View File

@@ -0,0 +1,12 @@
<svg width="415" height="412" viewBox="0 0 415 412" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 28C0 12.536 12.536 0 28 0H387C402.464 0 415 12.536 415 28V384C415 399.464 402.464 412 387 412H28C12.536 412 0 399.464 0 384V28Z" fill="#3C77FB"/>
<rect x="60" y="64" width="296" height="283" rx="37" fill="white"/>
<path d="M268.422 175.657C276.308 180.298 276.308 191.702 268.422 196.343L186.335 244.641C178.336 249.348 168.25 243.58 168.25 234.298V137.702C168.25 128.42 178.336 122.652 186.335 127.359L268.422 175.657Z" fill="#3C77FB"/>
<path d="M17 282C17 270.954 25.9543 262 37 262H83C94.0457 262 103 270.954 103 282V282C103 293.046 94.0457 302 83 302H37C25.9543 302 17 293.046 17 282V282Z" fill="#3C77FB"/>
<path d="M38 281.5C38 274.044 44.0442 268 51.5 268H82.5C89.9558 268 96 274.044 96 281.5V281.5C96 288.956 89.9558 295 82.5 295H51.5C44.0442 295 38 288.956 38 281.5V281.5Z" fill="white"/>
<path d="M17 206C17 194.954 25.9543 186 37 186H83C94.0457 186 103 194.954 103 206V206C103 217.046 94.0457 226 83 226H37C25.9543 226 17 217.046 17 206V206Z" fill="#3C77FB"/>
<path d="M38 205.5C38 198.044 44.0442 192 51.5 192H82.5C89.9558 192 96 198.044 96 205.5V205.5C96 212.956 89.9558 219 82.5 219H51.5C44.0442 219 38 212.956 38 205.5V205.5Z" fill="white"/>
<path d="M17 130C17 118.954 25.9543 110 37 110H83C94.0457 110 103 118.954 103 130V130C103 141.046 94.0457 150 83 150H37C25.9543 150 17 141.046 17 130V130Z" fill="#3C77FB"/>
<path d="M38 129.5C38 122.044 44.0442 116 51.5 116H82.5C89.9558 116 96 122.044 96 129.5V129.5C96 136.956 89.9558 143 82.5 143H51.5C44.0442 143 38 136.956 38 129.5V129.5Z" fill="white"/>
<path d="M145 290C145 285.582 148.582 282 153 282H284C288.418 282 292 285.582 292 290V299C292 303.418 288.418 307 284 307H153C148.582 307 145 303.418 145 299V290Z" fill="#3C77FB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,18 @@
import { isFirefox, isForbiddenUrl } from '~/env'
// Firefox fetch files from cache instead of reloading changes from disk,
// hmr will not work as Chromium based browser
browser.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => {
// Filter out non main window events.
if (frameId !== 0)
return
if (isForbiddenUrl(url))
return
// inject the latest scripts
browser.tabs.executeScript(tabId, {
file: `${isFirefox ? '' : '.'}/dist/contentScripts/index.global.js`,
runAt: 'document_end',
}).catch(error => console.error(error))
})

View File

@@ -0,0 +1,187 @@
import { onMessage } from 'webext-bridge/background'
import type { Settings, TaskRecord } from '~/logic/types'
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
import { detectPlatform } from '~/logic/platform'
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
// only on dev mode
if (import.meta.hot) {
// @ts-expect-error for background HMR
import('/@vite/client')
// load latest content script
import('./contentScriptHMR')
}
// ---------- 直接操作 chrome.storageservice worker 里别用 Vue 反应式)----------
async function readSettings(): Promise<Settings> {
const obj = await browser.storage.local.get(SETTINGS_KEY)
const raw = obj[SETTINGS_KEY] as string | undefined
if (!raw)
return { ...DEFAULT_SETTINGS }
try {
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) }
}
catch {
return { ...DEFAULT_SETTINGS }
}
}
async function readTasks(): Promise<TaskRecord[]> {
const obj = await browser.storage.local.get(TASKS_KEY)
const raw = obj[TASKS_KEY] as string | undefined
if (!raw)
return []
try {
return JSON.parse(raw) as TaskRecord[]
}
catch {
return []
}
}
async function writeTasks(tasks: TaskRecord[]) {
await browser.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks.slice(0, MAX_TASKS)) })
}
async function upsertTask(record: TaskRecord) {
const tasks = await readTasks()
const idx = tasks.findIndex(t => t.taskId === record.taskId)
if (idx >= 0)
tasks.splice(idx, 1, { ...tasks[idx], ...record })
else
tasks.unshift(record)
await writeTasks(tasks)
}
// ---------- 启动任务 ----------
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
const platform = detectPlatform(url)
if (!platform)
return { ok: false, error: '当前链接不是支持的视频平台' }
const settings = await readSettings()
if (!settings.providerId || !settings.modelName)
return { ok: false, error: '请先在设置页选择供应商与模型' }
const backend = settings.backendUrl.replace(/\/$/, '')
// 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_url: url,
platform,
quality: settings.quality,
provider_id: settings.providerId,
model_name: settings.modelName,
// backend 同时接受 format 数组与 screenshot/link 单独布尔;从 formats 派生保持单一真相源
format: [...formats],
screenshot: formats.includes('screenshot'),
link: formats.includes('link'),
style: settings.style || undefined,
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,
}),
})
if (!res.ok)
return { ok: false, error: `HTTP ${res.status}` }
const body = await res.json() as { code: number, msg: string, data: { task_id: string } }
if (body.code !== 0)
return { ok: false, error: body.msg }
await upsertTask({
taskId: body.data.task_id,
videoUrl: url,
platform,
status: 'PENDING',
message: '已提交',
createdAt: Date.now(),
updatedAt: Date.now(),
})
return { ok: true, taskId: body.data.task_id }
}
catch (e) {
return { ok: false, error: (e as Error).message }
}
}
async function openSidePanelInTab(tabId?: number) {
try {
// @ts-expect-error chrome.sidePanel 类型在 webextension-polyfill 中尚未补全
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open && tabId !== undefined)
// @ts-expect-error see above
await chrome.sidePanel.open({ tabId })
}
catch (err) {
console.warn('打开侧边栏失败:', err)
}
}
// ---------- 消息桥 ----------
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
const result = await startTask(data.url)
// 成功就把侧边栏拉起来给用户看进度
if (result.ok)
await openSidePanelInTab(sender?.tabId)
return result
})
// ---------- 安装时事件 ----------
browser.runtime.onInstalled.addListener(() => {
console.log('BiliNote extension installed')
// 右键菜单:在视频页或视频链接上"用 BiliNote 总结"
try {
browser.contextMenus.create({
id: 'bilinote-summarize-page',
title: '用 BiliNote 总结此视频',
contexts: ['page', 'link', 'video'],
documentUrlPatterns: [
'*://*.bilibili.com/*',
'*://*.youtube.com/*',
'*://youtu.be/*',
'*://*.douyin.com/*',
'*://*.kuaishou.com/*',
],
})
}
catch (e) {
console.warn('注册右键菜单失败:', e)
}
})
browser.contextMenus?.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== 'bilinote-summarize-page')
return
const url = info.linkUrl || tab?.url
if (!url)
return
const result = await startTask(url)
if (result.ok)
await openSidePanelInTab(tab?.id)
else
console.warn('右键启动失败:', result.error)
})
// content script 占位握手 —— 未来可扩展为查询当前任务等
onMessage('get-current-tab', async () => {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
return { title: tab?.title, url: tab?.url }
}
catch {
return { title: undefined, url: undefined }
}
})

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { askChat, getChatStatus, indexChatTask, type ChatMessage } from '~/logic/api'
import { settings } from '~/logic/storage'
const props = defineProps<{ taskId: string }>()
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const messages = ref<ChatMessage[]>([])
const draft = ref('')
const sending = ref(false)
const indexState = ref<'idle' | 'indexing' | 'indexed' | 'failed' | 'unknown'>('unknown')
const error = ref('')
const scrollEl = ref<HTMLElement | null>(null)
let pollTimer: ReturnType<typeof setTimeout> | null = null
const ready = computed(() => indexState.value === 'indexed')
const canSend = computed(() => ready.value && draft.value.trim().length > 0 && !sending.value && !!settings.value.providerId && !!settings.value.modelName)
async function pollIndex() {
try {
const res = await getChatStatus(props.taskId)
indexState.value = res.status
if (res.status === 'indexing')
pollTimer = setTimeout(pollIndex, 2000)
}
catch (e) {
error.value = (e as Error).message
indexState.value = 'failed'
}
}
async function ensureIndexed() {
error.value = ''
indexState.value = 'unknown'
try {
const status = await getChatStatus(props.taskId)
indexState.value = status.status
if (status.indexed)
return
indexState.value = 'indexing'
await indexChatTask(props.taskId)
pollIndex()
}
catch (e) {
error.value = (e as Error).message
indexState.value = 'failed'
}
}
async function send() {
if (!canSend.value)
return
const question = draft.value.trim()
draft.value = ''
messages.value.push({ role: 'user', content: question })
await scrollDown()
sending.value = true
try {
const res = await askChat({
task_id: props.taskId,
question,
history: messages.value.slice(0, -1),
provider_id: settings.value.providerId,
model_name: settings.value.modelName,
}) as { answer?: string, content?: string, message?: string } | string
const reply = typeof res === 'string'
? res
: (res.answer ?? res.content ?? res.message ?? JSON.stringify(res))
messages.value.push({ role: 'assistant', content: reply })
await scrollDown()
}
catch (e) {
messages.value.push({ role: 'assistant', content: `❌ 调用失败:${(e as Error).message}` })
}
finally {
sending.value = false
}
}
async function scrollDown() {
await nextTick()
if (scrollEl.value)
scrollEl.value.scrollTop = scrollEl.value.scrollHeight
}
watch(() => props.taskId, () => {
messages.value = []
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
ensureIndexed()
}, { immediate: false })
onMounted(ensureIndexed)
onUnmounted(() => {
if (pollTimer)
clearTimeout(pollTimer)
})
</script>
<template>
<div class="flex flex-col h-full bg-white">
<header class="px-2 py-1 text-xs border-b flex items-center gap-2">
<span v-if="indexState === 'indexed'" class="tag bg-green-100 text-green-700">已索引</span>
<span v-else-if="indexState === 'indexing'" class="tag bg-yellow-100 text-yellow-700">索引中</span>
<span v-else-if="indexState === 'failed'" class="tag bg-red-100 text-red-700">索引失败</span>
<span v-else class="tag bg-gray-100 text-gray-500">检查中</span>
<button class="ml-auto text-xs text-gray-500 hover:text-gray-800" @click="ensureIndexed">
重新索引
</button>
</header>
<div v-if="error" class="text-xs text-red-600 px-2 py-1">{{ error }}</div>
<div ref="scrollEl" class="flex-1 overflow-auto px-2 py-2 flex flex-col gap-2">
<div v-if="messages.length === 0 && ready" class="text-xs text-gray-400 italic">
基于这条笔记的全文 + 视频元信息提问例如这个视频的核心论点是什么
</div>
<div
v-for="(m, i) in messages"
:key="i"
class="text-sm"
>
<div
class="inline-block max-w-[90%] px-3 py-2 rounded"
:class="m.role === 'user'
? 'bg-blue-600 text-white ml-auto block'
: 'bg-gray-100 text-gray-800'"
>
<div v-if="m.role === 'assistant'" v-html="md.render(m.content)" class="prose prose-sm max-w-none" />
<div v-else class="whitespace-pre-wrap break-words">{{ m.content }}</div>
</div>
</div>
<div v-if="sending" class="text-xs text-gray-500 italic">思考中</div>
</div>
<footer class="border-t p-2 flex gap-2">
<textarea
v-model="draft"
class="input flex-1 resize-none"
rows="2"
:placeholder="ready ? '问点什么…Cmd/Ctrl + Enter 发送)' : '索引完成后才能问答'"
:disabled="!ready"
@keydown.enter.exact.meta.prevent="send"
@keydown.enter.exact.ctrl.prevent="send"
/>
<button class="btn-primary" :disabled="!canSend" @click="send">
{{ sending ? '' : '发送' }}
</button>
</footer>
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<a class="icon-btn mx-2 text-2xl" rel="noreferrer" href="https://github.com/antfu/vitesse-webext" target="_blank" title="GitHub">
<pixelarticons-power />
</a>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
const props = defineProps<{ markdown: string, title?: string, hideActions?: boolean }>()
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const html = computed(() => md.render(absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))))
async function copy() {
await navigator.clipboard.writeText(props.markdown)
}
function download() {
const blob = new Blob([props.markdown], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.title || 'bilinote'}.md`
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<div v-if="!hideActions" class="flex gap-2 justify-end shrink-0">
<button class="btn-secondary" @click="copy">复制 Markdown</button>
<button class="btn-secondary" @click="download">下载 .md</button>
</div>
<div class="prose prose-sm max-w-none px-3 py-2 flex-1 min-h-0 overflow-auto" v-html="html" />
</div>
</template>
<style>
.prose img { max-width: 100%; }
.prose h1, .prose h2, .prose h3 { font-weight: 600; margin-top: 0.8em; margin-bottom: 0.4em; }
.prose p { margin-bottom: 0.5em; line-height: 1.55; }
.prose ul, .prose ol { padding-left: 1.4em; margin-bottom: 0.5em; }
.prose code { background: #eee; padding: 0 4px; border-radius: 3px; font-size: 0.9em; }
.prose a { color: #2563eb; text-decoration: underline; }
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { Transformer } from 'markmap-lib'
import { Markmap } from 'markmap-view'
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
const props = defineProps<{ markdown: string }>()
const svgRef = ref<SVGSVGElement | null>(null)
let mm: Markmap | null = null
const transformer = new Transformer()
function render() {
if (!svgRef.value)
return
const md = absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))
const { root } = transformer.transform(md)
if (!mm)
mm = Markmap.create(svgRef.value, undefined, root)
else
mm.setData(root).then(() => mm?.fit())
}
onMounted(render)
watch(() => props.markdown, render)
</script>
<template>
<div class="w-full h-full bg-white rounded border overflow-hidden">
<svg ref="svgRef" class="w-full h-full" />
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Platform } from '~/logic/types'
import { PLATFORM_LABELS } from '~/logic/platform'
const props = defineProps<{ platform: Platform | null }>()
const colorMap: Record<Platform, string> = {
bilibili: 'bg-pink-100 text-pink-700',
youtube: 'bg-red-100 text-red-700',
douyin: 'bg-zinc-200 text-zinc-800',
kuaishou: 'bg-orange-100 text-orange-700',
local: 'bg-gray-100 text-gray-600',
}
const cls = computed(() => (props.platform ? colorMap[props.platform] : 'bg-gray-100 text-gray-500'))
const label = computed(() => (props.platform ? PLATFORM_LABELS[props.platform] : '未识别'))
</script>
<template>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :class="cls">
{{ label }}
</span>
</template>

View File

@@ -0,0 +1,11 @@
## Components
Components in this dir will be auto-registered and on-demand, powered by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components).
Components can be shared in all views.
### Icons
You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/).
It will only bundle the icons you use. Check out [unplugin-icons](https://github.com/unplugin/unplugin-icons) for more details.

View File

@@ -0,0 +1,5 @@
<template>
<p class="mt-2 opacity-50">
This is the {{ $app.context }} page
</p>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TaskStatus } from '~/logic/types'
const props = defineProps<{ status: TaskStatus, message?: string }>()
const STAGE_ORDER: TaskStatus[] = ['PENDING', 'PARSING', 'DOWNLOADING', 'TRANSCRIBING', 'SUMMARIZING', 'FORMATTING', 'SAVING', 'SUCCESS']
const STAGE_LABELS: Record<TaskStatus, string> = {
PENDING: '排队中',
PARSING: '解析中',
DOWNLOADING: '下载中',
TRANSCRIBING: '转写中',
SUMMARIZING: '总结中',
FORMATTING: '格式化',
SAVING: '保存中',
SUCCESS: '完成',
FAILED: '失败',
}
const currentIdx = computed(() => STAGE_ORDER.indexOf(props.status))
const isFailed = computed(() => props.status === 'FAILED')
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 text-sm">
<span :class="isFailed ? 'text-red-600' : 'text-blue-600'" class="font-medium">
{{ STAGE_LABELS[status] }}
</span>
<span v-if="message" class="text-gray-500 text-xs truncate">{{ message }}</span>
</div>
<div v-if="!isFailed" class="flex gap-1">
<div
v-for="(s, i) in STAGE_ORDER"
:key="s"
class="h-1 flex-1 rounded-full"
:class="i <= currentIdx ? 'bg-blue-500' : 'bg-gray-200'"
/>
</div>
<div v-else class="h-1 rounded-full bg-red-500" />
</div>
</template>

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import Logo from '../Logo.vue'
describe('logo component', () => {
it('should render', () => {
const wrapper = mount(Logo)
expect(wrapper.html()).toBeTruthy()
})
})

View File

@@ -0,0 +1,166 @@
import { StorageSerializers } from '@vueuse/core'
import { pausableWatch, toValue, tryOnScopeDispose } from '@vueuse/shared'
import { ref, shallowRef } from 'vue-demi'
import { storage } from 'webextension-polyfill'
import type {
StorageLikeAsync,
UseStorageAsyncOptions,
} from '@vueuse/core'
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/shared'
import type { Ref } from 'vue-demi'
import type { Storage } from 'webextension-polyfill'
export type WebExtensionStorageOptions<T> = UseStorageAsyncOptions<T>
// https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorage/guess.ts
export function guessSerializerType(rawInit: unknown) {
return rawInit == null
? 'any'
: rawInit instanceof Set
? 'set'
: rawInit instanceof Map
? 'map'
: rawInit instanceof Date
? 'date'
: typeof rawInit === 'boolean'
? 'boolean'
: typeof rawInit === 'string'
? 'string'
: typeof rawInit === 'object'
? 'object'
: Number.isNaN(rawInit)
? 'any'
: 'number'
}
const storageInterface: StorageLikeAsync = {
removeItem(key: string) {
return storage.local.remove(key)
},
setItem(key: string, value: string) {
return storage.local.set({ [key]: value })
},
async getItem(key: string) {
const storedData = await storage.local.get(key)
return storedData[key] as string
},
}
/**
* https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorageAsync/index.ts
*
* @param key
* @param initialValue
* @param options
*/
export function useWebExtensionStorage<T>(
key: string,
initialValue: MaybeRefOrGetter<T>,
options: WebExtensionStorageOptions<T> = {},
): { data: RemovableRef<T>, dataReady: Promise<T> } {
const {
flush = 'pre',
deep = true,
listenToStorageChanges = true,
writeDefaults = true,
mergeDefaults = false,
shallow,
eventFilter,
onError = (e) => {
console.error(e)
},
} = options
const rawInit: T = toValue(initialValue)
const type = guessSerializerType(rawInit)
const data = (shallow ? shallowRef : ref)(initialValue) as Ref<T>
const serializer = options.serializer ?? StorageSerializers[type]
async function read(event?: { key: string, newValue: string | null }) {
if (event && event.key !== key)
return
try {
const rawValue = event ? event.newValue : await storageInterface.getItem(key)
if (rawValue == null) {
data.value = rawInit
if (writeDefaults && rawInit !== null)
await storageInterface.setItem(key, await serializer.write(rawInit))
}
else if (mergeDefaults) {
const value = await serializer.read(rawValue) as T
if (typeof mergeDefaults === 'function')
data.value = mergeDefaults(value, rawInit)
else if (type === 'object' && !Array.isArray(value))
data.value = { ...(rawInit as Record<keyof unknown, unknown>), ...(value as Record<keyof unknown, unknown>) } as T
else data.value = value
}
else {
data.value = await serializer.read(rawValue) as T
}
}
catch (error) {
onError(error)
}
}
const dataReadyPromise = new Promise<T>((resolve, reject) => {
read().then(() => resolve(data.value)).catch(reject)
})
async function write() {
try {
await (
data.value == null
? storageInterface.removeItem(key)
: storageInterface.setItem(key, await serializer.write(data.value))
)
}
catch (error) {
onError(error)
}
}
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
data,
write,
{
flush,
deep,
eventFilter,
},
)
if (listenToStorageChanges) {
const listener = async (changes: Record<string, Storage.StorageChange>) => {
try {
pauseWatch()
for (const [key, change] of Object.entries(changes)) {
await read({
key,
newValue: change.newValue as string | null,
})
}
}
finally {
resumeWatch()
}
}
storage.onChanged.addListener(listener)
tryOnScopeDispose(() => {
storage.onChanged.removeListener(listener)
})
}
return {
data: data as RemovableRef<T>,
dataReady: dataReadyPromise,
}
}

View File

@@ -0,0 +1,24 @@
import { createApp } from 'vue'
import App from './views/App.vue'
import { setupApp } from '~/logic/common-setup'
import { detectPlatform } from '~/logic/platform'
// 只在支持的视频平台上挂悬浮按钮,避免污染其他网站
(() => {
if (!detectPlatform(window.location.href))
return
const container = document.createElement('div')
container.id = __NAME__
const root = document.createElement('div')
const styleEl = document.createElement('link')
const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container
styleEl.setAttribute('rel', 'stylesheet')
styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css'))
shadowDOM.appendChild(styleEl)
shadowDOM.appendChild(root)
document.body.appendChild(container)
const app = createApp(App)
setupApp(app)
app.mount(root)
})()

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import 'uno.css'
import { computed, ref } from 'vue'
import { sendMessage } from 'webext-bridge/content-script'
import { detectPlatform, PLATFORM_LABELS } from '~/logic/platform'
const platform = detectPlatform(window.location.href)
const busy = ref(false)
const toast = ref<{ kind: 'ok' | 'err', text: string } | null>(null)
const label = computed(() => platform ? `用 BiliNote 总结这个${PLATFORM_LABELS[platform]}视频` : '')
async function trigger() {
if (!platform || busy.value)
return
busy.value = true
toast.value = null
try {
const res = await sendMessage('bilinote-start', {
url: window.location.href,
platform,
}, 'background')
const ok = res && (res as any).ok
toast.value = ok
? { kind: 'ok', text: '已开始生成笔记,可在侧边栏 / popup 查看进度' }
: { kind: 'err', text: (res as any)?.error || '提交失败,请打开设置检查后端与供应商' }
}
catch (e) {
toast.value = { kind: 'err', text: (e as Error).message }
}
finally {
busy.value = false
setTimeout(() => { toast.value = null }, 4000)
}
}
</script>
<template>
<div v-if="platform" class="bilinote-fab fixed bottom-24 right-6 z-[2147483647] flex flex-col items-end gap-2 font-sans select-none">
<div
v-if="toast"
class="text-xs px-3 py-2 rounded shadow max-w-[260px]"
:class="toast.kind === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'"
>
{{ toast.text }}
</div>
<button
class="flex items-center gap-2 px-3 py-2 rounded-full shadow-lg cursor-pointer border-none text-white text-sm font-medium bg-pink-600 hover:bg-pink-700 disabled:bg-pink-300"
:disabled="busy"
:title="label"
@click="trigger"
>
<span class="text-base">📝</span>
<span>{{ busy ? '提交中…' : 'BiliNote' }}</span>
</button>
</div>
</template>

View File

@@ -0,0 +1,14 @@
const forbiddenProtocols = [
'chrome-extension://',
'chrome-search://',
'chrome://',
'devtools://',
'edge://',
'https://chrome.google.com/webstore',
]
export function isForbiddenUrl(url: string): boolean {
return forbiddenProtocols.some(protocol => url.startsWith(protocol))
}
export const isFirefox = navigator.userAgent.includes('Firefox')

8
BillNote_extension/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare const __DEV__: boolean
/** Extension name, defined in packageJson.name */
declare const __NAME__: string
declare module '*.vue' {
const component: any
export default component
}

View File

@@ -0,0 +1,235 @@
import type {
DeployStatus,
GenerateRequest,
Model,
Provider,
ProviderCreatePayload,
ProviderUpdatePayload,
TaskStatusResponse,
TranscriberConfig,
TranscriberModelsStatus,
TranscriberType,
WhisperModelSize,
} from './types'
import { settings } from './storage'
interface ApiEnvelope<T> {
code: number
msg: string
data: T
}
function backendUrl(): string {
return (settings.value?.backendUrl || 'http://localhost:8483').replace(/\/$/, '')
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${backendUrl()}${path}`, {
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
...init,
})
if (!res.ok)
throw new Error(`HTTP ${res.status}: ${await res.text()}`)
const body = (await res.json()) as ApiEnvelope<T> | T
// 后端 ResponseWrapper 包了 {code, msg, data};非 0 视为业务错
if (body && typeof body === 'object' && 'code' in body) {
const env = body as ApiEnvelope<T>
if (env.code !== 0)
throw new Error(env.msg || '后端返回失败')
return env.data
}
return body as T
}
export async function getProviders(): Promise<Provider[]> {
return request<Provider[]>('/api/get_all_providers')
}
export async function getModelsByProvider(providerId: string): Promise<Model[]> {
return request<Model[]>(`/api/model_enable/${providerId}`)
}
export async function setDownloaderCookie(platform: string, cookie: string): Promise<void> {
await request('/api/update_downloader_cookie', {
method: 'POST',
body: JSON.stringify({ platform, cookie }),
})
}
export async function getDownloaderCookie(platform: string): Promise<string | null> {
// 后端:未配置时返回 {code:0, msg:'未找到Cookies', data:null};配置时 data: {platform, cookie}
const data = await request<{ platform: string, cookie: string } | null>(
`/api/get_downloader_cookie/${platform}`,
)
return data?.cookie ?? null
}
// ---- Provider CRUD ----
export async function addProvider(payload: ProviderCreatePayload): Promise<string | null> {
return request<string | null>('/api/add_provider', {
method: 'POST',
body: JSON.stringify({ logo: 'custom', ...payload }),
})
}
export async function updateProvider(payload: ProviderUpdatePayload): Promise<{ id: string, enabled: number }> {
return request<{ id: string, enabled: number }>('/api/update_provider', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function getProviderById(id: string): Promise<Provider> {
return request<Provider>(`/api/get_provider_by_id/${id}`)
}
export async function connectTest(id: string): Promise<void> {
await request('/api/connect_test', {
method: 'POST',
body: JSON.stringify({ id }),
})
}
// ---- Model CRUD ----
export async function listAllModels(providerId: string): Promise<Model[]> {
return request<Model[]>(`/api/model_list/${providerId}`)
}
export async function addModel(providerId: string, modelName: string): Promise<void> {
await request('/api/models', {
method: 'POST',
body: JSON.stringify({ provider_id: providerId, model_name: modelName }),
})
}
export async function deleteModel(modelId: number | string): Promise<void> {
await request(`/api/models/delete/${modelId}`)
}
// ---- Transcriber ----
export async function getTranscriberConfig(): Promise<TranscriberConfig> {
return request<TranscriberConfig>('/api/transcriber_config')
}
export async function setTranscriberConfig(transcriberType: TranscriberType, whisperModelSize?: WhisperModelSize): Promise<TranscriberConfig> {
return request<TranscriberConfig>('/api/transcriber_config', {
method: 'POST',
body: JSON.stringify({
transcriber_type: transcriberType,
whisper_model_size: whisperModelSize ?? null,
}),
})
}
export async function getTranscriberModelsStatus(): Promise<TranscriberModelsStatus> {
return request<TranscriberModelsStatus>('/api/transcriber_models_status')
}
export async function downloadTranscriberModel(modelSize: WhisperModelSize, transcriberType: TranscriberType = 'fast-whisper'): Promise<void> {
await request('/api/transcriber_download', {
method: 'POST',
body: JSON.stringify({ model_size: modelSize, transcriber_type: transcriberType }),
})
}
// ---- RAG Chat ----
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export async function indexChatTask(taskId: string): Promise<void> {
await request('/api/chat/index', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}
export async function getChatStatus(taskId: string): Promise<{ status: 'idle' | 'indexing' | 'indexed' | 'failed', indexed: boolean }> {
return request(`/api/chat/status?task_id=${encodeURIComponent(taskId)}`)
}
export async function askChat(payload: {
task_id: string
question: string
history: ChatMessage[]
provider_id: string
model_name: string
}): Promise<unknown> {
return request('/api/chat/ask', {
method: 'POST',
body: JSON.stringify(payload),
})
}
// ---- Monitor ----
export async function getDeployStatus(): Promise<DeployStatus> {
return request<DeployStatus>('/api/deploy_status')
}
export async function getSysHealth(): Promise<{ ok: boolean, msg?: string }> {
try {
await request('/api/sys_health')
return { ok: true }
}
catch (e) {
return { ok: false, msg: (e as Error).message }
}
}
export async function generateNote(payload: GenerateRequest): Promise<{ task_id: string }> {
return request<{ task_id: string }>('/api/generate_note', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function getTaskStatus(taskId: string): Promise<TaskStatusResponse> {
// /task_status 永远 HTTP 200body 是 ResponseWrapper
// 成功:{code:0, data:{status, message, task_id, result?}}
// 任务失败:{code:500, msg:'xxx', data:null}
// 这里手动拆,把任务失败翻译成 status:'FAILED',避免 request() 抛错让 UI 收不到状态
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`)
if (!res.ok)
throw new Error(`HTTP ${res.status}`)
const body = (await res.json()) as { code: number, msg: string, data: TaskStatusResponse | null }
if (body.code === 0 && body.data)
return body.data
return { status: 'FAILED', message: body.msg || '任务失败', task_id: taskId }
}
export async function ping(): Promise<boolean> {
try {
await getProviders()
return true
}
catch {
return false
}
}
// markdown 里的 /static/screenshots/xxx 是相对路径extension 渲染时需要拼绝对地址
export function absolutizeMarkdownImages(md: string): string {
const base = backendUrl()
return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`)
}
// backend 用 note_helper 在笔记开头插一行 '> 来源链接URL'。侧边栏顶部已经有原片链接卡片,
// 渲染前把它剥掉,避免重复占位。复制/下载的 .md 保留原样以便溯源。
// 与 BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx:468 对齐
export function stripSourceLink(md: string): string {
return md.replace(/^>\s*来源链接:[^\n]*\n*/m, '')
}
// 单个图片 URL 的处理:相对路径 → 拼后端域名B 站等带防盗链的封面 → 走后端 image_proxy
export function resolveImageUrl(url: string | undefined | null): string {
if (!url)
return ''
const base = backendUrl()
if (url.startsWith('/'))
return `${base}${url}`
// B 站封面、抖音封面等会做 referer 校验;走后端代理
if (/(hdslb|byteimg|kpcdn|akamaized|ytimg)\.com/i.test(url))
return `${base}/api/image_proxy?url=${encodeURIComponent(url)}`
return url
}

View File

@@ -0,0 +1,125 @@
// 在浏览器里直接调 B 站 player API 抓字幕。
// 因为 manifest host_permissions: '*://*/*' 覆盖 api.bilibili.comservice worker 里的
// fetch 会自动带 .bilibili.com 域下的用户 cookie并且绕过 CORS——AI 字幕需要登录态,
// 这等于用用户当前浏览器的登录身份代替了 backend 那边的 SESSDATA 配置。
//
// 与 backend/app/downloaders/bilibili_subtitle.py 的 BilibiliSubtitleFetcher 行为对齐。
const UA
= 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
export interface PrefetchedTranscript {
language: string
full_text: string
segments: Array<{ start: number, end: number, text: string }>
source: 'bilibili_extension'
}
interface SubtitleEntry {
lan?: string
ai_type?: number
subtitle_url?: string
}
function extractBvid(url: string): string | null {
const m = url.match(/BV([0-9A-Za-z]+)/)
return m ? `BV${m[1]}` : null
}
async function jsonGet<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url, {
credentials: 'include',
headers: { 'User-Agent': UA, 'Referer': 'https://www.bilibili.com' },
})
if (!res.ok)
return null
return await res.json() as T
}
catch (e) {
console.warn('[bilinote] B 站 API 请求失败:', url, e)
return null
}
}
async function getCid(bvid: string): Promise<number | null> {
const data = await jsonGet<{ code: number, data?: { cid?: number } }>(
`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
)
if (!data || data.code !== 0)
return null
return data.data?.cid ?? null
}
async function listSubtitles(bvid: string, cid: number): Promise<SubtitleEntry[]> {
const data = await jsonGet<{
code: number
data?: { subtitle?: { subtitles?: SubtitleEntry[] } }
}>(`https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`)
if (!data || data.code !== 0)
return []
return data.data?.subtitle?.subtitles ?? []
}
function pickSubtitle(subtitles: SubtitleEntry[]): SubtitleEntry | null {
if (!subtitles.length)
return null
const isZh = (s: SubtitleEntry) => {
const lan = (s.lan || '').toLowerCase()
return lan.startsWith('zh') || lan === 'ai-zh'
}
// 优先级:人工中文 > AI 中文 > 任意非空
return (
subtitles.find(s => isZh(s) && !s.ai_type)
|| subtitles.find(s => isZh(s))
|| subtitles[0]
)
}
function normalizeUrl(url: string): string {
return url.startsWith('//') ? `https:${url}` : url
}
interface SubtitleBody {
body?: Array<{ from?: number, to?: number, content?: string }>
}
export async function fetchBilibiliSubtitle(videoUrl: string): Promise<PrefetchedTranscript | null> {
const bvid = extractBvid(videoUrl)
if (!bvid)
return null
const cid = await getCid(bvid)
if (!cid)
return null
const subtitles = await listSubtitles(bvid, cid)
const track = pickSubtitle(subtitles)
if (!track?.subtitle_url) {
console.info(`[bilinote] B 站 ${bvid} 没找到可用字幕轨(可能未登录或视频无字幕)`)
return null
}
const sub = await jsonGet<SubtitleBody>(normalizeUrl(track.subtitle_url))
const body = sub?.body || []
const segments: PrefetchedTranscript['segments'] = []
for (const item of body) {
const text = (item.content || '').trim()
if (!text)
continue
segments.push({
start: Number(item.from || 0),
end: Number(item.to || 0),
text,
})
}
if (!segments.length)
return null
return {
language: track.lan || 'zh',
full_text: segments.map(s => s.text).join(' '),
segments,
source: 'bilibili_extension',
}
}

View File

@@ -0,0 +1,15 @@
import type { App } from 'vue'
export function setupApp(app: App) {
// Inject a globally available `$app` object in template
app.config.globalProperties.$app = {
context: '',
}
// Provide access to `app` in script setup with `const app = inject('app')`
app.provide('app', app.config.globalProperties.$app)
// Here you can install additional plugins for all contexts: popup, options page and content-script.
// example: app.use(i18n)
// example excluding content-script context: if (context !== 'content-script') app.use(i18n)
}

View File

@@ -0,0 +1,23 @@
import type { Settings } from './types'
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
export const DEFAULT_SETTINGS: Settings = {
backendUrl: DEFAULT_BACKEND_URL,
providerId: '',
modelName: '',
quality: 'medium',
formats: ['toc', 'summary'],
screenshot: false,
link: false,
style: 'minimal',
extras: '',
video_understanding: false,
video_interval: 6,
grid_size: [2, 2],
}
export const MAX_TASKS = 30
export const SETTINGS_KEY = 'bilinote-settings'
export const TASKS_KEY = 'bilinote-tasks'

View File

@@ -0,0 +1,38 @@
import { setDownloaderCookie } from './api'
import type { Platform } from './types'
// 后端期望的 cookie 字符串格式name=value; name=value; ...
// 见 backend/app/downloaders/bilibili_downloader.py 的 split("; ")
const COOKIE_DOMAINS: Record<Exclude<Platform, 'local'>, string> = {
bilibili: '.bilibili.com',
youtube: '.youtube.com',
douyin: '.douyin.com',
kuaishou: '.kuaishou.com',
}
export const SUPPORTED_COOKIE_PLATFORMS: Array<Exclude<Platform, 'local'>> = [
'bilibili',
'douyin',
'kuaishou',
'youtube',
]
export async function readBrowserCookies(platform: Exclude<Platform, 'local'>): Promise<string> {
const domain = COOKIE_DOMAINS[platform]
const list = await browser.cookies.getAll({ domain })
return list.map(c => `${c.name}=${c.value}`).join('; ')
}
export async function syncCookieToBackend(platform: Exclude<Platform, 'local'>): Promise<{ ok: boolean, count: number, error?: string }> {
try {
const cookieStr = await readBrowserCookies(platform)
if (!cookieStr)
return { ok: false, count: 0, error: '当前浏览器没有该域名的 cookie先在浏览器内登录目标站点' }
const count = cookieStr.split('; ').length
await setDownloaderCookie(platform, cookieStr)
return { ok: true, count }
}
catch (e) {
return { ok: false, count: 0, error: (e as Error).message }
}
}

View File

@@ -0,0 +1 @@
export * from './storage'

View File

@@ -0,0 +1,24 @@
import type { Platform } from './types'
// 与 backend/app/validators/video_url_validator.py 保持一致
export function detectPlatform(url: string | undefined | null): Platform | null {
if (!url)
return null
if (/bilibili\.com\/video\//.test(url))
return 'bilibili'
if (/(youtube\.com\/watch|youtu\.be\/)/.test(url))
return 'youtube'
if (url.includes('douyin'))
return 'douyin'
if (url.includes('kuaishou'))
return 'kuaishou'
return null
}
export const PLATFORM_LABELS: Record<Platform, string> = {
bilibili: '哔哩哔哩',
youtube: 'YouTube',
douyin: '抖音',
kuaishou: '快手',
local: '本地',
}

View File

@@ -0,0 +1,33 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
import type { Settings, TaskRecord } from './types'
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from './constants'
export { DEFAULT_BACKEND_URL, DEFAULT_SETTINGS, MAX_TASKS } from './constants'
// 全局共享设置popup / options / sidepanel 三个 Vue 上下文都读这一份)
// 注意background service worker 不要 import 这个文件,改用 chrome.storage 直读
export const { data: settings, dataReady: settingsReady } = useWebExtensionStorage<Settings>(
SETTINGS_KEY,
DEFAULT_SETTINGS,
{ mergeDefaults: true },
)
export const { data: tasks, dataReady: tasksReady } = useWebExtensionStorage<TaskRecord[]>(
TASKS_KEY,
[],
)
export function upsertTask(record: TaskRecord) {
const list = tasks.value ?? []
const idx = list.findIndex(t => t.taskId === record.taskId)
if (idx >= 0)
list.splice(idx, 1, { ...list[idx], ...record })
else
list.unshift(record)
tasks.value = list.slice(0, MAX_TASKS)
}
export function removeTask(taskId: string) {
const list = tasks.value ?? []
tasks.value = list.filter(t => t.taskId !== taskId)
}

View File

@@ -0,0 +1,183 @@
// 与 backend/app/routers/note.py / provider.py / model.py 对齐
export type Platform = 'bilibili' | 'youtube' | 'douyin' | 'kuaishou' | 'local'
export type Quality = 'fast' | 'medium' | 'slow'
export type TaskStatus =
| 'PENDING'
| 'PARSING'
| 'DOWNLOADING'
| 'TRANSCRIBING'
| 'SUMMARIZING'
| 'FORMATTING'
| 'SAVING'
| 'SUCCESS'
| 'FAILED'
export interface Provider {
id: string
name: string
logo: string
type: string
enabled: number
base_url?: string
api_key?: string
}
export interface Model {
id: string
model_name: string
provider_id: string
}
export interface GenerateRequest {
video_url: string
platform: Platform
quality: Quality
model_name: string
provider_id: string
screenshot?: boolean
link?: boolean
format?: string[]
style?: string
extras?: string
video_understanding?: boolean
video_interval?: number
grid_size?: [number, number]
// 客户端在浏览器里直接抓到的字幕,跳过后端的 download_subtitles + 音频转写
prefetched_transcript?: {
language: string
full_text: string
segments: Array<{ start: number, end: number, text: string }>
source?: string
}
}
export interface NoteResult {
markdown: string
transcript?: unknown
audio_meta?: {
title?: string
duration?: number
cover_url?: string
[k: string]: unknown
}
}
export interface TaskStatusResponse {
status: TaskStatus
message: string
task_id: string
result?: NoteResult
}
export interface TaskRecord {
taskId: string
videoUrl: string
platform: Platform
status: TaskStatus
message: string
createdAt: number
updatedAt: number
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: 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 {
id: string
name?: string
api_key?: string
base_url?: string
type?: string
enabled?: number
}
export interface ProviderCreatePayload {
name: string
api_key: string
base_url: string
type: string
logo?: string
}
export type TranscriberType = 'fast-whisper' | 'bcut' | 'kuaishou' | 'groq' | 'mlx-whisper'
export type WhisperModelSize = 'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo'
export interface TranscriberOption {
value: TranscriberType
label: string
}
export interface TranscriberConfig {
transcriber_type: TranscriberType
whisper_model_size: WhisperModelSize | null
available_types: TranscriberOption[]
whisper_model_sizes: WhisperModelSize[]
mlx_whisper_available: boolean
}
export interface WhisperModelStatus {
model_size: WhisperModelSize
downloaded: boolean
downloading: boolean
}
export interface TranscriberModelsStatus {
whisper: WhisperModelStatus[]
mlx_whisper: WhisperModelStatus[]
mlx_available: boolean
}
export interface DeployStatus {
backend: { status: string, port: number }
cuda: { available: boolean, version: string | null, gpu_name: string | null }
whisper: { model_size: string, transcriber_type: string }
ffmpeg: { available: boolean }
}

View File

@@ -0,0 +1,93 @@
import fs from 'fs-extra'
import type { Manifest } from 'webextension-polyfill'
import type PkgType from '../package.json'
import { isDev, isFirefox, port, r } from '../scripts/utils'
export async function getManifest() {
const pkg = await fs.readJSON(r('package.json')) as typeof PkgType
// update this file to update this manifest.json
// can also be conditional based on your need
const manifest: Manifest.WebExtensionManifest = {
manifest_version: 3,
name: pkg.displayName || pkg.name,
version: pkg.version,
description: pkg.description,
action: {
default_icon: 'assets/icon-512.png',
default_popup: 'dist/popup/index.html',
},
options_ui: {
page: 'dist/options/index.html',
open_in_tab: true,
},
background: isFirefox
? {
scripts: ['dist/background/index.mjs'],
type: 'module',
}
: {
service_worker: 'dist/background/index.mjs',
},
icons: {
16: 'assets/icon-512.png',
48: 'assets/icon-512.png',
128: 'assets/icon-512.png',
},
permissions: [
'tabs',
'storage',
'activeTab',
'sidePanel',
'contextMenus',
'cookies',
],
host_permissions: ['*://*/*'],
content_scripts: [
{
matches: [
'<all_urls>',
],
js: [
'dist/contentScripts/index.global.js',
],
},
],
web_accessible_resources: [
{
resources: ['dist/contentScripts/style.css'],
matches: ['<all_urls>'],
},
],
content_security_policy: {
extension_pages: isDev
// this is required on dev for Vite script to load
? `script-src \'self\' http://localhost:${port}; object-src \'self\'`
: 'script-src \'self\'; object-src \'self\'',
},
}
// add sidepanel
if (isFirefox) {
manifest.sidebar_action = {
default_panel: 'dist/sidepanel/index.html',
}
}
else {
// the sidebar_action does not work for chromium based
(manifest as any).side_panel = {
default_path: 'dist/sidepanel/index.html',
}
}
// FIXME: not work in MV3
if (isDev && false) {
// for content script, as browsers will cache them for each reload,
// we use a background script to always inject the latest version
// see src/background/contentScriptHMR.ts
delete manifest.content_scripts
manifest.permissions?.push('webNavigation')
}
return manifest
}

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import GeneralPage from './pages/General.vue'
import ProvidersPage from './pages/Providers.vue'
import TranscriberPage from './pages/Transcriber.vue'
import DownloaderPage from './pages/Downloader.vue'
import MonitorPage from './pages/Monitor.vue'
const TABS = [
{ id: 'general', label: '通用', icon: '⚙️', component: GeneralPage },
{ id: 'providers', label: '模型供应商', icon: '🧠', component: ProvidersPage },
{ id: 'transcriber', label: '音频转写配置', icon: '🎙️', component: TranscriberPage },
{ id: 'downloader', label: '下载配置', icon: '🍪', component: DownloaderPage },
{ id: 'monitor', label: '部署监控', icon: '📊', component: MonitorPage },
] as const
const activeTab = ref<typeof TABS[number]['id']>('general')
const ActiveComponent = computed(() => TABS.find(t => t.id === activeTab.value)?.component ?? GeneralPage)
</script>
<template>
<div class="flex h-screen bg-gray-50 text-gray-800">
<aside class="w-56 shrink-0 border-r bg-white flex flex-col">
<div class="px-4 py-4 border-b">
<div class="text-lg font-bold">BiliNote</div>
<div class="text-xs text-gray-500">浏览器插件设置</div>
</div>
<nav class="flex-1 overflow-auto py-2">
<button
v-for="tab in TABS"
:key="tab.id"
class="w-full text-left px-4 py-2 text-sm flex items-center gap-2 hover:bg-gray-100"
:class="activeTab === tab.id ? 'bg-blue-50 text-blue-700 font-medium border-l-2 border-blue-500' : 'text-gray-700'"
@click="activeTab = tab.id"
>
<span>{{ tab.icon }}</span>
<span>{{ tab.label }}</span>
</button>
</nav>
<div class="px-4 py-2 text-xs text-gray-400 border-t">
v0.1.0
</div>
</aside>
<main class="flex-1 overflow-auto">
<component :is="ActiveComponent" />
</main>
</div>
</template>
<style>
.btn-primary { @apply bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm; }
.btn-secondary { @apply bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200 text-sm disabled:opacity-50; }
.btn-danger { @apply bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600 text-sm disabled:opacity-50; }
.tag { @apply text-xs px-1.5 py-0.5 rounded; }
.input { @apply border rounded px-2 py-1 text-sm; }
.section-card { @apply bg-white border rounded p-4 mb-4 flex flex-col gap-3; }
</style>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_blank">
<title>Options</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './Options.vue'
import { setupApp } from '~/logic/common-setup'
import '../styles'
const app = createApp(App)
setupApp(app)
app.mount('#app')

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { getDownloaderCookie, setDownloaderCookie } from '~/logic/api'
import { SUPPORTED_COOKIE_PLATFORMS, syncCookieToBackend } from '~/logic/cookies'
import { PLATFORM_LABELS } from '~/logic/platform'
import type { Platform } from '~/logic/types'
interface Row {
cookie: string
busy: boolean
status: { kind: 'ok' | 'err' | 'idle', text: string }
}
const rows = reactive<Record<string, Row>>({})
const refreshing = ref(false)
function ensureRow(p: string) {
if (!rows[p])
rows[p] = { cookie: '', busy: false, status: { kind: 'idle', text: '' } }
return rows[p]
}
async function refreshOne(p: Exclude<Platform, 'local'>) {
const r = ensureRow(p)
try {
r.cookie = (await getDownloaderCookie(p)) ?? ''
}
catch (e) {
r.status = { kind: 'err', text: `读取失败:${(e as Error).message}` }
}
}
async function refreshAll() {
refreshing.value = true
await Promise.all(SUPPORTED_COOKIE_PLATFORMS.map(refreshOne))
refreshing.value = false
}
async function syncFromBrowser(p: Exclude<Platform, 'local'>) {
const r = ensureRow(p)
r.busy = true
r.status = { kind: 'idle', text: '从浏览器读取并同步…' }
const res = await syncCookieToBackend(p)
r.status = res.ok
? { kind: 'ok', text: `已同步 ${res.count} 条 cookie ✓` }
: { kind: 'err', text: res.error || '同步失败' }
if (res.ok)
await refreshOne(p)
r.busy = false
}
async function saveManual(p: Exclude<Platform, 'local'>) {
const r = ensureRow(p)
r.busy = true
r.status = { kind: 'idle', text: '保存中…' }
try {
await setDownloaderCookie(p, r.cookie || '')
r.status = { kind: 'ok', text: '已保存 ✓' }
}
catch (e) {
r.status = { kind: 'err', text: `保存失败:${(e as Error).message}` }
}
finally {
r.busy = false
}
}
onMounted(() => {
SUPPORTED_COOKIE_PLATFORMS.forEach(ensureRow)
refreshAll()
})
</script>
<template>
<div class="p-6 max-w-3xl">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-xl font-bold">下载配置</h1>
<p class="text-xs text-gray-500 mt-1">
每平台的 cookie 写入后端 (config/downloader.json)下载时由对应 downloader 读取注入 yt-dlp
</p>
</div>
<button class="btn-secondary" :disabled="refreshing" @click="refreshAll">
{{ refreshing ? '刷新中' : '刷新' }}
</button>
</div>
<section
v-for="p in SUPPORTED_COOKIE_PLATFORMS"
:key="p"
class="section-card"
>
<div class="flex items-center justify-between">
<h2 class="font-semibold">{{ PLATFORM_LABELS[p] }}</h2>
<span
v-if="rows[p]?.cookie"
class="tag bg-green-100 text-green-700"
>已配置</span>
<span v-else class="tag bg-gray-100 text-gray-500">未配置</span>
</div>
<textarea
v-model="rows[p].cookie"
class="input font-mono text-xs h-20 resize-y"
placeholder="name=value; name=value; ..."
/>
<div class="flex items-center gap-2">
<button class="btn-primary" :disabled="rows[p]?.busy" @click="syncFromBrowser(p)">
{{ rows[p]?.busy ? '处理中' : '从浏览器同步' }}
</button>
<button class="btn-secondary" :disabled="rows[p]?.busy" @click="saveManual(p)">
手动保存
</button>
<span
v-if="rows[p]?.status?.text"
class="text-xs"
:class="{
'text-green-700': rows[p].status.kind === 'ok',
'text-red-600': rows[p].status.kind === 'err',
'text-gray-500': rows[p].status.kind === 'idle',
}"
>{{ rows[p].status.text }}</span>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getProviders, ping } from '~/logic/api'
import { settings, settingsReady } from '~/logic/storage'
import { getModelsByProvider } from '~/logic/api'
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: '' })
const loading = ref(false)
async function refresh() {
loading.value = true
status.value = { kind: 'idle', text: '' }
try {
providers.value = (await getProviders()).filter(p => p.enabled === 1)
if (settings.value.providerId)
await refreshModels(settings.value.providerId)
status.value = { kind: 'ok', text: `已加载 ${providers.value.length} 个供应商` }
}
catch (e) {
status.value = { kind: 'err', text: `加载失败:${(e as Error).message}` }
providers.value = []
models.value = []
}
finally {
loading.value = false
}
}
async function refreshModels(providerId: string) {
if (!providerId) {
models.value = []
return
}
try {
models.value = await getModelsByProvider(providerId)
}
catch {
models.value = []
}
}
async function testConnection() {
status.value = { kind: 'idle', text: '正在测试…' }
const ok = await ping()
status.value = ok
? { kind: 'ok', text: '后端连通 ✓' }
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS' }
}
watch(() => settings.value?.providerId, (id) => {
if (id)
refreshModels(id)
})
onMounted(async () => {
await settingsReady
if (settings.value.backendUrl)
await refresh()
})
</script>
<template>
<div class="p-6 max-w-2xl">
<h1 class="text-xl font-bold mb-4">通用</h1>
<section class="section-card">
<h2 class="font-semibold">后端地址</h2>
<div class="flex gap-2">
<input v-model="settings.backendUrl" class="input flex-1" placeholder="http://localhost:8483">
<button class="btn-secondary" @click="testConnection">测试连通</button>
<button class="btn-secondary" :disabled="loading" @click="refresh">
{{ loading ? '加载中' : '刷新' }}
</button>
</div>
<div
v-if="status.text"
class="text-xs"
:class="{
'text-green-700': status.kind === 'ok',
'text-red-600': status.kind === 'err',
'text-gray-500': status.kind === 'idle',
}"
>
{{ status.text }}
</div>
<p class="text-xs text-gray-500">
默认 http://localhost:8483 — 需要在该地址先跑起 BiliNote 后端
</p>
</section>
<section class="section-card">
<h2 class="font-semibold">默认供应商与模型</h2>
<label class="flex flex-col gap-1 text-sm">
<span class="text-gray-600">供应商</span>
<select v-model="settings.providerId" class="input">
<option value=""> 选择供应商 </option>
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} <span v-if="p.type === 'built-in'">(内置)</span>
</option>
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-gray-600">模型</span>
<select v-model="settings.modelName" class="input" :disabled="!settings.providerId">
<option value=""> 选择模型 </option>
<option v-for="m in models" :key="m.id" :value="m.model_name">{{ m.model_name }}</option>
</select>
<span v-if="settings.providerId && models.length === 0" class="text-xs text-amber-700">
该供应商还没添加可用模型模型供应商页编辑
</span>
</label>
</section>
<section class="section-card">
<h2 class="font-semibold">默认生成选项</h2>
<div class="grid grid-cols-2 gap-3 text-sm">
<label class="flex flex-col gap-1">
<span class="text-gray-600">画质</span>
<select v-model="settings.quality" class="input">
<option value="fast">快速 (32k)</option>
<option value="medium">中等 (64k)</option>
<option value="slow">高质 (128k)</option>
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-gray-600">笔记风格</span>
<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>
</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 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>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getDeployStatus, getSysHealth } from '~/logic/api'
import type { DeployStatus } from '~/logic/types'
const status = ref<DeployStatus | null>(null)
const health = ref<{ ok: boolean, msg?: string } | null>(null)
const loading = ref(false)
const error = ref('')
async function refresh() {
loading.value = true
error.value = ''
try {
const [s, h] = await Promise.all([getDeployStatus(), getSysHealth()])
status.value = s
health.value = h
}
catch (e) {
error.value = (e as Error).message
}
finally {
loading.value = false
}
}
onMounted(refresh)
</script>
<template>
<div class="p-6 max-w-2xl">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">部署监控</h1>
<button class="btn-secondary" :disabled="loading" @click="refresh">
{{ loading ? '检查中' : '刷新' }}
</button>
</div>
<div v-if="error" class="text-red-600 text-sm mb-4">{{ error }}</div>
<template v-if="status">
<section class="section-card">
<h2 class="font-semibold">后端</h2>
<div class="text-sm">
<span class="tag bg-green-100 text-green-700">{{ status.backend.status }}</span>
<span class="ml-2 text-gray-600">端口 {{ status.backend.port }}</span>
</div>
</section>
<section class="section-card">
<h2 class="font-semibold">FFmpeg</h2>
<div class="text-sm flex items-center gap-3">
<span
class="tag"
:class="status.ffmpeg.available ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>{{ status.ffmpeg.available ? '可用' : '不可用' }}</span>
<span v-if="health && !health.ok" class="text-red-600 text-xs">{{ health.msg }}</span>
</div>
</section>
<section class="section-card">
<h2 class="font-semibold">CUDA / GPU</h2>
<div class="text-sm">
<span
class="tag"
:class="status.cuda.available ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
>{{ status.cuda.available ? '可用' : '不可用' }}</span>
<div v-if="status.cuda.available" class="mt-1 text-gray-600 text-xs">
CUDA {{ status.cuda.version }} · {{ status.cuda.gpu_name }}
</div>
</div>
</section>
<section class="section-card">
<h2 class="font-semibold">Whisper</h2>
<div class="text-sm text-gray-600">
引擎<span class="text-gray-800">{{ status.whisper.transcriber_type }}</span>
<span v-if="status.whisper.model_size" class="ml-3">
模型<span class="text-gray-800">{{ status.whisper.model_size }}</span>
</span>
</div>
</section>
</template>
</div>
</template>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
addModel,
addProvider,
connectTest,
deleteModel,
getProviderById,
getProviders,
listAllModels,
updateProvider,
} from '~/logic/api'
import type { Model, Provider, ProviderUpdatePayload } from '~/logic/types'
const providers = ref<Provider[]>([])
const selectedId = ref<string>('')
const editing = ref<Partial<Provider> & { api_key?: string, base_url?: string }>({})
const models = ref<Model[]>([])
const newModelName = ref('')
const isCreating = ref(false)
const message = ref<{ kind: 'ok' | 'err' | 'idle', text: string }>({ kind: 'idle', text: '' })
const isBuiltIn = computed(() => editing.value?.type === 'built-in')
async function refresh() {
try {
providers.value = await getProviders()
}
catch (e) {
message.value = { kind: 'err', text: `加载供应商失败:${(e as Error).message}` }
}
}
async function select(id: string) {
isCreating.value = false
selectedId.value = id
message.value = { kind: 'idle', text: '' }
try {
const p = await getProviderById(id)
editing.value = { ...p }
models.value = await listAllModels(id)
}
catch (e) {
message.value = { kind: 'err', text: `读取供应商失败:${(e as Error).message}` }
}
}
function startCreate() {
isCreating.value = true
selectedId.value = ''
editing.value = {
name: '',
api_key: '',
base_url: '',
type: 'custom',
enabled: 1,
}
models.value = []
}
async function save() {
message.value = { kind: 'idle', text: '保存中…' }
try {
if (isCreating.value) {
const id = await addProvider({
name: editing.value.name || '',
api_key: editing.value.api_key || '',
base_url: editing.value.base_url || '',
type: 'custom',
})
await refresh()
message.value = { kind: 'ok', text: '已创建' }
if (id)
await select(id as unknown as string)
}
else if (selectedId.value) {
const payload: ProviderUpdatePayload = {
id: selectedId.value,
name: editing.value.name,
api_key: editing.value.api_key,
base_url: editing.value.base_url,
enabled: editing.value.enabled,
}
await updateProvider(payload)
await refresh()
message.value = { kind: 'ok', text: '已保存' }
}
}
catch (e) {
message.value = { kind: 'err', text: `保存失败:${(e as Error).message}` }
}
}
async function toggleEnabled(p: Provider) {
try {
await updateProvider({ id: p.id, enabled: p.enabled === 1 ? 0 : 1 })
await refresh()
}
catch (e) {
message.value = { kind: 'err', text: `切换启用失败:${(e as Error).message}` }
}
}
async function test() {
if (!selectedId.value)
return
message.value = { kind: 'idle', text: '测试中…' }
try {
await connectTest(selectedId.value)
message.value = { kind: 'ok', text: '连接成功 ✓' }
}
catch (e) {
message.value = { kind: 'err', text: `连接失败:${(e as Error).message}` }
}
}
async function addNewModel() {
if (!selectedId.value || !newModelName.value.trim())
return
try {
await addModel(selectedId.value, newModelName.value.trim())
newModelName.value = ''
models.value = await listAllModels(selectedId.value)
}
catch (e) {
message.value = { kind: 'err', text: `添加模型失败:${(e as Error).message}` }
}
}
async function removeModel(modelId: number | string) {
if (!confirm('确认删除该模型?'))
return
try {
await deleteModel(modelId)
if (selectedId.value)
models.value = await listAllModels(selectedId.value)
}
catch (e) {
message.value = { kind: 'err', text: `删除模型失败:${(e as Error).message}` }
}
}
onMounted(refresh)
</script>
<template>
<div class="p-6 flex gap-6">
<aside class="w-64 shrink-0 flex flex-col gap-2">
<div class="flex justify-between items-center">
<h1 class="text-xl font-bold">模型供应商</h1>
<button class="btn-secondary" @click="startCreate">新增</button>
</div>
<div class="bg-white border rounded">
<div
v-for="p in providers"
:key="p.id"
class="flex items-center justify-between gap-2 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-gray-50"
:class="{ 'bg-blue-50': p.id === selectedId }"
@click="select(p.id)"
>
<div class="flex items-center gap-2 min-w-0">
<div class="truncate">{{ p.name }}</div>
<span
class="tag"
:class="p.type === 'built-in' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'"
>{{ p.type === 'built-in' ? '内置' : '自定义' }}</span>
</div>
<button
class="text-xs"
:class="p.enabled === 1 ? 'text-green-600' : 'text-gray-400'"
:title="p.enabled === 1 ? '已启用,点击禁用' : '已禁用,点击启用'"
@click.stop="toggleEnabled(p)"
>
{{ p.enabled === 1 ? '✓ 启用' : '○ 禁用' }}
</button>
</div>
</div>
</aside>
<main class="flex-1 max-w-2xl">
<div v-if="!selectedId && !isCreating" class="text-gray-400 text-sm pt-12 text-center">
左侧选一个供应商查看 / 编辑或点新增添加新供应商
</div>
<div v-else class="flex flex-col gap-4">
<h2 class="text-lg font-semibold">
{{ isCreating ? '新增供应商' : '编辑供应商' }}
</h2>
<section class="section-card">
<label class="flex items-center gap-3 text-sm">
<span class="w-20 text-right text-gray-600">名称</span>
<input v-model="editing.name" class="input flex-1" :disabled="isBuiltIn">
</label>
<label class="flex items-center gap-3 text-sm">
<span class="w-20 text-right text-gray-600">API Key</span>
<input v-model="editing.api_key" class="input flex-1" type="password">
</label>
<label class="flex items-center gap-3 text-sm">
<span class="w-20 text-right text-gray-600">API 地址</span>
<input v-model="editing.base_url" class="input flex-1">
</label>
<label v-if="!isCreating" class="flex items-center gap-3 text-sm">
<span class="w-20 text-right text-gray-600">类型</span>
<input :value="editing.type" class="input flex-1" disabled>
</label>
<div class="flex items-center gap-2 pt-2">
<button class="btn-primary" @click="save">{{ isCreating ? '创建' : '保存' }}</button>
<button v-if="!isCreating" class="btn-secondary" @click="test">测试连接</button>
<span
v-if="message.text"
class="text-xs"
:class="{
'text-green-700': message.kind === 'ok',
'text-red-600': message.kind === 'err',
'text-gray-500': message.kind === 'idle',
}"
>{{ message.text }}</span>
</div>
</section>
<section v-if="!isCreating" class="section-card">
<h3 class="font-semibold">模型列表</h3>
<div class="flex gap-2">
<input v-model="newModelName" class="input flex-1" placeholder="例如 gpt-4o-mini">
<button class="btn-secondary" @click="addNewModel">添加模型</button>
</div>
<ul class="flex flex-col gap-1">
<li v-for="m in models" :key="m.id" class="flex justify-between items-center px-2 py-1 rounded hover:bg-gray-50">
<span class="text-sm">{{ m.model_name }}</span>
<button class="text-xs text-red-500 hover:text-red-700" @click="removeModel(m.id)">删除</button>
</li>
<li v-if="models.length === 0" class="text-xs text-gray-400">该供应商下还没有模型</li>
</ul>
</section>
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
downloadTranscriberModel,
getTranscriberConfig,
getTranscriberModelsStatus,
setTranscriberConfig,
} from '~/logic/api'
import type {
TranscriberConfig,
TranscriberModelsStatus,
TranscriberType,
WhisperModelSize,
WhisperModelStatus,
} from '~/logic/types'
const config = ref<TranscriberConfig | null>(null)
const status = ref<TranscriberModelsStatus | null>(null)
const selType = ref<TranscriberType>('fast-whisper')
const selSize = ref<WhisperModelSize>('medium')
const loading = ref(false)
const saving = ref(false)
const message = ref<{ kind: 'ok' | 'err' | 'idle', text: string }>({ kind: 'idle', text: '' })
const isWhisperLike = computed(() => selType.value === 'fast-whisper' || selType.value === 'mlx-whisper')
async function refresh() {
loading.value = true
message.value = { kind: 'idle', text: '' }
try {
const [cfg, st] = await Promise.all([getTranscriberConfig(), getTranscriberModelsStatus()])
config.value = cfg
status.value = st
selType.value = cfg.transcriber_type
if (cfg.whisper_model_size)
selSize.value = cfg.whisper_model_size
}
catch (e) {
message.value = { kind: 'err', text: `读取失败:${(e as Error).message}` }
}
finally {
loading.value = false
}
}
async function save() {
saving.value = true
message.value = { kind: 'idle', text: '保存中…' }
try {
const cfg = await setTranscriberConfig(selType.value, isWhisperLike.value ? selSize.value : undefined)
config.value = cfg
message.value = { kind: 'ok', text: '已保存。下一次生成笔记会用新配置。' }
}
catch (e) {
message.value = { kind: 'err', text: `保存失败:${(e as Error).message}` }
}
finally {
saving.value = false
}
}
async function triggerDownload(size: WhisperModelSize) {
try {
await downloadTranscriberModel(size, selType.value === 'mlx-whisper' ? 'mlx-whisper' : 'fast-whisper')
message.value = { kind: 'ok', text: `已开始下载 ${size}` }
await refresh()
}
catch (e) {
message.value = { kind: 'err', text: `触发下载失败:${(e as Error).message}` }
}
}
const currentSizeStatus = computed<WhisperModelStatus[]>(() => {
if (!status.value)
return []
return selType.value === 'mlx-whisper' ? status.value.mlx_whisper : status.value.whisper
})
onMounted(refresh)
</script>
<template>
<div class="p-6 max-w-3xl">
<h1 class="text-xl font-bold mb-1">音频转写配置</h1>
<p class="text-xs text-gray-500 mb-4">
选择把视频音频转成文字的引擎在线引擎Groq / 必剪 / 快手走第三方 API本地 Whisper 需要先下载模型
</p>
<div v-if="loading" class="text-sm text-gray-500">加载中</div>
<template v-else-if="config">
<section class="section-card">
<h2 class="font-semibold">引擎</h2>
<select v-model="selType" class="input">
<option v-for="opt in config.available_types" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<p v-if="selType === 'mlx-whisper' && !config.mlx_whisper_available" class="text-xs text-red-600">
当前后端没有装 mlx_whisper macOS 可用如果不是 Mac请改用 fast-whisper / Groq / 必剪 / 快手
</p>
</section>
<section v-if="isWhisperLike" class="section-card">
<h2 class="font-semibold">Whisper 模型大小</h2>
<select v-model="selSize" class="input">
<option v-for="s in config.whisper_model_sizes" :key="s" :value="s">
{{ s }}
</option>
</select>
<h3 class="text-sm font-medium mt-2">下载状态</h3>
<table class="text-sm w-full">
<thead>
<tr class="text-left text-gray-500">
<th class="py-1 font-normal">模型</th>
<th class="py-1 font-normal">本地</th>
<th class="py-1 font-normal">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in currentSizeStatus" :key="row.model_size" class="border-t">
<td class="py-1">{{ row.model_size }}</td>
<td class="py-1">
<span v-if="row.downloaded" class="tag bg-green-100 text-green-700">已下载</span>
<span v-else-if="row.downloading" class="tag bg-yellow-100 text-yellow-700">下载中</span>
<span v-else class="tag bg-gray-100 text-gray-500">未下载</span>
</td>
<td class="py-1">
<button
v-if="!row.downloaded && !row.downloading"
class="btn-secondary"
@click="triggerDownload(row.model_size)"
>
下载
</button>
</td>
</tr>
</tbody>
</table>
</section>
<section class="flex items-center gap-3">
<button class="btn-primary" :disabled="saving" @click="save">
{{ saving ? '保存中' : '保存配置' }}
</button>
<button class="btn-secondary" @click="refresh">刷新</button>
<span
v-if="message.text"
class="text-xs"
:class="{
'text-green-700': message.kind === 'ok',
'text-red-600': message.kind === 'err',
'text-gray-500': message.kind === 'idle',
}"
>{{ message.text }}</span>
</section>
</template>
</div>
</template>

View File

@@ -0,0 +1,348 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
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 { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
const tabUrl = ref<string>('')
const tabTitle = ref<string>('')
const tabId = ref<number | undefined>(undefined)
const platform = computed(() => detectPlatform(tabUrl.value))
const supported = computed(() => platform.value !== null)
const submitting = ref(false)
const errorMsg = ref('')
const activeTaskId = ref<string>('')
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function loadActiveTab() {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
tabUrl.value = tab?.url ?? ''
tabTitle.value = tab?.title ?? ''
tabId.value = tab?.id
}
catch (e) {
console.warn('无法读取当前 tab:', e)
}
}
async function poll(taskId: string) {
try {
const res = await getTaskStatus(taskId)
upsertTask({
taskId,
videoUrl: activeTask.value?.videoUrl ?? tabUrl.value,
platform: (activeTask.value?.platform ?? platform.value)!,
status: res.status,
message: res.message,
createdAt: activeTask.value?.createdAt ?? Date.now(),
updatedAt: Date.now(),
result: res.result ?? activeTask.value?.result,
})
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
pollTimer = setTimeout(() => poll(taskId), 3000)
}
catch (e) {
errorMsg.value = (e as Error).message
pollTimer = setTimeout(() => poll(taskId), 5000)
}
}
async function start() {
errorMsg.value = ''
if (!supported.value) {
errorMsg.value = '当前页面不是支持的视频链接'
return
}
if (!settings.value.providerId || !settings.value.modelName) {
errorMsg.value = '请先去设置页选择供应商和模型'
return
}
submitting.value = true
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,
// backend VideoRequest 同时接受 format 数组与 screenshot/link 单独布尔,从 formats 派生保持单一真相源
format: [...formats],
screenshot: formats.includes('screenshot'),
link: formats.includes('link'),
style: settings.value.style || undefined,
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
upsertTask({
taskId: task_id,
videoUrl: tabUrl.value,
platform: platform.value!,
status: 'PENDING',
message: '已提交',
createdAt: Date.now(),
updatedAt: Date.now(),
})
poll(task_id)
// 提交后顺手把侧边栏拉起来,免得用户来回切窗口
openSidePanel()
}
catch (e) {
errorMsg.value = (e as Error).message
}
finally {
submitting.value = false
}
}
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 {
const target = tabId.value ?? (await browser.tabs.query({ active: true, currentWindow: true }))[0]?.id
if (target == null)
return
// @ts-expect-error sidePanel 类型在 polyfill 中不全
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open)
// @ts-expect-error see above
await chrome.sidePanel.open({ tabId: target })
}
catch (err) {
console.warn('打开侧边栏失败:', err)
}
}
function selectTask(id: string) {
activeTaskId.value = id
const t = tasks.value?.find(x => x.taskId === id)
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
poll(id)
}
const activeCover = computed(() => activeTask.value?.result?.audio_meta?.cover_url as string | undefined)
const activeTitle = computed(() => (activeTask.value?.result?.audio_meta?.title as string | undefined) || tabTitle.value)
function fmtTime(ts?: number) {
if (!ts)
return ''
const d = new Date(ts)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
onMounted(async () => {
await Promise.all([settingsReady, tasksReady])
await loadActiveTab()
const running = tasks.value?.find(t => t.status !== 'SUCCESS' && t.status !== 'FAILED')
if (running) {
activeTaskId.value = running.taskId
poll(running.taskId)
}
})
onUnmounted(() => {
if (pollTimer)
clearTimeout(pollTimer)
})
</script>
<template>
<main class="w-[400px] p-3 text-sm text-gray-800 flex flex-col gap-3 bg-white">
<header class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-base">BiliNote</span>
<PlatformBadge :platform="platform" />
</div>
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
</header>
<div class="text-xs text-gray-500 truncate" :title="tabUrl">
{{ tabUrl || '当前没有打开的标签页' }}
</div>
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
当前页面不是 BiliNote 支持的视频链接Bilibili / YouTube / Douyin / Kuaishou
</div>
<fieldset class="border rounded p-2 flex flex-col gap-2" :disabled="!supported || submitting">
<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">
<option value="fast">快速</option>
<option value="medium">中等</option>
<option value="slow">高质</option>
</select>
</label>
<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 }}
</span>
<span v-else class="text-amber-700">
未选择供应商/模型
<button class="underline" @click="openOptions">去设置</button>
</span>
</div>
<button class="btn-primary" :disabled="!supported || submitting || !settings.providerId" @click="start">
{{ submitting ? '提交中' : '生成笔记' }}
</button>
</fieldset>
<div v-if="errorMsg" class="text-xs text-red-600 break-words">
{{ errorMsg }}
</div>
<section v-if="activeTask" class="flex flex-col gap-2">
<div v-if="activeCover || activeTitle" class="flex gap-3 items-start">
<img
v-if="activeCover"
:src="resolveImageUrl(activeCover)"
class="w-20 h-12 object-cover rounded border bg-gray-100 shrink-0"
alt="cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-snug line-clamp-2 break-words" :title="activeTitle">
{{ activeTitle || '(未取到标题)' }}
</div>
<div class="text-xs text-gray-400 mt-0.5">
{{ fmtTime(activeTask.updatedAt) }}
</div>
</div>
</div>
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
<button
v-if="activeTask.status === 'SUCCESS'"
class="btn-primary"
@click="openSidePanel"
>
在侧边栏查看笔记 / 思维导图 / AI 问答
</button>
<button
v-else
class="btn-secondary"
@click="openSidePanel"
>
在侧边栏看进度
</button>
</section>
<details v-if="(tasks?.length ?? 0) > 0" class="text-xs">
<summary class="cursor-pointer text-gray-500">最近任务{{ tasks!.length }}</summary>
<ul class="mt-1 flex flex-col gap-1 max-h-32 overflow-auto">
<li
v-for="t in tasks"
:key="t.taskId"
class="flex justify-between items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-100 cursor-pointer"
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
@click="selectTask(t.taskId)"
>
<span class="truncate flex-1" :title="t.videoUrl">
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
</span>
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
</li>
</ul>
</details>
</main>
</template>
<style>
.btn-primary { @apply bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm; }
.btn-secondary { @apply bg-gray-100 text-gray-700 px-2 py-1 rounded hover:bg-gray-200 text-xs; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_blank">
<title>Popup</title>
</head>
<body style="min-width: 100px">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './Popup.vue'
import { setupApp } from '~/logic/common-setup'
import '../styles'
const app = createApp(App)
setupApp(app)
app.mount('#app')

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { getTaskStatus, resolveImageUrl } from '~/logic/api'
import { tasks, tasksReady, settingsReady, upsertTask } from '~/logic/storage'
import type { TaskRecord } from '~/logic/types'
type ViewMode = 'markdown' | 'mindmap' | 'chat'
const activeTaskId = ref<string>('')
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
const errorMsg = ref('')
const viewMode = ref<ViewMode>('markdown')
const showHistory = ref(false)
const isDone = computed(() => activeTask.value?.status === 'SUCCESS')
const isFailed = computed(() => activeTask.value?.status === 'FAILED')
const isRunning = computed(() => !!activeTask.value && !isDone.value && !isFailed.value)
const STAGE_LABELS: Record<string, string> = {
PENDING: '排队中',
PARSING: '解析中',
DOWNLOADING: '下载中',
TRANSCRIBING: '转写中',
SUMMARIZING: '总结中',
FORMATTING: '格式化',
SAVING: '保存中',
SUCCESS: '完成',
FAILED: '失败',
}
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function poll(taskId: string) {
try {
const res = await getTaskStatus(taskId)
const cur = tasks.value?.find(t => t.taskId === taskId)
if (cur) {
upsertTask({
...cur,
status: res.status,
message: res.message,
result: res.result ?? cur.result,
updatedAt: Date.now(),
})
}
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
pollTimer = setTimeout(() => poll(taskId), 3000)
}
catch (e) {
errorMsg.value = (e as Error).message
pollTimer = setTimeout(() => poll(taskId), 5000)
}
}
function selectTask(id: string) {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
activeTaskId.value = id
showHistory.value = false
const t = tasks.value?.find(x => x.taskId === id)
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
poll(id)
}
function openOptions() {
browser.runtime.openOptionsPage()
}
async function copyMarkdown() {
const md = activeTask.value?.result?.markdown
if (md)
await navigator.clipboard.writeText(md)
}
function downloadMarkdown() {
const md = activeTask.value?.result?.markdown
if (!md)
return
const title = (activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || 'bilinote'
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${title}.md`
a.click()
URL.revokeObjectURL(url)
}
const activeTitle = computed(() =>
(activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || activeTask.value?.videoUrl || '')
const activeCover = computed(() =>
(activeTask.value?.result?.audio_meta as { cover_url?: string } | undefined)?.cover_url)
onMounted(async () => {
await Promise.all([settingsReady, tasksReady])
const latest = tasks.value?.[0]
if (latest) {
activeTaskId.value = latest.taskId
if (latest.status !== 'SUCCESS' && latest.status !== 'FAILED')
poll(latest.taskId)
}
})
onUnmounted(() => {
if (pollTimer)
clearTimeout(pollTimer)
})
</script>
<template>
<main class="w-full h-full flex flex-col bg-white text-sm text-gray-800">
<!-- 顶栏极简 -->
<header class="flex items-center justify-between px-3 py-2 border-b shrink-0">
<div class="font-semibold">BiliNote</div>
<div class="flex items-center gap-1">
<button
v-if="(tasks?.length ?? 0) > 0"
class="text-xs text-gray-500 hover:text-gray-800 px-2 py-0.5 rounded hover:bg-gray-100"
:class="{ 'bg-gray-100': showHistory }"
@click="showHistory = !showHistory"
>
历史 {{ tasks?.length }}
</button>
<button class="text-xs text-gray-500 hover:text-gray-800 px-2 py-0.5 rounded hover:bg-gray-100" @click="openOptions">
设置
</button>
</div>
</header>
<!-- 历史弹层覆盖在内容上方 -->
<div v-if="showHistory" class="border-b bg-gray-50 px-2 py-2 max-h-60 overflow-auto shrink-0">
<ul class="flex flex-col gap-0.5 text-xs">
<li
v-for="t in tasks"
:key="t.taskId"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-white"
:class="{ 'bg-white border': t.taskId === activeTaskId }"
@click="selectTask(t.taskId)"
>
<span class="truncate flex-1" :title="t.videoUrl">
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
</span>
<span class="text-gray-400 shrink-0">{{ STAGE_LABELS[t.status] || t.status }}</span>
</li>
</ul>
</div>
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-1 break-words bg-red-50 shrink-0">
{{ errorMsg }}
</div>
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
还没有任务在视频页点悬浮按钮 popup 提交或右键菜单选 BiliNote 总结
</section>
<section v-else class="flex-1 flex flex-col min-h-0">
<!-- 标题区紧凑一行 -->
<div class="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<img
v-if="activeCover"
:src="resolveImageUrl(activeCover)"
class="w-12 h-7 object-cover rounded bg-gray-100 shrink-0"
alt=""
@error="($event.target as HTMLImageElement).style.display = 'none'"
>
<a
class="text-sm font-medium leading-tight line-clamp-1 break-all flex-1 min-w-0 hover:text-blue-600"
:href="activeTask.videoUrl"
target="_blank"
:title="activeTask.videoUrl"
>{{ activeTitle }}</a>
<span
v-if="isDone"
class="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 shrink-0"
title="完成"
></span>
<span
v-else-if="isFailed"
class="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 shrink-0"
:title="activeTask.message"
>失败</span>
<span
v-else
class="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 shrink-0 animate-pulse"
>{{ STAGE_LABELS[activeTask.status] || activeTask.status }}</span>
</div>
<!-- 进行中进度条完成tab + 操作按钮 -->
<div v-if="isRunning" class="px-3 py-2 border-b shrink-0">
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
</div>
<div
v-else-if="isDone && activeTask.result?.markdown"
class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0 text-xs"
>
<button
class="px-2 py-1 rounded"
:class="viewMode === 'markdown' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
@click="viewMode = 'markdown'"
>Markdown</button>
<button
class="px-2 py-1 rounded"
:class="viewMode === 'mindmap' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
@click="viewMode = 'mindmap'"
>思维导图</button>
<button
class="px-2 py-1 rounded"
:class="viewMode === 'chat' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
@click="viewMode = 'chat'"
>AI 问答</button>
<div class="flex-1" />
<button
v-if="viewMode === 'markdown'"
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
title="复制 Markdown"
@click="copyMarkdown"
>复制</button>
<button
v-if="viewMode === 'markdown'"
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
title="下载 .md"
@click="downloadMarkdown"
>下载</button>
</div>
<!-- 内容区占满剩余空间 -->
<div class="flex-1 overflow-auto min-h-0">
<MarkdownView
v-if="isDone && activeTask.result?.markdown && viewMode === 'markdown'"
:markdown="activeTask.result.markdown"
:title="(activeTask.result.audio_meta as { title?: string } | undefined)?.title"
:hide-actions="true"
/>
<MindMap
v-else-if="isDone && activeTask.result?.markdown && viewMode === 'mindmap'"
:markdown="activeTask.result.markdown"
class="h-full"
/>
<ChatPanel
v-else-if="isDone && viewMode === 'chat'"
:task-id="activeTask.taskId"
class="h-full"
/>
<div v-else-if="isFailed" class="p-4 text-sm text-red-600">
{{ activeTask.message || '任务失败' }}
</div>
</div>
</section>
</main>
</template>
<style>
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_blank">
<title>Sidepanel</title>
</head>
<body style="min-width: 100px">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './Sidepanel.vue'
import { setupApp } from '~/logic/common-setup'
import '../styles'
const app = createApp(App)
setupApp(app)
app.mount('#app')

View File

@@ -0,0 +1,3 @@
import '@unocss/reset/tailwind.css'
import './main.css'
import 'uno.css'

View File

@@ -0,0 +1,20 @@
html,
body,
#app {
margin: 0;
padding: 0;
}
.btn {
@apply px-4 py-1 rounded inline-block
bg-teal-600 text-white cursor-pointer
hover:bg-teal-700
disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50;
}
.icon-btn {
@apply inline-block cursor-pointer select-none
opacity-75 transition duration-200 ease-in-out
hover:opacity-100 hover:text-teal-600;
font-size: 0.9em;
}

View File

@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
describe('demo', () => {
it('should work', () => {
expect(1 + 1).toBe(2)
})
})

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"incremental": false,
"target": "es2016",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"paths": {
"~/*": ["src/*"]
},
"resolveJsonModule": true,
"types": [
"vite/client"
],
"strict": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'unocss/vite'
import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss'
export default defineConfig({
presets: [
presetUno(),
presetAttributify(),
presetIcons(),
],
transformers: [
transformerDirectives(),
],
})

View File

@@ -0,0 +1,36 @@
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config.mjs'
import { isDev, r } from './scripts/utils'
import packageJson from './package.json'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
define: {
'__DEV__': isDev,
'__NAME__': JSON.stringify(packageJson.name),
// https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
},
build: {
watch: isDev
? {}
: undefined,
outDir: r('extension/dist/background'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/background/main.ts'),
name: packageJson.name,
formats: ['iife'],
},
rollupOptions: {
output: {
entryFileNames: 'index.mjs',
extend: true,
},
},
},
})

View File

@@ -0,0 +1,36 @@
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config.mjs'
import { isDev, r } from './scripts/utils'
import packageJson from './package.json'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
define: {
'__DEV__': isDev,
'__NAME__': JSON.stringify(packageJson.name),
// https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
},
build: {
watch: isDev
? {}
: undefined,
outDir: r('extension/dist/contentScripts'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/contentScripts/index.ts'),
name: packageJson.name,
formats: ['iife'],
},
rollupOptions: {
output: {
entryFileNames: 'index.global.js',
extend: true,
},
},
},
})

View File

@@ -0,0 +1,115 @@
/// <reference types="vitest" />
import { dirname, relative } from 'node:path'
import type { UserConfig } from 'vite'
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import UnoCSS from 'unocss/vite'
import { isDev, port, r } from './scripts/utils'
import packageJson from './package.json'
export const sharedConfig: UserConfig = {
root: r('src'),
resolve: {
alias: {
'~/': `${r('src')}/`,
},
},
define: {
__DEV__: isDev,
__NAME__: JSON.stringify(packageJson.name),
},
plugins: [
Vue(),
AutoImport({
imports: [
'vue',
{
'webextension-polyfill': [
['=', 'browser'],
],
},
],
dts: r('src/auto-imports.d.ts'),
}),
// https://github.com/antfu/unplugin-vue-components
Components({
dirs: [r('src/components')],
// generate `components.d.ts` for ts support with Volar
dts: r('src/components.d.ts'),
resolvers: [
// auto import icons
IconsResolver({
prefix: '',
}),
],
}),
// https://github.com/antfu/unplugin-icons
Icons(),
// https://github.com/unocss/unocss
UnoCSS(),
// rewrite assets to use relative path
{
name: 'assets-rewrite',
enforce: 'post',
apply: 'build',
transformIndexHtml(html, { path }) {
return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`)
},
},
],
optimizeDeps: {
include: [
'vue',
'@vueuse/core',
'webextension-polyfill',
],
exclude: [
'vue-demi',
],
},
}
export default defineConfig(({ command }) => ({
...sharedConfig,
base: command === 'serve' ? `http://localhost:${port}/` : '/dist/',
server: {
port,
hmr: {
host: 'localhost',
},
origin: `http://localhost:${port}`,
},
build: {
watch: isDev
? {}
: undefined,
outDir: r('extension/dist'),
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
// https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements
terserOptions: {
mangle: false,
},
rollupOptions: {
input: {
options: r('src/options/index.html'),
popup: r('src/popup/index.html'),
sidepanel: r('src/sidepanel/index.html'),
},
},
},
test: {
globals: true,
environment: 'jsdom',
},
}))

View File

@@ -22,5 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/pnpm-lock.yaml
/src-tauri/bin/

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

View File

@@ -33,6 +33,7 @@
"clsx": "^2.1.1",
"fuse.js": "^7.1.0",
"github-markdown-css": "^5.8.1",
"idb-keyval": "^6.2.2",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"lottie-react": "^2.4.1",

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

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "BiliNote",
"version": "1.8.1",
"version": "2.0.0",
"identifier": "com.jefferyhuang.bilinote",
"build": {
"frontendDist": "../dist",

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 />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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

@@ -8,9 +8,11 @@ interface AILogoProps {
}
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
const Icon = Icons[name as keyof typeof Icons]
const Icon = name ? Icons[name as keyof typeof Icons] : undefined
if (!Icon) {
console.error(`❌ 图标组件不存在: ${name}`)
if (name && name !== 'custom') {
console.warn(`AILogo: 未匹配到图标,使用自定义占位: ${name}`)
}
return (
<span style={{ fontSize: size }}>
<img src={CustomLogo} alt="CustomLogo" style={{ width: size, height: size }} />

View File

@@ -4,47 +4,51 @@ import styles from './index.module.css'
import { useNavigate, useParams } from 'react-router-dom'
import AILogo from '@/components/Form/modelForm/Icons'
import { useProviderStore } from '@/store/providerStore'
export interface IProviderCardProps {
id: string
providerName: string
Icon: string
enable: number
}
const ProviderCard: FC<IProviderCardProps> = ({
providerName,
Icon,
id,
enable,
}: IProviderCardProps) => {
const navigate = useNavigate()
const updateProvider = useProviderStore(state => state.updateProvider)
const handleClick = () => {
navigate(`/settings/model/${id}`)
}
const handleEnable = () => {
console.log('enable', enable)
const enabled = useProviderStore(state => state.provider.find(p => p.id === id)?.enabled)
const isChecked = enabled === 1
const handleToggle = (checked: boolean) => {
const allProviders = useProviderStore.getState().provider
const provider = allProviders.find(p => p.id === id)
if (!provider) return
updateProvider({
id,
enabled: enable == 1 ? 0 : 1,
...provider,
enabled: checked ? 1 : 0,
})
}
const rawId = useParams()
console.log('rawId', rawId)
// @ts-ignore
const { id: currentId } = useParams()
const isActive = currentId === id
return (
<div
onClick={() => {
handleClick()
}}
className={
styles.card +
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
}
>
<div className="flex items-center text-lg">
<div
className="flex items-center text-lg"
onClick={() => navigate(`/settings/model/${id}`)}
>
<div className="flex h-9 w-9 items-center">
<AILogo name={Icon} />
</div>
@@ -53,11 +57,8 @@ const ProviderCard: FC<IProviderCardProps> = ({
<div>
<Switch
onClick={e => {
e.preventDefault()
handleEnable()
}}
checked={enable == 1}
checked={isChecked}
onCheckedChange={handleToggle}
/>
</div>
</div>

View File

@@ -7,9 +7,11 @@ interface AILogoProps {
}
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
const Icon = Icons[name as keyof typeof Icons];
const Icon = name ? Icons[name as keyof typeof Icons] : undefined;
if (!Icon) {
console.error(`❌ 图标组件不存在: ${name}`);
if (name && name !== 'custom') {
console.warn(`AILogo: 未匹配到图标,使用占位: ${name}`);
}
return <span style={{ fontSize: size }}>🚫</span>;
}

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

@@ -3,11 +3,11 @@ import { Outlet } from 'react-router-dom'
const Model = () => {
return (
<div className={'flex h-full bg-white'}>
<div className={'flex-1/5 border-r border-neutral-200 p-2'}>
<div className={'flex h-full min-h-0 bg-white'}>
<div className={'flex-1/5 min-h-0 overflow-y-auto border-r border-neutral-200 p-2'}>
<Provider></Provider>
</div>
<div className={'flex-4/5'}>
<div className={'flex-4/5 min-h-0 overflow-y-auto'}>
<Outlet />
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Github, Star, ExternalLink, Download } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
import logo from '@/assets/icon.svg'
import wechatQr from '@/assets/wechat.png'
export default function AboutPage() {
const images = [
@@ -26,7 +27,7 @@ export default function AboutPage() {
height={50}
className="rounded-lg"
/>
<h1 className="text-4xl font-bold">BiliNote v1.8.1</h1>
<h1 className="text-4xl font-bold">BiliNote v2.0.0</h1>
</div>
<p className="text-muted-foreground mb-6 text-xl italic">
AI AI
@@ -197,14 +198,10 @@ export default function AboutPage() {
<h2 className="mb-8 text-center text-3xl font-bold"></h2>
<div className="mx-auto max-w-3xl">
<div className="flex flex-col items-center justify-center gap-8">
<div className="text-center">
<h3 className="mb-3 text-xl font-semibold">BiliNote QQ </h3>
<p className="text-lg font-medium">785367111</p>
</div>
<div className="text-center">
<h3 className="mb-3 text-xl font-semibold">BiliNote </h3>
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
<img src={'https://common-1304618721.cos.ap-chengdu.myqcloud.com/wechat.png'} />
<img src={wechatQr} alt="BiliNote 交流微信群" className="h-full w-full object-contain" />
</div>
</div>
</div>

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 } = {

Some files were not shown because too many files have changed in this diff Show More