mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-08 09:13:11 +08:00
Compare commits
329 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 | ||
|
|
3582e65dc5 | ||
|
|
3010690d2e | ||
|
|
a40bb19743 | ||
|
|
6090982261 | ||
|
|
90aeb22853 | ||
|
|
f6a3438079 | ||
|
|
c553fd898f | ||
|
|
f4801d5be7 | ||
|
|
5861ef4168 | ||
|
|
27758f95dd | ||
|
|
a2ab457f75 | ||
|
|
795615f0f7 | ||
|
|
c46c971e64 | ||
|
|
55cc3bcd63 | ||
|
|
05877a2197 | ||
|
|
3e9f908d7b | ||
|
|
8a8e448e22 | ||
|
|
a92c779dd6 | ||
|
|
ef1dec1e47 | ||
|
|
fea376d1cb | ||
|
|
b18277a3a0 | ||
|
|
d8fbceaadf | ||
|
|
dea393e713 | ||
|
|
ae2bfe4d0a | ||
|
|
2f2eb646a4 | ||
|
|
fdc888512a | ||
|
|
3cd4c749c1 | ||
|
|
efadbc267d | ||
|
|
63b8ac7e2b | ||
|
|
c105342ded | ||
|
|
1cd8c33983 | ||
|
|
dd73e56c30 | ||
|
|
4a53b6aa32 | ||
|
|
15d851f0d0 | ||
|
|
8172e64510 | ||
|
|
7969d9a75c | ||
|
|
7fb4fcba77 | ||
|
|
d9a7b89e7d | ||
|
|
8cd8c6f7b4 | ||
|
|
769aca10db | ||
|
|
7b45db2f59 | ||
|
|
a5f0211fcb | ||
|
|
658d29e72f | ||
|
|
2b3f850478 | ||
|
|
caa4619aab | ||
|
|
85b24dee40 | ||
|
|
844e1a102a | ||
|
|
10311c1438 | ||
|
|
3a0f86e74e | ||
|
|
208aed41a1 | ||
|
|
6e385b8d75 | ||
|
|
9ba895fa8d | ||
|
|
df72fa9366 | ||
|
|
6d077a4ed3 | ||
|
|
b1b0e87d85 | ||
|
|
7d325517b3 | ||
|
|
dc29319a3e | ||
|
|
880f745718 | ||
|
|
1ce8b41bde | ||
|
|
f667e9460b | ||
|
|
5f346f1b04 | ||
|
|
b813d83246 | ||
|
|
564eee2682 | ||
|
|
8fecf293bb | ||
|
|
ce76b78b34 | ||
|
|
a8c10d3961 | ||
|
|
c009afaa6c | ||
|
|
5ff88ac765 | ||
|
|
fabf4b7cd5 | ||
|
|
d8768d5d5b | ||
|
|
f05ae6a27f | ||
|
|
e487b5382b | ||
|
|
b20725cb00 | ||
|
|
e40c97b3fd | ||
|
|
ebb6d174ad | ||
|
|
ef4e67eda6 | ||
|
|
d5c3fe472a | ||
|
|
50bf467341 | ||
|
|
caad44414f | ||
|
|
f23ed6ec6c | ||
|
|
0919e65ad7 | ||
|
|
7f8d4faa44 | ||
|
|
7687e898bc | ||
|
|
467deefd28 | ||
|
|
d3f42f967b | ||
|
|
601bd7c4e3 | ||
|
|
6d06cb662d | ||
|
|
63e0345812 | ||
|
|
c24fcc6d7d | ||
|
|
2b0b1d2a85 | ||
|
|
29372bab6b | ||
|
|
03cb670bfa | ||
|
|
8ad1ea6d38 | ||
|
|
cdcbfc89bc | ||
|
|
2a510e8059 | ||
|
|
f8808737a3 | ||
|
|
0aaec4a53f | ||
|
|
1ab91965f3 | ||
|
|
689a6d99b0 | ||
|
|
2a164828a2 | ||
|
|
abbda6848a | ||
|
|
3afc0c1166 | ||
|
|
e9ac6e499f | ||
|
|
02d2b6d983 | ||
|
|
2683569a0b | ||
|
|
5876b88a8a | ||
|
|
58648399a2 | ||
|
|
4981e09ede | ||
|
|
29c4926306 | ||
|
|
7d9d47d7b7 | ||
|
|
3b3e6b86f3 | ||
|
|
d92cc4a977 | ||
|
|
4a0f483224 | ||
|
|
5e63630033 | ||
|
|
80f1b6b48b | ||
|
|
032446d5eb | ||
|
|
35ef60b956 | ||
|
|
cf512e226f | ||
|
|
2dfc1c068f | ||
|
|
2b0fb8f4ad | ||
|
|
f1cc79aab4 | ||
|
|
fff4fdc9c9 | ||
|
|
1945586b55 | ||
|
|
8b1bc54f2d | ||
|
|
707241bf6b | ||
|
|
b965020491 | ||
|
|
df5c0f771a | ||
|
|
31f42aa26e | ||
|
|
be3db5faaf | ||
|
|
9b298d3094 | ||
|
|
ee9f6ed80c | ||
|
|
0a43bca0c0 | ||
|
|
9063026d45 | ||
|
|
32c57b61b5 | ||
|
|
3d91a4f29e | ||
|
|
44203c5382 | ||
|
|
44d89e3b73 | ||
|
|
539d9f3868 | ||
|
|
d6e7a6e394 | ||
|
|
d4f18feaf9 | ||
|
|
f365dfe5de | ||
|
|
8c4d59918e | ||
|
|
53be1f341c | ||
|
|
aeae3410a0 | ||
|
|
41b067305e | ||
|
|
3862c657b7 | ||
|
|
1c848c727f | ||
|
|
4a0bff9919 | ||
|
|
a5a523f918 | ||
|
|
1b78fb6417 | ||
|
|
c1ef98f6d9 | ||
|
|
fbb292d0e3 | ||
|
|
6ff8b4d90f | ||
|
|
490ee11a85 | ||
|
|
591f0d5ddd | ||
|
|
b2034c0865 | ||
|
|
3239675e69 | ||
|
|
137cf81d29 | ||
|
|
c6aa6603ef | ||
|
|
235a044a1a | ||
|
|
1888849270 | ||
|
|
8c0f637ab1 | ||
|
|
00ca7e891c | ||
|
|
5e7c381e07 | ||
|
|
da645291a2 | ||
|
|
c7656e5609 | ||
|
|
faad0fd4d4 | ||
|
|
048a3b70df | ||
|
|
bab8e3af65 | ||
|
|
b75caaea0e | ||
|
|
140c9b1d88 | ||
|
|
668785ebe5 | ||
|
|
883b112fc2 | ||
|
|
8e917ee15e | ||
|
|
5298e6adb3 | ||
|
|
17216534cb | ||
|
|
1071da2bed | ||
|
|
2dfcb600ae | ||
|
|
274d7b9677 | ||
|
|
0a5196a475 | ||
|
|
892ccd9ee4 | ||
|
|
732ea0ba2b | ||
|
|
8ed50ba662 | ||
|
|
d4d5e063d0 | ||
|
|
8e1ab5373f | ||
|
|
61ca6d2fe6 | ||
|
|
21c9d47495 | ||
|
|
321d22271a | ||
|
|
1af6cde68f | ||
|
|
5fe78c2a68 | ||
|
|
17f5bad16d | ||
|
|
51fb59e3e1 | ||
|
|
3d9cb1aaa9 | ||
|
|
ae92ec190a | ||
|
|
832c0fe437 | ||
|
|
894e34b28d | ||
|
|
b31588e00d | ||
|
|
d1f108041b | ||
|
|
e2757a18b9 | ||
|
|
051a099d5f | ||
|
|
be4c3313d4 | ||
|
|
bab61d8462 | ||
|
|
41a79d60a5 | ||
|
|
0bedd7ff6f | ||
|
|
be2a749905 | ||
|
|
c1b1439510 | ||
|
|
03c950eb63 | ||
|
|
3d8981f970 | ||
|
|
cbc94fafce | ||
|
|
d8cec22f54 | ||
|
|
0f40a99f70 | ||
|
|
b9b0e581e7 | ||
|
|
d6b50773b9 | ||
|
|
97f153646f | ||
|
|
6ea9023558 | ||
|
|
c0746aab57 | ||
|
|
c492f0780b | ||
|
|
bf9098db3c | ||
|
|
0e055b34ca | ||
|
|
5fbf84fc36 | ||
|
|
bb64936a38 | ||
|
|
749635e156 | ||
|
|
6e084f720d | ||
|
|
23e7104f5a | ||
|
|
bbba401637 | ||
|
|
72daeda465 | ||
|
|
fd3b105821 | ||
|
|
94220c8b97 | ||
|
|
e4c1c0f7d1 | ||
|
|
58402c6554 | ||
|
|
244bf73260 | ||
|
|
f766966802 | ||
|
|
6496fd097b | ||
|
|
04dad3b72a | ||
|
|
7066b4288a | ||
|
|
1e2a2d33a8 | ||
|
|
d04c7f50ef | ||
|
|
0a13a22d1d | ||
|
|
f3839951bd | ||
|
|
b66c366a08 | ||
|
|
f3c8deb367 | ||
|
|
44e991a9d0 | ||
|
|
ad730bd52d | ||
|
|
1309a592df | ||
|
|
eea22fb1c5 | ||
|
|
c65de4654f | ||
|
|
eb0a46183d | ||
|
|
75541f3d34 | ||
|
|
c037c4b385 | ||
|
|
06e0eb2ce3 | ||
|
|
02688f1600 | ||
|
|
bd68ba35b9 | ||
|
|
885083e8e6 | ||
|
|
e24979f6f4 | ||
|
|
6fcffb635e | ||
|
|
246e8a1406 | ||
|
|
508a0efd92 |
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]
|
||||
}
|
||||
}
|
||||
35
.dockerignore
Normal file
35
.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
||||
# Git 和 IDE
|
||||
.git
|
||||
.github
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
# Tauri 构建产物(非常大)
|
||||
BillNote_frontend/src-tauri/target
|
||||
BillNote_frontend/src-tauri/bin
|
||||
|
||||
# 运行时数据
|
||||
backend/data
|
||||
backend/static
|
||||
backend/models
|
||||
backend/logs
|
||||
backend/uploads
|
||||
backend/*.db
|
||||
backend/note_results
|
||||
backend/bin/
|
||||
|
||||
# 依赖和构建缓存
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
dist/
|
||||
build/
|
||||
*.tar
|
||||
*.egg-info/
|
||||
|
||||
# 环境文件
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
27
.env.example
27
.env.example
@@ -1,29 +1,24 @@
|
||||
###
|
||||
# @Author: 思诺特 jefferyhcool@gmail.com
|
||||
# @Date: 2025-04-14 08:49:59
|
||||
# @LastEditors: 思诺特 jefferyhcool@gmail.com
|
||||
# @LastEditTime: 2025-04-26 19:56:50
|
||||
# @FilePath: \BiliNote\.env.example
|
||||
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
###
|
||||
# 通用端口配置
|
||||
BACKEND_PORT=8001
|
||||
BACKEND_PORT=8483 # 后端端口
|
||||
FRONTEND_PORT=3015
|
||||
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
|
||||
|
||||
# 前端访问后端用(生产环境建议写公网或宿主机 IP)
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8001/static/screenshots
|
||||
|
||||
APP_PORT= 3015 # docker 部署时用
|
||||
# 前端访问后端用 (开发环境使用)
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
|
||||
VITE_FRONTEND_PORT=3015
|
||||
# 生产环境配置
|
||||
ENV=production
|
||||
STATIC=/static
|
||||
OUT_DIR=./static/screenshots
|
||||
NOTE_OUTPUT_DIR=note_results
|
||||
IMAGE_BASE_URL=/static/screenshots
|
||||
DATA_DIR=data
|
||||
# FFMPEG 配置
|
||||
FFMPEG_BIN_PATH=
|
||||
|
||||
# transcriber 相关配置
|
||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)
|
||||
WHISPER_MODEL_SIZE=base
|
||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
|
||||
WHISPER_MODEL_SIZE=medium
|
||||
|
||||
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo
|
||||
|
||||
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、参考资料、产品截图等
|
||||
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-提交规范
|
||||
73
.github/workflows/docker-build.yml
vendored
Normal file
73
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.complete
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Generate Usage Instructions
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo "Docker Image Published!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Pull the image:"
|
||||
echo " docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||
echo ""
|
||||
echo "Run the container:"
|
||||
echo " docker run -d -p 80:80 \\"
|
||||
echo " -v bilinote-data:/app/backend/data \\"
|
||||
echo " --name bilinote \\"
|
||||
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||
echo ""
|
||||
echo "Access the application at: http://localhost"
|
||||
echo "=========================================="
|
||||
148
.github/workflows/main.yml
vendored
Normal file
148
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
name: Build & Release Desktop App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: macos-latest
|
||||
target: universal-apple-darwin
|
||||
- platform: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 设置 Python 环境(带 pip 缓存)
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: backend/requirements.txt
|
||||
|
||||
# 安装 Python 依赖并执行构建
|
||||
- name: Install Python dependencies & Build backend
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
backend\\build.bat
|
||||
else
|
||||
chmod +x backend/build.sh
|
||||
./backend/build.sh
|
||||
fi
|
||||
|
||||
# 设置 pnpm
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
# 设置 Node 环境
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: BillNote_frontend
|
||||
run: pnpm install
|
||||
|
||||
# 设置 Rust 环境
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
# Cargo 缓存
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
BillNote_frontend/src-tauri/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
# 打包 Tauri 应用
|
||||
- name: Build Tauri App
|
||||
working-directory: BillNote_frontend
|
||||
run: pnpm tauri build
|
||||
|
||||
# 收集产物到统一目录
|
||||
- name: Collect release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-artifacts
|
||||
BUNDLE_DIR="BillNote_frontend/src-tauri/target/release/bundle"
|
||||
|
||||
# macOS: .dmg
|
||||
find "$BUNDLE_DIR" -name "*.dmg" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
# Windows: .msi, .exe (NSIS)
|
||||
find "$BUNDLE_DIR" -name "*.msi" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
|
||||
echo "=== Collected artifacts ==="
|
||||
ls -lh release-artifacts/
|
||||
|
||||
# 生成 SHA256 校验和
|
||||
- name: Generate checksums
|
||||
shell: bash
|
||||
run: |
|
||||
cd release-artifacts
|
||||
sha256sum * > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
# 上传产物(供 release job 使用)
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-${{ matrix.platform }}
|
||||
path: release-artifacts/
|
||||
|
||||
# 创建 GitHub Release 并上传所有产物
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List all artifacts
|
||||
run: |
|
||||
echo "=== All release artifacts ==="
|
||||
ls -lhR all-artifacts/
|
||||
|
||||
# 创建 Release 并上传产物
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: BiliNote ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: all-artifacts/*
|
||||
115
.github/workflows/release-extension.yml
vendored
Normal file
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 }}
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -190,7 +190,7 @@ cover/
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
.idea/
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
@@ -316,4 +316,14 @@ cython_debug/
|
||||
/backend/note_results
|
||||
/backend/models
|
||||
/backend/.idea/*
|
||||
/backend/bili_note.db
|
||||
/backend/bili_note.db
|
||||
/backend/uploads/*
|
||||
/backend/.idea/*
|
||||
/backend/config/*
|
||||
/backend/vector_db/
|
||||
/BiliNote_frontend/.idea/*
|
||||
/BiliNote_frontend/src-tauri/bin/
|
||||
|
||||
# FFmpeg 构建文件(不应该提交到仓库)
|
||||
ffmpeg*/
|
||||
ffmpg*/
|
||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
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',
|
||||
},
|
||||
}))
|
||||
2
BillNote_frontend/.env.tauri
Normal file
2
BillNote_frontend/.env.tauri
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8483/api
|
||||
VITE_PLATFORM=tauri
|
||||
2
BillNote_frontend/.gitignore
vendored
2
BillNote_frontend/.gitignore
vendored
@@ -22,4 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/pnpm-lock.yaml
|
||||
/src-tauri/bin/
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
# === 前端构建阶段 ===
|
||||
FROM node:18-alpine AS build
|
||||
# Tailwind v4 / Vite 6 需要 Node 20+,alpine + pnpm 会按 lockfile 拉 musl native binary。
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# 安装 pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 拷贝前端源码
|
||||
COPY ./BillNote_frontend /app
|
||||
# 先复制 lockfile 利用依赖层缓存
|
||||
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 安装依赖并构建
|
||||
RUN pnpm install && pnpm run build
|
||||
# 再复制源代码并构建
|
||||
COPY ./BillNote_frontend/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
# === nginx 运行阶段 ===
|
||||
FROM nginx:alpine
|
||||
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
# 拷贝模板配置
|
||||
COPY ./BillNote_frontend/deploy/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 拷贝构建产物
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# 拷贝启动脚本
|
||||
COPY ./BillNote_frontend/deploy/start.sh /start.sh
|
||||
RUN chmod +x /start.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# 使用启动脚本启动容器
|
||||
CMD ["/start.sh"]
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
11
BillNote_frontend/deploy/default.conf
Normal file
11
BillNote_frontend/deploy/default.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
18705
BillNote_frontend/package-lock.json
generated
Normal file
18705
BillNote_frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/x": "^2.4.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@lobehub/icons": "^1.97.1",
|
||||
"@lobehub/icons-static-svg": "^1.45.0",
|
||||
@@ -21,37 +22,54 @@
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@tauri-apps/plugin-shell": "~2.2.2",
|
||||
"@uiw/react-markdown-preview": "^5.1.3",
|
||||
"antd": "^5.24.8",
|
||||
"axios": "^1.8.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"katex": "^0.16.21",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.22",
|
||||
"lottie-react": "^2.4.1",
|
||||
"lucide-react": "^0.487.0",
|
||||
"markdown-navbar": "^1.4.3",
|
||||
"markmap-common": "^0.18.9",
|
||||
"markmap-lib": "^0.18.11",
|
||||
"markmap-toolbar": "^0.18.10",
|
||||
"markmap-view": "^0.18.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"pinyin-match": "^1.2.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-medium-image-zoom": "^5.2.14",
|
||||
"react-resizable-panels": "^2.1.8",
|
||||
"react-router-dom": "^7.5.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"remark-gfm": "1.0.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"remark-gfm": "3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
|
||||
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
BIN
BillNote_frontend/public/preview_1.png
Normal file
BIN
BillNote_frontend/public/preview_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
4
BillNote_frontend/src-tauri/.gitignore
vendored
Normal file
4
BillNote_frontend/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
5027
BillNote_frontend/src-tauri/Cargo.lock
generated
Normal file
5027
BillNote_frontend/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
BillNote_frontend/src-tauri/Cargo.toml
Normal file
31
BillNote_frontend/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.5.0", features = ["devtools"] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
[package.metadata.tauri.bundle.macOS]
|
||||
frameworks = ["bin/BiliNoteBackend/_internal/"]
|
||||
|
||||
|
||||
3
BillNote_frontend/src-tauri/build.rs
Normal file
3
BillNote_frontend/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
19
BillNote_frontend/src-tauri/capabilities/default.json
Normal file
19
BillNote_frontend/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{
|
||||
"name": "BiliNoteBackend",
|
||||
"sidecar": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
BIN
BillNote_frontend/src-tauri/icons/icon.ico
Normal file
BIN
BillNote_frontend/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
BillNote_frontend/src-tauri/icons/icon.png
Normal file
BIN
BillNote_frontend/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
285
BillNote_frontend/src-tauri/src/lib.rs
Normal file
285
BillNote_frontend/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
use tauri::{Manager, Emitter};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::process::CommandEvent;
|
||||
use std::env;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().expect("无法获取当前可执行文件路径");
|
||||
let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录");
|
||||
|
||||
// 收集所有系统环境变量
|
||||
let mut all_env_vars = HashMap::new();
|
||||
for (key, value) in env::vars() {
|
||||
all_env_vars.insert(key, value);
|
||||
}
|
||||
|
||||
// 增强 PATH 环境变量,添加常见的二进制路径
|
||||
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
|
||||
let additional_paths = get_additional_binary_paths();
|
||||
let enhanced_path = enhance_path_variable(¤t_path, &additional_paths);
|
||||
all_env_vars.insert("PATH".to_string(), enhanced_path);
|
||||
|
||||
// 打印一些关键环境变量用于调试
|
||||
println!("Enhanced PATH: {}", all_env_vars.get("PATH").unwrap_or(&"Not found".to_string()));
|
||||
println!("Total environment variables: {}", all_env_vars.len());
|
||||
|
||||
// 检查 ffmpeg 是否在 PATH 中可用
|
||||
check_ffmpeg_availability();
|
||||
|
||||
// 启动 Python 后端侧车
|
||||
let mut sidecar_command = app.shell().sidecar("BiliNoteBackend").unwrap();
|
||||
|
||||
// 设置所有环境变量到 sidecar
|
||||
for (key, value) in &all_env_vars {
|
||||
sidecar_command = sidecar_command.env(key, value);
|
||||
}
|
||||
|
||||
let (mut rx, _child) = sidecar_command
|
||||
.current_dir(sidecar_dir)
|
||||
.spawn()
|
||||
.expect("Failed to spawn sidecar");
|
||||
|
||||
// 获取主窗口句柄用于发送事件
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 读取诸如 stdout 之类的事件
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
let output = String::from_utf8_lossy(&line);
|
||||
println!("Backend stdout: {}", output);
|
||||
|
||||
// 发送到前端
|
||||
window
|
||||
.emit("backend-message", Some(format!("'{}'", output)))
|
||||
.expect("failed to emit event");
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
let error = String::from_utf8_lossy(&line);
|
||||
eprintln!("Backend stderr: {}", error);
|
||||
|
||||
window
|
||||
.emit("backend-error", Some(format!("'{}'", error)))
|
||||
.expect("failed to emit event");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
println!("Backend terminated with code: {:?}", payload.code);
|
||||
window
|
||||
.emit("backend-terminated", Some(payload.code))
|
||||
.expect("failed to emit event");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("Backend event: {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_system_env_vars,
|
||||
find_executable_path,
|
||||
run_command_with_env,
|
||||
test_ffmpeg_access
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
// 获取额外的二进制路径
|
||||
fn get_additional_binary_paths() -> Vec<String> {
|
||||
if cfg!(target_os = "windows") {
|
||||
vec![
|
||||
"C:\\ffmpeg\\bin".to_string(),
|
||||
"C:\\Program Files\\ffmpeg\\bin".to_string(),
|
||||
"C:\\Program Files (x86)\\ffmpeg\\bin".to_string(),
|
||||
"C:\\tools\\ffmpeg\\bin".to_string(),
|
||||
"C:\\ProgramData\\chocolatey\\bin".to_string(),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
"/usr/local/bin".to_string(),
|
||||
"/opt/homebrew/bin".to_string(),
|
||||
"/usr/bin".to_string(),
|
||||
"/bin".to_string(),
|
||||
"/opt/local/bin".to_string(), // MacPorts
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"/usr/local/bin".to_string(),
|
||||
"/usr/bin".to_string(),
|
||||
"/bin".to_string(),
|
||||
"/snap/bin".to_string(),
|
||||
"/opt/bin".to_string(),
|
||||
"/usr/local/sbin".to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 增强 PATH 环境变量
|
||||
fn enhance_path_variable(current_path: &str, additional_paths: &[String]) -> String {
|
||||
let path_separator = if cfg!(target_os = "windows") { ";" } else { ":" };
|
||||
|
||||
let mut paths: Vec<String> = additional_paths.to_vec();
|
||||
|
||||
// 添加当前 PATH
|
||||
if !current_path.is_empty() {
|
||||
paths.push(current_path.to_string());
|
||||
}
|
||||
|
||||
paths.join(path_separator)
|
||||
}
|
||||
|
||||
// 检查 ffmpeg 可用性
|
||||
fn check_ffmpeg_availability() {
|
||||
use std::process::Command;
|
||||
|
||||
match Command::new("ffmpeg").arg("-version").output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
println!("✓ FFmpeg is available in PATH");
|
||||
let version_info = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = version_info.lines().next().unwrap_or("Unknown version");
|
||||
println!("FFmpeg version: {}", first_line);
|
||||
} else {
|
||||
println!("✗ FFmpeg found but returned error");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✗ FFmpeg not found in PATH: {}", e);
|
||||
|
||||
// 尝试在常见路径中查找
|
||||
let common_paths = get_additional_binary_paths();
|
||||
for path in common_paths {
|
||||
let ffmpeg_path = if cfg!(target_os = "windows") {
|
||||
format!("{}\\ffmpeg.exe", path)
|
||||
} else {
|
||||
format!("{}/ffmpeg", path)
|
||||
};
|
||||
|
||||
if std::path::Path::new(&ffmpeg_path).exists() {
|
||||
println!("✓ Found FFmpeg at: {}", ffmpeg_path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
println!("✗ FFmpeg not found in common installation paths");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri 命令:获取系统环境变量
|
||||
#[tauri::command]
|
||||
fn get_system_env_vars() -> HashMap<String, String> {
|
||||
env::vars().collect()
|
||||
}
|
||||
|
||||
// Tauri 命令:查找可执行文件路径
|
||||
#[tauri::command]
|
||||
fn find_executable_path(executable_name: String) -> Option<String> {
|
||||
use std::process::Command;
|
||||
|
||||
// 首先尝试直接执行
|
||||
if Command::new(&executable_name).arg("--version").output().is_ok() {
|
||||
return Some(executable_name);
|
||||
}
|
||||
|
||||
// 使用 which/where 命令查找
|
||||
let which_cmd = if cfg!(target_os = "windows") { "where" } else { "which" };
|
||||
|
||||
if let Ok(output) = Command::new(which_cmd).arg(&executable_name).output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在常见路径中搜索
|
||||
let common_paths = get_additional_binary_paths();
|
||||
for base_path in common_paths {
|
||||
let executable_path = if cfg!(target_os = "windows") {
|
||||
format!("{}\\{}.exe", base_path, executable_name)
|
||||
} else {
|
||||
format!("{}/{}", base_path, executable_name)
|
||||
};
|
||||
|
||||
if std::path::Path::new(&executable_path).exists() {
|
||||
return Some(executable_path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// Tauri 命令:使用完整环境变量运行命令
|
||||
#[tauri::command]
|
||||
async fn run_command_with_env(
|
||||
program: String,
|
||||
args: Vec<String>
|
||||
) -> Result<String, String> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut cmd = Command::new(&program);
|
||||
cmd.args(&args);
|
||||
|
||||
// 设置所有环境变量
|
||||
for (key, value) in env::vars() {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
// 增强 PATH
|
||||
let current_path = env::var("PATH").unwrap_or_default();
|
||||
let additional_paths = get_additional_binary_paths();
|
||||
let enhanced_path = enhance_path_variable(¤t_path, &additional_paths);
|
||||
cmd.env("PATH", enhanced_path);
|
||||
|
||||
match cmd.output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Failed to execute {}: {}", program, e))
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri 命令:测试 ffmpeg 访问
|
||||
#[tauri::command]
|
||||
async fn test_ffmpeg_access() -> Result<String, String> {
|
||||
run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await
|
||||
}
|
||||
|
||||
// 可选:添加一个函数来动态更新 sidecar 的环境变量
|
||||
#[tauri::command]
|
||||
async fn update_sidecar_environment(
|
||||
app_handle: tauri::AppHandle,
|
||||
additional_env_vars: HashMap<String, String>
|
||||
) -> Result<(), String> {
|
||||
// 这个函数可以用来在运行时更新环境变量
|
||||
// 注意:这需要重启 sidecar 才能生效
|
||||
|
||||
for (key, value) in additional_env_vars {
|
||||
env::set_var(key, value);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
6
BillNote_frontend/src-tauri/src/main.rs
Normal file
6
BillNote_frontend/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user