mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-08 09:13:11 +08:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d299ce48 | ||
|
|
ed1ee0a151 | ||
|
|
a7c717abbd | ||
|
|
799ab64a28 | ||
|
|
c0837e0132 | ||
|
|
c9497b502c | ||
|
|
1aea86a8d6 | ||
|
|
9237cac9c3 | ||
|
|
f97ab0b7bc | ||
|
|
ac72cc6d6e | ||
|
|
7358cd0123 | ||
|
|
80f081613b | ||
|
|
26e23d0f2c | ||
|
|
234e3b9d2a | ||
|
|
1d93d1c5f5 | ||
|
|
c19d462505 | ||
|
|
64882e6a77 | ||
|
|
f32a6944d1 | ||
|
|
c5c84a8ec7 | ||
|
|
c4413c66a1 | ||
|
|
26ee15ce28 | ||
|
|
29fa3d9540 | ||
|
|
61cb4ec9fa | ||
|
|
a46880f169 | ||
|
|
c187dce5cb | ||
|
|
424c7f84e2 | ||
|
|
f6a9af4658 | ||
|
|
3ff7086491 | ||
|
|
f583f3cc8c | ||
|
|
2e7fe8d3a8 | ||
|
|
1e055c3068 | ||
|
|
b2af0e4e53 | ||
|
|
3d0838ba72 | ||
|
|
ee58a65bcd | ||
|
|
dbe7b89754 | ||
|
|
cfc3053be8 | ||
|
|
4dc5b97f0b | ||
|
|
817bbd9807 | ||
|
|
406789f834 | ||
|
|
d64741628b | ||
|
|
bb9637f30a | ||
|
|
0793949516 | ||
|
|
e694b460e8 | ||
|
|
e471054beb | ||
|
|
f37d2e95d1 | ||
|
|
be5e1637fa | ||
|
|
702b57c165 | ||
|
|
3bd8b670ca | ||
|
|
880587f2db | ||
|
|
b8f359e7e7 | ||
|
|
108ad270bf | ||
|
|
2cd43770eb | ||
|
|
e3134f2078 | ||
|
|
118b7357c5 | ||
|
|
c9ab763f1b | ||
|
|
c5e08e1ec6 | ||
|
|
20fcf2c29c | ||
|
|
8fa3101f0f | ||
|
|
499366da02 | ||
|
|
1f52185539 | ||
|
|
cb5c11d41a | ||
|
|
c9ee3e6957 | ||
|
|
8e2f74c0f5 | ||
|
|
274e5d25a8 | ||
|
|
c0f978bd77 | ||
|
|
e7db5124ea | ||
|
|
4bff57c774 | ||
|
|
198f01d079 | ||
|
|
341d3ded06 | ||
|
|
7da4f9587b | ||
|
|
0e10a3d906 | ||
|
|
6d5d1ad373 | ||
|
|
3010690d2e | ||
|
|
a40bb19743 | ||
|
|
6090982261 | ||
|
|
90aeb22853 | ||
|
|
c553fd898f | ||
|
|
f4801d5be7 |
28
.commitlintrc.json
Normal file
28
.commitlintrc.json
Normal 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]
|
||||
}
|
||||
}
|
||||
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
93
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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)
|
||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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、参考资料、产品截图等
|
||||
29
.github/ISSUE_TEMPLATE/新增功能建议.md
vendored
29
.github/ISSUE_TEMPLATE/新增功能建议.md
vendored
@@ -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
39
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
PR 标题请遵循 type(scope): subject 格式,例如:
|
||||
feat(extension): 侧边栏接入思维导图
|
||||
fix(bilibili): 修正字幕优先链路在未登录态下的回退
|
||||
分支命名 / 提交规范见 CONTRIBUTING.md。
|
||||
-->
|
||||
|
||||
## 改动概述
|
||||
|
||||
<!-- 一句话说清这个 PR 做了什么 -->
|
||||
|
||||
## 为什么
|
||||
|
||||
<!-- 背景、关联 issue(Fixes #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
30
.github/workflows/commitlint.yml
vendored
Normal 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-提交规范
|
||||
18
.github/workflows/main.yml
vendored
18
.github/workflows/main.yml
vendored
@@ -13,8 +13,6 @@ jobs:
|
||||
include:
|
||||
- platform: macos-latest
|
||||
target: universal-apple-darwin
|
||||
- platform: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
@@ -24,13 +22,6 @@ jobs:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Linux 系统依赖(Tauri 需要)
|
||||
- name: Install Linux Dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
# 设置 Python 环境(带 pip 缓存)
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -59,17 +50,15 @@ jobs:
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
# 设置 Node 环境(带 pnpm 缓存)
|
||||
# 设置 Node 环境
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: BillNote_frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: BillNote_frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install
|
||||
|
||||
# 设置 Rust 环境
|
||||
- name: Set up Rust
|
||||
@@ -105,9 +94,6 @@ jobs:
|
||||
# Windows: .msi, .exe (NSIS)
|
||||
find "$BUNDLE_DIR" -name "*.msi" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
# Linux: .deb, .AppImage
|
||||
find "$BUNDLE_DIR" -name "*.deb" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
find "$BUNDLE_DIR" -name "*.AppImage" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
|
||||
echo "=== Collected artifacts ==="
|
||||
ls -lh release-artifacts/
|
||||
|
||||
115
.github/workflows/release-extension.yml
vendored
Normal file
115
.github/workflows/release-extension.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
name: Release Extension
|
||||
|
||||
# 在 v* tag push 时触发,构建插件并把产物挂到对应 GitHub Release。
|
||||
# 商店上传仍走人工(详见 RELEASING.md);如果将来配齐了商店 API secrets,
|
||||
# 把本文件末尾注释的 publish-* job 解开就是自动发布。
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & attach to release
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: BillNote_extension
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
cache-dependency-path: BillNote_extension/pnpm-lock.yaml
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Pack zip (Chrome / Edge upload format)
|
||||
run: pnpm pack:zip
|
||||
|
||||
- name: Pack xpi (Firefox Add-ons)
|
||||
run: pnpm pack:xpi
|
||||
|
||||
- name: Pack crx (self-host sideload)
|
||||
# crx 需要稳定 key.pem 才能保持插件 ID 不变;CI 没有就跳过,不阻塞主流程。
|
||||
# 想生成稳定 crx:把 key 存到 secret EXTENSION_CRX_KEY,下面解开几行。
|
||||
run: |
|
||||
# if [ -n "${{ secrets.EXTENSION_CRX_KEY }}" ]; then
|
||||
# echo "${{ secrets.EXTENSION_CRX_KEY }}" > key.pem
|
||||
# pnpm pack:crx
|
||||
# else
|
||||
pnpm pack:crx || true
|
||||
# fi
|
||||
continue-on-error: true
|
||||
|
||||
- name: Rename artifacts with version suffix
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
[ -f extension.zip ] && mv extension.zip "bilinote-extension-${VERSION}.zip"
|
||||
[ -f extension.xpi ] && mv extension.xpi "bilinote-extension-${VERSION}.xpi"
|
||||
[ -f extension.crx ] && mv extension.crx "bilinote-extension-${VERSION}.crx"
|
||||
ls -la *.zip *.xpi *.crx 2>/dev/null || true
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
BillNote_extension/bilinote-extension-*.zip
|
||||
BillNote_extension/bilinote-extension-*.xpi
|
||||
BillNote_extension/bilinote-extension-*.crx
|
||||
fail_on_unmatched_files: false
|
||||
generate_release_notes: false
|
||||
|
||||
# ---------- 商店自动发布(默认禁用,配齐 secrets 后可启用) ----------
|
||||
#
|
||||
# publish-chrome:
|
||||
# needs: build
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/download-artifact@v4
|
||||
# - uses: mnao305/chrome-extension-upload@v5
|
||||
# with:
|
||||
# file-path: BillNote_extension/bilinote-extension-${{ github.ref_name }}.zip
|
||||
# extension-id: ${{ secrets.CHROME_EXTENSION_ID }}
|
||||
# client-id: ${{ secrets.CHROME_CLIENT_ID }}
|
||||
# client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
|
||||
# refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
|
||||
#
|
||||
# publish-edge:
|
||||
# needs: build
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: wdzeng/edge-addon@v2
|
||||
# with:
|
||||
# product-id: ${{ secrets.EDGE_PRODUCT_ID }}
|
||||
# zip-path: BillNote_extension/bilinote-extension-${{ github.ref_name }}.zip
|
||||
# client-id: ${{ secrets.EDGE_CLIENT_ID }}
|
||||
# api-key: ${{ secrets.EDGE_API_KEY }}
|
||||
#
|
||||
# publish-firefox:
|
||||
# needs: build
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: trmcnvn/firefox-addon@v3
|
||||
# with:
|
||||
# uuid: ${{ secrets.FIREFOX_ADDON_UUID }}
|
||||
# xpi: BillNote_extension/bilinote-extension-${{ github.ref_name }}.xpi
|
||||
# api-key: ${{ secrets.FIREFOX_API_KEY }}
|
||||
# api-secret: ${{ secrets.FIREFOX_API_SECRET }}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -322,4 +322,8 @@ cython_debug/
|
||||
/backend/config/*
|
||||
/backend/vector_db/
|
||||
/BiliNote_frontend/.idea/*
|
||||
/BiliNote_frontend/src-tauri/bin/
|
||||
/BiliNote_frontend/src-tauri/bin/
|
||||
|
||||
# FFmpeg 构建文件(不应该提交到仓库)
|
||||
ffmpeg*/
|
||||
ffmpg*/
|
||||
17
BillNote_extension/.gitignore
vendored
Normal file
17
BillNote_extension/.gitignore
vendored
Normal 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
7
BillNote_extension/.gitpod.Dockerfile
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM gitpod/workspace-full-vnc
|
||||
|
||||
USER root
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y firefox
|
||||
23
BillNote_extension/.gitpod.yml
Normal file
23
BillNote_extension/.gitpod.yml
Normal 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
|
||||
2
BillNote_extension/.npmrc
Normal file
2
BillNote_extension/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
shamefully-hoist=true
|
||||
auto-install-peers=true
|
||||
9
BillNote_extension/.vscode/extensions.json
vendored
Normal file
9
BillNote_extension/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar",
|
||||
"antfu.iconify",
|
||||
"antfu.unocss",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"csstools.postcss"
|
||||
]
|
||||
}
|
||||
12
BillNote_extension/.vscode/settings.json
vendored
Normal file
12
BillNote_extension/.vscode/settings.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
21
BillNote_extension/LICENSE
Normal file
21
BillNote_extension/LICENSE
Normal 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.
|
||||
53
BillNote_extension/README.md
Normal file
53
BillNote_extension/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# BiliNote 浏览器插件
|
||||
|
||||
把 BiliNote 的"视频链接 → Markdown 笔记"能力下沉到浏览器插件。当前为 P1 MVP(仅工具栏 popup)。
|
||||
|
||||
## 当前状态(P1 MVP)
|
||||
|
||||
- ✅ 工具栏图标 popup:自动读当前 tab URL,识别支持平台,触发笔记生成
|
||||
- ✅ 设置页:后端地址、供应商/模型、画质、截图/跳转/风格默认值
|
||||
- ✅ 任务进度可视化、Markdown 渲染、复制 / 下载 .md
|
||||
- ✅ chrome.storage.local 持久化设置和最近 30 个任务
|
||||
- ⏳ P2:视频页悬浮按钮 + 右键菜单 + 浏览器 cookie 直通
|
||||
- ⏳ P3:side panel + 思维导图(markmap)
|
||||
- ⏳ P4:RAG 问答
|
||||
|
||||
## 开发
|
||||
|
||||
依赖: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)。
|
||||
20
BillNote_extension/e2e/basic.spec.ts
Normal file
20
BillNote_extension/e2e/basic.spec.ts
Normal 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')
|
||||
})
|
||||
48
BillNote_extension/e2e/fixtures.ts
Normal file
48
BillNote_extension/e2e/fixtures.ts
Normal 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'),
|
||||
)
|
||||
}
|
||||
5
BillNote_extension/eslint.config.mjs
Normal file
5
BillNote_extension/eslint.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu(
|
||||
|
||||
)
|
||||
BIN
BillNote_extension/extension/assets/icon-512.png
Normal file
BIN
BillNote_extension/extension/assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
12
BillNote_extension/extension/assets/icon.svg
Normal file
12
BillNote_extension/extension/assets/icon.svg
Normal 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
10
BillNote_extension/modules.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$app: {
|
||||
context: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/64189046/479957
|
||||
export {}
|
||||
77
BillNote_extension/package.json
Normal file
77
BillNote_extension/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
BillNote_extension/playwright.config.ts
Normal file
15
BillNote_extension/playwright.config.ts
Normal 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
9983
BillNote_extension/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
BillNote_extension/scripts/manifest.ts
Normal file
10
BillNote_extension/scripts/manifest.ts
Normal 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()
|
||||
40
BillNote_extension/scripts/prepare.ts
Normal file
40
BillNote_extension/scripts/prepare.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
12
BillNote_extension/scripts/utils.ts
Normal file
12
BillNote_extension/scripts/utils.ts
Normal 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
10
BillNote_extension/shim.d.ts
vendored
Normal 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 }>
|
||||
}
|
||||
}
|
||||
12
BillNote_extension/src/assets/logo.svg
Normal file
12
BillNote_extension/src/assets/logo.svg
Normal 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 |
18
BillNote_extension/src/background/contentScriptHMR.ts
Normal file
18
BillNote_extension/src/background/contentScriptHMR.ts
Normal 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))
|
||||
})
|
||||
187
BillNote_extension/src/background/main.ts
Normal file
187
BillNote_extension/src/background/main.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { onMessage } from 'webext-bridge/background'
|
||||
import type { Settings, TaskRecord } from '~/logic/types'
|
||||
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
|
||||
import { detectPlatform } from '~/logic/platform'
|
||||
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||
|
||||
// only on dev mode
|
||||
if (import.meta.hot) {
|
||||
// @ts-expect-error for background HMR
|
||||
import('/@vite/client')
|
||||
// load latest content script
|
||||
import('./contentScriptHMR')
|
||||
}
|
||||
|
||||
// ---------- 直接操作 chrome.storage(service worker 里别用 Vue 反应式)----------
|
||||
|
||||
async function readSettings(): Promise<Settings> {
|
||||
const obj = await browser.storage.local.get(SETTINGS_KEY)
|
||||
const raw = obj[SETTINGS_KEY] as string | undefined
|
||||
if (!raw)
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) }
|
||||
}
|
||||
catch {
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
}
|
||||
|
||||
async function readTasks(): Promise<TaskRecord[]> {
|
||||
const obj = await browser.storage.local.get(TASKS_KEY)
|
||||
const raw = obj[TASKS_KEY] as string | undefined
|
||||
if (!raw)
|
||||
return []
|
||||
try {
|
||||
return JSON.parse(raw) as TaskRecord[]
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTasks(tasks: TaskRecord[]) {
|
||||
await browser.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks.slice(0, MAX_TASKS)) })
|
||||
}
|
||||
|
||||
async function upsertTask(record: TaskRecord) {
|
||||
const tasks = await readTasks()
|
||||
const idx = tasks.findIndex(t => t.taskId === record.taskId)
|
||||
if (idx >= 0)
|
||||
tasks.splice(idx, 1, { ...tasks[idx], ...record })
|
||||
else
|
||||
tasks.unshift(record)
|
||||
await writeTasks(tasks)
|
||||
}
|
||||
|
||||
// ---------- 启动任务 ----------
|
||||
|
||||
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
|
||||
const platform = detectPlatform(url)
|
||||
if (!platform)
|
||||
return { ok: false, error: '当前链接不是支持的视频平台' }
|
||||
|
||||
const settings = await readSettings()
|
||||
if (!settings.providerId || !settings.modelName)
|
||||
return { ok: false, error: '请先在设置页选择供应商与模型' }
|
||||
|
||||
const backend = settings.backendUrl.replace(/\/$/, '')
|
||||
|
||||
// B 站:先在浏览器里抓字幕(带本地登录态 cookie),随提交带过去
|
||||
const prefetched = platform === 'bilibili' ? await fetchBilibiliSubtitle(url) : null
|
||||
|
||||
const formats = settings.formats || []
|
||||
try {
|
||||
const res = await fetch(`${backend}/api/generate_note`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
video_url: url,
|
||||
platform,
|
||||
quality: settings.quality,
|
||||
provider_id: settings.providerId,
|
||||
model_name: settings.modelName,
|
||||
// backend 同时接受 format 数组与 screenshot/link 单独布尔;从 formats 派生保持单一真相源
|
||||
format: [...formats],
|
||||
screenshot: formats.includes('screenshot'),
|
||||
link: formats.includes('link'),
|
||||
style: settings.style || undefined,
|
||||
extras: settings.extras || undefined,
|
||||
video_understanding: settings.video_understanding || undefined,
|
||||
video_interval: settings.video_understanding ? settings.video_interval : undefined,
|
||||
grid_size: settings.video_understanding ? settings.grid_size : undefined,
|
||||
prefetched_transcript: prefetched ?? undefined,
|
||||
}),
|
||||
})
|
||||
if (!res.ok)
|
||||
return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = await res.json() as { code: number, msg: string, data: { task_id: string } }
|
||||
if (body.code !== 0)
|
||||
return { ok: false, error: body.msg }
|
||||
|
||||
await upsertTask({
|
||||
taskId: body.data.task_id,
|
||||
videoUrl: url,
|
||||
platform,
|
||||
status: 'PENDING',
|
||||
message: '已提交',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return { ok: true, taskId: body.data.task_id }
|
||||
}
|
||||
catch (e) {
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
async function openSidePanelInTab(tabId?: number) {
|
||||
try {
|
||||
// @ts-expect-error chrome.sidePanel 类型在 webextension-polyfill 中尚未补全
|
||||
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open && tabId !== undefined)
|
||||
// @ts-expect-error see above
|
||||
await chrome.sidePanel.open({ tabId })
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('打开侧边栏失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 消息桥 ----------
|
||||
|
||||
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
|
||||
const result = await startTask(data.url)
|
||||
// 成功就把侧边栏拉起来给用户看进度
|
||||
if (result.ok)
|
||||
await openSidePanelInTab(sender?.tabId)
|
||||
return result
|
||||
})
|
||||
|
||||
// ---------- 安装时事件 ----------
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
console.log('BiliNote extension installed')
|
||||
|
||||
// 右键菜单:在视频页或视频链接上"用 BiliNote 总结"
|
||||
try {
|
||||
browser.contextMenus.create({
|
||||
id: 'bilinote-summarize-page',
|
||||
title: '用 BiliNote 总结此视频',
|
||||
contexts: ['page', 'link', 'video'],
|
||||
documentUrlPatterns: [
|
||||
'*://*.bilibili.com/*',
|
||||
'*://*.youtube.com/*',
|
||||
'*://youtu.be/*',
|
||||
'*://*.douyin.com/*',
|
||||
'*://*.kuaishou.com/*',
|
||||
],
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('注册右键菜单失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
browser.contextMenus?.onClicked.addListener(async (info, tab) => {
|
||||
if (info.menuItemId !== 'bilinote-summarize-page')
|
||||
return
|
||||
const url = info.linkUrl || tab?.url
|
||||
if (!url)
|
||||
return
|
||||
const result = await startTask(url)
|
||||
if (result.ok)
|
||||
await openSidePanelInTab(tab?.id)
|
||||
else
|
||||
console.warn('右键启动失败:', result.error)
|
||||
})
|
||||
|
||||
// content script 占位握手 —— 未来可扩展为查询当前任务等
|
||||
onMessage('get-current-tab', async () => {
|
||||
try {
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
return { title: tab?.title, url: tab?.url }
|
||||
}
|
||||
catch {
|
||||
return { title: undefined, url: undefined }
|
||||
}
|
||||
})
|
||||
156
BillNote_extension/src/components/ChatPanel.vue
Normal file
156
BillNote_extension/src/components/ChatPanel.vue
Normal 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>
|
||||
5
BillNote_extension/src/components/Logo.vue
Normal file
5
BillNote_extension/src/components/Logo.vue
Normal 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>
|
||||
44
BillNote_extension/src/components/MarkdownView.vue
Normal file
44
BillNote_extension/src/components/MarkdownView.vue
Normal 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>
|
||||
32
BillNote_extension/src/components/MindMap.vue
Normal file
32
BillNote_extension/src/components/MindMap.vue
Normal 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>
|
||||
24
BillNote_extension/src/components/PlatformBadge.vue
Normal file
24
BillNote_extension/src/components/PlatformBadge.vue
Normal 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>
|
||||
11
BillNote_extension/src/components/README.md
Normal file
11
BillNote_extension/src/components/README.md
Normal 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.
|
||||
5
BillNote_extension/src/components/SharedSubtitle.vue
Normal file
5
BillNote_extension/src/components/SharedSubtitle.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p class="mt-2 opacity-50">
|
||||
This is the {{ $app.context }} page
|
||||
</p>
|
||||
</template>
|
||||
42
BillNote_extension/src/components/TaskProgress.vue
Normal file
42
BillNote_extension/src/components/TaskProgress.vue
Normal 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>
|
||||
11
BillNote_extension/src/components/__tests__/Logo.test.ts
Normal file
11
BillNote_extension/src/components/__tests__/Logo.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
166
BillNote_extension/src/composables/useWebExtensionStorage.ts
Normal file
166
BillNote_extension/src/composables/useWebExtensionStorage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
24
BillNote_extension/src/contentScripts/index.ts
Normal file
24
BillNote_extension/src/contentScripts/index.ts
Normal 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)
|
||||
})()
|
||||
57
BillNote_extension/src/contentScripts/views/App.vue
Normal file
57
BillNote_extension/src/contentScripts/views/App.vue
Normal 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>
|
||||
14
BillNote_extension/src/env.ts
Normal file
14
BillNote_extension/src/env.ts
Normal 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
8
BillNote_extension/src/global.d.ts
vendored
Normal 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
|
||||
}
|
||||
235
BillNote_extension/src/logic/api.ts
Normal file
235
BillNote_extension/src/logic/api.ts
Normal 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 200;body 是 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) => ``)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
125
BillNote_extension/src/logic/bilibili-subtitle.ts
Normal file
125
BillNote_extension/src/logic/bilibili-subtitle.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// 在浏览器里直接调 B 站 player API 抓字幕。
|
||||
// 因为 manifest host_permissions: '*://*/*' 覆盖 api.bilibili.com,service 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',
|
||||
}
|
||||
}
|
||||
15
BillNote_extension/src/logic/common-setup.ts
Normal file
15
BillNote_extension/src/logic/common-setup.ts
Normal 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)
|
||||
}
|
||||
23
BillNote_extension/src/logic/constants.ts
Normal file
23
BillNote_extension/src/logic/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Settings } from './types'
|
||||
|
||||
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
backendUrl: DEFAULT_BACKEND_URL,
|
||||
providerId: '',
|
||||
modelName: '',
|
||||
quality: 'medium',
|
||||
formats: ['toc', 'summary'],
|
||||
screenshot: false,
|
||||
link: false,
|
||||
style: 'minimal',
|
||||
extras: '',
|
||||
video_understanding: false,
|
||||
video_interval: 6,
|
||||
grid_size: [2, 2],
|
||||
}
|
||||
|
||||
export const MAX_TASKS = 30
|
||||
|
||||
export const SETTINGS_KEY = 'bilinote-settings'
|
||||
export const TASKS_KEY = 'bilinote-tasks'
|
||||
38
BillNote_extension/src/logic/cookies.ts
Normal file
38
BillNote_extension/src/logic/cookies.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
1
BillNote_extension/src/logic/index.ts
Normal file
1
BillNote_extension/src/logic/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './storage'
|
||||
24
BillNote_extension/src/logic/platform.ts
Normal file
24
BillNote_extension/src/logic/platform.ts
Normal 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: '本地',
|
||||
}
|
||||
33
BillNote_extension/src/logic/storage.ts
Normal file
33
BillNote_extension/src/logic/storage.ts
Normal 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)
|
||||
}
|
||||
183
BillNote_extension/src/logic/types.ts
Normal file
183
BillNote_extension/src/logic/types.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// 与 backend/app/routers/note.py / provider.py / model.py 对齐
|
||||
export type Platform = 'bilibili' | 'youtube' | 'douyin' | 'kuaishou' | 'local'
|
||||
export type Quality = 'fast' | 'medium' | 'slow'
|
||||
|
||||
export type TaskStatus =
|
||||
| 'PENDING'
|
||||
| 'PARSING'
|
||||
| 'DOWNLOADING'
|
||||
| 'TRANSCRIBING'
|
||||
| 'SUMMARIZING'
|
||||
| 'FORMATTING'
|
||||
| 'SAVING'
|
||||
| 'SUCCESS'
|
||||
| 'FAILED'
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
logo: string
|
||||
type: string
|
||||
enabled: number
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string
|
||||
model_name: string
|
||||
provider_id: string
|
||||
}
|
||||
|
||||
export interface GenerateRequest {
|
||||
video_url: string
|
||||
platform: Platform
|
||||
quality: Quality
|
||||
model_name: string
|
||||
provider_id: string
|
||||
screenshot?: boolean
|
||||
link?: boolean
|
||||
format?: string[]
|
||||
style?: string
|
||||
extras?: string
|
||||
video_understanding?: boolean
|
||||
video_interval?: number
|
||||
grid_size?: [number, number]
|
||||
// 客户端在浏览器里直接抓到的字幕,跳过后端的 download_subtitles + 音频转写
|
||||
prefetched_transcript?: {
|
||||
language: string
|
||||
full_text: string
|
||||
segments: Array<{ start: number, end: number, text: string }>
|
||||
source?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface NoteResult {
|
||||
markdown: string
|
||||
transcript?: unknown
|
||||
audio_meta?: {
|
||||
title?: string
|
||||
duration?: number
|
||||
cover_url?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface TaskStatusResponse {
|
||||
status: TaskStatus
|
||||
message: string
|
||||
task_id: string
|
||||
result?: NoteResult
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
platform: Platform
|
||||
status: TaskStatus
|
||||
message: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
result?: NoteResult
|
||||
}
|
||||
|
||||
// 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐
|
||||
export type NoteStyle =
|
||||
| 'minimal' | 'detailed' | 'academic' | 'tutorial'
|
||||
| 'xiaohongshu' | 'life_journal' | 'task_oriented'
|
||||
| 'business' | 'meeting_minutes'
|
||||
|
||||
// 与 backend/app/gpt/prompt_builder.py note_formats 一一对齐
|
||||
export type NoteFormat = 'toc' | 'link' | 'screenshot' | 'summary'
|
||||
|
||||
export const NOTE_STYLES: Array<{ value: NoteStyle, label: string }> = [
|
||||
{ value: 'minimal', label: '精简' },
|
||||
{ value: 'detailed', label: '详细' },
|
||||
{ value: 'tutorial', label: '教程' },
|
||||
{ value: 'academic', label: '学术' },
|
||||
{ value: 'xiaohongshu', label: '小红书' },
|
||||
{ value: 'life_journal', label: '生活向' },
|
||||
{ value: 'task_oriented', label: '任务导向' },
|
||||
{ value: 'business', label: '商业风格' },
|
||||
{ value: 'meeting_minutes', label: '会议纪要' },
|
||||
]
|
||||
|
||||
export const NOTE_FORMATS: Array<{ value: NoteFormat, label: string }> = [
|
||||
{ value: 'toc', label: '目录' },
|
||||
{ value: 'summary', label: 'AI 总结' },
|
||||
{ value: 'screenshot', label: '原片截图' },
|
||||
{ value: 'link', label: '原片跳转' },
|
||||
]
|
||||
|
||||
export interface Settings {
|
||||
backendUrl: string
|
||||
providerId: string
|
||||
modelName: string
|
||||
quality: Quality
|
||||
// 输出 format 的 toggle 集合(screenshot / link 与下方两个布尔保持联动)
|
||||
formats: NoteFormat[]
|
||||
screenshot: boolean
|
||||
link: boolean
|
||||
style: NoteStyle
|
||||
extras: string
|
||||
// 多模态视频理解:抽帧拼图喂给视觉模型,提升画面相关问题的回答质量
|
||||
// 要求所选 model 是视觉模型(如 gpt-4o / gemini / claude-opus 系列),文字模型会忽略图片
|
||||
video_understanding: boolean
|
||||
// 抽帧间隔(秒),范围 1-30,默认 6
|
||||
video_interval: number
|
||||
// 拼图网格 [rows, cols],每张拼图最多 rows*cols 帧。默认 [2,2]
|
||||
grid_size: [number, number]
|
||||
}
|
||||
|
||||
export interface ProviderUpdatePayload {
|
||||
id: string
|
||||
name?: string
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
type?: string
|
||||
enabled?: number
|
||||
}
|
||||
|
||||
export interface ProviderCreatePayload {
|
||||
name: string
|
||||
api_key: string
|
||||
base_url: string
|
||||
type: string
|
||||
logo?: string
|
||||
}
|
||||
|
||||
export type TranscriberType = 'fast-whisper' | 'bcut' | 'kuaishou' | 'groq' | 'mlx-whisper'
|
||||
export type WhisperModelSize = 'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo'
|
||||
|
||||
export interface TranscriberOption {
|
||||
value: TranscriberType
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface TranscriberConfig {
|
||||
transcriber_type: TranscriberType
|
||||
whisper_model_size: WhisperModelSize | null
|
||||
available_types: TranscriberOption[]
|
||||
whisper_model_sizes: WhisperModelSize[]
|
||||
mlx_whisper_available: boolean
|
||||
}
|
||||
|
||||
export interface WhisperModelStatus {
|
||||
model_size: WhisperModelSize
|
||||
downloaded: boolean
|
||||
downloading: boolean
|
||||
}
|
||||
|
||||
export interface TranscriberModelsStatus {
|
||||
whisper: WhisperModelStatus[]
|
||||
mlx_whisper: WhisperModelStatus[]
|
||||
mlx_available: boolean
|
||||
}
|
||||
|
||||
export interface DeployStatus {
|
||||
backend: { status: string, port: number }
|
||||
cuda: { available: boolean, version: string | null, gpu_name: string | null }
|
||||
whisper: { model_size: string, transcriber_type: string }
|
||||
ffmpeg: { available: boolean }
|
||||
}
|
||||
|
||||
93
BillNote_extension/src/manifest.ts
Normal file
93
BillNote_extension/src/manifest.ts
Normal 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
|
||||
}
|
||||
58
BillNote_extension/src/options/Options.vue
Normal file
58
BillNote_extension/src/options/Options.vue
Normal 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>
|
||||
12
BillNote_extension/src/options/index.html
Normal file
12
BillNote_extension/src/options/index.html
Normal 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>
|
||||
8
BillNote_extension/src/options/main.ts
Normal file
8
BillNote_extension/src/options/main.ts
Normal 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')
|
||||
127
BillNote_extension/src/options/pages/Downloader.vue
Normal file
127
BillNote_extension/src/options/pages/Downloader.vue
Normal 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>
|
||||
203
BillNote_extension/src/options/pages/General.vue
Normal file
203
BillNote_extension/src/options/pages/General.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getProviders, ping } from '~/logic/api'
|
||||
import { settings, settingsReady } from '~/logic/storage'
|
||||
import { getModelsByProvider } from '~/logic/api'
|
||||
import { NOTE_FORMATS, NOTE_STYLES, type Model, type NoteFormat, type Provider } from '~/logic/types'
|
||||
import { watch } from 'vue'
|
||||
|
||||
function toggleFormat(value: NoteFormat, checked: boolean) {
|
||||
const cur = settings.value.formats || []
|
||||
settings.value.formats = checked
|
||||
? Array.from(new Set([...cur, value]))
|
||||
: cur.filter(v => v !== value)
|
||||
}
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const models = ref<Model[]>([])
|
||||
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
status.value = { kind: 'idle', text: '' }
|
||||
try {
|
||||
providers.value = (await getProviders()).filter(p => p.enabled === 1)
|
||||
if (settings.value.providerId)
|
||||
await refreshModels(settings.value.providerId)
|
||||
status.value = { kind: 'ok', text: `已加载 ${providers.value.length} 个供应商` }
|
||||
}
|
||||
catch (e) {
|
||||
status.value = { kind: 'err', text: `加载失败:${(e as Error).message}` }
|
||||
providers.value = []
|
||||
models.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModels(providerId: string) {
|
||||
if (!providerId) {
|
||||
models.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
models.value = await getModelsByProvider(providerId)
|
||||
}
|
||||
catch {
|
||||
models.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
status.value = { kind: 'idle', text: '正在测试…' }
|
||||
const ok = await ping()
|
||||
status.value = ok
|
||||
? { kind: 'ok', text: '后端连通 ✓' }
|
||||
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS' }
|
||||
}
|
||||
|
||||
watch(() => settings.value?.providerId, (id) => {
|
||||
if (id)
|
||||
refreshModels(id)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsReady
|
||||
if (settings.value.backendUrl)
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl">
|
||||
<h1 class="text-xl font-bold mb-4">通用</h1>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">后端地址</h2>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="settings.backendUrl" class="input flex-1" placeholder="http://localhost:8483">
|
||||
<button class="btn-secondary" @click="testConnection">测试连通</button>
|
||||
<button class="btn-secondary" :disabled="loading" @click="refresh">
|
||||
{{ loading ? '加载中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="status.text"
|
||||
class="text-xs"
|
||||
:class="{
|
||||
'text-green-700': status.kind === 'ok',
|
||||
'text-red-600': status.kind === 'err',
|
||||
'text-gray-500': status.kind === 'idle',
|
||||
}"
|
||||
>
|
||||
{{ status.text }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
默认 http://localhost:8483 — 需要在该地址先跑起 BiliNote 后端
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">默认供应商与模型</h2>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">供应商</span>
|
||||
<select v-model="settings.providerId" class="input">
|
||||
<option value="">— 选择供应商 —</option>
|
||||
<option v-for="p in providers" :key="p.id" :value="p.id">
|
||||
{{ p.name }} <span v-if="p.type === 'built-in'">(内置)</span>
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">模型</span>
|
||||
<select v-model="settings.modelName" class="input" :disabled="!settings.providerId">
|
||||
<option value="">— 选择模型 —</option>
|
||||
<option v-for="m in models" :key="m.id" :value="m.model_name">{{ m.model_name }}</option>
|
||||
</select>
|
||||
<span v-if="settings.providerId && models.length === 0" class="text-xs text-amber-700">
|
||||
该供应商还没添加可用模型,去「模型供应商」页编辑
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">默认生成选项</h2>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">画质</span>
|
||||
<select v-model="settings.quality" class="input">
|
||||
<option value="fast">快速 (32k)</option>
|
||||
<option value="medium">中等 (64k)</option>
|
||||
<option value="slow">高质 (128k)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<select v-model="settings.style" class="input">
|
||||
<option v-for="s in NOTE_STYLES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">输出形式(与 web 端 NoteForm 对齐)</span>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-2">
|
||||
<label v-for="f in NOTE_FORMATS" :key="f.value" class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(settings.formats || []).includes(f.value)"
|
||||
@change="toggleFormat(f.value, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">额外提示词(追加到 prompt 末尾)</span>
|
||||
<textarea
|
||||
v-model="settings.extras"
|
||||
class="input resize-y"
|
||||
rows="3"
|
||||
placeholder="例如:重点关注游戏开发部分;保留所有专业术语原文"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">视频理解(多模态)</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
启用后会按抽帧间隔截取视频帧拼成网格图,连同字幕一起喂给视觉模型,提升画面相关问题的回答质量。
|
||||
<strong class="text-amber-700">需要选择视觉模型</strong>(GPT-4o / Gemini / Claude 等),文字模型会忽略图片。
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="settings.video_understanding" type="checkbox">
|
||||
启用视频理解
|
||||
</label>
|
||||
<div v-if="settings.video_understanding" class="grid grid-cols-3 gap-3 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">抽帧间隔(秒, 1-30)</span>
|
||||
<input v-model.number="settings.video_interval" type="number" min="1" max="30" class="input">
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图行 (1-10)</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[0] ?? 2"
|
||||
type="number" min="1" max="10" class="input"
|
||||
@input="settings.grid_size = [Number(($event.target as HTMLInputElement).value) || 2, settings.grid_size?.[1] ?? 2]"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图列 (1-10)</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[1] ?? 2"
|
||||
type="number" min="1" max="10" class="input"
|
||||
@input="settings.grid_size = [settings.grid_size?.[0] ?? 2, Number(($event.target as HTMLInputElement).value) || 2]"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
85
BillNote_extension/src/options/pages/Monitor.vue
Normal file
85
BillNote_extension/src/options/pages/Monitor.vue
Normal 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>
|
||||
239
BillNote_extension/src/options/pages/Providers.vue
Normal file
239
BillNote_extension/src/options/pages/Providers.vue
Normal 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>
|
||||
162
BillNote_extension/src/options/pages/Transcriber.vue
Normal file
162
BillNote_extension/src/options/pages/Transcriber.vue
Normal 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>
|
||||
348
BillNote_extension/src/popup/Popup.vue
Normal file
348
BillNote_extension/src/popup/Popup.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { detectPlatform } from '~/logic/platform'
|
||||
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
|
||||
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
|
||||
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
|
||||
|
||||
const tabUrl = ref<string>('')
|
||||
const tabTitle = ref<string>('')
|
||||
const tabId = ref<number | undefined>(undefined)
|
||||
const platform = computed(() => detectPlatform(tabUrl.value))
|
||||
const supported = computed(() => platform.value !== null)
|
||||
|
||||
const submitting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const activeTaskId = ref<string>('')
|
||||
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function loadActiveTab() {
|
||||
try {
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
tabUrl.value = tab?.url ?? ''
|
||||
tabTitle.value = tab?.title ?? ''
|
||||
tabId.value = tab?.id
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('无法读取当前 tab:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(taskId: string) {
|
||||
try {
|
||||
const res = await getTaskStatus(taskId)
|
||||
upsertTask({
|
||||
taskId,
|
||||
videoUrl: activeTask.value?.videoUrl ?? tabUrl.value,
|
||||
platform: (activeTask.value?.platform ?? platform.value)!,
|
||||
status: res.status,
|
||||
message: res.message,
|
||||
createdAt: activeTask.value?.createdAt ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
result: res.result ?? activeTask.value?.result,
|
||||
})
|
||||
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||
pollTimer = setTimeout(() => poll(taskId), 3000)
|
||||
}
|
||||
catch (e) {
|
||||
errorMsg.value = (e as Error).message
|
||||
pollTimer = setTimeout(() => poll(taskId), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
errorMsg.value = ''
|
||||
if (!supported.value) {
|
||||
errorMsg.value = '当前页面不是支持的视频链接'
|
||||
return
|
||||
}
|
||||
if (!settings.value.providerId || !settings.value.modelName) {
|
||||
errorMsg.value = '请先去设置页选择供应商和模型'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
// B 站:在用户浏览器里直接抓字幕(带本地登录态 cookie),跳过后端的 download_subtitles 与音频转写
|
||||
const prefetched = platform.value === 'bilibili' ? await fetchBilibiliSubtitle(tabUrl.value) : null
|
||||
const formats = settings.value.formats || []
|
||||
const { task_id } = await generateNote({
|
||||
video_url: tabUrl.value,
|
||||
platform: platform.value!,
|
||||
quality: settings.value.quality,
|
||||
provider_id: settings.value.providerId,
|
||||
model_name: settings.value.modelName,
|
||||
// backend VideoRequest 同时接受 format 数组与 screenshot/link 单独布尔,从 formats 派生保持单一真相源
|
||||
format: [...formats],
|
||||
screenshot: formats.includes('screenshot'),
|
||||
link: formats.includes('link'),
|
||||
style: settings.value.style || undefined,
|
||||
extras: settings.value.extras || undefined,
|
||||
video_understanding: settings.value.video_understanding || undefined,
|
||||
video_interval: settings.value.video_understanding ? settings.value.video_interval : undefined,
|
||||
grid_size: settings.value.video_understanding ? settings.value.grid_size : undefined,
|
||||
prefetched_transcript: prefetched ?? undefined,
|
||||
})
|
||||
activeTaskId.value = task_id
|
||||
upsertTask({
|
||||
taskId: task_id,
|
||||
videoUrl: tabUrl.value,
|
||||
platform: platform.value!,
|
||||
status: 'PENDING',
|
||||
message: '已提交',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
poll(task_id)
|
||||
// 提交后顺手把侧边栏拉起来,免得用户来回切窗口
|
||||
openSidePanel()
|
||||
}
|
||||
catch (e) {
|
||||
errorMsg.value = (e as Error).message
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openOptions() {
|
||||
browser.runtime.openOptionsPage()
|
||||
}
|
||||
|
||||
function toggleFormat(value: NoteFormat, checked: boolean) {
|
||||
const cur = settings.value.formats || []
|
||||
settings.value.formats = checked
|
||||
? Array.from(new Set([...cur, value]))
|
||||
: cur.filter(v => v !== value)
|
||||
}
|
||||
|
||||
async function openSidePanel() {
|
||||
// 只能在用户操作触发的同步上下文里调,且需要明确的 tabId
|
||||
try {
|
||||
const target = tabId.value ?? (await browser.tabs.query({ active: true, currentWindow: true }))[0]?.id
|
||||
if (target == null)
|
||||
return
|
||||
// @ts-expect-error sidePanel 类型在 polyfill 中不全
|
||||
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open)
|
||||
// @ts-expect-error see above
|
||||
await chrome.sidePanel.open({ tabId: target })
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('打开侧边栏失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function selectTask(id: string) {
|
||||
activeTaskId.value = id
|
||||
const t = tasks.value?.find(x => x.taskId === id)
|
||||
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
|
||||
poll(id)
|
||||
}
|
||||
|
||||
const activeCover = computed(() => activeTask.value?.result?.audio_meta?.cover_url as string | undefined)
|
||||
const activeTitle = computed(() => (activeTask.value?.result?.audio_meta?.title as string | undefined) || tabTitle.value)
|
||||
|
||||
function fmtTime(ts?: number) {
|
||||
if (!ts)
|
||||
return ''
|
||||
const d = new Date(ts)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([settingsReady, tasksReady])
|
||||
await loadActiveTab()
|
||||
const running = tasks.value?.find(t => t.status !== 'SUCCESS' && t.status !== 'FAILED')
|
||||
if (running) {
|
||||
activeTaskId.value = running.taskId
|
||||
poll(running.taskId)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer)
|
||||
clearTimeout(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="w-[400px] p-3 text-sm text-gray-800 flex flex-col gap-3 bg-white">
|
||||
<header class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base">BiliNote</span>
|
||||
<PlatformBadge :platform="platform" />
|
||||
</div>
|
||||
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
|
||||
</header>
|
||||
|
||||
<div class="text-xs text-gray-500 truncate" :title="tabUrl">
|
||||
{{ tabUrl || '当前没有打开的标签页' }}
|
||||
</div>
|
||||
|
||||
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
|
||||
当前页面不是 BiliNote 支持的视频链接(Bilibili / YouTube / Douyin / Kuaishou)
|
||||
</div>
|
||||
|
||||
<fieldset class="border rounded p-2 flex flex-col gap-2" :disabled="!supported || submitting">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">画质</span>
|
||||
<select v-model="settings.quality" class="border rounded px-1 py-0.5">
|
||||
<option value="fast">快速</option>
|
||||
<option value="medium">中等</option>
|
||||
<option value="slow">高质</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<select v-model="settings.style" class="border rounded px-1 py-0.5">
|
||||
<option v-for="s in NOTE_STYLES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-gray-600">输出形式</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<label v-for="f in NOTE_FORMATS" :key="f.value" class="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(settings.formats || []).includes(f.value)"
|
||||
@change="toggleFormat(f.value, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="text-xs">
|
||||
<summary class="cursor-pointer text-gray-500">高级</summary>
|
||||
<label class="flex flex-col gap-1 mt-2">
|
||||
<span class="text-gray-600">额外提示词(追加到 prompt 末尾)</span>
|
||||
<textarea
|
||||
v-model="settings.extras"
|
||||
class="border rounded px-1 py-1 resize-y"
|
||||
rows="2"
|
||||
placeholder="例如:重点关注游戏开发部分;保留所有专业术语原文"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 mt-2">
|
||||
<input v-model="settings.video_understanding" type="checkbox">
|
||||
<span class="text-gray-600">启用视频理解(抽帧拼图喂视觉模型)</span>
|
||||
</label>
|
||||
<div v-if="settings.video_understanding" class="grid grid-cols-3 gap-2 mt-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">抽帧间隔(秒)</span>
|
||||
<input
|
||||
v-model.number="settings.video_interval"
|
||||
type="number" min="1" max="30"
|
||||
class="border rounded px-1 py-0.5"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图行</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[0] ?? 2"
|
||||
type="number" min="1" max="10"
|
||||
class="border rounded px-1 py-0.5"
|
||||
@input="settings.grid_size = [Number(($event.target as HTMLInputElement).value) || 2, settings.grid_size?.[1] ?? 2]"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图列</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[1] ?? 2"
|
||||
type="number" min="1" max="10"
|
||||
class="border rounded px-1 py-0.5"
|
||||
@input="settings.grid_size = [settings.grid_size?.[0] ?? 2, Number(($event.target as HTMLInputElement).value) || 2]"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="settings.video_understanding" class="text-amber-700 mt-1">
|
||||
⚠ 需要选择视觉模型(GPT-4o / Gemini / Claude 等),文字模型会忽略图片
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<div class="text-xs text-gray-600">
|
||||
<span v-if="settings.providerId && settings.modelName">
|
||||
模型:{{ settings.modelName }}
|
||||
</span>
|
||||
<span v-else class="text-amber-700">
|
||||
⚠ 未选择供应商/模型,
|
||||
<button class="underline" @click="openOptions">去设置</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" :disabled="!supported || submitting || !settings.providerId" @click="start">
|
||||
{{ submitting ? '提交中…' : '生成笔记' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<div v-if="errorMsg" class="text-xs text-red-600 break-words">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<section v-if="activeTask" class="flex flex-col gap-2">
|
||||
<div v-if="activeCover || activeTitle" class="flex gap-3 items-start">
|
||||
<img
|
||||
v-if="activeCover"
|
||||
:src="resolveImageUrl(activeCover)"
|
||||
class="w-20 h-12 object-cover rounded border bg-gray-100 shrink-0"
|
||||
alt="cover"
|
||||
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium leading-snug line-clamp-2 break-words" :title="activeTitle">
|
||||
{{ activeTitle || '(未取到标题)' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">
|
||||
{{ fmtTime(activeTask.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
|
||||
|
||||
<button
|
||||
v-if="activeTask.status === 'SUCCESS'"
|
||||
class="btn-primary"
|
||||
@click="openSidePanel"
|
||||
>
|
||||
在侧边栏查看笔记 / 思维导图 / AI 问答
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-secondary"
|
||||
@click="openSidePanel"
|
||||
>
|
||||
在侧边栏看进度
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<details v-if="(tasks?.length ?? 0) > 0" class="text-xs">
|
||||
<summary class="cursor-pointer text-gray-500">最近任务({{ tasks!.length }})</summary>
|
||||
<ul class="mt-1 flex flex-col gap-1 max-h-32 overflow-auto">
|
||||
<li
|
||||
v-for="t in tasks"
|
||||
:key="t.taskId"
|
||||
class="flex justify-between items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-100 cursor-pointer"
|
||||
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
|
||||
@click="selectTask(t.taskId)"
|
||||
>
|
||||
<span class="truncate flex-1" :title="t.videoUrl">
|
||||
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
|
||||
</span>
|
||||
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.btn-primary { @apply bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm; }
|
||||
.btn-secondary { @apply bg-gray-100 text-gray-700 px-2 py-1 rounded hover:bg-gray-200 text-xs; }
|
||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
</style>
|
||||
12
BillNote_extension/src/popup/index.html
Normal file
12
BillNote_extension/src/popup/index.html
Normal 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>
|
||||
8
BillNote_extension/src/popup/main.ts
Normal file
8
BillNote_extension/src/popup/main.ts
Normal 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')
|
||||
258
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal file
258
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal 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>
|
||||
12
BillNote_extension/src/sidepanel/index.html
Normal file
12
BillNote_extension/src/sidepanel/index.html
Normal 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>
|
||||
8
BillNote_extension/src/sidepanel/main.ts
Normal file
8
BillNote_extension/src/sidepanel/main.ts
Normal 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')
|
||||
3
BillNote_extension/src/styles/index.ts
Normal file
3
BillNote_extension/src/styles/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import './main.css'
|
||||
import 'uno.css'
|
||||
20
BillNote_extension/src/styles/main.css
Executable file
20
BillNote_extension/src/styles/main.css
Executable 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;
|
||||
}
|
||||
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('demo', () => {
|
||||
it('should work', () => {
|
||||
expect(1 + 1).toBe(2)
|
||||
})
|
||||
})
|
||||
24
BillNote_extension/tsconfig.json
Normal file
24
BillNote_extension/tsconfig.json
Normal 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"]
|
||||
}
|
||||
13
BillNote_extension/unocss.config.ts
Normal file
13
BillNote_extension/unocss.config.ts
Normal 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(),
|
||||
],
|
||||
})
|
||||
36
BillNote_extension/vite.config.background.mts
Normal file
36
BillNote_extension/vite.config.background.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
36
BillNote_extension/vite.config.content.mts
Normal file
36
BillNote_extension/vite.config.content.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
115
BillNote_extension/vite.config.mts
Normal file
115
BillNote_extension/vite.config.mts
Normal 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',
|
||||
},
|
||||
}))
|
||||
1
BillNote_frontend/.gitignore
vendored
1
BillNote_frontend/.gitignore
vendored
@@ -22,5 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/pnpm-lock.yaml
|
||||
/src-tauri/bin/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# === 前端构建阶段 ===
|
||||
FROM node:18-alpine AS builder
|
||||
# Tailwind v4 / Vite 6 需要 Node 20+,alpine + pnpm 会按 lockfile 拉 musl native binary。
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.22",
|
||||
"lottie-react": "^2.4.1",
|
||||
|
||||
10810
BillNote_frontend/pnpm-lock.yaml
generated
Normal file
10810
BillNote_frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
BIN
BillNote_frontend/src/assets/wechat.png
Normal file
BIN
BillNote_frontend/src/assets/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -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 }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Outlet } from 'react-router-dom'
|
||||
|
||||
const Model = () => {
|
||||
return (
|
||||
<div className={'flex h-full bg-white'}>
|
||||
<div className={'flex-1/5 border-r border-neutral-200 p-2'}>
|
||||
<div className={'flex h-full min-h-0 bg-white'}>
|
||||
<div className={'flex-1/5 min-h-0 overflow-y-auto border-r border-neutral-200 p-2'}>
|
||||
<Provider></Provider>
|
||||
</div>
|
||||
<div className={'flex-4/5'}>
|
||||
<div className={'flex-4/5 min-h-0 overflow-y-auto'}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Github, Star, ExternalLink, Download } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import logo from '@/assets/icon.svg'
|
||||
import wechatQr from '@/assets/wechat.png'
|
||||
|
||||
export default function AboutPage() {
|
||||
const images = [
|
||||
@@ -26,7 +27,7 @@ export default function AboutPage() {
|
||||
height={50}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<h1 className="text-4xl font-bold">BiliNote v1.8.1</h1>
|
||||
<h1 className="text-4xl font-bold">BiliNote v2.0.0</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 text-xl italic">
|
||||
AI 视频笔记生成工具 让 AI 为你的视频做笔记
|
||||
@@ -197,14 +198,10 @@ export default function AboutPage() {
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">联系和加入社区</h2>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<div className="text-center">
|
||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流 QQ 群</h3>
|
||||
<p className="text-lg font-medium">785367111</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流微信群</h3>
|
||||
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
|
||||
<img src={'https://common-1304618721.cos.ap-chengdu.myqcloud.com/wechat.png'} />
|
||||
<img src={wechatQr} alt="BiliNote 交流微信群" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,19 +75,19 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
getProviderById: id => get().provider.find(p => p.id === id),
|
||||
updateProvider: async (provider: IProvider) => {
|
||||
try {
|
||||
const existing = get().provider.find(p => p.id === provider.id)
|
||||
const merged = { ...existing, ...provider }
|
||||
|
||||
const data = {
|
||||
...provider,
|
||||
api_key: provider.apiKey,
|
||||
base_url: provider.baseUrl,
|
||||
}
|
||||
const res = await updateProviderById(data)
|
||||
if (res.data.code === 0) {
|
||||
const item = res.data.data
|
||||
console.log('Provider ', item)
|
||||
await get().fetchProviderList()
|
||||
...merged,
|
||||
api_key: merged.apiKey,
|
||||
base_url: merged.baseUrl,
|
||||
}
|
||||
// 拦截器已解包:成功时直接返回 data 部分
|
||||
await updateProviderById(data)
|
||||
await get().fetchProviderList()
|
||||
} catch (error) {
|
||||
console.error('Error fetching provider:', error)
|
||||
console.error('Error updating provider:', error)
|
||||
}
|
||||
},
|
||||
getProviderList: () => get().provider,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { delete_task, generateNote } from '@/services/note.ts'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import toast from 'react-hot-toast'
|
||||
import { get, set, del } from 'idb-keyval'
|
||||
|
||||
|
||||
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
|
||||
@@ -211,6 +212,18 @@ export const useTaskStore = create<TaskStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'task-storage',
|
||||
storage: createJSONStorage(() => ({
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
const value = await get(name)
|
||||
return value ?? null
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await set(name, value)
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await del(name)
|
||||
},
|
||||
})),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd() + '/../')
|
||||
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
|
||||
const envDir = process.env.DOCKER_BUILD ? __dirname : path.resolve(__dirname, '../')
|
||||
const env = loadEnv(mode, envDir)
|
||||
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
|
||||
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
|
||||
|
||||
118
CHANGELOG.md
Normal file
118
CHANGELOG.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Changelog
|
||||
|
||||
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [2.1.4] - 2026-05-07
|
||||
|
||||
CI 工程化修复,无运行时行为变化。
|
||||
|
||||
### Internal
|
||||
|
||||
- 桌面端 Tauri 构建矩阵去掉 Linux(`ubuntu-22.04 / x86_64-unknown-linux-gnu`)。Linux 桌面端构建持续 17m+,且无对应分发渠道;Linux 用户继续可以走 Docker 镜像 (`ghcr.io/jefferyhcool/bilinote`)
|
||||
- commitlint workflow 去掉无效的 `firstParent` input(wagoid/commitlint-github-action@v6 不支持,被忽略并打 warn)
|
||||
- 规范 release merge commit 标题:`chore(release): vX.Y.Z`(合 master)/ `chore(release): merge release/X.Y.Z back into develop`(回灌 develop),让 commitlint 能正确识别。`RELEASING.md` §3 与 `CONTRIBUTING.md` §6.3 同步更新
|
||||
|
||||
## [2.1.3] - 2026-05-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- DeepSeek 等非多模态供应商被 400 拒绝(issue #282):`UniversalGPT.create_messages` 与 `_build_merge_messages` 此前**无条件**把 content 拼成 OpenAI 多模态数组 `[{"type":"text",...}]`,DeepSeek `deepseek-chat` 等模型不识别 `image_url` 变体直接报 `invalid_request_error`。`GPTFactory.from_config` 一律实例化 `UniversalGPT`,所以问题覆盖**所有**通过模型设置页接入的非多模态供应商,不止 DeepSeek。
|
||||
- 现按 `video_img_urls` 是否非空切换 content 形态:有图保留多模态数组(视觉模型不退化),无图退回 string。合并阶段历来不带图,统一改 string。
|
||||
- 与同包内 `deepseek_gpt.py` / `openai_gpt.py` / `qwen_gpt.py` 的 message builder 行为对齐。
|
||||
- 新增 `backend/tests/test_universal_gpt_content_format.py` 6 个 case 回归覆盖(含 `image_url` 字面 not-in JSON 断言)。
|
||||
|
||||
感谢 @voidborne-d 的修复(#345)。
|
||||
|
||||
## [2.1.2] - 2026-05-07
|
||||
|
||||
补 v2.1.1 上 ghcr.io 镜像构建失败的坑。
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker 镜像构建失败:v2.1.1 tag 触发的 ghcr.io 推送在 frontend-builder 第 7/7 步 `pnpm run build` 挂掉(vite `loadConfigFromBundledFile` 加载 `@tailwindcss/vite` plugin 时 1.5s 内异常退出)。
|
||||
- `Dockerfile.complete` 与 `BillNote_frontend/Dockerfile` 升 `node:18-alpine` → `node:20-alpine`:Tailwind v4 已不再支持 Node 18,Vite 6 也推荐 Node 20+
|
||||
- `Dockerfile.complete` 的 frontend 阶段同时复制 `pnpm-lock.yaml` 并改用 `--frozen-lockfile`,杜绝每次构建重解析 semver 拉到比本地新的 native dep
|
||||
- `BillNote_frontend/pnpm-lock.yaml` 强制入库(之前一直未提交,导致 CI / 本地依赖图持续漂移)
|
||||
- README 联系社区段补上微信群二维码(之前只写"年会恢复更新以后放出最新社区地址")
|
||||
|
||||
## [2.1.1] - 2026-05-07
|
||||
|
||||
工程化与文档收尾,无运行时行为变化。
|
||||
|
||||
### Added
|
||||
|
||||
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — 贡献指南,落地简化 Git Flow(master + develop + 短生命周期分支)+ 提交规范 + 合并规范
|
||||
- [`RELEASING.md`](./RELEASING.md) — 发版手册(Release Manager 视角),含 release/* 流程 + 各商店人工上传步骤 + 自动发布所需 secrets
|
||||
- `.github/ISSUE_TEMPLATE/{config,bug_report,feature_request}.yml` — 表单形式的 issue 模板,按工作区分类
|
||||
- `.github/pull_request_template.md` — PR 模板,把 CONTRIBUTING §5.2 落成 checklist
|
||||
- `.commitlintrc.json` + `.github/workflows/commitlint.yml` — commitlint CI(PR + push develop/master 时校验,自定义 type 白名单,兼容中文 subject)
|
||||
- `.github/workflows/release-extension.yml` — `v*` tag push 时自动构建插件 .zip / .xpi / .crx 并挂到对应 GitHub Release(商店自动发布以注释形式预留)
|
||||
|
||||
### Changed
|
||||
|
||||
- 关于页二维码改为 `import @/assets/wechat.png`,不再依赖腾讯云 COS CDN,更新只需替换文件 + 跑构建
|
||||
- 群聊 QR 替换为最新版本(`doc/wechat.png` + `BillNote_frontend/src/assets/wechat.png`)
|
||||
|
||||
### Removed
|
||||
|
||||
- 关于页 QQ 群联系方式(号 785367111,已不再活跃维护)
|
||||
- 旧版 `.md` 格式 issue 模板(被新 yml 表单模板取代)
|
||||
|
||||
## [2.1.0] - 2026-05-07
|
||||
|
||||
本次发布的主线是**浏览器插件**和 **B 站字幕优先链路**。配合一些后端 / 前端体验修复。
|
||||
|
||||
### Added — 浏览器插件 (`BillNote_extension/`)
|
||||
|
||||
全新 Chrome / Edge / Firefox MV3 扩展。Vue 3 + Vite + UnoCSS,骨架基于 vitesse-webext。
|
||||
|
||||
- **入口四件套**
|
||||
- 工具栏 popup:识别当前 tab → 一键提交,紧凑展示标题 + 封面 + 进度
|
||||
- 视频页悬浮按钮:仅在支持平台注入,点击即触发任务
|
||||
- 右键菜单"用 BiliNote 总结此视频":限定 4 个支持域名
|
||||
- 侧边栏(side panel):详情视图 + 三模式切换
|
||||
- **侧边栏三视图**
|
||||
- Markdown:渲染笔记,复制 / 下载 .md
|
||||
- 思维导图:基于 markmap-lib + markmap-view 的可缩放 mind map
|
||||
- AI 问答:复用后端 RAG `/chat/index`、`/chat/status`、`/chat/ask` 三件套,自动索引 + 多轮历史
|
||||
- **设置页五大块**(搬入 web 端全部配置能力,今后插件即配置中心)
|
||||
- 通用:后端地址、连通性测试、默认供应商 / 模型 / 画质 / 截图 / 跳转 / 风格
|
||||
- 模型供应商:完整 CRUD、启用切换、连接测试、模型增删
|
||||
- 音频转写配置:fast-whisper / mlx-whisper / Groq / 必剪 / 快手 切换、Whisper 模型大小、本地下载状态、触发下载
|
||||
- 下载配置:每平台 cookie 显示、浏览器一键同步、手动粘贴
|
||||
- 部署监控:后端 / FFmpeg / CUDA / Whisper 状态总览
|
||||
- **浏览器 cookie 直通**:`chrome.cookies.getAll` 一键把 `.bilibili.com` 等域 cookie 同步到后端 `/api/update_downloader_cookie`
|
||||
- **B 站字幕浏览器抓取**:插件直接调 player API 拿字幕,借 host_permissions 自动带本地登录态 cookie,绕过 CORS;随提交以 `prefetched_transcript` 字段附给后端,后端跳过 `download_subtitles` + 音频转写,直接进 GPT 总结
|
||||
|
||||
### Added — 后端
|
||||
|
||||
- `BilibiliSubtitleFetcher`(`app/downloaders/bilibili_subtitle.py`):直接调 B 站 player API 拿字幕,作为非插件场景下 yt-dlp 路径的更可靠替代
|
||||
- `VideoRequest.prefetched_transcript` 字段:客户端预取的字幕直接落到 `<task_id>_transcript.json`,NoteGenerator cache-hit 自动复用
|
||||
|
||||
### Added — 前端 Web
|
||||
|
||||
- Zustand persist 迁移到 IndexedDB(#318)
|
||||
|
||||
### Changed
|
||||
|
||||
- 后端 CORS:从静态 origin 列表改为 regex,兼容 `chrome-extension://`、`moz-extension://`、`localhost`、`tauri.localhost`
|
||||
- mlx-whisper 仓库 ID 改用 `MLX_MODEL_MAP`:`whisper-{size}-mlx` 命名(`large-v3-turbo` 例外),不再 hardcode 出 404
|
||||
- BilibiliDownloader 从 `CookieConfigManager` 读取 cookie 并注入 yt-dlp cookiefile(#333)
|
||||
- CLAUDE.md 补充 v2.0.0 引入的子系统说明(RAG / Chat、可选 Nacos+RabbitMQ、i18n、cookie/transcriber 管理器)以及浏览器插件 workspace
|
||||
|
||||
### Fixed
|
||||
|
||||
- AILogo:自定义供应商(`logo='custom'`)走兜底渲染时不再误报 `console.error`,未匹配的名称降级为 warn
|
||||
- SettingPage `Model.tsx` 双栏布局加 `min-h-0 overflow-y-auto`:供应商列表过长时无法滚动
|
||||
- 供应商开关切换不能实时生效(#336)
|
||||
- `/get_all_providers` 中 301 行历史伪内置脏数据清理 + `add_provider` 加防御(强制 `type='custom'`、同名查重、错误向上抛)
|
||||
- `/api/task_status` 拆 ResponseWrapper:插件侧进度条因未拆 `data` 全灰;同时把 `R.error` 翻译为 `status:'FAILED'`,避免 UI 卡在轮询循环
|
||||
- ESLint / ESM `__dirname` 在 production build 中未定义(多个 docker / vite 配置修复)
|
||||
- GitHub Actions 构建错误 + apt-get 安装失败 + 删除仓库内 ffmpeg 二进制
|
||||
- 渲染时剥掉 backend 注入的 `> 来源链接:URL` 行(与 web 端 MarkdownViewer 一致),导出文件保留原行便于溯源
|
||||
- 侧边栏布局收紧:完成后不再渲染 8 段进度条;标题压成单行;视图切换 + 复制 / 下载并入一行;历史任务从底部 details 改为顶栏下拉
|
||||
|
||||
### Internal
|
||||
|
||||
- 新增分支策略:`develop` / `release/x.y.z` / `master` git-flow
|
||||
- 备份 backend SQLite DB 前 / 清理脏数据后均落盘存档
|
||||
40
CLAUDE.md
40
CLAUDE.md
@@ -36,29 +36,58 @@ cd backend && ./build.sh # Build PyInstaller backend binary
|
||||
cd BillNote_frontend && pnpm tauri build
|
||||
```
|
||||
|
||||
### Browser Extension (Vue 3 + vitesse-webext, MV3)
|
||||
```bash
|
||||
cd BillNote_extension
|
||||
pnpm install
|
||||
pnpm dev # watch mode → ./extension/
|
||||
pnpm build # production build → ./extension/
|
||||
pnpm typecheck
|
||||
```
|
||||
Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension/`. Talks to the same backend at `http://localhost:8483` (configurable in the options page). CORS in `backend/main.py` already accepts `chrome-extension://` and `moz-extension://` via regex.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Backend** (`backend/`) — FastAPI app, entry point `main.py`:
|
||||
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`
|
||||
- `app/services/` — Business logic: `note.py` (NoteGenerator orchestrates the full pipeline), `task_serial_executor.py` (task queue)
|
||||
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`, `chat.py` (RAG Q&A on generated notes)
|
||||
- `app/services/` — Business logic:
|
||||
- `note.py` — `NoteGenerator` orchestrates the full pipeline (download → transcribe → LLM → notes)
|
||||
- `task_serial_executor.py` — task queue
|
||||
- `chat_service.py` + `chat_tools.py` + `vector_store.py` — RAG-based AI Q&A with Function Calling, indexing transcripts and video metadata
|
||||
- `cookie_manager.py` — per-platform cookie storage; injected into yt-dlp by downloaders (e.g. Bilibili)
|
||||
- `transcriber_config_manager.py` — persisted transcriber settings
|
||||
- `worker_registry.py` — **optional** Nacos registration + heartbeat for distributed worker mode (no-op when `NACOS_SERVER_ADDR` unset)
|
||||
- `app/messaging/` — **optional** RabbitMQ producer/consumer publishing task progress/results to `bilinote.task.feedback` exchange. Silently degrades when `RABBITMQ_URL` is unset; always import-safe.
|
||||
- `app/downloaders/` — Platform adapters (bilibili, youtube, douyin, kuaishou, local) with shared `base.py` interface
|
||||
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`
|
||||
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`. YouTube path prefers existing subtitles and skips audio download when available.
|
||||
- `app/gpt/` — LLM integration with factory pattern (`gpt_factory.py`), prompt templates (`prompt.py`, `prompt_builder.py`), and `request_chunker.py` for long transcripts
|
||||
- `app/db/` — SQLite + SQLAlchemy: DAO pattern (`provider_dao.py`, `model_dao.py`, `video_task_dao.py`), models in `models/`
|
||||
- `app/utils/` — `response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX)
|
||||
- `app/utils/` — `response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX), `ppt_generator.py`, `minio_client.py`
|
||||
- `app/i18n/` — backend localization
|
||||
- `events/` (root level) — Blinker signal system for post-processing (e.g., temp file cleanup after transcription)
|
||||
|
||||
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
|
||||
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
|
||||
- `pages/SettingPage/` — LLM provider management, system monitoring, transcriber config
|
||||
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`
|
||||
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`. Persists to IndexedDB.
|
||||
- `services/` — Axios API clients matching backend routes
|
||||
- `hooks/useTaskPolling.ts` — Polls task status every 3 seconds
|
||||
- `components/ui/` — shadcn/ui (Radix-based) components
|
||||
- `i18n/` — `react-i18next` setup with locale JSON in `i18n/locales/`; toggled via `components/LanguageSwitcher.tsx`
|
||||
- Path alias: `@` → `./src`
|
||||
|
||||
**Core Workflow**: User submits URL → task queued → download video → extract audio (FFmpeg) → transcribe (Whisper/Groq/etc) → generate notes (LLM) → frontend polls for completion → display Markdown + mind map.
|
||||
|
||||
**Browser Extension** (`BillNote_extension/`) — Vue 3 + Vite + UnoCSS + webextension-polyfill, MV3:
|
||||
- `src/popup/Popup.vue` — main entry: detects platform from active tab URL, drives generate flow, shows progress + markdown
|
||||
- `src/options/Options.vue` — settings: backend URL, default provider/model (loaded from `/get_all_providers` + `/get_models_by_provider/{id}`), quality, screenshot/link toggles, style
|
||||
- `src/logic/api.ts` — backend API client (uses `settings.backendUrl`, unwraps `ResponseWrapper`, absolutizes `/static/screenshots/...` image paths)
|
||||
- `src/logic/storage.ts` — `chrome.storage.local`-backed Pinia-like state via `useWebExtensionStorage` for settings + last 30 tasks
|
||||
- `src/logic/platform.ts` — URL → platform detection mirroring `backend/app/validators/video_url_validator.py`
|
||||
- `src/sidepanel/`, `src/contentScripts/` — placeholders for P2/P3 (floating button, side panel mind map, RAG chat); not wired into MVP UX
|
||||
- `src/manifest.ts` — MV3 manifest, popup is default action; `host_permissions: *://*/*`
|
||||
- Polling lives client-side in popup (3 s interval while open); MV3 service worker is intentionally thin in P1
|
||||
|
||||
## Key Configuration
|
||||
|
||||
- **Ports**: Backend 8483, Frontend dev 3015, Docker maps 3015→80
|
||||
@@ -66,6 +95,7 @@ cd BillNote_frontend && pnpm tauri build
|
||||
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
|
||||
- **FFmpeg**: Required system dependency for video/audio processing
|
||||
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir)
|
||||
- **Distributed mode (optional)**: Setting `NACOS_SERVER_ADDR` enables Nacos worker registration; setting `RABBITMQ_URL` enables MQ feedback. Both are no-ops when unset — single-node deployment works without either. Other knobs: `WORKER_ID`, `WORKER_SELF_URL`, `WORKER_MAX_CONCURRENT`, `TASK_MAX_WORKERS`.
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
341
CONTRIBUTING.md
Normal file
341
CONTRIBUTING.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# 贡献指南
|
||||
|
||||
欢迎为 BiliNote 贡献代码。本文档约定分支管理、提交规范、合并流程。新贡献者请通读一遍后再开 PR。
|
||||
|
||||
> 关联文档
|
||||
> - [README.md](./README.md):项目概览、快速开始
|
||||
> - [CLAUDE.md](./CLAUDE.md):仓库结构 + 各 workspace 开发命令
|
||||
> - [CHANGELOG.md](./CHANGELOG.md):版本变更记录
|
||||
> - [RELEASING.md](./RELEASING.md):发版执行手册(Release Manager 视角)
|
||||
|
||||
---
|
||||
|
||||
## 1. 仓库结构与工作区
|
||||
|
||||
本仓库为多工作区单体仓:
|
||||
|
||||
| 路径 | 内容 | 主要命令 |
|
||||
|---|---|---|
|
||||
| `backend/` | Python 3.11 + FastAPI | `pip install -r requirements.txt && python main.py` |
|
||||
| `BillNote_frontend/` | React 19 + Vite | `pnpm install && pnpm dev` |
|
||||
| `BillNote_extension/` | Vue 3 + Vite + WebExtension MV3 | `pnpm install && pnpm dev` |
|
||||
|
||||
详细结构与开发命令见 [CLAUDE.md](./CLAUDE.md)。提交时**单 PR 不要跨多个工作区做无关改动**,便于评审与回滚。
|
||||
|
||||
---
|
||||
|
||||
## 2. 分支模型
|
||||
|
||||
采用简化 Git Flow:稳定主干 `master` + 长期开发集成 `develop` + 短生命周期业务分支。
|
||||
|
||||
| 分支类型 | 命名 | 长期保留 | 创建来源 | 合并去向 | 用途 |
|
||||
|---|---|---|---|---|---|
|
||||
| 生产主干 | `master` | ✅ | 仓库默认分支 | — | 始终保持可发布状态;只接收 `release/*` 与 `hotfix/*` 合入 |
|
||||
| 开发主干 | `develop` | ✅ | `master` | `release/*` 回灌后 | 日常需求集成;常规开发都从这里起 |
|
||||
| 功能分支 | `feature/*` | ❌ | `develop` | `develop` | 新功能 / 需求开发 |
|
||||
| 修复分支 | `fix/*` | ❌ | `develop` | `develop` | 开发期间发现的缺陷修复(非线上问题) |
|
||||
| 发布分支 | `release/*` | ❌ | `develop` | `master` + `develop` | 版本冻结、回归、发版准备 |
|
||||
| 热修复 | `hotfix/*` | ❌ | `master` | `master` + `develop` | 线上紧急问题 |
|
||||
|
||||
### 基本原则
|
||||
|
||||
- `master` 只保存**已发布代码**,不接受日常开发提交。
|
||||
- `develop` 是**唯一**长期开发集成分支。
|
||||
- 所有业务开发**必须**用短生命周期分支,禁止长期占用个人开发分支。
|
||||
- 一个分支只承载一个需求或一类明确的修复事项。
|
||||
|
||||
---
|
||||
|
||||
## 3. 分支命名
|
||||
|
||||
### 命名格式
|
||||
|
||||
```
|
||||
feature/<scope>-<事项>
|
||||
fix/<scope>-<事项>
|
||||
release/<版本号>
|
||||
hotfix/<scope>-<事项>
|
||||
```
|
||||
|
||||
`<scope>` 优先用代码 scope(与 commit message scope 对齐,见 §5),常用:
|
||||
|
||||
- `extension` — 浏览器插件(`BillNote_extension/`)
|
||||
- `frontend` — Web 前端(`BillNote_frontend/`)
|
||||
- `backend` — Python 后端(`backend/`)
|
||||
- `bilibili` / `youtube` / `douyin` / `kuaishou` — 平台特定改动
|
||||
- `transcriber` — 音频转写
|
||||
- `gpt` / `chat` — LLM / RAG
|
||||
- `docker` / `ci` — 构建与发布
|
||||
- `docs` — 文档
|
||||
|
||||
### 命名示例
|
||||
|
||||
```bash
|
||||
# 功能开发
|
||||
feature/extension-side-panel
|
||||
feature/youtube-subtitle-innertube
|
||||
feature/backend-rag-chat
|
||||
|
||||
# 开发期修复
|
||||
fix/extension-task-status-unwrap
|
||||
fix/bilibili-cookie-injection
|
||||
fix/mlx-whisper-repo-id
|
||||
|
||||
# 发版
|
||||
release/2.1.0
|
||||
release/2.2.0
|
||||
|
||||
# 线上热修
|
||||
hotfix/backend-cors-regex
|
||||
hotfix/frontend-provider-switch
|
||||
```
|
||||
|
||||
### 命名要求
|
||||
|
||||
- 全小写字母 / 数字 / 中划线
|
||||
- `<事项>` 要表达**具体行为**,避免 `test` / `update` / `temp` / `wip` 这类无意义名
|
||||
- `release/<版本号>` 必须与实际 tag 一致(如 `release/2.1.0` ↔ `v2.1.0`)
|
||||
|
||||
---
|
||||
|
||||
## 4. 标准协作流程
|
||||
|
||||
### 4.1 日常需求开发
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/<scope>-<事项>
|
||||
|
||||
# … 开发 + 自测 + commit …
|
||||
|
||||
git push -u origin feature/<scope>-<事项>
|
||||
# 在 GitHub 上发起 PR:base = develop,compare = 你的分支
|
||||
```
|
||||
|
||||
合并通过且 PR closed 后,**删除本地与远端分支**:
|
||||
|
||||
```bash
|
||||
git branch -d feature/<scope>-<事项>
|
||||
git push origin --delete feature/<scope>-<事项>
|
||||
```
|
||||
|
||||
### 4.2 开发期缺陷修复
|
||||
|
||||
提测或联调中发现问题时,从 `develop` 切 `fix/*`。**不要在原 `feature/*` 上长期叠加零散修复**。
|
||||
|
||||
适用场景:
|
||||
- 已合入 `develop` 后被测试打回的问题
|
||||
- 多个功能集成后暴露的兼容性问题
|
||||
- 非线上环境问题
|
||||
|
||||
### 4.3 版本发布
|
||||
|
||||
```bash
|
||||
# 1. 从 develop 切 release
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout -b release/<版本号>
|
||||
|
||||
# 2. 在 release 分支上:更新 README 版本号、写 CHANGELOG.md、必要的小修
|
||||
git commit -am "docs: <版本号> CHANGELOG + README 版本"
|
||||
git push -u origin release/<版本号>
|
||||
|
||||
# 3. 进入冻结期,PR base=master 合并;同时 PR base=develop 回灌
|
||||
# 4. master 上打 tag
|
||||
git checkout master && git pull
|
||||
git tag -a v<版本号> -m "BiliNote v<版本号>" && git push origin v<版本号>
|
||||
|
||||
# 5. release 分支已合入两边,删除
|
||||
git push origin --delete release/<版本号>
|
||||
```
|
||||
|
||||
要点:
|
||||
- **冻结期内 `release/*` 不再合入新需求**,只允许修复发布缺陷。
|
||||
- 发版窗口期内的新需求继续基于 `develop` 开发,**不进入当前 `release/*`**。
|
||||
- 发布完成后必须把 `release/*` 同步回 `develop`,避免漏修。
|
||||
|
||||
### 4.4 线上紧急修复
|
||||
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
git checkout -b hotfix/<scope>-<事项>
|
||||
# … 修 + commit …
|
||||
# PR base=master,合入后立刻打 patch tag(如 v2.1.1)发版
|
||||
# 同一改动同时 PR base=develop 回灌
|
||||
```
|
||||
|
||||
要点:
|
||||
- `hotfix/*` 仅处理**线上阻断性 / 高优先级**缺陷。
|
||||
- 非紧急问题不得绕过 `develop` 直接走热修流程。
|
||||
- 若当前存在 `release/*` 即将发布,需评估是否同步到对应 release,避免修复丢失。
|
||||
|
||||
---
|
||||
|
||||
## 5. 提交规范
|
||||
|
||||
### 5.1 Commit message 格式
|
||||
|
||||
> CI 已接入 [commitlint](https://commitlint.js.org)([`.commitlintrc.json`](./.commitlintrc.json) + [`.github/workflows/commitlint.yml`](./.github/workflows/commitlint.yml))。
|
||||
> PR 上所有 commit 都会被校验,type 不在白名单时合并按钮会被卡。
|
||||
|
||||
```
|
||||
type(scope): subject
|
||||
```
|
||||
|
||||
例:
|
||||
|
||||
```
|
||||
feat(extension): 侧边栏接入思维导图(markmap)与 RAG 问答
|
||||
fix(bilibili): 修正字幕优先链路在未登录态下的回退
|
||||
docs(contributing): 新增贡献指南
|
||||
chore(ci): 优化 docker 构建缓存
|
||||
```
|
||||
|
||||
#### type
|
||||
|
||||
| type | 说明 |
|
||||
|---|---|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | 缺陷修复 |
|
||||
| `docs` | 文档变更 |
|
||||
| `style` | 代码风格调整(不影响行为) |
|
||||
| `refactor` | 重构(非 feat / 非 fix) |
|
||||
| `perf` | 性能优化 |
|
||||
| `test` | 测试增删 |
|
||||
| `build` | 构建系统 / 依赖变更 |
|
||||
| `ci` | CI 配置变更 |
|
||||
| `chore` | 杂项(不归入以上类别) |
|
||||
| `ui` | 界面 / 交互层调整(仓库内既有用法) |
|
||||
| `revert` | 回滚 |
|
||||
|
||||
#### scope
|
||||
|
||||
与 §3 的分支 scope 保持一致:`extension` / `frontend` / `backend` / `bilibili` / `youtube` / `douyin` / `kuaishou` / `transcriber` / `gpt` / `chat` / `docker` / `ci` / `docs` 等。
|
||||
|
||||
#### subject
|
||||
|
||||
- 用中文或英文都可以,**保持一种风格**。
|
||||
- 用现在时陈述("新增 X" / "修复 Y" / "Add X" / "Fix Y")。
|
||||
- 首字母不大写,结尾不加句号。
|
||||
- 单行控制在 72 字符以内;如需详细说明,正文与标题之间空一行。
|
||||
|
||||
### 5.2 PR 标题与正文
|
||||
|
||||
- **PR 标题**沿用 commit message 格式,描述本次 PR 的总体改动。
|
||||
- **PR 正文**应包含:
|
||||
- 改动的"为什么"(背景 / issue / 用户场景)
|
||||
- 改动的"做了什么"(关键文件、关键决策)
|
||||
- **测试方式**(如何验证、覆盖了哪些 case)
|
||||
- **回归风险**与影响面
|
||||
- 是否需要后端 / 前端 / 配置同步部署
|
||||
|
||||
---
|
||||
|
||||
## 6. 合并规范
|
||||
|
||||
### 6.1 合并前要求
|
||||
|
||||
- 合并前必须**先同步**目标分支最新代码(`git pull --rebase` 或在 PR 上点 "Update branch")。
|
||||
- 合并前必须完成**自测**,确保核心流程可用。
|
||||
- 后端改动需 `python -m py_compile` 至少通过;前端 / 插件改动需 `pnpm typecheck && pnpm build` 通过。
|
||||
- **冲突由分支负责人解决**,不得留给评审人或发版人员。
|
||||
|
||||
### 6.2 评审
|
||||
|
||||
- 默认通过 PR 合并,**不允许**绕过 PR 直接 push 到 `master` 或 `develop`。
|
||||
- 评审人至少关注:业务影响、回归风险、是否夹带无关改动、目录归属是否合理。
|
||||
- 修文档 / 改注释这种小变更允许 1 人评审通过;改业务逻辑 / 协议 / 共享模块至少 2 人评审。
|
||||
|
||||
### 6.3 合并方式
|
||||
|
||||
- `feature/*` / `fix/*` 合入 `develop`:推荐 **Squash and merge**,保持 develop 历史线性。
|
||||
- `release/*` 合入 `master` 与回灌 `develop`:使用 **Merge commit (--no-ff)**,保留发版结构。
|
||||
· merge commit 标题用 `chore(release): vX.Y.Z`(合 master)或 `chore(release): merge release/X.Y.Z back into develop`(回灌 develop),保证 commitlint 通过。
|
||||
- `hotfix/*` 同上 release。
|
||||
|
||||
### 6.4 合并后
|
||||
|
||||
- 短期分支合并完成后**必须删除**(远端 + 本地)。
|
||||
- 已完成的分支不得继续承接新需求;如需后续迭代请重新基于最新目标分支开新分支。
|
||||
|
||||
---
|
||||
|
||||
## 7. Git 钩子注意事项
|
||||
|
||||
`BillNote_extension/` 早期使用 `simple-git-hooks` 的 `postinstall`,会在仓库根目录 `.git/hooks/pre-commit` 注入 `pnpm lint-staged`,但仓库根没有 `package.json`,导致**任何 commit 都被钩子卡死**。**已在 v2.1.0 起移除**该 postinstall 配置。
|
||||
|
||||
如果你机器上还残留旧版本装下来的 hook:
|
||||
|
||||
```bash
|
||||
# 一次性清理
|
||||
rm .git/hooks/pre-commit
|
||||
|
||||
# 或临时绕过本次 commit
|
||||
SKIP_SIMPLE_GIT_HOOKS=1 git commit -m "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 版本号与 CHANGELOG
|
||||
|
||||
- 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/):`MAJOR.MINOR.PATCH`
|
||||
- `MAJOR`:破坏性 API 变更或重大重构
|
||||
- `MINOR`:新增特性、向后兼容
|
||||
- `PATCH`:bug 修复、向后兼容
|
||||
- 每次发版**必须更新 [CHANGELOG.md](./CHANGELOG.md)**,按 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式分类(Added / Changed / Fixed / Removed / Security / Internal)。
|
||||
- 发版同时更新 `README.md` 顶部的版本号与"v\<版本号\> 新增"摘要段。
|
||||
- `master` 上打 tag 形如 `vX.Y.Z`,注释中包含本次发布主线 + 引用 `CHANGELOG.md`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 禁止事项
|
||||
|
||||
- ❌ 直接在 `master` 上开发、提交、修复普通问题
|
||||
- ❌ 新增长期 `dev-*` / `wip-*` / `<姓名>-dev` 个人分支作为日常协作分支
|
||||
- ❌ 一个分支同时承载多个需求 / 多个缺陷 / 跨版本内容
|
||||
- ❌ 未评审、未自测、未通过基础校验直接合并
|
||||
- ❌ `release/*` 冻结后继续混入新需求
|
||||
- ❌ `hotfix/*` 只合入 `master` 而不回灌 `develop`
|
||||
- ❌ 提交 / 仓库内包含密钥、API key、`.env` 等敏感文件
|
||||
- ❌ 提交 `node_modules/` / `dist/` / `extension/dist/` / `__pycache__/` / 大型二进制(参考各级 `.gitignore`)
|
||||
|
||||
---
|
||||
|
||||
## 10. 历史分支迁移
|
||||
|
||||
仓库历史上存在多条已合并但未删除的分支(见 `git branch -a`)。即日起:
|
||||
|
||||
- 不再创建 `dev-*` / `<姓名>-*` 个人分支
|
||||
- 已合入主干的旧分支,由发版人统一清理
|
||||
- 未完成需求应尽快迁移到符合 §3 命名规范的新分支
|
||||
|
||||
---
|
||||
|
||||
## 11. 推荐流程图
|
||||
|
||||
```text
|
||||
master ← hotfix/* (线上紧急修复)
|
||||
↑ ↑
|
||||
│ │
|
||||
release/* ← develop ← feature/* (功能开发)
|
||||
│ ↑
|
||||
└───────────┘
|
||||
fix/* (开发期修复)
|
||||
回灌
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 执行口径速查
|
||||
|
||||
| 场景 | 流程 |
|
||||
|---|---|
|
||||
| 新功能 | `develop` → `feature/*` → `develop` |
|
||||
| 提测后发现问题 | `develop` → `fix/*` → `develop` |
|
||||
| 版本发布 | `develop` → `release/*` → `master` + `develop`;打 tag |
|
||||
| 线上紧急故障 | `master` → `hotfix/*` → `master` + `develop`;打 patch tag |
|
||||
| 发版期内新需求 | 基于 `develop` 开 `feature/*`,**不**进入当前 `release/*` |
|
||||
|
||||
---
|
||||
|
||||
如有改进建议,欢迎开 PR 修订本文档(`docs(contributing): ...`)。
|
||||
@@ -4,11 +4,12 @@ FROM python:3.11-slim AS backend-builder
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN rm -f /etc/apt/sources.list && \
|
||||
RUN set -ex && \
|
||||
rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb https://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
@@ -25,17 +26,23 @@ RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
|
||||
COPY ./backend /tmp/backend
|
||||
|
||||
# === 阶段2:构建 Frontend ===
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
# Node 18-alpine 跑不动 Tailwind v4 / Vite 6(前者要求 Node 20+,后者推荐 Node 20+),
|
||||
# 升到 node:20-alpine。alpine 走 musl,pnpm 会按 lockfile 拉 *-linux-x64-musl native binary。
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /tmp/frontend
|
||||
|
||||
# 先复制 lockfile 利用依赖层缓存
|
||||
# 先复制 package.json + lockfile 利用依赖层缓存
|
||||
# --frozen-lockfile 保证 CI 与本地开发依赖版本一致,杜绝 semver 漂移引入的破坏性升级
|
||||
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ./BillNote_frontend /tmp/frontend
|
||||
|
||||
# 设置环境变量,告诉 vite.config.ts 这是 Docker 构建
|
||||
ENV DOCKER_BUILD=1
|
||||
RUN pnpm run build
|
||||
|
||||
# === 阶段3:完整应用镜像 ===
|
||||
@@ -44,11 +51,12 @@ FROM python:3.11-slim
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
|
||||
# 安装必要的运行时依赖
|
||||
RUN rm -f /etc/apt/sources.list && \
|
||||
RUN set -ex && \
|
||||
rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb https://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg nginx supervisor procps && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
118
README.md
118
README.md
@@ -3,17 +3,17 @@
|
||||
<p align="center">
|
||||
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
|
||||
</p>
|
||||
<h1 align="center" > BiliNote v1.8.1</h1>
|
||||
<h1 align="center" > BiliNote v2.1.4</h1>
|
||||
</div>
|
||||
|
||||
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
||||
<img src="https://img.shields.io/badge/frontend-react-blue" />
|
||||
<img src="https://img.shields.io/badge/frontend-react%2019-blue" />
|
||||
<img src="https://img.shields.io/badge/backend-fastapi-green" />
|
||||
<img src="https://img.shields.io/badge/GPT-openai%20%7C%20deepseek%20%7C%20qwen-ff69b4" />
|
||||
<img src="https://img.shields.io/badge/docker-compose-blue" />
|
||||
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" />
|
||||
<img src="https://img.shields.io/badge/status-active-success" />
|
||||
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
|
||||
</p>
|
||||
@@ -22,30 +22,80 @@
|
||||
|
||||
## ✨ 项目简介
|
||||
|
||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。
|
||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。
|
||||
|
||||
## 📝 使用文档
|
||||
详细文档可以查看[这里](https://docs.bilinote.app/)
|
||||
|
||||
## 体验地址
|
||||
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
|
||||
## 📦 Windows 打包版
|
||||
本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.1.1)进行下载。**注意一定要在没有中文路径的环境下运行。**
|
||||
|
||||
## 📦 桌面版下载
|
||||
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
|
||||
|
||||
> Windows 用户请注意:一定要在没有中文路径的环境下运行。
|
||||
|
||||
## 🔧 功能特性
|
||||
|
||||
- 支持多平台:Bilibili、YouTube、本地视频、抖音(后续会加入更多平台)
|
||||
- 支持多平台:Bilibili、YouTube、本地视频、抖音、快手
|
||||
- 支持返回笔记格式选择
|
||||
- 支持笔记风格选择
|
||||
- 支持多模态视频理解
|
||||
- 支持多版本记录保留
|
||||
- 支持自行配置 GPT 大模型
|
||||
- 本地模型音频转写(支持 Fast-Whisper)
|
||||
- 支持自行配置 GPT 大模型(OpenAI、DeepSeek、Qwen 等)
|
||||
- 本地模型音频转写(支持 Fast-Whisper、MLX-Whisper、Groq、BCut)
|
||||
- GPT 大模型总结视频内容
|
||||
- 自动生成结构化 Markdown 笔记
|
||||
- 可选插入截图(自动截取)
|
||||
- 可选内容跳转链接(关联原视频)
|
||||
- 任务记录与历史回看
|
||||
- 基于 RAG 的笔记内容 AI 问答(支持 Function Calling)
|
||||
- 笔记顶部视频封面 Banner 展示
|
||||
- 工作区和生成历史面板支持折叠/展开
|
||||
|
||||
### v2.1.4 修订
|
||||
|
||||
- CI:桌面端 Tauri 构建去掉 Linux(17m+ 慢线退役;Linux 用户继续走 Docker 镜像)
|
||||
- CI:commitlint workflow 修复 + 规范 release merge commit 标题约定
|
||||
|
||||
### v2.1.3 修订
|
||||
|
||||
- 修复 DeepSeek 等非多模态供应商被 400 拒绝的问题(issue #282):`UniversalGPT` 的 message builder 按是否带图切换 string / 多模态数组形态
|
||||
- 感谢 @voidborne-d (#345)
|
||||
|
||||
### v2.1.2 修订
|
||||
|
||||
- 修复 v2.1.1 触发的 ghcr.io Docker 镜像构建失败(Node 18 + Tailwind v4 不兼容、缺 lockfile)
|
||||
- README 补上微信群二维码
|
||||
|
||||
### v2.1.1 修订
|
||||
|
||||
- 工程化与文档收尾:CONTRIBUTING.md / RELEASING.md / issue + PR 模板 / commitlint CI / 插件发版工作流
|
||||
- 关于页群聊二维码:换成最新版,改为 import 本地资源,不再依赖 CDN
|
||||
- 关于页移除 QQ 群入口(仅保留微信群)
|
||||
- 详见 [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
### v2.1.0 新增
|
||||
|
||||
- 浏览器插件(Chrome / Edge / Firefox MV3)—— 工具栏 popup、视频页悬浮按钮、右键菜单、侧边栏(Markdown / 思维导图 / AI 问答)四件套
|
||||
- 插件设置页五大块:模型供应商 CRUD、音频转写配置、下载配置(含浏览器 Cookie 一键同步)、部署监控
|
||||
- B 站字幕优先:插件在用户浏览器里直接抓字幕(带本地登录态 cookie),跳过后端音频转写
|
||||
- 后端 `BilibiliSubtitleFetcher`:非插件场景下走 player API 拿字幕,作为 yt-dlp 兜底
|
||||
- mlx-whisper 仓库 ID 修正(修复模型 404)
|
||||
- 后端 CORS 改用 regex,兼容浏览器扩展源
|
||||
- 详见 [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
### v2.0.0 新增
|
||||
|
||||
- 基于 RAG 的笔记内容 AI 问答功能,支持半屏/全屏模式
|
||||
- AI 问答支持 Function Calling,模型可主动查询原文数据
|
||||
- RAG 索引支持视频元信息(标题、作者、简介、标签等)
|
||||
- AI 回复支持 Markdown 渲染
|
||||
- 笔记顶部新增视频封面 Banner
|
||||
- 工作区和生成历史面板支持折叠/展开
|
||||
- 笔记开头添加来源链接功能
|
||||
- YouTube 字幕优先获取,有字幕时跳过音频下载
|
||||
- 性能优化与转写器配置改进
|
||||
|
||||
## 📸 截图预览
|
||||

|
||||
@@ -56,7 +106,34 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 克隆仓库
|
||||
### 方式一:Docker 部署(推荐)
|
||||
|
||||
确保已安装 Docker,直接拉取预构建镜像运行:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/jefferyhcool/bilinote:latest
|
||||
|
||||
docker run -d -p 80:80 \
|
||||
-v bilinote-data:/app/backend/data \
|
||||
--name bilinote \
|
||||
ghcr.io/jefferyhcool/bilinote:latest
|
||||
```
|
||||
|
||||
访问:`http://localhost`
|
||||
|
||||
也可以使用 docker-compose 本地构建:
|
||||
|
||||
```bash
|
||||
# 标准部署
|
||||
docker-compose up -d
|
||||
|
||||
# GPU 加速部署(需要 NVIDIA GPU)
|
||||
docker-compose -f docker-compose.gpu.yml up -d
|
||||
```
|
||||
|
||||
### 方式二:源码部署
|
||||
|
||||
#### 1. 克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/JefferyHcool/BiliNote.git
|
||||
@@ -64,7 +141,7 @@ cd BiliNote
|
||||
mv .env.example .env
|
||||
```
|
||||
|
||||
### 2. 启动后端(FastAPI)
|
||||
#### 2. 启动后端(FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
@@ -72,7 +149,7 @@ pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 3. 启动前端(Vite + React)
|
||||
#### 3. 启动前端(Vite + React)
|
||||
|
||||
```bash
|
||||
cd BillNote_frontend
|
||||
@@ -80,11 +157,12 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问:`http://localhost:5173`
|
||||
访问:`http://localhost:3015`
|
||||
|
||||
## ⚙️ 依赖说明
|
||||
|
||||
### 🎬 FFmpeg
|
||||
本项目依赖 ffmpeg 用于音频处理与转码,必须安装:
|
||||
本项目依赖 ffmpeg 用于音频处理与转码,源码部署时必须安装:
|
||||
```bash
|
||||
# Mac (brew)
|
||||
brew install ffmpeg
|
||||
@@ -96,6 +174,8 @@ sudo apt install ffmpeg
|
||||
# 请从官网下载安装:https://ffmpeg.org/download.html
|
||||
```
|
||||
> ⚠️ 若系统无法识别 ffmpeg,请将其加入系统环境变量 PATH
|
||||
>
|
||||
> Docker 部署已内置 FFmpeg,无需额外安装。
|
||||
|
||||
### 🚀 CUDA 加速(可选)
|
||||
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
|
||||
@@ -134,12 +214,18 @@ docker-compose -f docker-compose.gpu.yml up -d
|
||||
- [x] 支持抖音及快手等视频平台
|
||||
- [x] 支持前端设置切换 AI 模型切换、语音转文字模型
|
||||
- [x] AI 摘要风格自定义(学术风、口语风、重点提取等)
|
||||
- [ ] 笔记导出为 PDF / Word / Notion
|
||||
- [x] 加入更多模型支持
|
||||
- [x] 加入更多音频转文本模型支持
|
||||
- [x] 基于 RAG 的笔记内容 AI 问答
|
||||
- [ ] 笔记导出为 PDF / Word / Notion
|
||||
|
||||
### Contact and Join-联系和加入社区
|
||||
年会恢复更新以后放出最新社区地址
|
||||
|
||||
扫码加入 BiliNote 交流微信群(如二维码失效,请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈):
|
||||
|
||||
<p align="center">
|
||||
<img src="./doc/wechat.png" alt="BiliNote 交流微信群" width="240" />
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user