mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-17 21:50:15 +08:00
Compare commits
439 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddaa0eef92 | ||
|
|
e41a3e27eb | ||
|
|
fd18aa2955 | ||
|
|
0a93911f3e | ||
|
|
d0760bcbbc | ||
|
|
2fc558e00a | ||
|
|
a83642e602 | ||
|
|
9bc3b2960b | ||
|
|
095d772c7d | ||
|
|
2e3fda7df4 | ||
|
|
2ea95b2fad | ||
|
|
e78b687096 | ||
|
|
9d1a7cd699 | ||
|
|
f2d8ece0c1 | ||
|
|
7bc4b0114e | ||
|
|
f7ea6f72d9 | ||
|
|
905dbcce47 | ||
|
|
ebdb254fc6 | ||
|
|
1eb213e215 | ||
|
|
4425239717 | ||
|
|
64a0400792 | ||
|
|
58d992f28f | ||
|
|
db556b8991 | ||
|
|
717df2af7b | ||
|
|
b431db545a | ||
|
|
25face4b67 | ||
|
|
edfd6e4765 | ||
|
|
b53cafda5a | ||
|
|
adda5fd240 | ||
|
|
3e28f1fe38 | ||
|
|
bffa285cd0 | ||
|
|
b740e70068 | ||
|
|
261c95cf12 | ||
|
|
1cc7f38e14 | ||
|
|
7fffd6873b | ||
|
|
7b927db363 | ||
|
|
c42ceaaa32 | ||
|
|
177ee4ba3a | ||
|
|
aae17abf9a | ||
|
|
33d44e32d2 | ||
|
|
ce58cb9352 | ||
|
|
2043d89288 | ||
|
|
56e075253a | ||
|
|
f1b091b846 | ||
|
|
37f7ee6e15 | ||
|
|
41f17592c2 | ||
|
|
88d25f8cc1 | ||
|
|
de630dadb3 | ||
|
|
7b5e6099e8 | ||
|
|
bb9a70eee2 | ||
|
|
e9d4740cc7 | ||
|
|
c5900a9026 | ||
|
|
63577aa1aa | ||
|
|
ec33ae35ed | ||
|
|
2e69d1179b | ||
|
|
7e5be46cda | ||
|
|
0742387235 | ||
|
|
604cdefa15 | ||
|
|
ff91f74bef | ||
|
|
9bbae2c0c4 | ||
|
|
5b5bf802af | ||
|
|
ecc2e56246 | ||
|
|
d8470bacbc | ||
|
|
0af2efb4de | ||
|
|
721bda5280 | ||
|
|
a928e0e38f | ||
|
|
1329390f98 | ||
|
|
b117ab9f71 | ||
|
|
c4abaf4e60 | ||
|
|
50f0816dab | ||
|
|
9a64a2da8e | ||
|
|
2bb69d1581 | ||
|
|
e89090bed0 | ||
|
|
edf2083d71 | ||
|
|
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 | ||
|
|
89ceef60d0 | ||
|
|
84cd345b9f | ||
|
|
446e2a60c7 | ||
|
|
489fa78946 | ||
|
|
1291910961 | ||
|
|
c1237996e7 | ||
|
|
8ff074c0d9 | ||
|
|
f43c423a65 | ||
|
|
af18ba0250 | ||
|
|
6055118539 | ||
|
|
30da57ddab | ||
|
|
03300d86c3 | ||
|
|
171dea5e0d | ||
|
|
1323cfd1ec | ||
|
|
d519f284e9 | ||
|
|
7cfade6f78 | ||
|
|
d26655a0a2 | ||
|
|
eff6e7fe75 | ||
|
|
30e6a6ddd7 | ||
|
|
84da8ba7c2 | ||
|
|
4012a45c93 | ||
|
|
76ce0f58ef | ||
|
|
82e69734ee | ||
|
|
b17a6f39a5 | ||
|
|
842ae97883 | ||
|
|
bb974b0b89 | ||
|
|
7344d93053 | ||
|
|
a567788448 | ||
|
|
369de19572 | ||
|
|
0b7f6ca4ee | ||
|
|
2aad103a77 | ||
|
|
c21669d518 | ||
|
|
40450199f2 | ||
|
|
d7e800697b | ||
|
|
e845b3011b | ||
|
|
58f9a57886 |
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
|
||||||
47
.env.example
47
.env.example
@@ -1,30 +1,45 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# BiliNote 环境变量示例
|
||||||
|
# Docker 部署:cp .env.example .env,按需修改,然后 docker-compose up --build -d
|
||||||
|
#
|
||||||
|
# 注意区分两类变量:
|
||||||
|
# 1) VITE_* 是【构建时】变量,会被烘进前端 JS bundle。改完必须
|
||||||
|
# docker-compose build frontend && docker-compose up -d 才会生效,
|
||||||
|
# 只 docker-compose restart 不行。
|
||||||
|
# 2) 其他后端变量是【运行时】变量,改完 docker-compose up -d 即可。
|
||||||
|
#
|
||||||
|
# 提醒:LLM API key 不要写在这里!请部署完成后,从前端「模型供应商」页面录入,
|
||||||
|
# 这些 key 会保存到 SQLite 数据库(./backend/bili_note.db)并随容器持久化。
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
# 通用端口配置
|
# 通用端口配置
|
||||||
BACKEND_PORT=8001
|
BACKEND_PORT=8483 # 后端端口
|
||||||
FRONTEND_PORT=3015
|
FRONTEND_PORT=3015
|
||||||
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
|
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
|
||||||
|
APP_PORT=3015 # docker 部署时对外暴露端口(浏览器访问的端口)
|
||||||
|
|
||||||
# 前端访问后端用(生产环境建议写公网或宿主机 IP)
|
# 前端访问后端用(开发环境直连;Docker 部署下走 nginx 代理,此值仅作回退)
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8001
|
VITE_API_BASE_URL=http://127.0.0.1:8483
|
||||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8001/static/screenshots
|
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
|
||||||
|
VITE_FRONTEND_PORT=3015
|
||||||
|
|
||||||
# 生产环境配置
|
# 生产环境配置
|
||||||
ENV=production
|
ENV=production
|
||||||
STATIC=/static
|
STATIC=/static
|
||||||
OUT_DIR=./static/screenshots
|
OUT_DIR=./static/screenshots
|
||||||
|
NOTE_OUTPUT_DIR=note_results
|
||||||
IMAGE_BASE_URL=/static/screenshots
|
IMAGE_BASE_URL=/static/screenshots
|
||||||
DATA_DIR=data
|
DATA_DIR=data
|
||||||
|
|
||||||
|
# FFMPEG 配置(Docker 镜像已内置 ffmpeg,留空即可;自建/桌面端可填绝对路径)
|
||||||
|
FFMPEG_BIN_PATH=
|
||||||
|
|
||||||
# AI 相关配置
|
# 转写器配置
|
||||||
OPENAI_API_KEY=
|
# TRANSCRIBER_TYPE 可选:fast-whisper / bcut / kuaishou / mlx-whisper(仅 Apple Silicon) / groq
|
||||||
OPENAI_API_BASE_URL=
|
TRANSCRIBER_TYPE=fast-whisper
|
||||||
OPENAI_MODEL=
|
# WHISPER_MODEL_SIZE 默认 tiny (~75MB),首次启动快;想要更高识别质量可在前端
|
||||||
DEEP_SEEK_API_KEY=
|
# 「音频转写配置」页切到 base/small/medium/large。直接在这里改大尺寸会触发
|
||||||
DEEP_SEEK_API_BASE_URL=
|
# 首次启动下载 ~1.5GB 文件,慢网络或 4GB 内存的容器容易 OOM。
|
||||||
DEEP_SEEK_MODEL=
|
WHISPER_MODEL_SIZE=tiny
|
||||||
QWEN_API_KEY=
|
|
||||||
QWEN_API_BASE_URL=
|
|
||||||
QWEN_MODEL=
|
|
||||||
|
|
||||||
# FFMPEG 配置
|
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo
|
||||||
FFMPEG_BIN_PATH=
|
|
||||||
|
|||||||
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-提交规范
|
||||||
88
.github/workflows/docker-build.yml
vendored
Normal file
88
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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: Resolve app version
|
||||||
|
id: app-version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "version=" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
|
build-args: |
|
||||||
|
VITE_APP_VERSION=${{ steps.app-version.outputs.version }}
|
||||||
|
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 " -v bilinote-config:/app/backend/config \\"
|
||||||
|
echo " -v bilinote-static:/app/backend/static \\"
|
||||||
|
echo " -v bilinote-models:/app/backend/models \\"
|
||||||
|
echo " --name bilinote \\"
|
||||||
|
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||||
|
echo ""
|
||||||
|
echo "Access the application at: http://localhost"
|
||||||
|
echo "=========================================="
|
||||||
163
.github/workflows/main.yml
vendored
Normal file
163
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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
|
||||||
|
# 不能用 'latest':pnpm 11+ 要求 Node 22+,与下方 Node 20 不兼容(ERR_UNKNOWN_BUILTIN_MODULE)。
|
||||||
|
# lockfile 是 pnpm 9 生成;统一 pin 到 9.15.0
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.0'
|
||||||
|
|
||||||
|
# 设置 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-
|
||||||
|
|
||||||
|
# 从 tag 注入版本号到 tauri.conf.json:Tauri 取该文件的静态 version 作为
|
||||||
|
# 产物版本,不同步的话构建产物会恒为 conf 里写死的值(此前的 2.0.0)。
|
||||||
|
# github.ref_name 形如 v2.3.2,去掉前缀 v。workflow_dispatch(无 tag)时跳过,保留静态值。
|
||||||
|
- name: Sync version from tag
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
working-directory: BillNote_frontend
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "Injecting version $VERSION into tauri.conf.json"
|
||||||
|
node -e "const f='src-tauri/tauri.conf.json'; const fs=require('fs'); const j=JSON.parse(fs.readFileSync(f,'utf8')); j.version=process.argv[1]; fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');" "$VERSION"
|
||||||
|
node -e "console.log('tauri.conf.json version =', require('./src-tauri/tauri.conf.json').version)"
|
||||||
|
|
||||||
|
# 打包 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 }}
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -190,7 +190,7 @@ cover/
|
|||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
*.pot
|
*.pot
|
||||||
|
.idea/
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
@@ -315,4 +315,15 @@ cython_debug/
|
|||||||
/backend/logs/
|
/backend/logs/
|
||||||
/backend/note_results
|
/backend/note_results
|
||||||
/backend/models
|
/backend/models
|
||||||
/backend/.idea
|
/backend/.idea/*
|
||||||
|
/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))
|
||||||
|
})
|
||||||
190
BillNote_extension/src/background/main.ts
Normal file
190
BillNote_extension/src/background/main.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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'
|
||||||
|
import { normalizeVideoTitle } from '~/logic/task-display'
|
||||||
|
|
||||||
|
// 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, title?: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
|
||||||
|
const platform = detectPlatform(url)
|
||||||
|
const displayTitle = normalizeVideoTitle(title)
|
||||||
|
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(),
|
||||||
|
title: displayTitle,
|
||||||
|
})
|
||||||
|
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; title?: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
|
||||||
|
const result = await startTask(data.url, data.title)
|
||||||
|
// 成功就把侧边栏拉起来给用户看进度
|
||||||
|
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, tab?.title)
|
||||||
|
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>
|
||||||
181
BillNote_extension/src/components/MindMap.vue
Normal file
181
BillNote_extension/src/components/MindMap.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onMounted, onUnmounted, 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 wrapRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
|
let mm: Markmap | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
const transformer = new Transformer()
|
||||||
|
const MIN_EXPORT_FONT_PX = 256
|
||||||
|
const MIN_EXPORT_WIDTH = 12800
|
||||||
|
const MAX_EXPORT_SCALE = 24
|
||||||
|
const MAX_CANVAS_SIDE = 32767
|
||||||
|
|
||||||
|
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob)
|
||||||
|
resolve(blob)
|
||||||
|
else
|
||||||
|
reject(new Error('导出思维导图图片失败'))
|
||||||
|
}, 'image/png')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSvgElement<K extends keyof SVGElementTagNameMap>(tag: K): SVGElementTagNameMap[K] {
|
||||||
|
return document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSvgForCanvas(svg: SVGSVGElement): SVGSVGElement {
|
||||||
|
const cloned = svg.cloneNode(true) as SVGSVGElement
|
||||||
|
|
||||||
|
cloned.querySelectorAll('image').forEach(el => el.remove())
|
||||||
|
cloned.querySelectorAll('foreignObject').forEach((foreignObject) => {
|
||||||
|
const textContent = foreignObject.textContent?.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!textContent) {
|
||||||
|
foreignObject.remove()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = Number(foreignObject.getAttribute('x') || 0)
|
||||||
|
const y = Number(foreignObject.getAttribute('y') || 0)
|
||||||
|
const height = Number(foreignObject.getAttribute('height') || 20)
|
||||||
|
const text = createSvgElement('text')
|
||||||
|
text.setAttribute('x', String(x))
|
||||||
|
text.setAttribute('y', String(y + height / 2))
|
||||||
|
text.setAttribute('dominant-baseline', 'middle')
|
||||||
|
text.setAttribute('font-size', '14')
|
||||||
|
text.setAttribute('font-family', 'Arial, "Microsoft YaHei", sans-serif')
|
||||||
|
text.setAttribute('fill', '#333')
|
||||||
|
text.textContent = textContent
|
||||||
|
foreignObject.replaceWith(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExportFontSize(svg: SVGSVGElement): number {
|
||||||
|
const text = svg.querySelector('text, foreignObject')
|
||||||
|
if (!text)
|
||||||
|
return 14
|
||||||
|
|
||||||
|
const fontSize = Number.parseFloat(getComputedStyle(text).fontSize || '')
|
||||||
|
if (Number.isFinite(fontSize) && fontSize > 0)
|
||||||
|
return fontSize
|
||||||
|
|
||||||
|
const attrSize = Number.parseFloat(text.getAttribute('font-size') || '')
|
||||||
|
return Number.isFinite(attrSize) && attrSize > 0 ? attrSize : 14
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMindmapNoise(md: string): string {
|
||||||
|
return absolutizeMarkdownImages(stripSourceLink(md || ''))
|
||||||
|
// 笔记里的截图/封面图片在思维导图中会被当作超大 SVG foreignObject,
|
||||||
|
// 容易把导图挤成截图里那种“只剩半框/一条竖线”的效果。导图只保留文字层级。
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||||
|
.replace(/<img\b[^>]*>/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fit() {
|
||||||
|
await nextTick()
|
||||||
|
requestAnimationFrame(() => mm?.fit())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
if (!svgRef.value)
|
||||||
|
return
|
||||||
|
const { root } = transformer.transform(stripMindmapNoise(props.markdown))
|
||||||
|
if (!mm)
|
||||||
|
mm = Markmap.create(svgRef.value, { autoFit: true }, root)
|
||||||
|
else
|
||||||
|
await mm.setData(root)
|
||||||
|
await fit()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toPngBlob(): Promise<Blob> {
|
||||||
|
await fit()
|
||||||
|
await nextTick()
|
||||||
|
if (!svgRef.value)
|
||||||
|
throw new Error('思维导图尚未渲染完成')
|
||||||
|
|
||||||
|
const svg = svgRef.value
|
||||||
|
const bbox = svg.getBBox()
|
||||||
|
const padding = 48
|
||||||
|
const x = Math.floor(bbox.x - padding)
|
||||||
|
const y = Math.floor(bbox.y - padding)
|
||||||
|
const width = Math.max(Math.ceil(bbox.width + padding * 2), 1)
|
||||||
|
const height = Math.max(Math.ceil(bbox.height + padding * 2), 1)
|
||||||
|
const cloned = sanitizeSvgForCanvas(svg)
|
||||||
|
|
||||||
|
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||||
|
cloned.setAttribute('width', String(width))
|
||||||
|
cloned.setAttribute('height', String(height))
|
||||||
|
cloned.setAttribute('viewBox', `${x} ${y} ${width} ${height}`)
|
||||||
|
cloned.insertAdjacentHTML('afterbegin', `<rect width="100%" height="100%" fill="#fff"/>`)
|
||||||
|
|
||||||
|
const svgText = new XMLSerializer().serializeToString(cloned)
|
||||||
|
const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.decoding = 'async'
|
||||||
|
img.src = url
|
||||||
|
await img.decode()
|
||||||
|
|
||||||
|
// 不写死某个导出宽度:按导图内容和文字字号动态反推 PNG 倍率。
|
||||||
|
// 目标是让导出的正文至少有 MIN_EXPORT_FONT_PX 像素高,小图自动放大,
|
||||||
|
// 大图则按内容尺寸导出;同时限制最大边长,避免复杂导图撑爆内存。
|
||||||
|
const fontScale = MIN_EXPORT_FONT_PX / getExportFontSize(svg)
|
||||||
|
const widthScale = MIN_EXPORT_WIDTH / width
|
||||||
|
const rawScale = Math.max(window.devicePixelRatio || 1, fontScale, widthScale)
|
||||||
|
const sideLimitScale = Math.min(MAX_CANVAS_SIDE / width, MAX_CANVAS_SIDE / height)
|
||||||
|
const scale = Math.max(1, Math.min(rawScale, MAX_EXPORT_SCALE, sideLimitScale))
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = Math.ceil(width * scale)
|
||||||
|
canvas.height = Math.ceil(height * scale)
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error('当前浏览器不支持 Canvas 导出')
|
||||||
|
ctx.fillStyle = '#fff'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.scale(scale, scale)
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
return await canvasToBlob(canvas)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toPngBlob,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
render()
|
||||||
|
if (wrapRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => fit())
|
||||||
|
resizeObserver.observe(wrapRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
mm?.destroy()
|
||||||
|
mm = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.markdown, render)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="wrapRef" class="w-full h-full min-h-[360px] bg-white rounded border overflow-hidden">
|
||||||
|
<svg ref="svgRef" class="w-full h-full min-h-[360px]" />
|
||||||
|
</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)
|
||||||
|
})()
|
||||||
58
BillNote_extension/src/contentScripts/views/App.vue
Normal file
58
BillNote_extension/src/contentScripts/views/App.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<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,
|
||||||
|
title: document.title,
|
||||||
|
}, '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)
|
||||||
|
}
|
||||||
21
BillNote_extension/src/logic/task-display.ts
Normal file
21
BillNote_extension/src/logic/task-display.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { TaskRecord } from './types'
|
||||||
|
|
||||||
|
const SITE_SUFFIX_RE = /\s*[-_—–||]\s*(哔哩哔哩|bilibili|youtube|抖音|douyin|快手|kuaishou)\s*$/i
|
||||||
|
|
||||||
|
export function normalizeVideoTitle(title: string | undefined | null): string | undefined {
|
||||||
|
const value = title?.trim()
|
||||||
|
if (!value)
|
||||||
|
return undefined
|
||||||
|
return value
|
||||||
|
.replace(SITE_SUFFIX_RE, '')
|
||||||
|
.trim() || value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskDisplayTitle(task: TaskRecord | undefined | null, fallbackTitle?: string): string {
|
||||||
|
if (!task)
|
||||||
|
return normalizeVideoTitle(fallbackTitle) || ''
|
||||||
|
return normalizeVideoTitle((task.result?.audio_meta as { title?: string } | undefined)?.title)
|
||||||
|
|| normalizeVideoTitle(task.title)
|
||||||
|
|| normalizeVideoTitle(fallbackTitle)
|
||||||
|
|| task.videoUrl
|
||||||
|
}
|
||||||
185
BillNote_extension/src/logic/types.ts
Normal file
185
BillNote_extension/src/logic/types.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// 与 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
|
||||||
|
// 从浏览器 tab.title 抓取,任务完成前用来替代 videoUrl 显示
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 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>
|
||||||
351
BillNote_extension/src/popup/Popup.vue
Normal file
351
BillNote_extension/src/popup/Popup.vue
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<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'
|
||||||
|
import { getTaskDisplayTitle, normalizeVideoTitle } from '~/logic/task-display'
|
||||||
|
|
||||||
|
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,
|
||||||
|
title: activeTask.value?.title || normalizeVideoTitle(tabTitle.value),
|
||||||
|
})
|
||||||
|
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(),
|
||||||
|
title: normalizeVideoTitle(tabTitle.value),
|
||||||
|
})
|
||||||
|
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(() => getTaskDisplayTitle(activeTask.value, 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="normalizeVideoTitle(tabTitle) || tabUrl">
|
||||||
|
{{ normalizeVideoTitle(tabTitle) || 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="getTaskDisplayTitle(t)">
|
||||||
|
{{ getTaskDisplayTitle(t) }}
|
||||||
|
</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')
|
||||||
322
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal file
322
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<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'
|
||||||
|
import { getTaskDisplayTitle } from '~/logic/task-display'
|
||||||
|
|
||||||
|
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 successMsg = ref('')
|
||||||
|
const viewMode = ref<ViewMode>('markdown')
|
||||||
|
const showHistory = ref(false)
|
||||||
|
const mindMapRef = ref<{ toPngBlob: () => Promise<Blob> } | null>(null)
|
||||||
|
|
||||||
|
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(),
|
||||||
|
title: cur.title || getTaskDisplayTitle(cur),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 safeFilename(name: string): string {
|
||||||
|
return (name || 'bilinote')
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 120) || 'bilinote'
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadMarkdown() {
|
||||||
|
const md = activeTask.value?.result?.markdown
|
||||||
|
if (!md)
|
||||||
|
return
|
||||||
|
const title = safeFilename(getTaskDisplayTitle(activeTask.value))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMindMapImage() {
|
||||||
|
try {
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
const blob = await mindMapRef.value?.toPngBlob()
|
||||||
|
if (!blob)
|
||||||
|
return
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ [blob.type]: blob }),
|
||||||
|
])
|
||||||
|
successMsg.value = '思维导图图片已复制'
|
||||||
|
setTimeout(() => { successMsg.value = '' }, 2000)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message || '复制思维导图图片失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadMindMapImage() {
|
||||||
|
try {
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
const blob = await mindMapRef.value?.toPngBlob()
|
||||||
|
if (!blob)
|
||||||
|
return
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${safeFilename(getTaskDisplayTitle(activeTask.value))}.png`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message || '下载思维导图图片失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTitle = computed(() => getTaskDisplayTitle(activeTask.value))
|
||||||
|
|
||||||
|
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="getTaskDisplayTitle(t)">
|
||||||
|
{{ getTaskDisplayTitle(t) }}
|
||||||
|
</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>
|
||||||
|
<div v-if="successMsg" class="text-xs text-green-700 px-3 py-1 break-words bg-green-50 shrink-0">
|
||||||
|
{{ successMsg }}
|
||||||
|
</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="activeTitle || 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>
|
||||||
|
<button
|
||||||
|
v-if="viewMode === 'mindmap'"
|
||||||
|
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
|
||||||
|
title="复制思维导图图片"
|
||||||
|
@click="copyMindMapImage"
|
||||||
|
>复制</button>
|
||||||
|
<button
|
||||||
|
v-if="viewMode === 'mindmap'"
|
||||||
|
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
|
||||||
|
title="下载思维导图 PNG"
|
||||||
|
@click="downloadMindMapImage"
|
||||||
|
>下载</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'"
|
||||||
|
ref="mindMapRef"
|
||||||
|
:markdown="activeTask.result.markdown"
|
||||||
|
class="h-full"
|
||||||
|
/>
|
||||||
|
<ChatPanel
|
||||||
|
v-else-if="isDone && viewMode === 'chat'"
|
||||||
|
:task-id="activeTask.taskId"
|
||||||
|
class="h-full"
|
||||||
|
/>
|
||||||
|
<div v-else-if="isFailed" class="p-4 text-sm text-red-600">
|
||||||
|
{{ activeTask.message || '任务失败' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
</style>
|
||||||
12
BillNote_extension/src/sidepanel/index.html
Normal file
12
BillNote_extension/src/sidepanel/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<base target="_blank">
|
||||||
|
<title>Sidepanel</title>
|
||||||
|
</head>
|
||||||
|
<body style="min-width: 100px">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
BillNote_extension/src/sidepanel/main.ts
Normal file
8
BillNote_extension/src/sidepanel/main.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './Sidepanel.vue'
|
||||||
|
import { setupApp } from '~/logic/common-setup'
|
||||||
|
import '../styles'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
setupApp(app)
|
||||||
|
app.mount('#app')
|
||||||
3
BillNote_extension/src/styles/index.ts
Normal file
3
BillNote_extension/src/styles/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import '@unocss/reset/tailwind.css'
|
||||||
|
import './main.css'
|
||||||
|
import 'uno.css'
|
||||||
20
BillNote_extension/src/styles/main.css
Executable file
20
BillNote_extension/src/styles/main.css
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-1 rounded inline-block
|
||||||
|
bg-teal-600 text-white cursor-pointer
|
||||||
|
hover:bg-teal-700
|
||||||
|
disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
@apply inline-block cursor-pointer select-none
|
||||||
|
opacity-75 transition duration-200 ease-in-out
|
||||||
|
hover:opacity-100 hover:text-teal-600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('demo', () => {
|
||||||
|
it('should work', () => {
|
||||||
|
expect(1 + 1).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
24
BillNote_extension/tsconfig.json
Normal file
24
BillNote_extension/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"incremental": false,
|
||||||
|
"target": "es2016",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["DOM", "ESNext"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
13
BillNote_extension/unocss.config.ts
Normal file
13
BillNote_extension/unocss.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'unocss/vite'
|
||||||
|
import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [
|
||||||
|
presetUno(),
|
||||||
|
presetAttributify(),
|
||||||
|
presetIcons(),
|
||||||
|
],
|
||||||
|
transformers: [
|
||||||
|
transformerDirectives(),
|
||||||
|
],
|
||||||
|
})
|
||||||
36
BillNote_extension/vite.config.background.mts
Normal file
36
BillNote_extension/vite.config.background.mts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { sharedConfig } from './vite.config.mjs'
|
||||||
|
import { isDev, r } from './scripts/utils'
|
||||||
|
import packageJson from './package.json'
|
||||||
|
|
||||||
|
// bundling the content script using Vite
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
define: {
|
||||||
|
'__DEV__': isDev,
|
||||||
|
'__NAME__': JSON.stringify(packageJson.name),
|
||||||
|
// https://github.com/vitejs/vite/issues/9320
|
||||||
|
// https://github.com/vitejs/vite/issues/9186
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
watch: isDev
|
||||||
|
? {}
|
||||||
|
: undefined,
|
||||||
|
outDir: r('extension/dist/background'),
|
||||||
|
cssCodeSplit: false,
|
||||||
|
emptyOutDir: false,
|
||||||
|
sourcemap: isDev ? 'inline' : false,
|
||||||
|
lib: {
|
||||||
|
entry: r('src/background/main.ts'),
|
||||||
|
name: packageJson.name,
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'index.mjs',
|
||||||
|
extend: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
36
BillNote_extension/vite.config.content.mts
Normal file
36
BillNote_extension/vite.config.content.mts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { sharedConfig } from './vite.config.mjs'
|
||||||
|
import { isDev, r } from './scripts/utils'
|
||||||
|
import packageJson from './package.json'
|
||||||
|
|
||||||
|
// bundling the content script using Vite
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
define: {
|
||||||
|
'__DEV__': isDev,
|
||||||
|
'__NAME__': JSON.stringify(packageJson.name),
|
||||||
|
// https://github.com/vitejs/vite/issues/9320
|
||||||
|
// https://github.com/vitejs/vite/issues/9186
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
watch: isDev
|
||||||
|
? {}
|
||||||
|
: undefined,
|
||||||
|
outDir: r('extension/dist/contentScripts'),
|
||||||
|
cssCodeSplit: false,
|
||||||
|
emptyOutDir: false,
|
||||||
|
sourcemap: isDev ? 'inline' : false,
|
||||||
|
lib: {
|
||||||
|
entry: r('src/contentScripts/index.ts'),
|
||||||
|
name: packageJson.name,
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'index.global.js',
|
||||||
|
extend: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
115
BillNote_extension/vite.config.mts
Normal file
115
BillNote_extension/vite.config.mts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
|
||||||
|
import { dirname, relative } from 'node:path'
|
||||||
|
import type { UserConfig } from 'vite'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import Vue from '@vitejs/plugin-vue'
|
||||||
|
import Icons from 'unplugin-icons/vite'
|
||||||
|
import IconsResolver from 'unplugin-icons/resolver'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import UnoCSS from 'unocss/vite'
|
||||||
|
import { isDev, port, r } from './scripts/utils'
|
||||||
|
import packageJson from './package.json'
|
||||||
|
|
||||||
|
export const sharedConfig: UserConfig = {
|
||||||
|
root: r('src'),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~/': `${r('src')}/`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__DEV__: isDev,
|
||||||
|
__NAME__: JSON.stringify(packageJson.name),
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
Vue(),
|
||||||
|
|
||||||
|
AutoImport({
|
||||||
|
imports: [
|
||||||
|
'vue',
|
||||||
|
{
|
||||||
|
'webextension-polyfill': [
|
||||||
|
['=', 'browser'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dts: r('src/auto-imports.d.ts'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// https://github.com/antfu/unplugin-vue-components
|
||||||
|
Components({
|
||||||
|
dirs: [r('src/components')],
|
||||||
|
// generate `components.d.ts` for ts support with Volar
|
||||||
|
dts: r('src/components.d.ts'),
|
||||||
|
resolvers: [
|
||||||
|
// auto import icons
|
||||||
|
IconsResolver({
|
||||||
|
prefix: '',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// https://github.com/antfu/unplugin-icons
|
||||||
|
Icons(),
|
||||||
|
|
||||||
|
// https://github.com/unocss/unocss
|
||||||
|
UnoCSS(),
|
||||||
|
|
||||||
|
// rewrite assets to use relative path
|
||||||
|
{
|
||||||
|
name: 'assets-rewrite',
|
||||||
|
enforce: 'post',
|
||||||
|
apply: 'build',
|
||||||
|
transformIndexHtml(html, { path }) {
|
||||||
|
return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'vue',
|
||||||
|
'@vueuse/core',
|
||||||
|
'webextension-polyfill',
|
||||||
|
],
|
||||||
|
exclude: [
|
||||||
|
'vue-demi',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ command }) => ({
|
||||||
|
...sharedConfig,
|
||||||
|
base: command === 'serve' ? `http://localhost:${port}/` : '/dist/',
|
||||||
|
server: {
|
||||||
|
port,
|
||||||
|
hmr: {
|
||||||
|
host: 'localhost',
|
||||||
|
},
|
||||||
|
origin: `http://localhost:${port}`,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
watch: isDev
|
||||||
|
? {}
|
||||||
|
: undefined,
|
||||||
|
outDir: r('extension/dist'),
|
||||||
|
emptyOutDir: false,
|
||||||
|
sourcemap: isDev ? 'inline' : false,
|
||||||
|
// https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements
|
||||||
|
terserOptions: {
|
||||||
|
mangle: false,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
options: r('src/options/index.html'),
|
||||||
|
popup: r('src/popup/index.html'),
|
||||||
|
sidepanel: r('src/sidepanel/index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 前端专用
|
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
|
||||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8000/static/screenshots
|
|
||||||
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
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
/pnpm-lock.yaml
|
/src-tauri/bin/
|
||||||
|
|||||||
8
BillNote_frontend/.prettierignore
Normal file
8
BillNote_frontend/.prettierignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
dist
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
*.svg
|
||||||
|
*.lock
|
||||||
|
*.png
|
||||||
|
public
|
||||||
|
coverage
|
||||||
11
BillNote_frontend/.prettierrc
Normal file
11
BillNote_frontend/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
@@ -1,32 +1,34 @@
|
|||||||
# === 前端构建阶段 ===
|
# === 前端构建阶段 ===
|
||||||
FROM node:18-alpine AS build
|
# Tailwind v4 / Vite 6 需要 Node 20+,alpine + pnpm 会按 lockfile 拉 musl native binary。
|
||||||
|
# BASE_REGISTRY 默认 docker.io,国内拉不到可换 daocloud / 阿里云镜像:
|
||||||
|
# docker-compose build --build-arg BASE_REGISTRY=docker.m.daocloud.io
|
||||||
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
FROM ${BASE_REGISTRY}/library/node:20-alpine AS builder
|
||||||
|
|
||||||
# 安装 pnpm
|
# 可由发布 workflow 从 git tag 注入,用于前端 About 页展示版本;未传时由 Vite 回退读取 tauri.conf.json。
|
||||||
RUN npm install -g pnpm
|
ARG VITE_APP_VERSION=
|
||||||
|
ENV VITE_APP_VERSION=${VITE_APP_VERSION}
|
||||||
|
|
||||||
|
# pnpm pin 到 9.x:lockfile 是 v9 生成;pnpm 11 要求 Node 22+ 与 node:20 不兼容
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||||
|
|
||||||
# 设置工作目录
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 拷贝前端源码
|
# 先复制 lockfile 利用依赖层缓存
|
||||||
COPY ./BillNote_frontend /app
|
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 运行阶段 ===
|
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
||||||
FROM nginx:alpine
|
# 重新声明 ARG —— buildkit 跨阶段不自动继承
|
||||||
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
FROM ${BASE_REGISTRY}/library/nginx:1.25-alpine
|
||||||
|
|
||||||
# 拷贝模板配置
|
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||||
COPY ./BillNote_frontend/deploy/default.conf.template /etc/nginx/templates/default.conf.template
|
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# 拷贝构建产物
|
# 拷贝构建产物
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# 拷贝启动脚本
|
|
||||||
COPY ./BillNote_frontend/deploy/start.sh /start.sh
|
|
||||||
RUN chmod +x /start.sh
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# 使用启动脚本启动容器
|
|
||||||
CMD ["/start.sh"]
|
|
||||||
|
|||||||
@@ -18,4 +18,4 @@
|
|||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,7 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': [
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
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,41 +10,68 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/x": "^2.4.0",
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
"@lobehub/icons": "^1.97.1",
|
||||||
|
"@lobehub/icons-static-svg": "^1.45.0",
|
||||||
"@lottiefiles/dotlottie-react": "^0.13.3",
|
"@lottiefiles/dotlottie-react": "^0.13.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.2.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.9",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
|
"@tauri-apps/plugin-shell": "~2.3.5",
|
||||||
"@uiw/react-markdown-preview": "^5.1.3",
|
"@uiw/react-markdown-preview": "^5.1.3",
|
||||||
|
"antd": "^5.24.8",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"github-markdown-css": "^5.8.1",
|
"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",
|
"lottie-react": "^2.4.1",
|
||||||
"lucide-react": "^0.487.0",
|
"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",
|
"next-themes": "^0.4.6",
|
||||||
|
"pinyin-match": "^1.2.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"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",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"remark-gfm": "1.0.0",
|
"rehype-katex": "^6.0.2",
|
||||||
|
"rehype-slug": "5.1.0",
|
||||||
|
"remark-gfm": "3.0.1",
|
||||||
|
"remark-math": "^5.1.1",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
"tailwind-merge": "^3.1.0",
|
"tailwind-merge": "^3.1.0",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
"@tailwindcss/postcss": "^4.1.3",
|
"@tailwindcss/postcss": "^4.1.3",
|
||||||
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
@@ -54,6 +81,8 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
"typescript-eslint": "^8.24.1",
|
"typescript-eslint": "^8.24.1",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
|
|||||||
10835
BillNote_frontend/pnpm-lock.yaml
generated
Normal file
10835
BillNote_frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
4966
BillNote_frontend/src-tauri/Cargo.lock
generated
Normal file
4966
BillNote_frontend/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user