138 Commits

Author SHA1 Message Date
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
huangjianwu
3582e65dc5 feat(ci): 桌面端构建自动创建 GitHub Release 并附带安装包
- 新增 release job:等所有平台构建完成后自动创建 Release
- 收集各平台产物(dmg/msi/exe/deb/AppImage)到统一目录
- 使用 softprops/action-gh-release 创建 Release 并上传产物
- 自动生成 SHA256 校验和文件
- 自动根据 commits 生成 Release Notes
- 仅在推送 tag 时触发 Release 创建

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:58:57 +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
huangjianwu
f6a3438079 feat(build): 全面优化打包流程,Docker 镜像自动发布到 GHCR
Docker 优化:
- Dockerfile 层缓存(requirements/lockfile 先复制再安装)
- ARG 可配置镜像源,国际用户可覆盖为默认源
- 前端 Dockerfile 改用 corepack + frozen-lockfile
- 精简 .dockerignore,排除 .git 和 Tauri 构建产物

CI/CD 优化:
- docker-build 自动推送到 GHCR,支持 amd64/arm64 双架构
- 桌面端 CI 增加 pip/pnpm/cargo 缓存,升级 actions 版本
- Python 版本对齐为 3.11,增加 Linux 构建矩阵
- build.sh 加 -y 覆盖标志

文档更新:
- README Docker 部署简化为 docker pull + docker run

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:48:34 +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
Jianwu Huang
5861ef4168 Merge pull request #300 from JefferyHcool/feature/ui-optimize
feat(ui): 工作区和生成历史面板支持折叠/展开
2026-03-23 16:12:32 +08:00
huangjianwu
27758f95dd feat(ui): 工作区和生成历史面板支持折叠/展开
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:09:57 +08:00
Jianwu Huang
a2ab457f75 Merge pull request #299 from JefferyHcool/feature/note-qa-chat-optimize
Feature/note qa chat optimize
2026-03-23 16:00:15 +08:00
huangjianwu
795615f0f7 fix(ui): 修复 Banner 封面图不显示
MarkdownViewer 的 baseURL 去掉了 /api 前缀,导致
image_proxy 请求路径错误。改为组件内部直接读取
VITE_API_BASE_URL,与 NoteHistory 保持一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:59:02 +08:00
huangjianwu
c46c971e64 fix(ui): Banner 封面通过后端代理加载,移除来源链接模块
- 封面图通过 /image_proxy 代理请求,解决 B 站等 CDN 跨域问题
- 渲染 markdown 时过滤掉开头的「来源链接」行,
  该信息已由 VideoBanner 的「原视频」按钮替代

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:56:48 +08:00
huangjianwu
55cc3bcd63 feat(ui): 笔记顶部新增视频封面 Banner
- 视频封面做模糊暗色背景,上方叠加视频信息
- 显示视频标题、作者/UP主、平台
- 右侧「原视频」按钮跳转原始链接
- 无封面时降级为渐变背景

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:54:36 +08:00
huangjianwu
05877a2197 feat(chat): 支持 function calling,模型可主动查询原文数据
新增三个工具供 LLM 调用:
- lookup_transcript: 查询转录原文(按时间范围、关键词、位置筛选)
- get_video_info: 获取视频元信息(标题、作者、简介、标签等)
- get_note_content: 获取完整笔记 Markdown 内容

实现 tool calling 循环(最多 3 轮),LLM 可根据问题
主动调用工具获取所需信息,不再完全依赖 RAG 检索。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:48:23 +08:00
huangjianwu
3e9f908d7b fix(chat): 按固定配额检索,确保三种来源均被召回
之前各来源各取 n_results 条再按距离排序取 top-n,
markdown 距离普遍更近导致 transcript 被挤掉。
改为固定配额:meta 1 条、markdown 2 条、transcript 3 条。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:44:25 +08:00
huangjianwu
8a8e448e22 feat(chat): 索引视频元信息(标题、作者、简介、标签等)
- 新增 _build_meta_chunk,将 audio_meta 中的标题、UP主、
  简介、标签、时长、平台、链接等构建为可检索的 chunk
