78 Commits

Author SHA1 Message Date
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
163 changed files with 26518 additions and 259896 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,5 +1,6 @@
# === 前端构建阶段 ===
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

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,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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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

@@ -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

@@ -75,19 +75,19 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
getProviderById: id => get().provider.find(p => p.id === id),
updateProvider: async (provider: IProvider) => {
try {
const existing = get().provider.find(p => p.id === provider.id)
const merged = { ...existing, ...provider }
const data = {
...provider,
api_key: provider.apiKey,
base_url: provider.baseUrl,
}
const res = await updateProviderById(data)
if (res.data.code === 0) {
const item = res.data.data
console.log('Provider ', item)
await get().fetchProviderList()
...merged,
api_key: merged.apiKey,
base_url: merged.baseUrl,
}
// 拦截器已解包:成功时直接返回 data 部分
await updateProviderById(data)
await get().fetchProviderList()
} catch (error) {
console.error('Error fetching provider:', error)
console.error('Error updating provider:', error)
}
},
getProviderList: () => get().provider,

View File

@@ -1,8 +1,9 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { persist, createJSONStorage } from 'zustand/middleware'
import { delete_task, generateNote } from '@/services/note.ts'
import { v4 as uuidv4 } from 'uuid'
import toast from 'react-hot-toast'
import { get, set, del } from 'idb-keyval'
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
@@ -211,6 +212,18 @@ export const useTaskStore = create<TaskStore>()(
}),
{
name: 'task-storage',
storage: createJSONStorage(() => ({
getItem: async (name: string): Promise<string | null> => {
const value = await get(name)
return value ?? null
},
setItem: async (name: string, value: string): Promise<void> => {
await set(name, value)
},
removeItem: async (name: string): Promise<void> => {
await del(name)
},
})),
}
)
)

View File

@@ -1,11 +1,16 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { fileURLToPath } from 'url'
import tailwindcss from '@tailwindcss/vite'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd() + '/../')
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
const envDir = process.env.DOCKER_BUILD ? __dirname : path.resolve(__dirname, '../')
const env = loadEnv(mode, envDir)
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)

118
CHANGELOG.md Normal file
View File

@@ -0,0 +1,118 @@
# Changelog
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [2.1.4] - 2026-05-07
CI 工程化修复,无运行时行为变化。
### Internal
- 桌面端 Tauri 构建矩阵去掉 Linux`ubuntu-22.04 / x86_64-unknown-linux-gnu`。Linux 桌面端构建持续 17m+且无对应分发渠道Linux 用户继续可以走 Docker 镜像 (`ghcr.io/jefferyhcool/bilinote`)
- commitlint workflow 去掉无效的 `firstParent` inputwagoid/commitlint-github-action@v6 不支持,被忽略并打 warn
- 规范 release merge commit 标题:`chore(release): vX.Y.Z`(合 master/ `chore(release): merge release/X.Y.Z back into develop`(回灌 develop让 commitlint 能正确识别。`RELEASING.md` §3 与 `CONTRIBUTING.md` §6.3 同步更新
## [2.1.3] - 2026-05-07
### Fixed
- DeepSeek 等非多模态供应商被 400 拒绝issue #282`UniversalGPT.create_messages``_build_merge_messages` 此前**无条件**把 content 拼成 OpenAI 多模态数组 `[{"type":"text",...}]`DeepSeek `deepseek-chat` 等模型不识别 `image_url` 变体直接报 `invalid_request_error``GPTFactory.from_config` 一律实例化 `UniversalGPT`,所以问题覆盖**所有**通过模型设置页接入的非多模态供应商,不止 DeepSeek。
- 现按 `video_img_urls` 是否非空切换 content 形态:有图保留多模态数组(视觉模型不退化),无图退回 string。合并阶段历来不带图统一改 string。
- 与同包内 `deepseek_gpt.py` / `openai_gpt.py` / `qwen_gpt.py` 的 message builder 行为对齐。
- 新增 `backend/tests/test_universal_gpt_content_format.py` 6 个 case 回归覆盖(含 `image_url` 字面 not-in JSON 断言)。
感谢 @voidborne-d 的修复(#345)。
## [2.1.2] - 2026-05-07
补 v2.1.1 上 ghcr.io 镜像构建失败的坑。
### Fixed
- Docker 镜像构建失败v2.1.1 tag 触发的 ghcr.io 推送在 frontend-builder 第 7/7 步 `pnpm run build` 挂掉vite `loadConfigFromBundledFile` 加载 `@tailwindcss/vite` plugin 时 1.5s 内异常退出)。
- `Dockerfile.complete``BillNote_frontend/Dockerfile``node:18-alpine``node:20-alpine`Tailwind v4 已不再支持 Node 18Vite 6 也推荐 Node 20+
- `Dockerfile.complete` 的 frontend 阶段同时复制 `pnpm-lock.yaml` 并改用 `--frozen-lockfile`,杜绝每次构建重解析 semver 拉到比本地新的 native dep
- `BillNote_frontend/pnpm-lock.yaml` 强制入库(之前一直未提交,导致 CI / 本地依赖图持续漂移)
- README 联系社区段补上微信群二维码(之前只写"年会恢复更新以后放出最新社区地址"
## [2.1.1] - 2026-05-07
工程化与文档收尾,无运行时行为变化。
### Added
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — 贡献指南,落地简化 Git Flowmaster + develop + 短生命周期分支)+ 提交规范 + 合并规范
- [`RELEASING.md`](./RELEASING.md) — 发版手册Release Manager 视角),含 release/* 流程 + 各商店人工上传步骤 + 自动发布所需 secrets
- `.github/ISSUE_TEMPLATE/{config,bug_report,feature_request}.yml` — 表单形式的 issue 模板,按工作区分类
- `.github/pull_request_template.md` — PR 模板,把 CONTRIBUTING §5.2 落成 checklist
- `.commitlintrc.json` + `.github/workflows/commitlint.yml` — commitlint CIPR + push develop/master 时校验,自定义 type 白名单,兼容中文 subject
- `.github/workflows/release-extension.yml``v*` tag push 时自动构建插件 .zip / .xpi / .crx 并挂到对应 GitHub Release商店自动发布以注释形式预留
### Changed
- 关于页二维码改为 `import @/assets/wechat.png`,不再依赖腾讯云 COS CDN更新只需替换文件 + 跑构建
- 群聊 QR 替换为最新版本(`doc/wechat.png` + `BillNote_frontend/src/assets/wechat.png`
### Removed
- 关于页 QQ 群联系方式(号 785367111已不再活跃维护
- 旧版 `.md` 格式 issue 模板(被新 yml 表单模板取代)
## [2.1.0] - 2026-05-07
本次发布的主线是**浏览器插件**和 **B 站字幕优先链路**。配合一些后端 / 前端体验修复。
### Added — 浏览器插件 (`BillNote_extension/`)
全新 Chrome / Edge / Firefox MV3 扩展。Vue 3 + Vite + UnoCSS骨架基于 vitesse-webext。
- **入口四件套**
- 工具栏 popup识别当前 tab → 一键提交,紧凑展示标题 + 封面 + 进度
- 视频页悬浮按钮:仅在支持平台注入,点击即触发任务
- 右键菜单"用 BiliNote 总结此视频":限定 4 个支持域名
- 侧边栏side panel详情视图 + 三模式切换
- **侧边栏三视图**
- Markdown渲染笔记复制 / 下载 .md
- 思维导图:基于 markmap-lib + markmap-view 的可缩放 mind map
- AI 问答:复用后端 RAG `/chat/index``/chat/status``/chat/ask` 三件套,自动索引 + 多轮历史
- **设置页五大块**(搬入 web 端全部配置能力,今后插件即配置中心)
- 通用:后端地址、连通性测试、默认供应商 / 模型 / 画质 / 截图 / 跳转 / 风格
- 模型供应商:完整 CRUD、启用切换、连接测试、模型增删
- 音频转写配置fast-whisper / mlx-whisper / Groq / 必剪 / 快手 切换、Whisper 模型大小、本地下载状态、触发下载
- 下载配置:每平台 cookie 显示、浏览器一键同步、手动粘贴
- 部署监控:后端 / FFmpeg / CUDA / Whisper 状态总览
- **浏览器 cookie 直通**`chrome.cookies.getAll` 一键把 `.bilibili.com` 等域 cookie 同步到后端 `/api/update_downloader_cookie`
- **B 站字幕浏览器抓取**:插件直接调 player API 拿字幕,借 host_permissions 自动带本地登录态 cookie绕过 CORS随提交以 `prefetched_transcript` 字段附给后端,后端跳过 `download_subtitles` + 音频转写,直接进 GPT 总结
### Added — 后端
- `BilibiliSubtitleFetcher``app/downloaders/bilibili_subtitle.py`):直接调 B 站 player API 拿字幕,作为非插件场景下 yt-dlp 路径的更可靠替代
- `VideoRequest.prefetched_transcript` 字段:客户端预取的字幕直接落到 `<task_id>_transcript.json`NoteGenerator cache-hit 自动复用
### Added — 前端 Web
- Zustand persist 迁移到 IndexedDB#318
### Changed
- 后端 CORS从静态 origin 列表改为 regex兼容 `chrome-extension://``moz-extension://``localhost``tauri.localhost`
- mlx-whisper 仓库 ID 改用 `MLX_MODEL_MAP``whisper-{size}-mlx` 命名(`large-v3-turbo` 例外),不再 hardcode 出 404
- BilibiliDownloader 从 `CookieConfigManager` 读取 cookie 并注入 yt-dlp cookiefile#333
- CLAUDE.md 补充 v2.0.0 引入的子系统说明RAG / Chat、可选 Nacos+RabbitMQ、i18n、cookie/transcriber 管理器)以及浏览器插件 workspace
### Fixed
- AILogo自定义供应商`logo='custom'`)走兜底渲染时不再误报 `console.error`,未匹配的名称降级为 warn
- SettingPage `Model.tsx` 双栏布局加 `min-h-0 overflow-y-auto`:供应商列表过长时无法滚动
- 供应商开关切换不能实时生效(#336
- `/get_all_providers` 中 301 行历史伪内置脏数据清理 + `add_provider` 加防御(强制 `type='custom'`、同名查重、错误向上抛)
- `/api/task_status` 拆 ResponseWrapper插件侧进度条因未拆 `data` 全灰;同时把 `R.error` 翻译为 `status:'FAILED'`,避免 UI 卡在轮询循环
- ESLint / ESM `__dirname` 在 production build 中未定义(多个 docker / vite 配置修复)
- GitHub Actions 构建错误 + apt-get 安装失败 + 删除仓库内 ffmpeg 二进制
- 渲染时剥掉 backend 注入的 `> 来源链接URL` 行(与 web 端 MarkdownViewer 一致),导出文件保留原行便于溯源
- 侧边栏布局收紧:完成后不再渲染 8 段进度条;标题压成单行;视图切换 + 复制 / 下载并入一行;历史任务从底部 details 改为顶栏下拉
### Internal
- 新增分支策略:`develop` / `release/x.y.z` / `master` git-flow
- 备份 backend SQLite DB 前 / 清理脏数据后均落盘存档

View File

@@ -36,29 +36,58 @@ cd backend && ./build.sh # Build PyInstaller backend binary
cd BillNote_frontend && pnpm tauri build
```
### Browser Extension (Vue 3 + vitesse-webext, MV3)
```bash
cd BillNote_extension
pnpm install
pnpm dev # watch mode → ./extension/
pnpm build # production build → ./extension/
pnpm typecheck
```
Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension/`. Talks to the same backend at `http://localhost:8483` (configurable in the options page). CORS in `backend/main.py` already accepts `chrome-extension://` and `moz-extension://` via regex.
## Architecture
**Backend** (`backend/`) — FastAPI app, entry point `main.py`:
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`
- `app/services/` — Business logic: `note.py` (NoteGenerator orchestrates the full pipeline), `task_serial_executor.py` (task queue)
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`, `chat.py` (RAG Q&A on generated notes)
- `app/services/` — Business logic:
- `note.py``NoteGenerator` orchestrates the full pipeline (download → transcribe → LLM → notes)
- `task_serial_executor.py` — task queue
- `chat_service.py` + `chat_tools.py` + `vector_store.py` — RAG-based AI Q&A with Function Calling, indexing transcripts and video metadata
- `cookie_manager.py` — per-platform cookie storage; injected into yt-dlp by downloaders (e.g. Bilibili)
- `transcriber_config_manager.py` — persisted transcriber settings
- `worker_registry.py`**optional** Nacos registration + heartbeat for distributed worker mode (no-op when `NACOS_SERVER_ADDR` unset)
- `app/messaging/`**optional** RabbitMQ producer/consumer publishing task progress/results to `bilinote.task.feedback` exchange. Silently degrades when `RABBITMQ_URL` is unset; always import-safe.
- `app/downloaders/` — Platform adapters (bilibili, youtube, douyin, kuaishou, local) with shared `base.py` interface
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`. YouTube path prefers existing subtitles and skips audio download when available.
- `app/gpt/` — LLM integration with factory pattern (`gpt_factory.py`), prompt templates (`prompt.py`, `prompt_builder.py`), and `request_chunker.py` for long transcripts
- `app/db/` — SQLite + SQLAlchemy: DAO pattern (`provider_dao.py`, `model_dao.py`, `video_task_dao.py`), models in `models/`
- `app/utils/``response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX)
- `app/utils/``response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX), `ppt_generator.py`, `minio_client.py`
- `app/i18n/` — backend localization
- `events/` (root level) — Blinker signal system for post-processing (e.g., temp file cleanup after transcription)
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
- `pages/SettingPage/` — LLM provider management, system monitoring, transcriber config
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`. Persists to IndexedDB.
- `services/` — Axios API clients matching backend routes
- `hooks/useTaskPolling.ts` — Polls task status every 3 seconds
- `components/ui/` — shadcn/ui (Radix-based) components
- `i18n/``react-i18next` setup with locale JSON in `i18n/locales/`; toggled via `components/LanguageSwitcher.tsx`
- Path alias: `@``./src`
**Core Workflow**: User submits URL → task queued → download video → extract audio (FFmpeg) → transcribe (Whisper/Groq/etc) → generate notes (LLM) → frontend polls for completion → display Markdown + mind map.
**Browser Extension** (`BillNote_extension/`) — Vue 3 + Vite + UnoCSS + webextension-polyfill, MV3:
- `src/popup/Popup.vue` — main entry: detects platform from active tab URL, drives generate flow, shows progress + markdown
- `src/options/Options.vue` — settings: backend URL, default provider/model (loaded from `/get_all_providers` + `/get_models_by_provider/{id}`), quality, screenshot/link toggles, style
- `src/logic/api.ts` — backend API client (uses `settings.backendUrl`, unwraps `ResponseWrapper`, absolutizes `/static/screenshots/...` image paths)
- `src/logic/storage.ts``chrome.storage.local`-backed Pinia-like state via `useWebExtensionStorage` for settings + last 30 tasks
- `src/logic/platform.ts` — URL → platform detection mirroring `backend/app/validators/video_url_validator.py`
- `src/sidepanel/`, `src/contentScripts/` — placeholders for P2/P3 (floating button, side panel mind map, RAG chat); not wired into MVP UX
- `src/manifest.ts` — MV3 manifest, popup is default action; `host_permissions: *://*/*`
- Polling lives client-side in popup (3 s interval while open); MV3 service worker is intentionally thin in P1
## Key Configuration
- **Ports**: Backend 8483, Frontend dev 3015, Docker maps 3015→80
@@ -66,6 +95,7 @@ cd BillNote_frontend && pnpm tauri build
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
- **FFmpeg**: Required system dependency for video/audio processing
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir)
- **Distributed mode (optional)**: Setting `NACOS_SERVER_ADDR` enables Nacos worker registration; setting `RABBITMQ_URL` enables MQ feedback. Both are no-ops when unset — single-node deployment works without either. Other knobs: `WORKER_ID`, `WORKER_SELF_URL`, `WORKER_MAX_CONCURRENT`, `TASK_MAX_WORKERS`.
## Code Style

341
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,341 @@
# 贡献指南
欢迎为 BiliNote 贡献代码。本文档约定分支管理、提交规范、合并流程。新贡献者请通读一遍后再开 PR。
> 关联文档
> - [README.md](./README.md):项目概览、快速开始
> - [CLAUDE.md](./CLAUDE.md):仓库结构 + 各 workspace 开发命令
> - [CHANGELOG.md](./CHANGELOG.md):版本变更记录
> - [RELEASING.md](./RELEASING.md)发版执行手册Release Manager 视角)
---
## 1. 仓库结构与工作区
本仓库为多工作区单体仓:
| 路径 | 内容 | 主要命令 |
|---|---|---|
| `backend/` | Python 3.11 + FastAPI | `pip install -r requirements.txt && python main.py` |
| `BillNote_frontend/` | React 19 + Vite | `pnpm install && pnpm dev` |
| `BillNote_extension/` | Vue 3 + Vite + WebExtension MV3 | `pnpm install && pnpm dev` |
详细结构与开发命令见 [CLAUDE.md](./CLAUDE.md)。提交时**单 PR 不要跨多个工作区做无关改动**,便于评审与回滚。
---
## 2. 分支模型
采用简化 Git Flow稳定主干 `master` + 长期开发集成 `develop` + 短生命周期业务分支。
| 分支类型 | 命名 | 长期保留 | 创建来源 | 合并去向 | 用途 |
|---|---|---|---|---|---|
| 生产主干 | `master` | ✅ | 仓库默认分支 | — | 始终保持可发布状态;只接收 `release/*``hotfix/*` 合入 |
| 开发主干 | `develop` | ✅ | `master` | `release/*` 回灌后 | 日常需求集成;常规开发都从这里起 |
| 功能分支 | `feature/*` | ❌ | `develop` | `develop` | 新功能 / 需求开发 |
| 修复分支 | `fix/*` | ❌ | `develop` | `develop` | 开发期间发现的缺陷修复(非线上问题) |
| 发布分支 | `release/*` | ❌ | `develop` | `master` + `develop` | 版本冻结、回归、发版准备 |
| 热修复 | `hotfix/*` | ❌ | `master` | `master` + `develop` | 线上紧急问题 |
### 基本原则
- `master` 只保存**已发布代码**,不接受日常开发提交。
- `develop` 是**唯一**长期开发集成分支。
- 所有业务开发**必须**用短生命周期分支,禁止长期占用个人开发分支。
- 一个分支只承载一个需求或一类明确的修复事项。
---
## 3. 分支命名
### 命名格式
```
feature/<scope>-<事项>
fix/<scope>-<事项>
release/<版本号>
hotfix/<scope>-<事项>
```
`<scope>` 优先用代码 scope与 commit message scope 对齐,见 §5常用
- `extension` — 浏览器插件(`BillNote_extension/`
- `frontend` — Web 前端(`BillNote_frontend/`
- `backend` — Python 后端(`backend/`
- `bilibili` / `youtube` / `douyin` / `kuaishou` — 平台特定改动
- `transcriber` — 音频转写
- `gpt` / `chat` — LLM / RAG
- `docker` / `ci` — 构建与发布
- `docs` — 文档
### 命名示例
```bash
# 功能开发
feature/extension-side-panel
feature/youtube-subtitle-innertube
feature/backend-rag-chat
# 开发期修复
fix/extension-task-status-unwrap
fix/bilibili-cookie-injection
fix/mlx-whisper-repo-id
# 发版
release/2.1.0
release/2.2.0
# 线上热修
hotfix/backend-cors-regex
hotfix/frontend-provider-switch
```
### 命名要求
- 全小写字母 / 数字 / 中划线
- `<事项>` 要表达**具体行为**,避免 `test` / `update` / `temp` / `wip` 这类无意义名
- `release/<版本号>` 必须与实际 tag 一致(如 `release/2.1.0``v2.1.0`
---
## 4. 标准协作流程
### 4.1 日常需求开发
```bash
git checkout develop
git pull origin develop
git checkout -b feature/<scope>-<事项>
# … 开发 + 自测 + commit …
git push -u origin feature/<scope>-<事项>
# 在 GitHub 上发起 PRbase = developcompare = 你的分支
```
合并通过且 PR closed 后,**删除本地与远端分支**
```bash
git branch -d feature/<scope>-<事项>
git push origin --delete feature/<scope>-<事项>
```
### 4.2 开发期缺陷修复
提测或联调中发现问题时,从 `develop``fix/*`。**不要在原 `feature/*` 上长期叠加零散修复**。
适用场景:
- 已合入 `develop` 后被测试打回的问题
- 多个功能集成后暴露的兼容性问题
- 非线上环境问题
### 4.3 版本发布
```bash
# 1. 从 develop 切 release
git checkout develop && git pull origin develop
git checkout -b release/<版本号>
# 2. 在 release 分支上:更新 README 版本号、写 CHANGELOG.md、必要的小修
git commit -am "docs: <版本号> CHANGELOG + README 版本"
git push -u origin release/<版本号>
# 3. 进入冻结期PR base=master 合并;同时 PR base=develop 回灌
# 4. master 上打 tag
git checkout master && git pull
git tag -a v<版本号> -m "BiliNote v<版本号>" && git push origin v<版本号>
# 5. release 分支已合入两边,删除
git push origin --delete release/<版本号>
```
要点:
- **冻结期内 `release/*` 不再合入新需求**,只允许修复发布缺陷。
- 发版窗口期内的新需求继续基于 `develop` 开发,**不进入当前 `release/*`**。
- 发布完成后必须把 `release/*` 同步回 `develop`,避免漏修。
### 4.4 线上紧急修复
```bash
git checkout master && git pull
git checkout -b hotfix/<scope>-<事项>
# … 修 + commit …
# PR base=master合入后立刻打 patch tag如 v2.1.1)发版
# 同一改动同时 PR base=develop 回灌
```
要点:
- `hotfix/*` 仅处理**线上阻断性 / 高优先级**缺陷。
- 非紧急问题不得绕过 `develop` 直接走热修流程。
- 若当前存在 `release/*` 即将发布,需评估是否同步到对应 release避免修复丢失。
---
## 5. 提交规范
### 5.1 Commit message 格式
> CI 已接入 [commitlint](https://commitlint.js.org)[`.commitlintrc.json`](./.commitlintrc.json) + [`.github/workflows/commitlint.yml`](./.github/workflows/commitlint.yml))。
> PR 上所有 commit 都会被校验type 不在白名单时合并按钮会被卡。
```
type(scope): subject
```
例:
```
feat(extension): 侧边栏接入思维导图markmap与 RAG 问答
fix(bilibili): 修正字幕优先链路在未登录态下的回退
docs(contributing): 新增贡献指南
chore(ci): 优化 docker 构建缓存
```
#### type
| type | 说明 |
|---|---|
| `feat` | 新功能 |
| `fix` | 缺陷修复 |
| `docs` | 文档变更 |
| `style` | 代码风格调整(不影响行为) |
| `refactor` | 重构(非 feat / 非 fix |
| `perf` | 性能优化 |
| `test` | 测试增删 |
| `build` | 构建系统 / 依赖变更 |
| `ci` | CI 配置变更 |
| `chore` | 杂项(不归入以上类别) |
| `ui` | 界面 / 交互层调整(仓库内既有用法) |
| `revert` | 回滚 |
#### scope
与 §3 的分支 scope 保持一致:`extension` / `frontend` / `backend` / `bilibili` / `youtube` / `douyin` / `kuaishou` / `transcriber` / `gpt` / `chat` / `docker` / `ci` / `docs` 等。
#### subject
- 用中文或英文都可以,**保持一种风格**。
- 用现在时陈述("新增 X" / "修复 Y" / "Add X" / "Fix Y")。
- 首字母不大写,结尾不加句号。
- 单行控制在 72 字符以内;如需详细说明,正文与标题之间空一行。
### 5.2 PR 标题与正文
- **PR 标题**沿用 commit message 格式,描述本次 PR 的总体改动。
- **PR 正文**应包含:
- 改动的"为什么"(背景 / issue / 用户场景)
- 改动的"做了什么"(关键文件、关键决策)
- **测试方式**(如何验证、覆盖了哪些 case
- **回归风险**与影响面
- 是否需要后端 / 前端 / 配置同步部署
---
## 6. 合并规范
### 6.1 合并前要求
- 合并前必须**先同步**目标分支最新代码(`git pull --rebase` 或在 PR 上点 "Update branch")。
- 合并前必须完成**自测**,确保核心流程可用。
- 后端改动需 `python -m py_compile` 至少通过;前端 / 插件改动需 `pnpm typecheck && pnpm build` 通过。
- **冲突由分支负责人解决**,不得留给评审人或发版人员。
### 6.2 评审
- 默认通过 PR 合并,**不允许**绕过 PR 直接 push 到 `master``develop`
- 评审人至少关注:业务影响、回归风险、是否夹带无关改动、目录归属是否合理。
- 修文档 / 改注释这种小变更允许 1 人评审通过;改业务逻辑 / 协议 / 共享模块至少 2 人评审。
### 6.3 合并方式
- `feature/*` / `fix/*` 合入 `develop`:推荐 **Squash and merge**,保持 develop 历史线性。
- `release/*` 合入 `master` 与回灌 `develop`:使用 **Merge commit (--no-ff)**,保留发版结构。
· merge commit 标题用 `chore(release): vX.Y.Z`(合 master`chore(release): merge release/X.Y.Z back into develop`(回灌 develop保证 commitlint 通过。
- `hotfix/*` 同上 release。
### 6.4 合并后
- 短期分支合并完成后**必须删除**(远端 + 本地)。
- 已完成的分支不得继续承接新需求;如需后续迭代请重新基于最新目标分支开新分支。
---
## 7. Git 钩子注意事项
`BillNote_extension/` 早期使用 `simple-git-hooks``postinstall`,会在仓库根目录 `.git/hooks/pre-commit` 注入 `pnpm lint-staged`,但仓库根没有 `package.json`,导致**任何 commit 都被钩子卡死**。**已在 v2.1.0 起移除**该 postinstall 配置。
如果你机器上还残留旧版本装下来的 hook
```bash
# 一次性清理
rm .git/hooks/pre-commit
# 或临时绕过本次 commit
SKIP_SIMPLE_GIT_HOOKS=1 git commit -m "..."
```
---
## 8. 版本号与 CHANGELOG
- 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)`MAJOR.MINOR.PATCH`
- `MAJOR`:破坏性 API 变更或重大重构
- `MINOR`:新增特性、向后兼容
- `PATCH`bug 修复、向后兼容
- 每次发版**必须更新 [CHANGELOG.md](./CHANGELOG.md)**,按 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式分类Added / Changed / Fixed / Removed / Security / Internal
- 发版同时更新 `README.md` 顶部的版本号与"v\<版本号\> 新增"摘要段。
- `master` 上打 tag 形如 `vX.Y.Z`,注释中包含本次发布主线 + 引用 `CHANGELOG.md`
---
## 9. 禁止事项
- ❌ 直接在 `master` 上开发、提交、修复普通问题
- ❌ 新增长期 `dev-*` / `wip-*` / `<姓名>-dev` 个人分支作为日常协作分支
- ❌ 一个分支同时承载多个需求 / 多个缺陷 / 跨版本内容
- ❌ 未评审、未自测、未通过基础校验直接合并
-`release/*` 冻结后继续混入新需求
-`hotfix/*` 只合入 `master` 而不回灌 `develop`
- ❌ 提交 / 仓库内包含密钥、API key、`.env` 等敏感文件
- ❌ 提交 `node_modules/` / `dist/` / `extension/dist/` / `__pycache__/` / 大型二进制(参考各级 `.gitignore`
---
## 10. 历史分支迁移
仓库历史上存在多条已合并但未删除的分支(见 `git branch -a`)。即日起:
- 不再创建 `dev-*` / `<姓名>-*` 个人分支
- 已合入主干的旧分支,由发版人统一清理
- 未完成需求应尽快迁移到符合 §3 命名规范的新分支
---
## 11. 推荐流程图
```text
master ← hotfix/* (线上紧急修复)
↑ ↑
│ │
release/* ← develop ← feature/* (功能开发)
│ ↑
└───────────┘
fix/* (开发期修复)
回灌
```
---
## 12. 执行口径速查
| 场景 | 流程 |
|---|---|
| 新功能 | `develop``feature/*``develop` |
| 提测后发现问题 | `develop``fix/*``develop` |
| 版本发布 | `develop``release/*``master` + `develop`;打 tag |
| 线上紧急故障 | `master``hotfix/*``master` + `develop`;打 patch tag |
| 发版期内新需求 | 基于 `develop``feature/*`**不**进入当前 `release/*` |
---
如有改进建议,欢迎开 PR 修订本文档(`docs(contributing): ...`)。

View File

@@ -4,11 +4,12 @@ FROM python:3.11-slim AS backend-builder
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
RUN rm -f /etc/apt/sources.list && \
RUN set -ex && \
rm -f /etc/apt/sources.list && \
rm -rf /etc/apt/sources.list.d/* && \
echo "deb https://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*
@@ -25,17 +26,23 @@ RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
COPY ./backend /tmp/backend
# === 阶段2构建 Frontend ===
FROM node:18-alpine AS frontend-builder
# Node 18-alpine 跑不动 Tailwind v4 / Vite 6前者要求 Node 20+,后者推荐 Node 20+
# 升到 node:20-alpine。alpine 走 muslpnpm 会按 lockfile 拉 *-linux-x64-musl native binary。
FROM node:20-alpine AS frontend-builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /tmp/frontend
# 先复制 lockfile 利用依赖层缓存
# 先复制 package.json + lockfile 利用依赖层缓存
# --frozen-lockfile 保证 CI 与本地开发依赖版本一致,杜绝 semver 漂移引入的破坏性升级
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY ./BillNote_frontend /tmp/frontend
# 设置环境变量,告诉 vite.config.ts 这是 Docker 构建
ENV DOCKER_BUILD=1
RUN pnpm run build
# === 阶段3完整应用镜像 ===
@@ -44,11 +51,12 @@ FROM python:3.11-slim
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
# 安装必要的运行时依赖
RUN rm -f /etc/apt/sources.list && \
RUN set -ex && \
rm -f /etc/apt/sources.list && \
rm -rf /etc/apt/sources.list.d/* && \
echo "deb https://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg nginx supervisor procps && \
rm -rf /var/lib/apt/lists/*

118
README.md
View File

@@ -3,17 +3,17 @@
<p align="center">
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
</p>
<h1 align="center" > BiliNote v1.8.1</h1>
<h1 align="center" > BiliNote v2.1.4</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
<p align="center">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" />
<img src="https://img.shields.io/badge/frontend-react-blue" />
<img src="https://img.shields.io/badge/frontend-react%2019-blue" />
<img src="https://img.shields.io/badge/backend-fastapi-green" />
<img src="https://img.shields.io/badge/GPT-openai%20%7C%20deepseek%20%7C%20qwen-ff69b4" />
<img src="https://img.shields.io/badge/docker-compose-blue" />
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" />
<img src="https://img.shields.io/badge/status-active-success" />
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
</p>
@@ -22,30 +22,80 @@
## ✨ 项目简介
BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、YouTube、抖音等视频链接自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。
BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、YouTube、抖音等视频链接自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。
## 📝 使用文档
详细文档可以查看[这里](https://docs.bilinote.app/)
## 体验地址
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
## 📦 Windows 打包版
本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.1.1)进行下载。**注意一定要在没有中文路径的环境下运行。**
## 📦 桌面版下载
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
> Windows 用户请注意:一定要在没有中文路径的环境下运行。
## 🔧 功能特性
- 支持多平台Bilibili、YouTube、本地视频、抖音(后续会加入更多平台)
- 支持多平台Bilibili、YouTube、本地视频、抖音、快手
- 支持返回笔记格式选择
- 支持笔记风格选择
- 支持多模态视频理解
- 支持多版本记录保留
- 支持自行配置 GPT 大模型
- 本地模型音频转写(支持 Fast-Whisper
- 支持自行配置 GPT 大模型OpenAI、DeepSeek、Qwen 等)
- 本地模型音频转写(支持 Fast-Whisper、MLX-Whisper、Groq、BCut
- GPT 大模型总结视频内容
- 自动生成结构化 Markdown 笔记
- 可选插入截图(自动截取)
- 可选内容跳转链接(关联原视频)
- 任务记录与历史回看
- 基于 RAG 的笔记内容 AI 问答(支持 Function Calling
- 笔记顶部视频封面 Banner 展示
- 工作区和生成历史面板支持折叠/展开
### v2.1.4 修订
- CI桌面端 Tauri 构建去掉 Linux17m+ 慢线退役Linux 用户继续走 Docker 镜像)
- CIcommitlint workflow 修复 + 规范 release merge commit 标题约定
### v2.1.3 修订
- 修复 DeepSeek 等非多模态供应商被 400 拒绝的问题issue #282`UniversalGPT` 的 message builder 按是否带图切换 string / 多模态数组形态
- 感谢 @voidborne-d (#345)
### v2.1.2 修订
- 修复 v2.1.1 触发的 ghcr.io Docker 镜像构建失败Node 18 + Tailwind v4 不兼容、缺 lockfile
- README 补上微信群二维码
### v2.1.1 修订
- 工程化与文档收尾CONTRIBUTING.md / RELEASING.md / issue + PR 模板 / commitlint CI / 插件发版工作流
- 关于页群聊二维码:换成最新版,改为 import 本地资源,不再依赖 CDN
- 关于页移除 QQ 群入口(仅保留微信群)
- 详见 [CHANGELOG.md](./CHANGELOG.md)
### v2.1.0 新增
- 浏览器插件Chrome / Edge / Firefox MV3—— 工具栏 popup、视频页悬浮按钮、右键菜单、侧边栏Markdown / 思维导图 / AI 问答)四件套
- 插件设置页五大块:模型供应商 CRUD、音频转写配置、下载配置含浏览器 Cookie 一键同步)、部署监控
- B 站字幕优先:插件在用户浏览器里直接抓字幕(带本地登录态 cookie跳过后端音频转写
- 后端 `BilibiliSubtitleFetcher`:非插件场景下走 player API 拿字幕,作为 yt-dlp 兜底
- mlx-whisper 仓库 ID 修正(修复模型 404
- 后端 CORS 改用 regex兼容浏览器扩展源
- 详见 [CHANGELOG.md](./CHANGELOG.md)
### v2.0.0 新增
- 基于 RAG 的笔记内容 AI 问答功能,支持半屏/全屏模式
- AI 问答支持 Function Calling模型可主动查询原文数据
- RAG 索引支持视频元信息(标题、作者、简介、标签等)
- AI 回复支持 Markdown 渲染
- 笔记顶部新增视频封面 Banner
- 工作区和生成历史面板支持折叠/展开
- 笔记开头添加来源链接功能
- YouTube 字幕优先获取,有字幕时跳过音频下载
- 性能优化与转写器配置改进
## 📸 截图预览
![screenshot](./doc/image1.png)
@@ -56,7 +106,34 @@ BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、Y
## 🚀 快速开始
### 1. 克隆仓库
### 方式一Docker 部署(推荐)
确保已安装 Docker直接拉取预构建镜像运行
```bash
docker pull ghcr.io/jefferyhcool/bilinote:latest
docker run -d -p 80:80 \
-v bilinote-data:/app/backend/data \
--name bilinote \
ghcr.io/jefferyhcool/bilinote:latest
```
访问:`http://localhost`
也可以使用 docker-compose 本地构建:
```bash
# 标准部署
docker-compose up -d
# GPU 加速部署(需要 NVIDIA GPU
docker-compose -f docker-compose.gpu.yml up -d
```
### 方式二:源码部署
#### 1. 克隆仓库
```bash
git clone https://github.com/JefferyHcool/BiliNote.git
@@ -64,7 +141,7 @@ cd BiliNote
mv .env.example .env
```
### 2. 启动后端FastAPI
#### 2. 启动后端FastAPI
```bash
cd backend
@@ -72,7 +149,7 @@ pip install -r requirements.txt
python main.py
```
### 3. 启动前端Vite + React
#### 3. 启动前端Vite + React
```bash
cd BillNote_frontend
@@ -80,11 +157,12 @@ pnpm install
pnpm dev
```
访问:`http://localhost:5173`
访问:`http://localhost:3015`
## ⚙️ 依赖说明
### 🎬 FFmpeg
本项目依赖 ffmpeg 用于音频处理与转码,必须安装:
本项目依赖 ffmpeg 用于音频处理与转码,源码部署时必须安装:
```bash
# Mac (brew)
brew install ffmpeg
@@ -96,6 +174,8 @@ sudo apt install ffmpeg
# 请从官网下载安装https://ffmpeg.org/download.html
```
> ⚠️ 若系统无法识别 ffmpeg请将其加入系统环境变量 PATH
>
> Docker 部署已内置 FFmpeg无需额外安装。
### 🚀 CUDA 加速(可选)
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
@@ -134,12 +214,18 @@ docker-compose -f docker-compose.gpu.yml up -d
- [x] 支持抖音及快手等视频平台
- [x] 支持前端设置切换 AI 模型切换、语音转文字模型
- [x] AI 摘要风格自定义(学术风、口语风、重点提取等)
- [ ] 笔记导出为 PDF / Word / Notion
- [x] 加入更多模型支持
- [x] 加入更多音频转文本模型支持
- [x] 基于 RAG 的笔记内容 AI 问答
- [ ] 笔记导出为 PDF / Word / Notion
### Contact and Join-联系和加入社区
年会恢复更新以后放出最新社区地址
扫码加入 BiliNote 交流微信群(如二维码失效,请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈):
<p align="center">
<img src="./doc/wechat.png" alt="BiliNote 交流微信群" width="240" />
</p>

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