- query 时同时从 meta/markdown/transcript 三种来源检索
- is_indexed 检测旧索引缺少 meta 时返回 false,自动触发重建
- system prompt 新增 [视频信息] 来源说明

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:41:07 +08:00
huangjianwu
a92c779dd6 fix(chat): RAG 检索同时召回笔记和转录内容
之前 query 只做一次全局检索,embedding 模型倾向匹配笔记,
导致转录原文几乎不会被召回。

- 改为分别对 markdown 和 transcript 各检索 n_results 条,
  合并后按距离排序取 top-n
- 更新 system prompt,明确区分笔记和转录两种来源,
  引导 LLM 根据问题类型选择合适的来源回答

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:35:31 +08:00
huangjianwu
ef1dec1e47 feat(chat): AI 回复支持 Markdown 渲染
通过 Bubble role 的 contentRender 使用 ReactMarkdown
渲染 AI 回复内容,支持列表、加粗、代码块等格式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:31:55 +08:00
huangjianwu
fea376d1cb fix(chat): 修复 avatar 传对象导致 React 渲染报错
avatar 属性类型是 ReactNode,不是 props 对象,
改为直接传 JSX 元素作为头像。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:30:15 +08:00
huangjianwu
b18277a3a0 fix(chat): 修复消息气泡左右布局不生效
- Bubble.List 的角色配置属性名是 role(单数)而非 roles
- 用户消息:右侧蓝色填充气泡 + 蓝色头像
- AI 回复:左侧描边气泡 + 灰色头像

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:29:09 +08:00
huangjianwu
d8fbceaadf refactor(chat): 全屏/半屏切换移入 ChatPanel 内部
- Header 恢复单个"AI 问答"按钮,点击默认打开半屏模式
- ChatPanel 头部新增全屏/半屏切换按钮(Maximize2/Minimize2 图标)
- 半屏:markdown 与问答并排各占一半
- 全屏:问答占满内容区,隐藏 markdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:23:55 +08:00
huangjianwu
dea393e713 feat(chat): 问答面板支持半屏和全屏两种模式
- 半屏模式:ChatPanel 与 markdown 各占一半并排显示
- 全屏模式:ChatPanel 占满整个内容区域,隐藏 markdown
- Header 新增两个按钮(问答 / 全屏问答),点击切换,再次点击关闭
- 当前激活的模式按钮高亮显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:20:07 +08:00
huangjianwu
ae2bfe4d0a fix(chat): 修复 ChatPanel 不显示的布局问题
- ChatPanel 容器添加 h-full shrink-0 确保有高度且不被压缩
- ScrollArea 从 w-full 改为 flex-1 min-w-0,为 ChatPanel 让出空间

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:15:14 +08:00
huangjianwu
2f2eb646a4 fix(chat): 索引改为后台异步执行,前端轮询状态并展示进度提示
后端:
- /chat/index 改为 BackgroundTasks 异步执行,立即返回
- /chat/status 返回细粒度状态(idle/indexing/indexed/failed)
- 内存追踪索引进度,避免重复触发

前端:
- ChatPanel 每 2 秒轮询索引状态,索引完成后自动停止
- 索引中显示"正在索引笔记内容..."及首次下载模型提示
- 索引失败显示重试按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:46:37 +08:00
huangjianwu
fdc888512a fix(chat): 修复 ChromaDB 1.x 兼容性问题导致索引失败
- ChromaDB 1.x delete/get 不存在的 collection 抛 NotFoundError
  而非 ValueError,统一改为 except Exception
- 简化 _collection_name,UUID 格式本身就是合法的 collection name
- requirements.txt 放宽 chromadb 版本约束

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:43:57 +08:00
huangjianwu
3cd4c749c1 fix(chat): 修复 ChatPanel 无限重渲染导致的 Maximum update depth exceeded
- useTaskStore 选择器内调用 getCurrentTask() 每次返回新对象引用,
  改为分别选取 currentTaskId + tasks 后用 useMemo 派生
- chatHistory[taskId] || [] 在选择器内每次创建新空数组引用,
  改为选择器返回原始值,外部用 ?? [] 兜底

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:40:47 +08:00
huangjianwu
efadbc267d feat(chat): 基于 RAG 的笔记内容 AI 问答功能
实现类似 Google NotebookLM 的效果:笔记生成后自动向量化,
用户可针对笔记内容进行 LLM 问答。

### 后端
- 新增 VectorStoreManager(ChromaDB),按标题/转录分块建立向量索引
- 新增 chat_service.py RAG 问答:检索相关片段 → 构建 prompt → 调用 LLM
- 新增 /chat/index, /chat/ask, /chat/status API 端点
- 笔记生成完成后自动建立向量索引

### 前端
- 使用 @ant-design/x Bubble.List + Sender 组件构建聊天面板
- 新增 chatStore(Zustand + persist)持久化聊天记录
- MarkdownViewer 右侧嵌入 ChatPanel,通过"AI 问答"按钮切换
- 首次打开自动检查/触发索引,支持重新索引

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:38:39 +08:00
Jianwu Huang
63b8ac7e2b Merge pull request #298 from JefferyHcool/bugfix/performance-and-transcriber-config
fix: 性能优化、前端转写器配置、任务进度丢失及 MLX Whisper 回退问题修复
2026-03-23 14:23:03 +08:00
huangjianwu
c105342ded fix: 性能优化、前端转写器配置、任务进度丢失及 MLX Whisper 回退问题修复
### 性能优化
- 后端任务执行从串行锁改为 ThreadPoolExecutor 并发执行(默认3线程)
- 添加 GZipMiddleware 响应压缩 + Nginx gzip 配置
- 数据库连接池参数优化(pool_size=10, max_overflow=20)
- 视频帧提取并行化(ThreadPoolExecutor)
- LLM 重试配置缓存到实例,避免每次请求读 env var
- 前端路由级代码拆分(React.lazy + Suspense)
- Vite manualChunks 拆分 markdown/markmap/vendor
- MarkdownViewer 用 React.memo + useMemo 减少不必要渲染
- NoteHistory Fuse.js 实例 useMemo 缓存
- useTaskPolling 无待处理任务时跳过轮询
- 移除 antd 依赖(NoteForm Alert、modelForm Tag),改用 shadcn/ui

### 前端转写器配置(新功能)
- 新增 TranscriberConfigManager(JSON 文件存储,替代环境变量)
- 新增 GET/POST /transcriber_config API 端点
- 新增 GET /transcriber_models_status 模型下载状态查询
- 新增 POST /transcriber_download 后台模型下载触发
- 前端转写器设置页面:引擎选择、模型大小选择、模型下载管理
- deploy_status 端点同步从配置文件读取

### Bug 修复
- 修复任务进行中切换页面后进度丢失:Home.tsx status 派生逻辑补全中间状态
- 修复 MLX Whisper 静默回退 fast-whisper:移除环境变量门控,macOS 下自动尝试导入
- MLX Whisper 不可用时抛出 RuntimeError 而非静默回退
- 前端展示 MLX Whisper 可用性状态,不可用时禁用保存

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:09:34 +08:00
huangjianwu
1cd8c33983 feat(note): 在笔记开头添加来源链接功能 2026-03-23 13:18:40 +08:00
Jianwu Huang
dd73e56c30 Merge pull request #273 from Sjshi763/Sjshi763/issue259
添加 Docker 构建工作流
2026-03-20 16:51:16 +08:00
Jianwu Huang
4a53b6aa32 Merge pull request #279 from CyanAutumn/master
🐞 fix: 增加错误之后对已解析段落的缓存功能,再次重试时不再重头开始
2026-03-20 16:50:52 +08:00
Jianwu Huang
15d851f0d0 Merge branch 'master' into master 2026-03-20 16:50:43 +08:00
Jianwu Huang
8172e64510 Merge pull request #275 from sibuchen/feature/deployment-monitor-clean
feat: 新增部署监控页面 (Deployment Monitor)
2026-03-20 16:49:46 +08:00
Jianwu Huang
7969d9a75c Merge pull request #283 from wanderer99176/fix-timestamp-format
再次优化 B站时间戳跳转格式
2026-03-20 16:49:13 +08:00
wanderer99176
7fb4fcba77 fix: update bilibili timestamp link format to - [MM:SS](URL#t=MM:SS) 2026-02-25 11:57:16 +08:00
CyanAutumn
d9a7b89e7d 🐞 fix: 增加错误之后对已解析段落的缓存功能,再次重试时不再重头开始
解析长视频时,当附件大小过大时不再调用后进行报错,而是将附件进行分批次发送

在每篇笔记开头默认增加地址来源链接,对模糊处可溯源
2026-02-12 18:28:11 +08:00
sibuchen
8cd8c6f7b4 feat: add deployment monitor page
- Add /deploy_status API endpoint for system status check
- Create Monitor.tsx component with real-time status display
- Support CUDA, FFmpeg, Whisper model status monitoring
- Auto-refresh every 30 seconds with manual refresh option
2026-02-06 16:15:11 +08:00
圣达生物多
769aca10db 添加 Docker 构建工作流和完整应用镜像的 Dockerfile 2026-02-05 21:34:57 +08:00
Jianwu Huang
7b45db2f59 Update README.md 2026-02-05 16:50:51 +08:00
Jianwu Huang
a5f0211fcb Merge pull request #262 from Sjshi763/Sjshi763/issue232
[BUG] 已经把ffmpeg加入到系统变量path了 还是检测不出来 ?
2026-02-05 16:05:03 +08:00
Jianwu Huang
658d29e72f Merge pull request #268 from nbzcy/feature/subtitle-priority-and-export-enhancements
feat: Add subtitle priority fetching and enhance mindmap export
2026-02-05 16:04:38 +08:00
Jianwu Huang
2b3f850478 Merge pull request #271 from kxuer/master
修复哔哩哔哩视频原片url问题
2026-02-05 16:04:23 +08:00
xuerk
caa4619aab 修复哔哩哔哩视频原片url问题 2026-02-05 15:41:04 +08:00
sunnyclubcn
85b24dee40 feat: Add subtitle priority fetching and enhance mindmap export
## Subtitle Priority (Backend)
- Add download_subtitles() method to base downloader
- Implement Bilibili subtitle fetching with cookies support
- Implement YouTube subtitle fetching
- Support SRT and JSON3 format parsing
- Prioritize platform subtitles over Whisper transcription

## Mindmap Export Enhancements (Frontend)
- Add SVG vector export with proper viewBox handling
- Add XMind format export with Chinese character encoding fix
- Fix PNG/SVG export to capture full content by calling fit() before export
- Add JSZip dependency for XMind export

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 17:12:14 +08:00
Sjshi763
844e1a102a [BUG] 已经把ffmpeg加入到系统变量path了 还是检测不出来 ?
Fixes #232
2026-01-03 14:57:42 +08:00
Jianwu Huang
10311c1438 Merge pull request #192 from HansYeoh/export-mind-map
Add an export mind map button to support exporting HTML and PNG.
2025-10-18 10:00:19 +08:00
Jianwu Huang
3a0f86e74e Merge pull request #193 from HansYeoh/allow-all-domains
Modify to allow access from all domains
2025-10-18 09:59:58 +08:00
Jianwu Huang
208aed41a1 Merge pull request #217 from MgAlNa3PO4/master
修复了docker部署当中前端页面图片显示异常的问题
2025-10-18 09:59:35 +08:00
userName
6e385b8d75 Fixed the problem of abnormal display of front-end page pictures in docker deployment 2025-09-06 16:17:32 +08:00
userName
9ba895fa8d Fixed the problem of abnormal display of front-end page pictures in docker deployment 2025-09-06 16:10:42 +08:00
Jianwu Huang
df72fa9366 Merge pull request #194 from Paper-Dragon/fixed_local_video
Refine local video form validation and update Docker configuration
2025-07-18 10:00:29 +08:00
Yang Han
6d077a4ed3 Update vite.config.ts
Modify to allow access from all domains
2025-07-17 01:29:59 +08:00
Yang Han
b1b0e87d85 Update MarkmapComponent.tsx
Add an export mind map button to support exporting HTML and PNG.
2025-07-17 01:27:33 +08:00
Yang Han
7d325517b3 Update MarkdownViewer.tsx
Add an export mind map button to support exporting HTML and PNG.
2025-07-17 01:27:09 +08:00
Paper-Dragon
dc29319a3e Refine form validation and update Docker configuration 2025-07-09 13:34:26 +08:00
Jianwu Huang
880f745718 Merge pull request #176 from Paper-Dragon/feat_docker_gpu_support
Add GPU support with Docker enhancements
2025-07-05 17:21:12 +08:00
Paper-Dragon
1ce8b41bde Add GPU support with Docker enhancements
- Introduced a `Dockerfile.gpu` for GPU-enabled backend setup.
- Added `docker-compose.gpu.yml` to utilize GPU resources via NVIDIA.
- Fixed Nginx configuration for GPU backend port changes.
2025-07-04 00:16:39 +08:00
Jianwu Huang
f667e9460b fix:修复 cpu 核心锁死问题
fix cpu 核心锁死问题
2025-07-03 10:28:33 +08:00
Jianwu Huang
5f346f1b04 Merge pull request #172 from Karasukaigan/master
fix: 修复多个前后端错误与警告
2025-07-02 15:13:35 +08:00
Karasukaigan
b813d83246 fix: 修复B站短链接无法解析的问题
增加了对b23.tv短链接的解析。
2025-07-02 15:03:03 +08:00
Karasukaigan
564eee2682 fix: 隐藏多余错误提示 2025-07-02 04:34:54 +08:00
Karasukaigan
8fecf293bb fix: 优化Schema校验逻辑
修复了以下问题:
1. 当视频链接为空时,原本的校验逻辑会导致首次点击生成笔记时报错“Required”而不是“视频链接不能为空”。
2. 当选择抖音时无法判断URL是否合法,即使填入“123”也能触发后面的逻辑。
2025-07-02 04:05:04 +08:00
Karasukaigan
ce76b78b34 fix: 缓解Ant Design与React版本兼容性问题
Ant Design v5与React 19+存在兼容性问题,出现报错:[antd: compatible] antd v5 support React is 16 ~ 18.

## 修复方式
1. 尝试升级antd到5.26.3,但不起作用。
2. 注释掉代码里的`message.error`,可以暂时解决问题。
2025-07-02 03:21:36 +08:00
Karasukaigan
a8c10d3961 微调前端左中右区域默认占比 2025-07-02 02:40:23 +08:00
Karasukaigan
c009afaa6c fix: 修复Checkbox组件受控与非受控切换的警告
修复React警告"Checkbox is changing from uncontrolled to controlled."
2025-07-02 01:33:31 +08:00
Karasukaigan
5ff88ac765 fix: 修复Input组件受控与非受控切换的警告
修复React警告"A component is changing an uncontrolled input to be controlled"。

## 原因
`input.tsx`中`<input>`元素未显式处理`value`和`onChange`属性,导致父组件传值时从`undefined`切换为具体值(或反之)

## 修复方式
- 显式提取`value`和`onChange`属性
- 使用`value ?? ''`保证默认值始终为字符串,避免从`undefined`切换
- 确保组件始终以受控模式运行
2025-07-02 01:21:35 +08:00
Karasukaigan
fabf4b7cd5 fix: 修复ResizablePanel默认大小总和非100%的警告
修复React可调整面板组件的布局警告"Invalid layout total size: 18%, 16%, 55%."
2025-07-02 01:09:46 +08:00
Karasukaigan
d8768d5d5b fix: 修复NoteHistory组件中的key警告
修复React警告"Each child in a list should have a unique 'key' prop"
2025-07-02 00:44:52 +08:00
Karasukaigan
f05ae6a27f refactor: 简化URL结构
将`HashRouter`替换为更现代的`BrowserRouter`,移除`#`片段以简化URL结构。
2025-07-02 00:20:28 +08:00
178 changed files with 49875 additions and 1024 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,321 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Git 和 IDE
.git
.github
.idea/
.vscode/
.DS_Store
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
BiliNote/pnpm-lock.yaml
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Tauri 构建产物(非常大)
BillNote_frontend/src-tauri/target
BillNote_frontend/src-tauri/bin
# Coverage directory used by tools like istanbul
coverage
*.lcov
# 运行时数据
backend/data
backend/static
backend/models
backend/logs
backend/uploads
backend/*.db
backend/note_results
backend/bin/
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
# 依赖和构建缓存
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.BiliNote-dev/*
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
build/
*.tar
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
.idea/
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
# 环境文件
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
/backend/data/*
/backend/static/*
/backend/note_tasks.db
/backend/bin/
/backend/logs/
/backend/note_results
/backend/models
/backend/.idea/*
/backend/bili_note.db
/backend/uploads/*
/BiliNote_frontend/.idea/*
.env.local
.env.*.local
!.env.example

View File

@@ -4,7 +4,7 @@ FRONTEND_PORT=3015
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
APP_PORT= 3015 # docker 部署时用
# 前端访问后端用 (开发环境使用)
VITE_API_BASE_URL=http://127.0.0.1:8483
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
VITE_FRONTEND_PORT=3015
# 生产环境配置
@@ -19,6 +19,6 @@ FFMPEG_BIN_PATH=
# transcriber 相关配置
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
WHISPER_MODEL_SIZE=base
WHISPER_MODEL_SIZE=medium
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo

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-提交规范

73
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Build and Publish Docker Image
on:
push:
tags:
- 'v*'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.complete
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Generate Usage Instructions
run: |
echo "=========================================="
echo "Docker Image Published!"
echo "=========================================="
echo ""
echo "Pull the image:"
echo " docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
echo ""
echo "Run the container:"
echo " docker run -d -p 80:80 \\"
echo " -v bilinote-data:/app/backend/data \\"
echo " --name bilinote \\"
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
echo ""
echo "Access the application at: http://localhost"
echo "=========================================="

View File

@@ -1,30 +1,36 @@
# .github/workflows/release.yml
name: Build Desktop App (Python Backend + Tauri Frontend)
name: Build & Release Desktop App
on:
push:
tags:
- 'v*' # 发布 tag 时触发
- 'v*'
workflow_dispatch:
jobs:
build:
strategy:
matrix:
platform: [macos-latest, windows-latest]
include:
- platform: macos-latest
target: universal-apple-darwin
- platform: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout Code
uses: actions/checkout@v3
uses: actions/checkout@v4
# 设置 Python 环境
# 设置 Python 环境(带 pip 缓存)
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.11'
cache: 'pip'
cache-dependency-path: backend/requirements.txt
# 安装 Python 依赖并执行你的 build.sh
# 安装 Python 依赖并执行构建
- name: Install Python dependencies & Build backend
shell: bash
run: |
@@ -38,30 +44,105 @@ jobs:
./backend/build.sh
fi
# 设置 Node 环境 + 安装前端依赖
# 设置 pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 'latest'
# 设置 Node 环境
- name: Set up Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack + Install pnpm
- name: Install frontend dependencies
working-directory: BillNote_frontend
run: |
corepack enable
pnpm install
run: pnpm install
# 设置 Rust 环境
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
# Cargo 缓存
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
BillNote_frontend/src-tauri/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
# 打包 Tauri 应用
- name: Build Tauri App
working-directory: BillNote_frontend
run: pnpm tauri build
# 可选:上传构建产物
- name: Upload Desktop Bundle
# 收集产物到统一目录
- name: Collect release artifacts
shell: bash
run: |
mkdir -p release-artifacts
BUNDLE_DIR="BillNote_frontend/src-tauri/target/release/bundle"
# macOS: .dmg
find "$BUNDLE_DIR" -name "*.dmg" -exec cp {} release-artifacts/ \; 2>/dev/null || true
# 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
echo "=== Collected artifacts ==="
ls -lh release-artifacts/
# 生成 SHA256 校验和
- name: Generate checksums
shell: bash
run: |
cd release-artifacts
sha256sum * > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * > SHA256SUMS.txt
cat SHA256SUMS.txt
# 上传产物(供 release job 使用)
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: app-${{ matrix.platform }}
path: BillNote_frontend/src-tauri/target/release/bundle/
name: artifacts-${{ matrix.platform }}
path: release-artifacts/
# 创建 GitHub Release 并上传所有产物
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
# 下载所有平台的构建产物
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: all-artifacts
merge-multiple: true
- name: List all artifacts
run: |
echo "=== All release artifacts ==="
ls -lhR all-artifacts/
# 创建 Release 并上传产物
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: BiliNote ${{ github.ref_name }}
draft: false
prerelease: false
generate_release_notes: true
files: all-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 }}

7
.gitignore vendored
View File

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

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

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,184 @@
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
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,
screenshot: settings.screenshot,
link: settings.link,
style: settings.style || undefined,
format: [
...(settings.screenshot ? ['screenshot'] : []),
...(settings.link ? ['link'] : []),
],
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,18 @@
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',
screenshot: false,
link: false,
style: '',
}
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,142 @@
// 与 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
// 客户端在浏览器里直接抓到的字幕,跳过后端的 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
}
export interface Settings {
backendUrl: string
providerId: string
modelName: string
quality: Quality
screenshot: boolean
link: boolean
style: string
}
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,142 @@
<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 type { Model, Provider } from '~/logic/types'
import { watch } from 'vue'
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>
<input v-model="settings.style" class="input" placeholder="留空使用默认">
</label>
<label class="flex items-center gap-2">
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
</label>
<label class="flex items-center gap-2">
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
</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,276 @@
<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 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 { task_id } = await generateNote({
video_url: tabUrl.value,
platform: platform.value!,
quality: settings.value.quality,
provider_id: settings.value.providerId,
model_name: settings.value.modelName,
screenshot: settings.value.screenshot,
link: settings.value.link,
style: settings.value.style || undefined,
format: [
...(settings.value.screenshot ? ['screenshot'] : []),
...(settings.value.link ? ['link'] : []),
],
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()
}
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-3 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 items-center gap-1 mt-4">
<input v-model="settings.screenshot" type="checkbox"> 截图
</label>
<label class="flex items-center gap-1 mt-4">
<input v-model="settings.link" type="checkbox"> 跳转
</label>
</div>
<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,25 +1,24 @@
# === 前端构建阶段 ===
FROM node:18-alpine AS builder
# Tailwind v4 / Vite 6 需要 Node 20+alpine + pnpm 会按 lockfile 拉 musl native binary。
FROM node:20-alpine AS builder
# 安装 pnpm
RUN npm install -g pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 设置工作目录
WORKDIR /app
# 拷贝前端源码
COPY ./BillNote_frontend /app
# 先复制 lockfile 利用依赖层缓存
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# 安装依赖并构建
RUN pnpm install && pnpm run build
# 再复制源代码并构建
COPY ./BillNote_frontend/ ./
RUN pnpm run build
# --- 阶段2使用 nginx 作为静态服务器 ---
FROM nginx:1.25-alpine
# 删除默认配置(可选)
RUN rm -rf /etc/nginx/conf.d/default.conf
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
# 拷贝构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=builder /app/dist /usr/share/nginx/html

18705
BillNote_frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/x": "^2.4.0",
"@hookform/resolvers": "^5.0.1",
"@lobehub/icons": "^1.97.1",
"@lobehub/icons-static-svg": "^1.45.0",
@@ -32,6 +33,8 @@
"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",
"lucide-react": "^0.487.0",

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

View File

@@ -1,24 +1,23 @@
import './App.css'
import { HomePage } from './pages/HomePage/Home.tsx'
import { lazy, Suspense, useEffect } from 'react'
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom'
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
import SettingPage from './pages/SettingPage/index.tsx'
import { BrowserRouter, HashRouter, Navigate, Routes } from 'react-router-dom'
import { Route } from 'react-router-dom'
import Index from '@/pages/Index.tsx'
import NotFoundPage from '@/pages/NotFoundPage'
import Model from '@/pages/SettingPage/Model.tsx'
import Transcriber from '@/pages/SettingPage/transcriber.tsx'
import ProviderForm from '@/components/Form/modelForm/Form.tsx'
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
import Downloading from '@/components/Lottie/download.tsx'
import Prompt from '@/pages/SettingPage/Prompt.tsx'
import AboutPage from '@/pages/SettingPage/about.tsx'
import Downloader from '@/pages/SettingPage/Downloader.tsx'
import DownloaderForm from '@/components/Form/DownloaderForm/Form.tsx'
import { useEffect } from 'react'
import { systemCheck } from '@/services/system.ts'
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
import { systemCheck } from '@/services/system.ts'
import BackendInitDialog from '@/components/BackendInitDialog'
import Index from '@/pages/Index.tsx'
import { HomePage } from './pages/HomePage/Home.tsx'
// 非首屏页面使用 React.lazy 按需加载
const SettingPage = lazy(() => import('./pages/SettingPage/index.tsx'))
const Model = lazy(() => import('@/pages/SettingPage/Model.tsx'))
const ProviderForm = lazy(() => import('@/components/Form/modelForm/Form.tsx'))
const AboutPage = lazy(() => import('@/pages/SettingPage/about.tsx'))
const Monitor = lazy(() => import('@/pages/SettingPage/Monitor.tsx'))
const Downloader = lazy(() => import('@/pages/SettingPage/Downloader.tsx'))
const DownloaderForm = lazy(() => import('@/components/Form/DownloaderForm/Form.tsx'))
const TranscriberPage = lazy(() => import('@/pages/SettingPage/transcriber.tsx'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
function App() {
useTaskPolling(3000) // 每 3 秒轮询一次
@@ -43,28 +42,32 @@ function App() {
// 后端已初始化,渲染主应用
return (
<>
<HashRouter>
<Routes>
<Route path="/" element={<Index />}>
<Route index element={<HomePage />} />
<Route path="settings" element={<SettingPage />}>
<Route index element={<Navigate to="model" replace />} />
<Route path="model" element={<Model />}>
<Route path="new" element={<ProviderForm isCreate />} />
<Route path=":id" element={<ProviderForm />} />
<BrowserRouter>
<Suspense fallback={<div className="flex h-screen items-center justify-center"></div>}>
<Routes>
<Route path="/" element={<Index />}>
<Route index element={<HomePage />} />
<Route path="settings" element={<SettingPage />}>
<Route index element={<Navigate to="model" replace />} />
<Route path="model" element={<Model />}>
<Route path="new" element={<ProviderForm isCreate />} />
<Route path=":id" element={<ProviderForm />} />
</Route>
<Route path="download" element={<Downloader />}>
<Route path=":id" element={<DownloaderForm />} />
</Route>
<Route path="transcriber" element={<TranscriberPage />} />
<Route path="monitor" element={<Monitor />}></Route>
<Route path="about" element={<AboutPage />}></Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="download" element={<Downloader />}>
<Route path=":id" element={<DownloaderForm />} />
</Route>
<Route path="about" element={<AboutPage />}></Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</HashRouter>
</Routes>
</Suspense>
</BrowserRouter>
</>
)
}
export default App
export default App

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -27,7 +27,7 @@ import {
import { ModelSelector } from '@/components/Form/modelForm/ModelSelector.tsx'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'
import { Tags } from 'lucide-react'
import { Tag } from 'antd'
import { X } from 'lucide-react'
import { useModelStore } from '@/store/modelStore'
// ✅ Provider表单schema
@@ -312,12 +312,12 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
{
models && models.map(model => {
return (
<>
<Tag onClose={()=>{
handelDelete(model.id)
}} key={model.id} closable color={'blue'}>
{model.model_name}
</Tag></>
<span key={model.id} className="inline-flex items-center gap-1 rounded-md bg-blue-100 px-2 py-0.5 text-sm text-blue-700">
{model.model_name}
<button type="button" onClick={() => handelDelete(model.id)} className="hover:text-blue-900">
<X className="h-3 w-3" />
</button>
</span>
)
})

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

@@ -1,10 +1,26 @@
import * as React from 'react'
import { useState, useEffect } from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
function Checkbox({ className, checked, onChange, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const [isChecked, setIsChecked] = useState(checked || false);
useEffect(() => {
if (checked !== undefined) {
setIsChecked(checked);
}
}, [checked]);
const handleCheckChange = (newChecked: boolean) => {
setIsChecked(newChecked);
if (onChange) {
onChange({} as React.FormEvent<HTMLButtonElement>);
}
};
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
@@ -12,6 +28,8 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
checked={isChecked}
onCheckedChange={handleCheckChange}
{...props}
>
<CheckboxPrimitive.Indicator

View File

@@ -2,7 +2,7 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
function Input({ className, type, value, onChange, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
@@ -13,6 +13,8 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
value={value ?? ''}
onChange={onChange}
{...props}
/>
)

View File

@@ -22,9 +22,11 @@ export const useTaskPolling = (interval = 3000) => {
task => task.status != 'SUCCESS' && task.status != 'FAILED'
)
// 无活跃任务时跳过轮询
if (pendingTasks.length === 0) return
for (const task of pendingTasks) {
try {
console.log('🔄 正在轮询任务:', task.id)
const res = await get_task_status(task.id)
const { status } = res
@@ -47,9 +49,7 @@ export const useTaskPolling = (interval = 3000) => {
}
} catch (e) {
console.error('❌ 任务轮询失败:', e)
// toast.error(`生成失败 ${e.message || e}`)
updateTaskContent(task.id, { status: 'FAILED' })
// removeTask(task.id)
}
}
}, interval)

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