mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-10 17:43:40 +08:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
334
.dockerignore
334
.dockerignore
@@ -1,321 +1,35 @@
|
|||||||
# Logs
|
# Git 和 IDE
|
||||||
logs
|
.git
|
||||||
*.log
|
.github
|
||||||
npm-debug.log*
|
.idea/
|
||||||
yarn-debug.log*
|
.vscode/
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
BiliNote/pnpm-lock.yaml
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
# Tauri 构建产物(非常大)
|
||||||
lib-cov
|
BillNote_frontend/src-tauri/target
|
||||||
|
BillNote_frontend/src-tauri/bin
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# 运行时数据
|
||||||
coverage
|
backend/data
|
||||||
*.lcov
|
backend/static
|
||||||
|
backend/models
|
||||||
|
backend/logs
|
||||||
|
backend/uploads
|
||||||
|
backend/*.db
|
||||||
|
backend/note_results
|
||||||
|
backend/bin/
|
||||||
|
|
||||||
# nyc test coverage
|
# 依赖和构建缓存
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
.BiliNote-dev/*
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
!.env.example
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
build/
|
||||||
eggs/
|
*.tar
|
||||||
.eggs/
|
|
||||||
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
# 环境文件
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
.idea/
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# UV
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
#uv.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
||||||
.pdm.toml
|
|
||||||
.pdm-python
|
|
||||||
.pdm-build/
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
.env
|
||||||
.venv
|
.env.local
|
||||||
env/
|
.env.*.local
|
||||||
venv/
|
!.env.example
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
|
||||||
.spyderproject
|
|
||||||
.spyproject
|
|
||||||
|
|
||||||
# Rope project settings
|
|
||||||
.ropeproject
|
|
||||||
|
|
||||||
# mkdocs documentation
|
|
||||||
/site
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|
||||||
# Ruff stuff:
|
|
||||||
.ruff_cache/
|
|
||||||
|
|
||||||
# PyPI configuration file
|
|
||||||
.pypirc
|
|
||||||
/backend/data/*
|
|
||||||
/backend/static/*
|
|
||||||
/backend/note_tasks.db
|
|
||||||
/backend/bin/
|
|
||||||
/backend/logs/
|
|
||||||
/backend/note_results
|
|
||||||
/backend/models
|
|
||||||
/backend/.idea/*
|
|
||||||
/backend/bili_note.db
|
|
||||||
/backend/uploads/*
|
|
||||||
/BiliNote_frontend/.idea/*
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 部署时用
|
APP_PORT= 3015 # docker 部署时用
|
||||||
# 前端访问后端用 (开发环境使用)
|
# 前端访问后端用 (开发环境使用)
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8483
|
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
|
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
|
||||||
VITE_FRONTEND_PORT=3015
|
VITE_FRONTEND_PORT=3015
|
||||||
# 生产环境配置
|
# 生产环境配置
|
||||||
@@ -19,6 +19,6 @@ FFMPEG_BIN_PATH=
|
|||||||
|
|
||||||
# transcriber 相关配置
|
# transcriber 相关配置
|
||||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
|
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
|
||||||
WHISPER_MODEL_SIZE=base
|
WHISPER_MODEL_SIZE=medium
|
||||||
|
|
||||||
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo
|
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo
|
||||||
|
|||||||
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: 上报一些bug
|
|
||||||
title: "[BUG]"
|
|
||||||
labels: bug
|
|
||||||
assignees: JefferyHcool
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
name: 🐛 Bug 反馈
|
|
||||||
about: 提交一个 Bug 报告,帮助我们改进
|
|
||||||
title: "[Bug] "
|
|
||||||
labels: bug
|
|
||||||
assignees: ''
|
|
||||||
---
|
|
||||||
|
|
||||||
**版本说明**
|
|
||||||
|
|
||||||
请说明的你的版本号
|
|
||||||
|
|
||||||
**部署方式**
|
|
||||||
|
|
||||||
使用的是什么方式部署(代码环境部署,docker部署,桌面端,在线预览)
|
|
||||||
|
|
||||||
**描述问题**
|
|
||||||
清晰、简明地描述你遇到的问题是什么。
|
|
||||||
|
|
||||||
**复现步骤**
|
|
||||||
复现该问题的步骤:
|
|
||||||
|
|
||||||
1. 进入页面 '...'
|
|
||||||
2. 点击 '...'
|
|
||||||
3. 滚动到 '...'
|
|
||||||
4. 出现错误
|
|
||||||
|
|
||||||
**预期行为**
|
|
||||||
清晰、简明地描述你本来预期发生的行为。
|
|
||||||
|
|
||||||
**截图**
|
|
||||||
如果适用,请添加截图以帮助说明问题。
|
|
||||||
|
|
||||||
**桌面端(请补充以下信息)**
|
|
||||||
|
|
||||||
- 操作系统:例如 Windows / macOS / Ubuntu
|
|
||||||
- 浏览器:例如 Chrome、Safari
|
|
||||||
|
|
||||||
**其他补充信息**
|
|
||||||
请补充任何其他相关信息。
|
|
||||||
93
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
93
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
name: 🐛 Bug 报告
|
||||||
|
description: 报告一个可复现的问题
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢反馈。请尽量提供完整的复现路径与日志,便于排查。
|
||||||
|
⚠️ **不要**贴 API key、SESSDATA、密钥等敏感信息。
|
||||||
|
- type: dropdown
|
||||||
|
id: workspace
|
||||||
|
attributes:
|
||||||
|
label: 受影响的工作区
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 后端 (backend/)
|
||||||
|
- Web 前端 (BillNote_frontend/)
|
||||||
|
- 浏览器插件 (BillNote_extension/)
|
||||||
|
- Tauri 桌面端
|
||||||
|
- 文档 / 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: 版本
|
||||||
|
description: BiliNote 版本号(README 顶部,例如 v2.1.0)
|
||||||
|
placeholder: v2.1.0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: deploy
|
||||||
|
attributes:
|
||||||
|
label: 部署方式
|
||||||
|
options:
|
||||||
|
- 源码运行
|
||||||
|
- Docker (docker-compose.yml)
|
||||||
|
- Docker GPU (docker-compose.gpu.yml)
|
||||||
|
- 桌面端安装包 (Tauri Release)
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤
|
||||||
|
description: 一步步说明如何触发问题
|
||||||
|
placeholder: |
|
||||||
|
1. 打开 ...
|
||||||
|
2. 点击 ...
|
||||||
|
3. 看到 ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: 期望行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: 实际行为
|
||||||
|
description: 含错误信息、截图、录屏均可
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: env
|
||||||
|
attributes:
|
||||||
|
label: 运行环境
|
||||||
|
description: 操作系统、Python 版本、Node 版本、浏览器(如适用)
|
||||||
|
placeholder: |
|
||||||
|
- OS: macOS 14.5 / Windows 11 / Ubuntu 22.04
|
||||||
|
- Python: 3.11.6
|
||||||
|
- Node: 20.18.0
|
||||||
|
- Browser: Chrome 124(如涉及插件/前端)
|
||||||
|
- GPU: 无 / NVIDIA 4070(如涉及 fast-whisper / video understanding)
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 日志 / 堆栈
|
||||||
|
description: 后端 console、前端 DevTools、扩展 background 页都可以贴
|
||||||
|
render: text
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-checks
|
||||||
|
attributes:
|
||||||
|
label: 提交前自查
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过 [Issues](https://github.com/JefferyHcool/BiliNote/issues?q=),确认不是重复问题
|
||||||
|
required: true
|
||||||
|
- label: 我提供的日志中**不**包含 API key、cookie、SESSDATA 等敏感信息
|
||||||
|
required: true
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 📖 文档与常见问题
|
||||||
|
url: https://docs.bilinote.app/
|
||||||
|
about: 安装与配置遇到问题,先看一下文档
|
||||||
|
- name: 💬 提问 / 讨论
|
||||||
|
url: https://github.com/JefferyHcool/BiliNote/discussions
|
||||||
|
about: 用法咨询、想法征集请发到 Discussions(不是 bug 才用 Issues)
|
||||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: ✨ 功能建议
|
||||||
|
description: 提议新功能或改进
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: 想解决什么问题?
|
||||||
|
description: 描述你遇到的实际场景或痛点。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: 建议方案
|
||||||
|
description: 期望的功能或交互。可附草图 / 示例。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 备选方案
|
||||||
|
description: 你考虑过哪些其他做法?为什么没采用?
|
||||||
|
- type: dropdown
|
||||||
|
id: workspace
|
||||||
|
attributes:
|
||||||
|
label: 涉及的工作区
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 后端 (backend/)
|
||||||
|
- Web 前端 (BillNote_frontend/)
|
||||||
|
- 浏览器插件 (BillNote_extension/)
|
||||||
|
- Tauri 桌面端
|
||||||
|
- 不确定
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: 其他补充
|
||||||
|
description: 关联 issue、参考资料、产品截图等
|
||||||
29
.github/ISSUE_TEMPLATE/新增功能建议.md
vendored
29
.github/ISSUE_TEMPLATE/新增功能建议.md
vendored
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
name: 新增功能建议
|
|
||||||
about: 一些新的功能建议
|
|
||||||
title: "[FEATHURE]"
|
|
||||||
labels: enhancement
|
|
||||||
assignees: JefferyHcool
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
name: ✨ 功能请求
|
|
||||||
about: 提出一个新的功能建议
|
|
||||||
title: "[Feature] "
|
|
||||||
labels: enhancement
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**这个功能请求是否与某个问题相关?请描述**
|
|
||||||
清晰简要地描述问题是什么。例如:每次遇到 [...] 都让我感到很沮丧。
|
|
||||||
|
|
||||||
**描述你希望实现的解决方案**
|
|
||||||
清晰简要地描述你希望发生的事情。
|
|
||||||
|
|
||||||
**描述你考虑过的备选方案**
|
|
||||||
清晰简要地描述你考虑过的其他解决方案或功能。
|
|
||||||
|
|
||||||
**其他补充信息**
|
|
||||||
请在此添加关于功能请求的其他上下文或截图。
|
|
||||||
39
.github/pull_request_template.md
vendored
Normal file
39
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!--
|
||||||
|
PR 标题请遵循 type(scope): subject 格式,例如:
|
||||||
|
feat(extension): 侧边栏接入思维导图
|
||||||
|
fix(bilibili): 修正字幕优先链路在未登录态下的回退
|
||||||
|
分支命名 / 提交规范见 CONTRIBUTING.md。
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 改动概述
|
||||||
|
|
||||||
|
<!-- 一句话说清这个 PR 做了什么 -->
|
||||||
|
|
||||||
|
## 为什么
|
||||||
|
|
||||||
|
<!-- 背景、关联 issue(Fixes #xxx / Refs #xxx)、用户场景 -->
|
||||||
|
|
||||||
|
## 做了什么
|
||||||
|
|
||||||
|
<!-- 关键文件、关键决策。可贴关键片段或截图 -->
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
- [ ] `pnpm typecheck && pnpm build`(前端 / 插件)通过
|
||||||
|
- [ ] `python -m py_compile <文件>` 或本地 backend 启动验证(后端)通过
|
||||||
|
- [ ] 手动验证步骤:
|
||||||
|
<!-- 描述如何复现验证;UI 改动请附截图 / 录屏 -->
|
||||||
|
|
||||||
|
## 回归风险
|
||||||
|
|
||||||
|
<!-- 影响面、可能受波及的功能、是否需要前后端 / 配置 同步部署 -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] 分支命名遵循 [CONTRIBUTING.md §3](../CONTRIBUTING.md#3-分支命名)(`feature/*` / `fix/*` / `release/*` / `hotfix/*`)
|
||||||
|
- [ ] base 分支正确(常规改动 → `develop`;线上紧急 → `master`;发版 → 见 §4.3)
|
||||||
|
- [ ] Commit message 遵循 `type(scope): subject` 格式([CONTRIBUTING.md §5.1](../CONTRIBUTING.md#51-commit-message-格式))
|
||||||
|
- [ ] 已自测核心流程
|
||||||
|
- [ ] 已更新相关文档(`README.md` / `CHANGELOG.md` / `CLAUDE.md` / 模块 README,如适用)
|
||||||
|
- [ ] 未夹带 secrets / `.env` / 大型二进制
|
||||||
|
- [ ] 单 PR 不跨多个工作区做无关改动
|
||||||
32
.github/workflows/commitlint.yml
vendored
Normal file
32
.github/workflows/commitlint.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
|
# PR 上检查 base..head 之间所有 commit;push 上只校验最新 commit
|
||||||
|
firstParent: false
|
||||||
|
failOnWarnings: false
|
||||||
|
helpURL: https://github.com/JefferyHcool/BiliNote/blob/develop/CONTRIBUTING.md#5-提交规范
|
||||||
73
.github/workflows/docker-build.yml
vendored
Normal file
73
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
name: Build and Publish Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and Push Docker Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.complete
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: Generate Usage Instructions
|
||||||
|
run: |
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Docker Image Published!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Pull the image:"
|
||||||
|
echo " docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||||
|
echo ""
|
||||||
|
echo "Run the container:"
|
||||||
|
echo " docker run -d -p 80:80 \\"
|
||||||
|
echo " -v bilinote-data:/app/backend/data \\"
|
||||||
|
echo " --name bilinote \\"
|
||||||
|
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||||
|
echo ""
|
||||||
|
echo "Access the application at: http://localhost"
|
||||||
|
echo "=========================================="
|
||||||
131
.github/workflows/main.yml
vendored
131
.github/workflows/main.yml
vendored
@@ -1,30 +1,45 @@
|
|||||||
# .github/workflows/release.yml
|
name: Build & Release Desktop App
|
||||||
name: Build Desktop App (Python Backend + Tauri Frontend)
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*' # 发布 tag 时触发
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [macos-latest, windows-latest]
|
include:
|
||||||
|
- platform: macos-latest
|
||||||
|
target: universal-apple-darwin
|
||||||
|
- platform: ubuntu-22.04
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
- platform: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# 设置 Python 环境
|
# Linux 系统依赖(Tauri 需要)
|
||||||
|
- name: Install Linux Dependencies
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
# 设置 Python 环境(带 pip 缓存)
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.11'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: backend/requirements.txt
|
||||||
|
|
||||||
# 安装 Python 依赖并执行你的 build.sh
|
# 安装 Python 依赖并执行构建
|
||||||
- name: Install Python dependencies & Build backend
|
- name: Install Python dependencies & Build backend
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -38,30 +53,108 @@ jobs:
|
|||||||
./backend/build.sh
|
./backend/build.sh
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 设置 Node 环境 + 安装前端依赖
|
# 设置 pnpm
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 'latest'
|
||||||
|
|
||||||
|
# 设置 Node 环境
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
- name: Enable Corepack + Install pnpm
|
- name: Install frontend dependencies
|
||||||
working-directory: BillNote_frontend
|
working-directory: BillNote_frontend
|
||||||
run: |
|
run: pnpm install
|
||||||
corepack enable
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# 设置 Rust 环境
|
# 设置 Rust 环境
|
||||||
- name: Set up Rust
|
- name: Set up Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
# Cargo 缓存
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
BillNote_frontend/src-tauri/target/
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
# 打包 Tauri 应用
|
# 打包 Tauri 应用
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
working-directory: BillNote_frontend
|
working-directory: BillNote_frontend
|
||||||
run: pnpm tauri build
|
run: pnpm tauri build
|
||||||
|
|
||||||
# 可选:上传构建产物
|
# 收集产物到统一目录
|
||||||
- name: Upload Desktop Bundle
|
- name: Collect release artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p release-artifacts
|
||||||
|
BUNDLE_DIR="BillNote_frontend/src-tauri/target/release/bundle"
|
||||||
|
|
||||||
|
# macOS: .dmg
|
||||||
|
find "$BUNDLE_DIR" -name "*.dmg" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||||
|
# Windows: .msi, .exe (NSIS)
|
||||||
|
find "$BUNDLE_DIR" -name "*.msi" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||||
|
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||||
|
# Linux: .deb, .AppImage
|
||||||
|
find "$BUNDLE_DIR" -name "*.deb" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||||
|
find "$BUNDLE_DIR" -name "*.AppImage" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== Collected artifacts ==="
|
||||||
|
ls -lh release-artifacts/
|
||||||
|
|
||||||
|
# 生成 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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: app-${{ matrix.platform }}
|
name: artifacts-${{ matrix.platform }}
|
||||||
path: BillNote_frontend/src-tauri/target/release/bundle/
|
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 }}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -320,5 +320,10 @@ cython_debug/
|
|||||||
/backend/uploads/*
|
/backend/uploads/*
|
||||||
/backend/.idea/*
|
/backend/.idea/*
|
||||||
/backend/config/*
|
/backend/config/*
|
||||||
|
/backend/vector_db/
|
||||||
/BiliNote_frontend/.idea/*
|
/BiliNote_frontend/.idea/*
|
||||||
/BiliNote_frontend/src-tauri/bin/
|
/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))
|
||||||
|
})
|
||||||
184
BillNote_extension/src/background/main.ts
Normal file
184
BillNote_extension/src/background/main.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { onMessage } from 'webext-bridge/background'
|
||||||
|
import type { Settings, TaskRecord } from '~/logic/types'
|
||||||
|
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
|
||||||
|
import { detectPlatform } from '~/logic/platform'
|
||||||
|
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||||
|
|
||||||
|
// only on dev mode
|
||||||
|
if (import.meta.hot) {
|
||||||
|
// @ts-expect-error for background HMR
|
||||||
|
import('/@vite/client')
|
||||||
|
// load latest content script
|
||||||
|
import('./contentScriptHMR')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 直接操作 chrome.storage(service worker 里别用 Vue 反应式)----------
|
||||||
|
|
||||||
|
async function readSettings(): Promise<Settings> {
|
||||||
|
const obj = await browser.storage.local.get(SETTINGS_KEY)
|
||||||
|
const raw = obj[SETTINGS_KEY] as string | undefined
|
||||||
|
if (!raw)
|
||||||
|
return { ...DEFAULT_SETTINGS }
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return { ...DEFAULT_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTasks(): Promise<TaskRecord[]> {
|
||||||
|
const obj = await browser.storage.local.get(TASKS_KEY)
|
||||||
|
const raw = obj[TASKS_KEY] as string | undefined
|
||||||
|
if (!raw)
|
||||||
|
return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as TaskRecord[]
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTasks(tasks: TaskRecord[]) {
|
||||||
|
await browser.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks.slice(0, MAX_TASKS)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertTask(record: TaskRecord) {
|
||||||
|
const tasks = await readTasks()
|
||||||
|
const idx = tasks.findIndex(t => t.taskId === record.taskId)
|
||||||
|
if (idx >= 0)
|
||||||
|
tasks.splice(idx, 1, { ...tasks[idx], ...record })
|
||||||
|
else
|
||||||
|
tasks.unshift(record)
|
||||||
|
await writeTasks(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 启动任务 ----------
|
||||||
|
|
||||||
|
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
|
||||||
|
const platform = detectPlatform(url)
|
||||||
|
if (!platform)
|
||||||
|
return { ok: false, error: '当前链接不是支持的视频平台' }
|
||||||
|
|
||||||
|
const settings = await readSettings()
|
||||||
|
if (!settings.providerId || !settings.modelName)
|
||||||
|
return { ok: false, error: '请先在设置页选择供应商与模型' }
|
||||||
|
|
||||||
|
const backend = settings.backendUrl.replace(/\/$/, '')
|
||||||
|
|
||||||
|
// B 站:先在浏览器里抓字幕(带本地登录态 cookie),随提交带过去
|
||||||
|
const prefetched = platform === 'bilibili' ? await fetchBilibiliSubtitle(url) : null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${backend}/api/generate_note`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
video_url: url,
|
||||||
|
platform,
|
||||||
|
quality: settings.quality,
|
||||||
|
provider_id: settings.providerId,
|
||||||
|
model_name: settings.modelName,
|
||||||
|
screenshot: settings.screenshot,
|
||||||
|
link: settings.link,
|
||||||
|
style: settings.style || undefined,
|
||||||
|
format: [
|
||||||
|
...(settings.screenshot ? ['screenshot'] : []),
|
||||||
|
...(settings.link ? ['link'] : []),
|
||||||
|
],
|
||||||
|
prefetched_transcript: prefetched ?? undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok)
|
||||||
|
return { ok: false, error: `HTTP ${res.status}` }
|
||||||
|
const body = await res.json() as { code: number, msg: string, data: { task_id: string } }
|
||||||
|
if (body.code !== 0)
|
||||||
|
return { ok: false, error: body.msg }
|
||||||
|
|
||||||
|
await upsertTask({
|
||||||
|
taskId: body.data.task_id,
|
||||||
|
videoUrl: url,
|
||||||
|
platform,
|
||||||
|
status: 'PENDING',
|
||||||
|
message: '已提交',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
return { ok: true, taskId: body.data.task_id }
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSidePanelInTab(tabId?: number) {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error chrome.sidePanel 类型在 webextension-polyfill 中尚未补全
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open && tabId !== undefined)
|
||||||
|
// @ts-expect-error see above
|
||||||
|
await chrome.sidePanel.open({ tabId })
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.warn('打开侧边栏失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 消息桥 ----------
|
||||||
|
|
||||||
|
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
|
||||||
|
const result = await startTask(data.url)
|
||||||
|
// 成功就把侧边栏拉起来给用户看进度
|
||||||
|
if (result.ok)
|
||||||
|
await openSidePanelInTab(sender?.tabId)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- 安装时事件 ----------
|
||||||
|
|
||||||
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
|
console.log('BiliNote extension installed')
|
||||||
|
|
||||||
|
// 右键菜单:在视频页或视频链接上"用 BiliNote 总结"
|
||||||
|
try {
|
||||||
|
browser.contextMenus.create({
|
||||||
|
id: 'bilinote-summarize-page',
|
||||||
|
title: '用 BiliNote 总结此视频',
|
||||||
|
contexts: ['page', 'link', 'video'],
|
||||||
|
documentUrlPatterns: [
|
||||||
|
'*://*.bilibili.com/*',
|
||||||
|
'*://*.youtube.com/*',
|
||||||
|
'*://youtu.be/*',
|
||||||
|
'*://*.douyin.com/*',
|
||||||
|
'*://*.kuaishou.com/*',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn('注册右键菜单失败:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
browser.contextMenus?.onClicked.addListener(async (info, tab) => {
|
||||||
|
if (info.menuItemId !== 'bilinote-summarize-page')
|
||||||
|
return
|
||||||
|
const url = info.linkUrl || tab?.url
|
||||||
|
if (!url)
|
||||||
|
return
|
||||||
|
const result = await startTask(url)
|
||||||
|
if (result.ok)
|
||||||
|
await openSidePanelInTab(tab?.id)
|
||||||
|
else
|
||||||
|
console.warn('右键启动失败:', result.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// content script 占位握手 —— 未来可扩展为查询当前任务等
|
||||||
|
onMessage('get-current-tab', async () => {
|
||||||
|
try {
|
||||||
|
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||||
|
return { title: tab?.title, url: tab?.url }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return { title: undefined, url: undefined }
|
||||||
|
}
|
||||||
|
})
|
||||||
156
BillNote_extension/src/components/ChatPanel.vue
Normal file
156
BillNote_extension/src/components/ChatPanel.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import { askChat, getChatStatus, indexChatTask, type ChatMessage } from '~/logic/api'
|
||||||
|
import { settings } from '~/logic/storage'
|
||||||
|
|
||||||
|
const props = defineProps<{ taskId: string }>()
|
||||||
|
|
||||||
|
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
||||||
|
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const draft = ref('')
|
||||||
|
const sending = ref(false)
|
||||||
|
const indexState = ref<'idle' | 'indexing' | 'indexed' | 'failed' | 'unknown'>('unknown')
|
||||||
|
const error = ref('')
|
||||||
|
const scrollEl = ref<HTMLElement | null>(null)
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const ready = computed(() => indexState.value === 'indexed')
|
||||||
|
const canSend = computed(() => ready.value && draft.value.trim().length > 0 && !sending.value && !!settings.value.providerId && !!settings.value.modelName)
|
||||||
|
|
||||||
|
async function pollIndex() {
|
||||||
|
try {
|
||||||
|
const res = await getChatStatus(props.taskId)
|
||||||
|
indexState.value = res.status
|
||||||
|
if (res.status === 'indexing')
|
||||||
|
pollTimer = setTimeout(pollIndex, 2000)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
error.value = (e as Error).message
|
||||||
|
indexState.value = 'failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureIndexed() {
|
||||||
|
error.value = ''
|
||||||
|
indexState.value = 'unknown'
|
||||||
|
try {
|
||||||
|
const status = await getChatStatus(props.taskId)
|
||||||
|
indexState.value = status.status
|
||||||
|
if (status.indexed)
|
||||||
|
return
|
||||||
|
indexState.value = 'indexing'
|
||||||
|
await indexChatTask(props.taskId)
|
||||||
|
pollIndex()
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
error.value = (e as Error).message
|
||||||
|
indexState.value = 'failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!canSend.value)
|
||||||
|
return
|
||||||
|
const question = draft.value.trim()
|
||||||
|
draft.value = ''
|
||||||
|
messages.value.push({ role: 'user', content: question })
|
||||||
|
await scrollDown()
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
const res = await askChat({
|
||||||
|
task_id: props.taskId,
|
||||||
|
question,
|
||||||
|
history: messages.value.slice(0, -1),
|
||||||
|
provider_id: settings.value.providerId,
|
||||||
|
model_name: settings.value.modelName,
|
||||||
|
}) as { answer?: string, content?: string, message?: string } | string
|
||||||
|
const reply = typeof res === 'string'
|
||||||
|
? res
|
||||||
|
: (res.answer ?? res.content ?? res.message ?? JSON.stringify(res))
|
||||||
|
messages.value.push({ role: 'assistant', content: reply })
|
||||||
|
await scrollDown()
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
messages.value.push({ role: 'assistant', content: `❌ 调用失败:${(e as Error).message}` })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollDown() {
|
||||||
|
await nextTick()
|
||||||
|
if (scrollEl.value)
|
||||||
|
scrollEl.value.scrollTop = scrollEl.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.taskId, () => {
|
||||||
|
messages.value = []
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
ensureIndexed()
|
||||||
|
}, { immediate: false })
|
||||||
|
|
||||||
|
onMounted(ensureIndexed)
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer)
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full bg-white">
|
||||||
|
<header class="px-2 py-1 text-xs border-b flex items-center gap-2">
|
||||||
|
<span v-if="indexState === 'indexed'" class="tag bg-green-100 text-green-700">已索引</span>
|
||||||
|
<span v-else-if="indexState === 'indexing'" class="tag bg-yellow-100 text-yellow-700">索引中…</span>
|
||||||
|
<span v-else-if="indexState === 'failed'" class="tag bg-red-100 text-red-700">索引失败</span>
|
||||||
|
<span v-else class="tag bg-gray-100 text-gray-500">检查中…</span>
|
||||||
|
<button class="ml-auto text-xs text-gray-500 hover:text-gray-800" @click="ensureIndexed">
|
||||||
|
重新索引
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-xs text-red-600 px-2 py-1">{{ error }}</div>
|
||||||
|
|
||||||
|
<div ref="scrollEl" class="flex-1 overflow-auto px-2 py-2 flex flex-col gap-2">
|
||||||
|
<div v-if="messages.length === 0 && ready" class="text-xs text-gray-400 italic">
|
||||||
|
基于这条笔记的全文 + 视频元信息提问,例如:「这个视频的核心论点是什么?」
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(m, i) in messages"
|
||||||
|
:key="i"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="inline-block max-w-[90%] px-3 py-2 rounded"
|
||||||
|
:class="m.role === 'user'
|
||||||
|
? 'bg-blue-600 text-white ml-auto block'
|
||||||
|
: 'bg-gray-100 text-gray-800'"
|
||||||
|
>
|
||||||
|
<div v-if="m.role === 'assistant'" v-html="md.render(m.content)" class="prose prose-sm max-w-none" />
|
||||||
|
<div v-else class="whitespace-pre-wrap break-words">{{ m.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="sending" class="text-xs text-gray-500 italic">思考中…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="border-t p-2 flex gap-2">
|
||||||
|
<textarea
|
||||||
|
v-model="draft"
|
||||||
|
class="input flex-1 resize-none"
|
||||||
|
rows="2"
|
||||||
|
:placeholder="ready ? '问点什么…(Cmd/Ctrl + Enter 发送)' : '索引完成后才能问答'"
|
||||||
|
:disabled="!ready"
|
||||||
|
@keydown.enter.exact.meta.prevent="send"
|
||||||
|
@keydown.enter.exact.ctrl.prevent="send"
|
||||||
|
/>
|
||||||
|
<button class="btn-primary" :disabled="!canSend" @click="send">
|
||||||
|
{{ sending ? '…' : '发送' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
BillNote_extension/src/components/Logo.vue
Normal file
5
BillNote_extension/src/components/Logo.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<a class="icon-btn mx-2 text-2xl" rel="noreferrer" href="https://github.com/antfu/vitesse-webext" target="_blank" title="GitHub">
|
||||||
|
<pixelarticons-power />
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
44
BillNote_extension/src/components/MarkdownView.vue
Normal file
44
BillNote_extension/src/components/MarkdownView.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
|
||||||
|
|
||||||
|
const props = defineProps<{ markdown: string, title?: string, hideActions?: boolean }>()
|
||||||
|
|
||||||
|
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
||||||
|
|
||||||
|
const html = computed(() => md.render(absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))))
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
await navigator.clipboard.writeText(props.markdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
const blob = new Blob([props.markdown], { type: 'text/markdown;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${props.title || 'bilinote'}.md`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 h-full">
|
||||||
|
<div v-if="!hideActions" class="flex gap-2 justify-end shrink-0">
|
||||||
|
<button class="btn-secondary" @click="copy">复制 Markdown</button>
|
||||||
|
<button class="btn-secondary" @click="download">下载 .md</button>
|
||||||
|
</div>
|
||||||
|
<div class="prose prose-sm max-w-none px-3 py-2 flex-1 min-h-0 overflow-auto" v-html="html" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose img { max-width: 100%; }
|
||||||
|
.prose h1, .prose h2, .prose h3 { font-weight: 600; margin-top: 0.8em; margin-bottom: 0.4em; }
|
||||||
|
.prose p { margin-bottom: 0.5em; line-height: 1.55; }
|
||||||
|
.prose ul, .prose ol { padding-left: 1.4em; margin-bottom: 0.5em; }
|
||||||
|
.prose code { background: #eee; padding: 0 4px; border-radius: 3px; font-size: 0.9em; }
|
||||||
|
.prose a { color: #2563eb; text-decoration: underline; }
|
||||||
|
</style>
|
||||||
32
BillNote_extension/src/components/MindMap.vue
Normal file
32
BillNote_extension/src/components/MindMap.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { Transformer } from 'markmap-lib'
|
||||||
|
import { Markmap } from 'markmap-view'
|
||||||
|
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
|
||||||
|
|
||||||
|
const props = defineProps<{ markdown: string }>()
|
||||||
|
|
||||||
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
|
let mm: Markmap | null = null
|
||||||
|
const transformer = new Transformer()
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!svgRef.value)
|
||||||
|
return
|
||||||
|
const md = absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))
|
||||||
|
const { root } = transformer.transform(md)
|
||||||
|
if (!mm)
|
||||||
|
mm = Markmap.create(svgRef.value, undefined, root)
|
||||||
|
else
|
||||||
|
mm.setData(root).then(() => mm?.fit())
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(render)
|
||||||
|
watch(() => props.markdown, render)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full h-full bg-white rounded border overflow-hidden">
|
||||||
|
<svg ref="svgRef" class="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
24
BillNote_extension/src/components/PlatformBadge.vue
Normal file
24
BillNote_extension/src/components/PlatformBadge.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Platform } from '~/logic/types'
|
||||||
|
import { PLATFORM_LABELS } from '~/logic/platform'
|
||||||
|
|
||||||
|
const props = defineProps<{ platform: Platform | null }>()
|
||||||
|
|
||||||
|
const colorMap: Record<Platform, string> = {
|
||||||
|
bilibili: 'bg-pink-100 text-pink-700',
|
||||||
|
youtube: 'bg-red-100 text-red-700',
|
||||||
|
douyin: 'bg-zinc-200 text-zinc-800',
|
||||||
|
kuaishou: 'bg-orange-100 text-orange-700',
|
||||||
|
local: 'bg-gray-100 text-gray-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls = computed(() => (props.platform ? colorMap[props.platform] : 'bg-gray-100 text-gray-500'))
|
||||||
|
const label = computed(() => (props.platform ? PLATFORM_LABELS[props.platform] : '未识别'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :class="cls">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
11
BillNote_extension/src/components/README.md
Normal file
11
BillNote_extension/src/components/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
## Components
|
||||||
|
|
||||||
|
Components in this dir will be auto-registered and on-demand, powered by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components).
|
||||||
|
|
||||||
|
Components can be shared in all views.
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/).
|
||||||
|
|
||||||
|
It will only bundle the icons you use. Check out [unplugin-icons](https://github.com/unplugin/unplugin-icons) for more details.
|
||||||
5
BillNote_extension/src/components/SharedSubtitle.vue
Normal file
5
BillNote_extension/src/components/SharedSubtitle.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<p class="mt-2 opacity-50">
|
||||||
|
This is the {{ $app.context }} page
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
42
BillNote_extension/src/components/TaskProgress.vue
Normal file
42
BillNote_extension/src/components/TaskProgress.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { TaskStatus } from '~/logic/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ status: TaskStatus, message?: string }>()
|
||||||
|
|
||||||
|
const STAGE_ORDER: TaskStatus[] = ['PENDING', 'PARSING', 'DOWNLOADING', 'TRANSCRIBING', 'SUMMARIZING', 'FORMATTING', 'SAVING', 'SUCCESS']
|
||||||
|
const STAGE_LABELS: Record<TaskStatus, string> = {
|
||||||
|
PENDING: '排队中',
|
||||||
|
PARSING: '解析中',
|
||||||
|
DOWNLOADING: '下载中',
|
||||||
|
TRANSCRIBING: '转写中',
|
||||||
|
SUMMARIZING: '总结中',
|
||||||
|
FORMATTING: '格式化',
|
||||||
|
SAVING: '保存中',
|
||||||
|
SUCCESS: '完成',
|
||||||
|
FAILED: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIdx = computed(() => STAGE_ORDER.indexOf(props.status))
|
||||||
|
const isFailed = computed(() => props.status === 'FAILED')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span :class="isFailed ? 'text-red-600' : 'text-blue-600'" class="font-medium">
|
||||||
|
{{ STAGE_LABELS[status] }}
|
||||||
|
</span>
|
||||||
|
<span v-if="message" class="text-gray-500 text-xs truncate">{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isFailed" class="flex gap-1">
|
||||||
|
<div
|
||||||
|
v-for="(s, i) in STAGE_ORDER"
|
||||||
|
:key="s"
|
||||||
|
class="h-1 flex-1 rounded-full"
|
||||||
|
:class="i <= currentIdx ? 'bg-blue-500' : 'bg-gray-200'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-1 rounded-full bg-red-500" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
11
BillNote_extension/src/components/__tests__/Logo.test.ts
Normal file
11
BillNote_extension/src/components/__tests__/Logo.test.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import Logo from '../Logo.vue'
|
||||||
|
|
||||||
|
describe('logo component', () => {
|
||||||
|
it('should render', () => {
|
||||||
|
const wrapper = mount(Logo)
|
||||||
|
|
||||||
|
expect(wrapper.html()).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
166
BillNote_extension/src/composables/useWebExtensionStorage.ts
Normal file
166
BillNote_extension/src/composables/useWebExtensionStorage.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { StorageSerializers } from '@vueuse/core'
|
||||||
|
import { pausableWatch, toValue, tryOnScopeDispose } from '@vueuse/shared'
|
||||||
|
import { ref, shallowRef } from 'vue-demi'
|
||||||
|
import { storage } from 'webextension-polyfill'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
StorageLikeAsync,
|
||||||
|
UseStorageAsyncOptions,
|
||||||
|
} from '@vueuse/core'
|
||||||
|
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/shared'
|
||||||
|
import type { Ref } from 'vue-demi'
|
||||||
|
import type { Storage } from 'webextension-polyfill'
|
||||||
|
|
||||||
|
export type WebExtensionStorageOptions<T> = UseStorageAsyncOptions<T>
|
||||||
|
|
||||||
|
// https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorage/guess.ts
|
||||||
|
export function guessSerializerType(rawInit: unknown) {
|
||||||
|
return rawInit == null
|
||||||
|
? 'any'
|
||||||
|
: rawInit instanceof Set
|
||||||
|
? 'set'
|
||||||
|
: rawInit instanceof Map
|
||||||
|
? 'map'
|
||||||
|
: rawInit instanceof Date
|
||||||
|
? 'date'
|
||||||
|
: typeof rawInit === 'boolean'
|
||||||
|
? 'boolean'
|
||||||
|
: typeof rawInit === 'string'
|
||||||
|
? 'string'
|
||||||
|
: typeof rawInit === 'object'
|
||||||
|
? 'object'
|
||||||
|
: Number.isNaN(rawInit)
|
||||||
|
? 'any'
|
||||||
|
: 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageInterface: StorageLikeAsync = {
|
||||||
|
removeItem(key: string) {
|
||||||
|
return storage.local.remove(key)
|
||||||
|
},
|
||||||
|
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
return storage.local.set({ [key]: value })
|
||||||
|
},
|
||||||
|
|
||||||
|
async getItem(key: string) {
|
||||||
|
const storedData = await storage.local.get(key)
|
||||||
|
|
||||||
|
return storedData[key] as string
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorageAsync/index.ts
|
||||||
|
*
|
||||||
|
* @param key
|
||||||
|
* @param initialValue
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export function useWebExtensionStorage<T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: MaybeRefOrGetter<T>,
|
||||||
|
options: WebExtensionStorageOptions<T> = {},
|
||||||
|
): { data: RemovableRef<T>, dataReady: Promise<T> } {
|
||||||
|
const {
|
||||||
|
flush = 'pre',
|
||||||
|
deep = true,
|
||||||
|
listenToStorageChanges = true,
|
||||||
|
writeDefaults = true,
|
||||||
|
mergeDefaults = false,
|
||||||
|
shallow,
|
||||||
|
eventFilter,
|
||||||
|
onError = (e) => {
|
||||||
|
console.error(e)
|
||||||
|
},
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const rawInit: T = toValue(initialValue)
|
||||||
|
const type = guessSerializerType(rawInit)
|
||||||
|
|
||||||
|
const data = (shallow ? shallowRef : ref)(initialValue) as Ref<T>
|
||||||
|
const serializer = options.serializer ?? StorageSerializers[type]
|
||||||
|
|
||||||
|
async function read(event?: { key: string, newValue: string | null }) {
|
||||||
|
if (event && event.key !== key)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = event ? event.newValue : await storageInterface.getItem(key)
|
||||||
|
if (rawValue == null) {
|
||||||
|
data.value = rawInit
|
||||||
|
if (writeDefaults && rawInit !== null)
|
||||||
|
await storageInterface.setItem(key, await serializer.write(rawInit))
|
||||||
|
}
|
||||||
|
else if (mergeDefaults) {
|
||||||
|
const value = await serializer.read(rawValue) as T
|
||||||
|
if (typeof mergeDefaults === 'function')
|
||||||
|
data.value = mergeDefaults(value, rawInit)
|
||||||
|
else if (type === 'object' && !Array.isArray(value))
|
||||||
|
data.value = { ...(rawInit as Record<keyof unknown, unknown>), ...(value as Record<keyof unknown, unknown>) } as T
|
||||||
|
else data.value = value
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
data.value = await serializer.read(rawValue) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataReadyPromise = new Promise<T>((resolve, reject) => {
|
||||||
|
read().then(() => resolve(data.value)).catch(reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function write() {
|
||||||
|
try {
|
||||||
|
await (
|
||||||
|
data.value == null
|
||||||
|
? storageInterface.removeItem(key)
|
||||||
|
: storageInterface.setItem(key, await serializer.write(data.value))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
|
||||||
|
data,
|
||||||
|
write,
|
||||||
|
{
|
||||||
|
flush,
|
||||||
|
deep,
|
||||||
|
eventFilter,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (listenToStorageChanges) {
|
||||||
|
const listener = async (changes: Record<string, Storage.StorageChange>) => {
|
||||||
|
try {
|
||||||
|
pauseWatch()
|
||||||
|
for (const [key, change] of Object.entries(changes)) {
|
||||||
|
await read({
|
||||||
|
key,
|
||||||
|
newValue: change.newValue as string | null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
resumeWatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.onChanged.addListener(listener)
|
||||||
|
|
||||||
|
tryOnScopeDispose(() => {
|
||||||
|
storage.onChanged.removeListener(listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data as RemovableRef<T>,
|
||||||
|
dataReady: dataReadyPromise,
|
||||||
|
}
|
||||||
|
}
|
||||||
24
BillNote_extension/src/contentScripts/index.ts
Normal file
24
BillNote_extension/src/contentScripts/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './views/App.vue'
|
||||||
|
import { setupApp } from '~/logic/common-setup'
|
||||||
|
import { detectPlatform } from '~/logic/platform'
|
||||||
|
|
||||||
|
// 只在支持的视频平台上挂悬浮按钮,避免污染其他网站
|
||||||
|
(() => {
|
||||||
|
if (!detectPlatform(window.location.href))
|
||||||
|
return
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.id = __NAME__
|
||||||
|
const root = document.createElement('div')
|
||||||
|
const styleEl = document.createElement('link')
|
||||||
|
const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container
|
||||||
|
styleEl.setAttribute('rel', 'stylesheet')
|
||||||
|
styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css'))
|
||||||
|
shadowDOM.appendChild(styleEl)
|
||||||
|
shadowDOM.appendChild(root)
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const app = createApp(App)
|
||||||
|
setupApp(app)
|
||||||
|
app.mount(root)
|
||||||
|
})()
|
||||||
57
BillNote_extension/src/contentScripts/views/App.vue
Normal file
57
BillNote_extension/src/contentScripts/views/App.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import 'uno.css'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { sendMessage } from 'webext-bridge/content-script'
|
||||||
|
import { detectPlatform, PLATFORM_LABELS } from '~/logic/platform'
|
||||||
|
|
||||||
|
const platform = detectPlatform(window.location.href)
|
||||||
|
const busy = ref(false)
|
||||||
|
const toast = ref<{ kind: 'ok' | 'err', text: string } | null>(null)
|
||||||
|
|
||||||
|
const label = computed(() => platform ? `用 BiliNote 总结这个${PLATFORM_LABELS[platform]}视频` : '')
|
||||||
|
|
||||||
|
async function trigger() {
|
||||||
|
if (!platform || busy.value)
|
||||||
|
return
|
||||||
|
busy.value = true
|
||||||
|
toast.value = null
|
||||||
|
try {
|
||||||
|
const res = await sendMessage('bilinote-start', {
|
||||||
|
url: window.location.href,
|
||||||
|
platform,
|
||||||
|
}, 'background')
|
||||||
|
const ok = res && (res as any).ok
|
||||||
|
toast.value = ok
|
||||||
|
? { kind: 'ok', text: '已开始生成笔记,可在侧边栏 / popup 查看进度' }
|
||||||
|
: { kind: 'err', text: (res as any)?.error || '提交失败,请打开设置检查后端与供应商' }
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.value = { kind: 'err', text: (e as Error).message }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
busy.value = false
|
||||||
|
setTimeout(() => { toast.value = null }, 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="platform" class="bilinote-fab fixed bottom-24 right-6 z-[2147483647] flex flex-col items-end gap-2 font-sans select-none">
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="text-xs px-3 py-2 rounded shadow max-w-[260px]"
|
||||||
|
:class="toast.kind === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'"
|
||||||
|
>
|
||||||
|
{{ toast.text }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-full shadow-lg cursor-pointer border-none text-white text-sm font-medium bg-pink-600 hover:bg-pink-700 disabled:bg-pink-300"
|
||||||
|
:disabled="busy"
|
||||||
|
:title="label"
|
||||||
|
@click="trigger"
|
||||||
|
>
|
||||||
|
<span class="text-base">📝</span>
|
||||||
|
<span>{{ busy ? '提交中…' : 'BiliNote' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
BillNote_extension/src/env.ts
Normal file
14
BillNote_extension/src/env.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const forbiddenProtocols = [
|
||||||
|
'chrome-extension://',
|
||||||
|
'chrome-search://',
|
||||||
|
'chrome://',
|
||||||
|
'devtools://',
|
||||||
|
'edge://',
|
||||||
|
'https://chrome.google.com/webstore',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function isForbiddenUrl(url: string): boolean {
|
||||||
|
return forbiddenProtocols.some(protocol => url.startsWith(protocol))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isFirefox = navigator.userAgent.includes('Firefox')
|
||||||
8
BillNote_extension/src/global.d.ts
vendored
Normal file
8
BillNote_extension/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
declare const __DEV__: boolean
|
||||||
|
/** Extension name, defined in packageJson.name */
|
||||||
|
declare const __NAME__: string
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
const component: any
|
||||||
|
export default component
|
||||||
|
}
|
||||||
235
BillNote_extension/src/logic/api.ts
Normal file
235
BillNote_extension/src/logic/api.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import type {
|
||||||
|
DeployStatus,
|
||||||
|
GenerateRequest,
|
||||||
|
Model,
|
||||||
|
Provider,
|
||||||
|
ProviderCreatePayload,
|
||||||
|
ProviderUpdatePayload,
|
||||||
|
TaskStatusResponse,
|
||||||
|
TranscriberConfig,
|
||||||
|
TranscriberModelsStatus,
|
||||||
|
TranscriberType,
|
||||||
|
WhisperModelSize,
|
||||||
|
} from './types'
|
||||||
|
import { settings } from './storage'
|
||||||
|
|
||||||
|
interface ApiEnvelope<T> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendUrl(): string {
|
||||||
|
return (settings.value?.backendUrl || 'http://localhost:8483').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${backendUrl()}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`HTTP ${res.status}: ${await res.text()}`)
|
||||||
|
const body = (await res.json()) as ApiEnvelope<T> | T
|
||||||
|
// 后端 ResponseWrapper 包了 {code, msg, data};非 0 视为业务错
|
||||||
|
if (body && typeof body === 'object' && 'code' in body) {
|
||||||
|
const env = body as ApiEnvelope<T>
|
||||||
|
if (env.code !== 0)
|
||||||
|
throw new Error(env.msg || '后端返回失败')
|
||||||
|
return env.data
|
||||||
|
}
|
||||||
|
return body as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProviders(): Promise<Provider[]> {
|
||||||
|
return request<Provider[]>('/api/get_all_providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModelsByProvider(providerId: string): Promise<Model[]> {
|
||||||
|
return request<Model[]>(`/api/model_enable/${providerId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDownloaderCookie(platform: string, cookie: string): Promise<void> {
|
||||||
|
await request('/api/update_downloader_cookie', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ platform, cookie }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDownloaderCookie(platform: string): Promise<string | null> {
|
||||||
|
// 后端:未配置时返回 {code:0, msg:'未找到Cookies', data:null};配置时 data: {platform, cookie}
|
||||||
|
const data = await request<{ platform: string, cookie: string } | null>(
|
||||||
|
`/api/get_downloader_cookie/${platform}`,
|
||||||
|
)
|
||||||
|
return data?.cookie ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Provider CRUD ----
|
||||||
|
export async function addProvider(payload: ProviderCreatePayload): Promise<string | null> {
|
||||||
|
return request<string | null>('/api/add_provider', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ logo: 'custom', ...payload }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProvider(payload: ProviderUpdatePayload): Promise<{ id: string, enabled: number }> {
|
||||||
|
return request<{ id: string, enabled: number }>('/api/update_provider', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProviderById(id: string): Promise<Provider> {
|
||||||
|
return request<Provider>(`/api/get_provider_by_id/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectTest(id: string): Promise<void> {
|
||||||
|
await request('/api/connect_test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Model CRUD ----
|
||||||
|
export async function listAllModels(providerId: string): Promise<Model[]> {
|
||||||
|
return request<Model[]>(`/api/model_list/${providerId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addModel(providerId: string, modelName: string): Promise<void> {
|
||||||
|
await request('/api/models', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ provider_id: providerId, model_name: modelName }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModel(modelId: number | string): Promise<void> {
|
||||||
|
await request(`/api/models/delete/${modelId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Transcriber ----
|
||||||
|
export async function getTranscriberConfig(): Promise<TranscriberConfig> {
|
||||||
|
return request<TranscriberConfig>('/api/transcriber_config')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTranscriberConfig(transcriberType: TranscriberType, whisperModelSize?: WhisperModelSize): Promise<TranscriberConfig> {
|
||||||
|
return request<TranscriberConfig>('/api/transcriber_config', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
transcriber_type: transcriberType,
|
||||||
|
whisper_model_size: whisperModelSize ?? null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTranscriberModelsStatus(): Promise<TranscriberModelsStatus> {
|
||||||
|
return request<TranscriberModelsStatus>('/api/transcriber_models_status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadTranscriberModel(modelSize: WhisperModelSize, transcriberType: TranscriberType = 'fast-whisper'): Promise<void> {
|
||||||
|
await request('/api/transcriber_download', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ model_size: modelSize, transcriber_type: transcriberType }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RAG Chat ----
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant' | 'system'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function indexChatTask(taskId: string): Promise<void> {
|
||||||
|
await request('/api/chat/index', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ task_id: taskId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatStatus(taskId: string): Promise<{ status: 'idle' | 'indexing' | 'indexed' | 'failed', indexed: boolean }> {
|
||||||
|
return request(`/api/chat/status?task_id=${encodeURIComponent(taskId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function askChat(payload: {
|
||||||
|
task_id: string
|
||||||
|
question: string
|
||||||
|
history: ChatMessage[]
|
||||||
|
provider_id: string
|
||||||
|
model_name: string
|
||||||
|
}): Promise<unknown> {
|
||||||
|
return request('/api/chat/ask', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Monitor ----
|
||||||
|
export async function getDeployStatus(): Promise<DeployStatus> {
|
||||||
|
return request<DeployStatus>('/api/deploy_status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysHealth(): Promise<{ ok: boolean, msg?: string }> {
|
||||||
|
try {
|
||||||
|
await request('/api/sys_health')
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, msg: (e as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateNote(payload: GenerateRequest): Promise<{ task_id: string }> {
|
||||||
|
return request<{ task_id: string }>('/api/generate_note', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskStatus(taskId: string): Promise<TaskStatusResponse> {
|
||||||
|
// /task_status 永远 HTTP 200;body 是 ResponseWrapper:
|
||||||
|
// 成功:{code:0, data:{status, message, task_id, result?}}
|
||||||
|
// 任务失败:{code:500, msg:'xxx', data:null}
|
||||||
|
// 这里手动拆,把任务失败翻译成 status:'FAILED',避免 request() 抛错让 UI 收不到状态
|
||||||
|
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`)
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`HTTP ${res.status}`)
|
||||||
|
const body = (await res.json()) as { code: number, msg: string, data: TaskStatusResponse | null }
|
||||||
|
if (body.code === 0 && body.data)
|
||||||
|
return body.data
|
||||||
|
return { status: 'FAILED', message: body.msg || '任务失败', task_id: taskId }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ping(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await getProviders()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdown 里的 /static/screenshots/xxx 是相对路径,extension 渲染时需要拼绝对地址
|
||||||
|
export function absolutizeMarkdownImages(md: string): string {
|
||||||
|
const base = backendUrl()
|
||||||
|
return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => ``)
|
||||||
|
}
|
||||||
|
|
||||||
|
// backend 用 note_helper 在笔记开头插一行 '> 来源链接:URL'。侧边栏顶部已经有原片链接卡片,
|
||||||
|
// 渲染前把它剥掉,避免重复占位。复制/下载的 .md 保留原样以便溯源。
|
||||||
|
// 与 BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx:468 对齐
|
||||||
|
export function stripSourceLink(md: string): string {
|
||||||
|
return md.replace(/^>\s*来源链接:[^\n]*\n*/m, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个图片 URL 的处理:相对路径 → 拼后端域名;B 站等带防盗链的封面 → 走后端 image_proxy
|
||||||
|
export function resolveImageUrl(url: string | undefined | null): string {
|
||||||
|
if (!url)
|
||||||
|
return ''
|
||||||
|
const base = backendUrl()
|
||||||
|
if (url.startsWith('/'))
|
||||||
|
return `${base}${url}`
|
||||||
|
// B 站封面、抖音封面等会做 referer 校验;走后端代理
|
||||||
|
if (/(hdslb|byteimg|kpcdn|akamaized|ytimg)\.com/i.test(url))
|
||||||
|
return `${base}/api/image_proxy?url=${encodeURIComponent(url)}`
|
||||||
|
return url
|
||||||
|
}
|
||||||
125
BillNote_extension/src/logic/bilibili-subtitle.ts
Normal file
125
BillNote_extension/src/logic/bilibili-subtitle.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// 在浏览器里直接调 B 站 player API 抓字幕。
|
||||||
|
// 因为 manifest host_permissions: '*://*/*' 覆盖 api.bilibili.com,service worker 里的
|
||||||
|
// fetch 会自动带 .bilibili.com 域下的用户 cookie,并且绕过 CORS——AI 字幕需要登录态,
|
||||||
|
// 这等于用用户当前浏览器的登录身份代替了 backend 那边的 SESSDATA 配置。
|
||||||
|
//
|
||||||
|
// 与 backend/app/downloaders/bilibili_subtitle.py 的 BilibiliSubtitleFetcher 行为对齐。
|
||||||
|
|
||||||
|
const UA
|
||||||
|
= 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||||
|
|
||||||
|
export interface PrefetchedTranscript {
|
||||||
|
language: string
|
||||||
|
full_text: string
|
||||||
|
segments: Array<{ start: number, end: number, text: string }>
|
||||||
|
source: 'bilibili_extension'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubtitleEntry {
|
||||||
|
lan?: string
|
||||||
|
ai_type?: number
|
||||||
|
subtitle_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBvid(url: string): string | null {
|
||||||
|
const m = url.match(/BV([0-9A-Za-z]+)/)
|
||||||
|
return m ? `BV${m[1]}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jsonGet<T>(url: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'User-Agent': UA, 'Referer': 'https://www.bilibili.com' },
|
||||||
|
})
|
||||||
|
if (!res.ok)
|
||||||
|
return null
|
||||||
|
return await res.json() as T
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn('[bilinote] B 站 API 请求失败:', url, e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCid(bvid: string): Promise<number | null> {
|
||||||
|
const data = await jsonGet<{ code: number, data?: { cid?: number } }>(
|
||||||
|
`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
|
||||||
|
)
|
||||||
|
if (!data || data.code !== 0)
|
||||||
|
return null
|
||||||
|
return data.data?.cid ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSubtitles(bvid: string, cid: number): Promise<SubtitleEntry[]> {
|
||||||
|
const data = await jsonGet<{
|
||||||
|
code: number
|
||||||
|
data?: { subtitle?: { subtitles?: SubtitleEntry[] } }
|
||||||
|
}>(`https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`)
|
||||||
|
if (!data || data.code !== 0)
|
||||||
|
return []
|
||||||
|
return data.data?.subtitle?.subtitles ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSubtitle(subtitles: SubtitleEntry[]): SubtitleEntry | null {
|
||||||
|
if (!subtitles.length)
|
||||||
|
return null
|
||||||
|
const isZh = (s: SubtitleEntry) => {
|
||||||
|
const lan = (s.lan || '').toLowerCase()
|
||||||
|
return lan.startsWith('zh') || lan === 'ai-zh'
|
||||||
|
}
|
||||||
|
// 优先级:人工中文 > AI 中文 > 任意非空
|
||||||
|
return (
|
||||||
|
subtitles.find(s => isZh(s) && !s.ai_type)
|
||||||
|
|| subtitles.find(s => isZh(s))
|
||||||
|
|| subtitles[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
return url.startsWith('//') ? `https:${url}` : url
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubtitleBody {
|
||||||
|
body?: Array<{ from?: number, to?: number, content?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBilibiliSubtitle(videoUrl: string): Promise<PrefetchedTranscript | null> {
|
||||||
|
const bvid = extractBvid(videoUrl)
|
||||||
|
if (!bvid)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const cid = await getCid(bvid)
|
||||||
|
if (!cid)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const subtitles = await listSubtitles(bvid, cid)
|
||||||
|
const track = pickSubtitle(subtitles)
|
||||||
|
if (!track?.subtitle_url) {
|
||||||
|
console.info(`[bilinote] B 站 ${bvid} 没找到可用字幕轨(可能未登录或视频无字幕)`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = await jsonGet<SubtitleBody>(normalizeUrl(track.subtitle_url))
|
||||||
|
const body = sub?.body || []
|
||||||
|
const segments: PrefetchedTranscript['segments'] = []
|
||||||
|
for (const item of body) {
|
||||||
|
const text = (item.content || '').trim()
|
||||||
|
if (!text)
|
||||||
|
continue
|
||||||
|
segments.push({
|
||||||
|
start: Number(item.from || 0),
|
||||||
|
end: Number(item.to || 0),
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!segments.length)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
language: track.lan || 'zh',
|
||||||
|
full_text: segments.map(s => s.text).join(' '),
|
||||||
|
segments,
|
||||||
|
source: 'bilibili_extension',
|
||||||
|
}
|
||||||
|
}
|
||||||
15
BillNote_extension/src/logic/common-setup.ts
Normal file
15
BillNote_extension/src/logic/common-setup.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { App } from 'vue'
|
||||||
|
|
||||||
|
export function setupApp(app: App) {
|
||||||
|
// Inject a globally available `$app` object in template
|
||||||
|
app.config.globalProperties.$app = {
|
||||||
|
context: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide access to `app` in script setup with `const app = inject('app')`
|
||||||
|
app.provide('app', app.config.globalProperties.$app)
|
||||||
|
|
||||||
|
// Here you can install additional plugins for all contexts: popup, options page and content-script.
|
||||||
|
// example: app.use(i18n)
|
||||||
|
// example excluding content-script context: if (context !== 'content-script') app.use(i18n)
|
||||||
|
}
|
||||||
18
BillNote_extension/src/logic/constants.ts
Normal file
18
BillNote_extension/src/logic/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Settings } from './types'
|
||||||
|
|
||||||
|
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
backendUrl: DEFAULT_BACKEND_URL,
|
||||||
|
providerId: '',
|
||||||
|
modelName: '',
|
||||||
|
quality: 'medium',
|
||||||
|
screenshot: false,
|
||||||
|
link: false,
|
||||||
|
style: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_TASKS = 30
|
||||||
|
|
||||||
|
export const SETTINGS_KEY = 'bilinote-settings'
|
||||||
|
export const TASKS_KEY = 'bilinote-tasks'
|
||||||
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)
|
||||||
|
}
|
||||||
142
BillNote_extension/src/logic/types.ts
Normal file
142
BillNote_extension/src/logic/types.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// 与 backend/app/routers/note.py / provider.py / model.py 对齐
|
||||||
|
export type Platform = 'bilibili' | 'youtube' | 'douyin' | 'kuaishou' | 'local'
|
||||||
|
export type Quality = 'fast' | 'medium' | 'slow'
|
||||||
|
|
||||||
|
export type TaskStatus =
|
||||||
|
| 'PENDING'
|
||||||
|
| 'PARSING'
|
||||||
|
| 'DOWNLOADING'
|
||||||
|
| 'TRANSCRIBING'
|
||||||
|
| 'SUMMARIZING'
|
||||||
|
| 'FORMATTING'
|
||||||
|
| 'SAVING'
|
||||||
|
| 'SUCCESS'
|
||||||
|
| 'FAILED'
|
||||||
|
|
||||||
|
export interface Provider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
logo: string
|
||||||
|
type: string
|
||||||
|
enabled: number
|
||||||
|
base_url?: string
|
||||||
|
api_key?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Model {
|
||||||
|
id: string
|
||||||
|
model_name: string
|
||||||
|
provider_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateRequest {
|
||||||
|
video_url: string
|
||||||
|
platform: Platform
|
||||||
|
quality: Quality
|
||||||
|
model_name: string
|
||||||
|
provider_id: string
|
||||||
|
screenshot?: boolean
|
||||||
|
link?: boolean
|
||||||
|
format?: string[]
|
||||||
|
style?: string
|
||||||
|
extras?: string
|
||||||
|
// 客户端在浏览器里直接抓到的字幕,跳过后端的 download_subtitles + 音频转写
|
||||||
|
prefetched_transcript?: {
|
||||||
|
language: string
|
||||||
|
full_text: string
|
||||||
|
segments: Array<{ start: number, end: number, text: string }>
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteResult {
|
||||||
|
markdown: string
|
||||||
|
transcript?: unknown
|
||||||
|
audio_meta?: {
|
||||||
|
title?: string
|
||||||
|
duration?: number
|
||||||
|
cover_url?: string
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStatusResponse {
|
||||||
|
status: TaskStatus
|
||||||
|
message: string
|
||||||
|
task_id: string
|
||||||
|
result?: NoteResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRecord {
|
||||||
|
taskId: string
|
||||||
|
videoUrl: string
|
||||||
|
platform: Platform
|
||||||
|
status: TaskStatus
|
||||||
|
message: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
result?: NoteResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
backendUrl: string
|
||||||
|
providerId: string
|
||||||
|
modelName: string
|
||||||
|
quality: Quality
|
||||||
|
screenshot: boolean
|
||||||
|
link: boolean
|
||||||
|
style: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderUpdatePayload {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
api_key?: string
|
||||||
|
base_url?: string
|
||||||
|
type?: string
|
||||||
|
enabled?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderCreatePayload {
|
||||||
|
name: string
|
||||||
|
api_key: string
|
||||||
|
base_url: string
|
||||||
|
type: string
|
||||||
|
logo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TranscriberType = 'fast-whisper' | 'bcut' | 'kuaishou' | 'groq' | 'mlx-whisper'
|
||||||
|
export type WhisperModelSize = 'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo'
|
||||||
|
|
||||||
|
export interface TranscriberOption {
|
||||||
|
value: TranscriberType
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriberConfig {
|
||||||
|
transcriber_type: TranscriberType
|
||||||
|
whisper_model_size: WhisperModelSize | null
|
||||||
|
available_types: TranscriberOption[]
|
||||||
|
whisper_model_sizes: WhisperModelSize[]
|
||||||
|
mlx_whisper_available: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhisperModelStatus {
|
||||||
|
model_size: WhisperModelSize
|
||||||
|
downloaded: boolean
|
||||||
|
downloading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriberModelsStatus {
|
||||||
|
whisper: WhisperModelStatus[]
|
||||||
|
mlx_whisper: WhisperModelStatus[]
|
||||||
|
mlx_available: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployStatus {
|
||||||
|
backend: { status: string, port: number }
|
||||||
|
cuda: { available: boolean, version: string | null, gpu_name: string | null }
|
||||||
|
whisper: { model_size: string, transcriber_type: string }
|
||||||
|
ffmpeg: { available: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
142
BillNote_extension/src/options/pages/General.vue
Normal file
142
BillNote_extension/src/options/pages/General.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { getProviders, ping } from '~/logic/api'
|
||||||
|
import { settings, settingsReady } from '~/logic/storage'
|
||||||
|
import { getModelsByProvider } from '~/logic/api'
|
||||||
|
import type { Model, Provider } from '~/logic/types'
|
||||||
|
import { watch } from 'vue'
|
||||||
|
|
||||||
|
const providers = ref<Provider[]>([])
|
||||||
|
const models = ref<Model[]>([])
|
||||||
|
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
status.value = { kind: 'idle', text: '' }
|
||||||
|
try {
|
||||||
|
providers.value = (await getProviders()).filter(p => p.enabled === 1)
|
||||||
|
if (settings.value.providerId)
|
||||||
|
await refreshModels(settings.value.providerId)
|
||||||
|
status.value = { kind: 'ok', text: `已加载 ${providers.value.length} 个供应商` }
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
status.value = { kind: 'err', text: `加载失败:${(e as Error).message}` }
|
||||||
|
providers.value = []
|
||||||
|
models.value = []
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshModels(providerId: string) {
|
||||||
|
if (!providerId) {
|
||||||
|
models.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
models.value = await getModelsByProvider(providerId)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
models.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
status.value = { kind: 'idle', text: '正在测试…' }
|
||||||
|
const ok = await ping()
|
||||||
|
status.value = ok
|
||||||
|
? { kind: 'ok', text: '后端连通 ✓' }
|
||||||
|
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS' }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => settings.value?.providerId, (id) => {
|
||||||
|
if (id)
|
||||||
|
refreshModels(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await settingsReady
|
||||||
|
if (settings.value.backendUrl)
|
||||||
|
await refresh()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 max-w-2xl">
|
||||||
|
<h1 class="text-xl font-bold mb-4">通用</h1>
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<h2 class="font-semibold">后端地址</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input v-model="settings.backendUrl" class="input flex-1" placeholder="http://localhost:8483">
|
||||||
|
<button class="btn-secondary" @click="testConnection">测试连通</button>
|
||||||
|
<button class="btn-secondary" :disabled="loading" @click="refresh">
|
||||||
|
{{ loading ? '加载中…' : '刷新' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="status.text"
|
||||||
|
class="text-xs"
|
||||||
|
:class="{
|
||||||
|
'text-green-700': status.kind === 'ok',
|
||||||
|
'text-red-600': status.kind === 'err',
|
||||||
|
'text-gray-500': status.kind === 'idle',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ status.text }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
默认 http://localhost:8483 — 需要在该地址先跑起 BiliNote 后端
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<h2 class="font-semibold">默认供应商与模型</h2>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-gray-600">供应商</span>
|
||||||
|
<select v-model="settings.providerId" class="input">
|
||||||
|
<option value="">— 选择供应商 —</option>
|
||||||
|
<option v-for="p in providers" :key="p.id" :value="p.id">
|
||||||
|
{{ p.name }} <span v-if="p.type === 'built-in'">(内置)</span>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-gray-600">模型</span>
|
||||||
|
<select v-model="settings.modelName" class="input" :disabled="!settings.providerId">
|
||||||
|
<option value="">— 选择模型 —</option>
|
||||||
|
<option v-for="m in models" :key="m.id" :value="m.model_name">{{ m.model_name }}</option>
|
||||||
|
</select>
|
||||||
|
<span v-if="settings.providerId && models.length === 0" class="text-xs text-amber-700">
|
||||||
|
该供应商还没添加可用模型,去「模型供应商」页编辑
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-card">
|
||||||
|
<h2 class="font-semibold">默认生成选项</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-gray-600">画质</span>
|
||||||
|
<select v-model="settings.quality" class="input">
|
||||||
|
<option value="fast">快速 (32k)</option>
|
||||||
|
<option value="medium">中等 (64k)</option>
|
||||||
|
<option value="slow">高质 (128k)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-gray-600">笔记风格</span>
|
||||||
|
<input v-model="settings.style" class="input" placeholder="留空使用默认">
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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>
|
||||||
276
BillNote_extension/src/popup/Popup.vue
Normal file
276
BillNote_extension/src/popup/Popup.vue
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { detectPlatform } from '~/logic/platform'
|
||||||
|
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
|
||||||
|
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
|
||||||
|
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||||
|
import type { TaskRecord } from '~/logic/types'
|
||||||
|
|
||||||
|
const tabUrl = ref<string>('')
|
||||||
|
const tabTitle = ref<string>('')
|
||||||
|
const tabId = ref<number | undefined>(undefined)
|
||||||
|
const platform = computed(() => detectPlatform(tabUrl.value))
|
||||||
|
const supported = computed(() => platform.value !== null)
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const activeTaskId = ref<string>('')
|
||||||
|
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function loadActiveTab() {
|
||||||
|
try {
|
||||||
|
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||||
|
tabUrl.value = tab?.url ?? ''
|
||||||
|
tabTitle.value = tab?.title ?? ''
|
||||||
|
tabId.value = tab?.id
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn('无法读取当前 tab:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(taskId: string) {
|
||||||
|
try {
|
||||||
|
const res = await getTaskStatus(taskId)
|
||||||
|
upsertTask({
|
||||||
|
taskId,
|
||||||
|
videoUrl: activeTask.value?.videoUrl ?? tabUrl.value,
|
||||||
|
platform: (activeTask.value?.platform ?? platform.value)!,
|
||||||
|
status: res.status,
|
||||||
|
message: res.message,
|
||||||
|
createdAt: activeTask.value?.createdAt ?? Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
result: res.result ?? activeTask.value?.result,
|
||||||
|
})
|
||||||
|
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||||
|
pollTimer = setTimeout(() => poll(taskId), 3000)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message
|
||||||
|
pollTimer = setTimeout(() => poll(taskId), 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
errorMsg.value = ''
|
||||||
|
if (!supported.value) {
|
||||||
|
errorMsg.value = '当前页面不是支持的视频链接'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!settings.value.providerId || !settings.value.modelName) {
|
||||||
|
errorMsg.value = '请先去设置页选择供应商和模型'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
// B 站:在用户浏览器里直接抓字幕(带本地登录态 cookie),跳过后端的 download_subtitles 与音频转写
|
||||||
|
const prefetched = platform.value === 'bilibili' ? await fetchBilibiliSubtitle(tabUrl.value) : null
|
||||||
|
const { task_id } = await generateNote({
|
||||||
|
video_url: tabUrl.value,
|
||||||
|
platform: platform.value!,
|
||||||
|
quality: settings.value.quality,
|
||||||
|
provider_id: settings.value.providerId,
|
||||||
|
model_name: settings.value.modelName,
|
||||||
|
screenshot: settings.value.screenshot,
|
||||||
|
link: settings.value.link,
|
||||||
|
style: settings.value.style || undefined,
|
||||||
|
format: [
|
||||||
|
...(settings.value.screenshot ? ['screenshot'] : []),
|
||||||
|
...(settings.value.link ? ['link'] : []),
|
||||||
|
],
|
||||||
|
prefetched_transcript: prefetched ?? undefined,
|
||||||
|
})
|
||||||
|
activeTaskId.value = task_id
|
||||||
|
upsertTask({
|
||||||
|
taskId: task_id,
|
||||||
|
videoUrl: tabUrl.value,
|
||||||
|
platform: platform.value!,
|
||||||
|
status: 'PENDING',
|
||||||
|
message: '已提交',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
poll(task_id)
|
||||||
|
// 提交后顺手把侧边栏拉起来,免得用户来回切窗口
|
||||||
|
openSidePanel()
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOptions() {
|
||||||
|
browser.runtime.openOptionsPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSidePanel() {
|
||||||
|
// 只能在用户操作触发的同步上下文里调,且需要明确的 tabId
|
||||||
|
try {
|
||||||
|
const target = tabId.value ?? (await browser.tabs.query({ active: true, currentWindow: true }))[0]?.id
|
||||||
|
if (target == null)
|
||||||
|
return
|
||||||
|
// @ts-expect-error sidePanel 类型在 polyfill 中不全
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open)
|
||||||
|
// @ts-expect-error see above
|
||||||
|
await chrome.sidePanel.open({ tabId: target })
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.warn('打开侧边栏失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTask(id: string) {
|
||||||
|
activeTaskId.value = id
|
||||||
|
const t = tasks.value?.find(x => x.taskId === id)
|
||||||
|
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
|
||||||
|
poll(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCover = computed(() => activeTask.value?.result?.audio_meta?.cover_url as string | undefined)
|
||||||
|
const activeTitle = computed(() => (activeTask.value?.result?.audio_meta?.title as string | undefined) || tabTitle.value)
|
||||||
|
|
||||||
|
function fmtTime(ts?: number) {
|
||||||
|
if (!ts)
|
||||||
|
return ''
|
||||||
|
const d = new Date(ts)
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([settingsReady, tasksReady])
|
||||||
|
await loadActiveTab()
|
||||||
|
const running = tasks.value?.find(t => t.status !== 'SUCCESS' && t.status !== 'FAILED')
|
||||||
|
if (running) {
|
||||||
|
activeTaskId.value = running.taskId
|
||||||
|
poll(running.taskId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer)
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="w-[400px] p-3 text-sm text-gray-800 flex flex-col gap-3 bg-white">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-base">BiliNote</span>
|
||||||
|
<PlatformBadge :platform="platform" />
|
||||||
|
</div>
|
||||||
|
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 truncate" :title="tabUrl">
|
||||||
|
{{ tabUrl || '当前没有打开的标签页' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
|
||||||
|
当前页面不是 BiliNote 支持的视频链接(Bilibili / YouTube / Douyin / Kuaishou)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="border rounded p-2 flex flex-col gap-2" :disabled="!supported || submitting">
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-gray-600">画质</span>
|
||||||
|
<select v-model="settings.quality" class="border rounded px-1 py-0.5">
|
||||||
|
<option value="fast">快速</option>
|
||||||
|
<option value="medium">中等</option>
|
||||||
|
<option value="slow">高质</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 mt-4">
|
||||||
|
<input v-model="settings.screenshot" type="checkbox"> 截图
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 mt-4">
|
||||||
|
<input v-model="settings.link" type="checkbox"> 跳转
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
<span v-if="settings.providerId && settings.modelName">
|
||||||
|
模型:{{ settings.modelName }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-amber-700">
|
||||||
|
⚠ 未选择供应商/模型,
|
||||||
|
<button class="underline" @click="openOptions">去设置</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary" :disabled="!supported || submitting || !settings.providerId" @click="start">
|
||||||
|
{{ submitting ? '提交中…' : '生成笔记' }}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="text-xs text-red-600 break-words">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="activeTask" class="flex flex-col gap-2">
|
||||||
|
<div v-if="activeCover || activeTitle" class="flex gap-3 items-start">
|
||||||
|
<img
|
||||||
|
v-if="activeCover"
|
||||||
|
:src="resolveImageUrl(activeCover)"
|
||||||
|
class="w-20 h-12 object-cover rounded border bg-gray-100 shrink-0"
|
||||||
|
alt="cover"
|
||||||
|
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium leading-snug line-clamp-2 break-words" :title="activeTitle">
|
||||||
|
{{ activeTitle || '(未取到标题)' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-0.5">
|
||||||
|
{{ fmtTime(activeTask.updatedAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="activeTask.status === 'SUCCESS'"
|
||||||
|
class="btn-primary"
|
||||||
|
@click="openSidePanel"
|
||||||
|
>
|
||||||
|
在侧边栏查看笔记 / 思维导图 / AI 问答
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="openSidePanel"
|
||||||
|
>
|
||||||
|
在侧边栏看进度
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details v-if="(tasks?.length ?? 0) > 0" class="text-xs">
|
||||||
|
<summary class="cursor-pointer text-gray-500">最近任务({{ tasks!.length }})</summary>
|
||||||
|
<ul class="mt-1 flex flex-col gap-1 max-h-32 overflow-auto">
|
||||||
|
<li
|
||||||
|
v-for="t in tasks"
|
||||||
|
:key="t.taskId"
|
||||||
|
class="flex justify-between items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-100 cursor-pointer"
|
||||||
|
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
|
||||||
|
@click="selectTask(t.taskId)"
|
||||||
|
>
|
||||||
|
<span class="truncate flex-1" :title="t.videoUrl">
|
||||||
|
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn-primary { @apply bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm; }
|
||||||
|
.btn-secondary { @apply bg-gray-100 text-gray-700 px-2 py-1 rounded hover:bg-gray-200 text-xs; }
|
||||||
|
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
</style>
|
||||||
12
BillNote_extension/src/popup/index.html
Normal file
12
BillNote_extension/src/popup/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<base target="_blank">
|
||||||
|
<title>Popup</title>
|
||||||
|
</head>
|
||||||
|
<body style="min-width: 100px">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
BillNote_extension/src/popup/main.ts
Normal file
8
BillNote_extension/src/popup/main.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './Popup.vue'
|
||||||
|
import { setupApp } from '~/logic/common-setup'
|
||||||
|
import '../styles'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
setupApp(app)
|
||||||
|
app.mount('#app')
|
||||||
258
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal file
258
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { getTaskStatus, resolveImageUrl } from '~/logic/api'
|
||||||
|
import { tasks, tasksReady, settingsReady, upsertTask } from '~/logic/storage'
|
||||||
|
import type { TaskRecord } from '~/logic/types'
|
||||||
|
|
||||||
|
type ViewMode = 'markdown' | 'mindmap' | 'chat'
|
||||||
|
|
||||||
|
const activeTaskId = ref<string>('')
|
||||||
|
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const viewMode = ref<ViewMode>('markdown')
|
||||||
|
const showHistory = ref(false)
|
||||||
|
|
||||||
|
const isDone = computed(() => activeTask.value?.status === 'SUCCESS')
|
||||||
|
const isFailed = computed(() => activeTask.value?.status === 'FAILED')
|
||||||
|
const isRunning = computed(() => !!activeTask.value && !isDone.value && !isFailed.value)
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
PENDING: '排队中',
|
||||||
|
PARSING: '解析中',
|
||||||
|
DOWNLOADING: '下载中',
|
||||||
|
TRANSCRIBING: '转写中',
|
||||||
|
SUMMARIZING: '总结中',
|
||||||
|
FORMATTING: '格式化',
|
||||||
|
SAVING: '保存中',
|
||||||
|
SUCCESS: '完成',
|
||||||
|
FAILED: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function poll(taskId: string) {
|
||||||
|
try {
|
||||||
|
const res = await getTaskStatus(taskId)
|
||||||
|
const cur = tasks.value?.find(t => t.taskId === taskId)
|
||||||
|
if (cur) {
|
||||||
|
upsertTask({
|
||||||
|
...cur,
|
||||||
|
status: res.status,
|
||||||
|
message: res.message,
|
||||||
|
result: res.result ?? cur.result,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||||
|
pollTimer = setTimeout(() => poll(taskId), 3000)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message
|
||||||
|
pollTimer = setTimeout(() => poll(taskId), 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTask(id: string) {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
activeTaskId.value = id
|
||||||
|
showHistory.value = false
|
||||||
|
const t = tasks.value?.find(x => x.taskId === id)
|
||||||
|
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
|
||||||
|
poll(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOptions() {
|
||||||
|
browser.runtime.openOptionsPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMarkdown() {
|
||||||
|
const md = activeTask.value?.result?.markdown
|
||||||
|
if (md)
|
||||||
|
await navigator.clipboard.writeText(md)
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadMarkdown() {
|
||||||
|
const md = activeTask.value?.result?.markdown
|
||||||
|
if (!md)
|
||||||
|
return
|
||||||
|
const title = (activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || 'bilinote'
|
||||||
|
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${title}.md`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTitle = computed(() =>
|
||||||
|
(activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || activeTask.value?.videoUrl || '')
|
||||||
|
|
||||||
|
const activeCover = computed(() =>
|
||||||
|
(activeTask.value?.result?.audio_meta as { cover_url?: string } | undefined)?.cover_url)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([settingsReady, tasksReady])
|
||||||
|
const latest = tasks.value?.[0]
|
||||||
|
if (latest) {
|
||||||
|
activeTaskId.value = latest.taskId
|
||||||
|
if (latest.status !== 'SUCCESS' && latest.status !== 'FAILED')
|
||||||
|
poll(latest.taskId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer)
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="w-full h-full flex flex-col bg-white text-sm text-gray-800">
|
||||||
|
<!-- 顶栏:极简 -->
|
||||||
|
<header class="flex items-center justify-between px-3 py-2 border-b shrink-0">
|
||||||
|
<div class="font-semibold">BiliNote</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
v-if="(tasks?.length ?? 0) > 0"
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-800 px-2 py-0.5 rounded hover:bg-gray-100"
|
||||||
|
:class="{ 'bg-gray-100': showHistory }"
|
||||||
|
@click="showHistory = !showHistory"
|
||||||
|
>
|
||||||
|
历史 {{ tasks?.length }}
|
||||||
|
</button>
|
||||||
|
<button class="text-xs text-gray-500 hover:text-gray-800 px-2 py-0.5 rounded hover:bg-gray-100" @click="openOptions">
|
||||||
|
设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 历史弹层(覆盖在内容上方) -->
|
||||||
|
<div v-if="showHistory" class="border-b bg-gray-50 px-2 py-2 max-h-60 overflow-auto shrink-0">
|
||||||
|
<ul class="flex flex-col gap-0.5 text-xs">
|
||||||
|
<li
|
||||||
|
v-for="t in tasks"
|
||||||
|
:key="t.taskId"
|
||||||
|
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-white"
|
||||||
|
:class="{ 'bg-white border': t.taskId === activeTaskId }"
|
||||||
|
@click="selectTask(t.taskId)"
|
||||||
|
>
|
||||||
|
<span class="truncate flex-1" :title="t.videoUrl">
|
||||||
|
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400 shrink-0">{{ STAGE_LABELS[t.status] || t.status }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-1 break-words bg-red-50 shrink-0">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
|
||||||
|
还没有任务。在视频页点悬浮按钮、在 popup 提交,或右键菜单选「用 BiliNote 总结」。
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="flex-1 flex flex-col min-h-0">
|
||||||
|
<!-- 标题区:紧凑一行 -->
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="activeCover"
|
||||||
|
:src="resolveImageUrl(activeCover)"
|
||||||
|
class="w-12 h-7 object-cover rounded bg-gray-100 shrink-0"
|
||||||
|
alt=""
|
||||||
|
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="text-sm font-medium leading-tight line-clamp-1 break-all flex-1 min-w-0 hover:text-blue-600"
|
||||||
|
:href="activeTask.videoUrl"
|
||||||
|
target="_blank"
|
||||||
|
:title="activeTask.videoUrl"
|
||||||
|
>{{ activeTitle }}</a>
|
||||||
|
<span
|
||||||
|
v-if="isDone"
|
||||||
|
class="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 shrink-0"
|
||||||
|
title="完成"
|
||||||
|
>✓</span>
|
||||||
|
<span
|
||||||
|
v-else-if="isFailed"
|
||||||
|
class="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 shrink-0"
|
||||||
|
:title="activeTask.message"
|
||||||
|
>失败</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 shrink-0 animate-pulse"
|
||||||
|
>{{ STAGE_LABELS[activeTask.status] || activeTask.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 进行中:进度条;完成:tab + 操作按钮 -->
|
||||||
|
<div v-if="isRunning" class="px-3 py-2 border-b shrink-0">
|
||||||
|
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="isDone && activeTask.result?.markdown"
|
||||||
|
class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 rounded"
|
||||||
|
:class="viewMode === 'markdown' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
|
||||||
|
@click="viewMode = 'markdown'"
|
||||||
|
>Markdown</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 rounded"
|
||||||
|
:class="viewMode === 'mindmap' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
|
||||||
|
@click="viewMode = 'mindmap'"
|
||||||
|
>思维导图</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 rounded"
|
||||||
|
:class="viewMode === 'chat' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
|
||||||
|
@click="viewMode = 'chat'"
|
||||||
|
>AI 问答</button>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<button
|
||||||
|
v-if="viewMode === 'markdown'"
|
||||||
|
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
|
||||||
|
title="复制 Markdown"
|
||||||
|
@click="copyMarkdown"
|
||||||
|
>复制</button>
|
||||||
|
<button
|
||||||
|
v-if="viewMode === 'markdown'"
|
||||||
|
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
|
||||||
|
title="下载 .md"
|
||||||
|
@click="downloadMarkdown"
|
||||||
|
>下载</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区:占满剩余空间 -->
|
||||||
|
<div class="flex-1 overflow-auto min-h-0">
|
||||||
|
<MarkdownView
|
||||||
|
v-if="isDone && activeTask.result?.markdown && viewMode === 'markdown'"
|
||||||
|
:markdown="activeTask.result.markdown"
|
||||||
|
:title="(activeTask.result.audio_meta as { title?: string } | undefined)?.title"
|
||||||
|
:hide-actions="true"
|
||||||
|
/>
|
||||||
|
<MindMap
|
||||||
|
v-else-if="isDone && activeTask.result?.markdown && viewMode === 'mindmap'"
|
||||||
|
:markdown="activeTask.result.markdown"
|
||||||
|
class="h-full"
|
||||||
|
/>
|
||||||
|
<ChatPanel
|
||||||
|
v-else-if="isDone && viewMode === 'chat'"
|
||||||
|
:task-id="activeTask.taskId"
|
||||||
|
class="h-full"
|
||||||
|
/>
|
||||||
|
<div v-else-if="isFailed" class="p-4 text-sm text-red-600">
|
||||||
|
{{ activeTask.message || '任务失败' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
</style>
|
||||||
12
BillNote_extension/src/sidepanel/index.html
Normal file
12
BillNote_extension/src/sidepanel/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<base target="_blank">
|
||||||
|
<title>Sidepanel</title>
|
||||||
|
</head>
|
||||||
|
<body style="min-width: 100px">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
BillNote_extension/src/sidepanel/main.ts
Normal file
8
BillNote_extension/src/sidepanel/main.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './Sidepanel.vue'
|
||||||
|
import { setupApp } from '~/logic/common-setup'
|
||||||
|
import '../styles'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
setupApp(app)
|
||||||
|
app.mount('#app')
|
||||||
3
BillNote_extension/src/styles/index.ts
Normal file
3
BillNote_extension/src/styles/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import '@unocss/reset/tailwind.css'
|
||||||
|
import './main.css'
|
||||||
|
import 'uno.css'
|
||||||
20
BillNote_extension/src/styles/main.css
Executable file
20
BillNote_extension/src/styles/main.css
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-1 rounded inline-block
|
||||||
|
bg-teal-600 text-white cursor-pointer
|
||||||
|
hover:bg-teal-700
|
||||||
|
disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
@apply inline-block cursor-pointer select-none
|
||||||
|
opacity-75 transition duration-200 ease-in-out
|
||||||
|
hover:opacity-100 hover:text-teal-600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('demo', () => {
|
||||||
|
it('should work', () => {
|
||||||
|
expect(1 + 1).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
24
BillNote_extension/tsconfig.json
Normal file
24
BillNote_extension/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"incremental": false,
|
||||||
|
"target": "es2016",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["DOM", "ESNext"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
13
BillNote_extension/unocss.config.ts
Normal file
13
BillNote_extension/unocss.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'unocss/vite'
|
||||||
|
import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [
|
||||||
|
presetUno(),
|
||||||
|
presetAttributify(),
|
||||||
|
presetIcons(),
|
||||||
|
],
|
||||||
|
transformers: [
|
||||||
|
transformerDirectives(),
|
||||||
|
],
|
||||||
|
})
|
||||||
36
BillNote_extension/vite.config.background.mts
Normal file
36
BillNote_extension/vite.config.background.mts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { sharedConfig } from './vite.config.mjs'
|
||||||
|
import { isDev, r } from './scripts/utils'
|
||||||
|
import packageJson from './package.json'
|
||||||
|
|
||||||
|
// bundling the content script using Vite
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
define: {
|
||||||
|
'__DEV__': isDev,
|
||||||
|
'__NAME__': JSON.stringify(packageJson.name),
|
||||||
|
// https://github.com/vitejs/vite/issues/9320
|
||||||
|
// https://github.com/vitejs/vite/issues/9186
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
watch: isDev
|
||||||
|
? {}
|
||||||
|
: undefined,
|
||||||
|
outDir: r('extension/dist/background'),
|
||||||
|
cssCodeSplit: false,
|
||||||
|
emptyOutDir: false,
|
||||||
|
sourcemap: isDev ? 'inline' : false,
|
||||||
|
lib: {
|
||||||
|
entry: r('src/background/main.ts'),
|
||||||
|
name: packageJson.name,
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'index.mjs',
|
||||||
|
extend: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
36
BillNote_extension/vite.config.content.mts
Normal file
36
BillNote_extension/vite.config.content.mts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { sharedConfig } from './vite.config.mjs'
|
||||||
|
import { isDev, r } from './scripts/utils'
|
||||||
|
import packageJson from './package.json'
|
||||||
|
|
||||||
|
// bundling the content script using Vite
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
define: {
|
||||||
|
'__DEV__': isDev,
|
||||||
|
'__NAME__': JSON.stringify(packageJson.name),
|
||||||
|
// https://github.com/vitejs/vite/issues/9320
|
||||||
|
// https://github.com/vitejs/vite/issues/9186
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
watch: isDev
|
||||||
|
? {}
|
||||||
|
: undefined,
|
||||||
|
outDir: r('extension/dist/contentScripts'),
|
||||||
|
cssCodeSplit: false,
|
||||||
|
emptyOutDir: false,
|
||||||
|
sourcemap: isDev ? 'inline' : false,
|
||||||
|
lib: {
|
||||||
|
entry: r('src/contentScripts/index.ts'),
|
||||||
|
name: packageJson.name,
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'index.global.js',
|
||||||
|
extend: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
115
BillNote_extension/vite.config.mts
Normal file
115
BillNote_extension/vite.config.mts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
|
||||||
|
import { dirname, relative } from 'node:path'
|
||||||
|
import type { UserConfig } from 'vite'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import Vue from '@vitejs/plugin-vue'
|
||||||
|
import Icons from 'unplugin-icons/vite'
|
||||||
|
import IconsResolver from 'unplugin-icons/resolver'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import UnoCSS from 'unocss/vite'
|
||||||
|
import { isDev, port, r } from './scripts/utils'
|
||||||
|
import packageJson from './package.json'
|
||||||
|
|
||||||
|
export const sharedConfig: UserConfig = {
|
||||||
|
root: r('src'),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~/': `${r('src')}/`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__DEV__: isDev,
|
||||||
|
__NAME__: JSON.stringify(packageJson.name),
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
Vue(),
|
||||||
|
|
||||||
|
AutoImport({
|
||||||
|
imports: [
|
||||||
|
'vue',
|
||||||
|
{
|
||||||
|
'webextension-polyfill': [
|
||||||
|
['=', 'browser'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dts: r('src/auto-imports.d.ts'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// https://github.com/antfu/unplugin-vue-components
|
||||||
|
Components({
|
||||||
|
dirs: [r('src/components')],
|
||||||
|
// generate `components.d.ts` for ts support with Volar
|
||||||
|
dts: r('src/components.d.ts'),
|
||||||
|
resolvers: [
|
||||||
|
// auto import icons
|
||||||
|
IconsResolver({
|
||||||
|
prefix: '',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// https://github.com/antfu/unplugin-icons
|
||||||
|
Icons(),
|
||||||
|
|
||||||
|
// https://github.com/unocss/unocss
|
||||||
|
UnoCSS(),
|
||||||
|
|
||||||
|
// rewrite assets to use relative path
|
||||||
|
{
|
||||||
|
name: 'assets-rewrite',
|
||||||
|
enforce: 'post',
|
||||||
|
apply: 'build',
|
||||||
|
transformIndexHtml(html, { path }) {
|
||||||
|
return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'vue',
|
||||||
|
'@vueuse/core',
|
||||||
|
'webextension-polyfill',
|
||||||
|
],
|
||||||
|
exclude: [
|
||||||
|
'vue-demi',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ command }) => ({
|
||||||
|
...sharedConfig,
|
||||||
|
base: command === 'serve' ? `http://localhost:${port}/` : '/dist/',
|
||||||
|
server: {
|
||||||
|
port,
|
||||||
|
hmr: {
|
||||||
|
host: 'localhost',
|
||||||
|
},
|
||||||
|
origin: `http://localhost:${port}`,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
watch: isDev
|
||||||
|
? {}
|
||||||
|
: undefined,
|
||||||
|
outDir: r('extension/dist'),
|
||||||
|
emptyOutDir: false,
|
||||||
|
sourcemap: isDev ? 'inline' : false,
|
||||||
|
// https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements
|
||||||
|
terserOptions: {
|
||||||
|
mangle: false,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
options: r('src/options/index.html'),
|
||||||
|
popup: r('src/popup/index.html'),
|
||||||
|
sidepanel: r('src/sidepanel/index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
}))
|
||||||
1
BillNote_frontend/.gitignore
vendored
1
BillNote_frontend/.gitignore
vendored
@@ -22,5 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
/pnpm-lock.yaml
|
|
||||||
/src-tauri/bin/
|
/src-tauri/bin/
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
# === 前端构建阶段 ===
|
# === 前端构建阶段 ===
|
||||||
FROM node:18-alpine AS builder
|
# Tailwind v4 / Vite 6 需要 Node 20+,alpine + pnpm 会按 lockfile 拉 musl native binary。
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
# 安装 pnpm
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
RUN npm install -g pnpm
|
|
||||||
|
|
||||||
# 设置工作目录
|
|
||||||
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
|
||||||
|
|
||||||
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
||||||
FROM nginx:1.25-alpine
|
FROM nginx:1.25-alpine
|
||||||
|
|
||||||
# 删除默认配置(可选)
|
|
||||||
RUN rm -rf /etc/nginx/conf.d/default.conf
|
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||||
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
|
||||||
# 拷贝构建产物
|
# 拷贝构建产物
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|||||||
18705
BillNote_frontend/package-lock.json
generated
Normal file
18705
BillNote_frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"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": "^1.97.1",
|
||||||
"@lobehub/icons-static-svg": "^1.45.0",
|
"@lobehub/icons-static-svg": "^1.45.0",
|
||||||
@@ -32,6 +33,8 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
|
|||||||
10810
BillNote_frontend/pnpm-lock.yaml
generated
Normal file
10810
BillNote_frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "BiliNote",
|
"productName": "BiliNote",
|
||||||
"version": "1.8.0",
|
"version": "2.0.0",
|
||||||
"identifier": "com.jefferyhuang.bilinote",
|
"identifier": "com.jefferyhuang.bilinote",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import './App.css'
|
import './App.css'
|
||||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
import { lazy, Suspense, useEffect } from 'react'
|
||||||
|
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom'
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
||||||
import SettingPage from './pages/SettingPage/index.tsx'
|
|
||||||
import { BrowserRouter, HashRouter, Navigate, Routes } from 'react-router-dom'
|
|
||||||
import { Route } from 'react-router-dom'
|
|
||||||
import Index from '@/pages/Index.tsx'
|
|
||||||
import NotFoundPage from '@/pages/NotFoundPage'
|
|
||||||
import Model from '@/pages/SettingPage/Model.tsx'
|
|
||||||
import Transcriber from '@/pages/SettingPage/transcriber.tsx'
|
|
||||||
import ProviderForm from '@/components/Form/modelForm/Form.tsx'
|
|
||||||
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
|
||||||
import Downloading from '@/components/Lottie/download.tsx'
|
|
||||||
import Prompt from '@/pages/SettingPage/Prompt.tsx'
|
|
||||||
import AboutPage from '@/pages/SettingPage/about.tsx'
|
|
||||||
import Downloader from '@/pages/SettingPage/Downloader.tsx'
|
|
||||||
import DownloaderForm from '@/components/Form/DownloaderForm/Form.tsx'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { systemCheck } from '@/services/system.ts'
|
|
||||||
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||||
|
import { systemCheck } from '@/services/system.ts'
|
||||||
import BackendInitDialog from '@/components/BackendInitDialog'
|
import BackendInitDialog from '@/components/BackendInitDialog'
|
||||||
|
import Index from '@/pages/Index.tsx'
|
||||||
|
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||||
|
|
||||||
|
// 非首屏页面使用 React.lazy 按需加载
|
||||||
|
const SettingPage = lazy(() => import('./pages/SettingPage/index.tsx'))
|
||||||
|
const Model = lazy(() => import('@/pages/SettingPage/Model.tsx'))
|
||||||
|
const ProviderForm = lazy(() => import('@/components/Form/modelForm/Form.tsx'))
|
||||||
|
const AboutPage = lazy(() => import('@/pages/SettingPage/about.tsx'))
|
||||||
|
const Monitor = lazy(() => import('@/pages/SettingPage/Monitor.tsx'))
|
||||||
|
const Downloader = lazy(() => import('@/pages/SettingPage/Downloader.tsx'))
|
||||||
|
const DownloaderForm = lazy(() => import('@/components/Form/DownloaderForm/Form.tsx'))
|
||||||
|
const TranscriberPage = lazy(() => import('@/pages/SettingPage/transcriber.tsx'))
|
||||||
|
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||||
@@ -43,28 +42,32 @@ function App() {
|
|||||||
// 后端已初始化,渲染主应用
|
// 后端已初始化,渲染主应用
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HashRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
||||||
<Route path="/" element={<Index />}>
|
<Routes>
|
||||||
<Route index element={<HomePage />} />
|
<Route path="/" element={<Index />}>
|
||||||
<Route path="settings" element={<SettingPage />}>
|
<Route index element={<HomePage />} />
|
||||||
<Route index element={<Navigate to="model" replace />} />
|
<Route path="settings" element={<SettingPage />}>
|
||||||
<Route path="model" element={<Model />}>
|
<Route index element={<Navigate to="model" replace />} />
|
||||||
<Route path="new" element={<ProviderForm isCreate />} />
|
<Route path="model" element={<Model />}>
|
||||||
<Route path=":id" element={<ProviderForm />} />
|
<Route path="new" element={<ProviderForm isCreate />} />
|
||||||
|
<Route path=":id" element={<ProviderForm />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="download" element={<Downloader />}>
|
||||||
|
<Route path=":id" element={<DownloaderForm />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="transcriber" element={<TranscriberPage />} />
|
||||||
|
<Route path="monitor" element={<Monitor />}></Route>
|
||||||
|
<Route path="about" element={<AboutPage />}></Route>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="download" element={<Downloader />}>
|
|
||||||
<Route path=":id" element={<DownloaderForm />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="about" element={<AboutPage />}></Route>
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
</Routes>
|
||||||
</Route>
|
</Suspense>
|
||||||
</Routes>
|
</BrowserRouter>
|
||||||
</HashRouter>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
BIN
BillNote_frontend/src/assets/wechat.png
Normal file
BIN
BillNote_frontend/src/assets/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -27,7 +27,7 @@ import {
|
|||||||
import { ModelSelector } from '@/components/Form/modelForm/ModelSelector.tsx'
|
import { ModelSelector } from '@/components/Form/modelForm/ModelSelector.tsx'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'
|
||||||
import { Tags } from 'lucide-react'
|
import { Tags } from 'lucide-react'
|
||||||
import { Tag } from 'antd'
|
import { X } from 'lucide-react'
|
||||||
import { useModelStore } from '@/store/modelStore'
|
import { useModelStore } from '@/store/modelStore'
|
||||||
|
|
||||||
// ✅ Provider表单schema
|
// ✅ Provider表单schema
|
||||||
@@ -312,12 +312,12 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
|||||||
{
|
{
|
||||||
models && models.map(model => {
|
models && models.map(model => {
|
||||||
return (
|
return (
|
||||||
<>
|
<span key={model.id} className="inline-flex items-center gap-1 rounded-md bg-blue-100 px-2 py-0.5 text-sm text-blue-700">
|
||||||
<Tag onClose={()=>{
|
{model.model_name}
|
||||||
handelDelete(model.id)
|
<button type="button" onClick={() => handelDelete(model.id)} className="hover:text-blue-900">
|
||||||
}} key={model.id} closable color={'blue'}>
|
<X className="h-3 w-3" />
|
||||||
{model.model_name}
|
</button>
|
||||||
</Tag></>
|
</span>
|
||||||
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ interface AILogoProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
|
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
|
||||||
const Icon = Icons[name as keyof typeof Icons]
|
const Icon = name ? Icons[name as keyof typeof Icons] : undefined
|
||||||
if (!Icon) {
|
if (!Icon) {
|
||||||
console.error(`❌ 图标组件不存在: ${name}`)
|
if (name && name !== 'custom') {
|
||||||
|
console.warn(`AILogo: 未匹配到图标,使用自定义占位: ${name}`)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span style={{ fontSize: size }}>
|
<span style={{ fontSize: size }}>
|
||||||
<img src={CustomLogo} alt="CustomLogo" style={{ width: size, height: size }} />
|
<img src={CustomLogo} alt="CustomLogo" style={{ width: size, height: size }} />
|
||||||
|
|||||||
@@ -4,47 +4,51 @@ import styles from './index.module.css'
|
|||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import AILogo from '@/components/Form/modelForm/Icons'
|
import AILogo from '@/components/Form/modelForm/Icons'
|
||||||
import { useProviderStore } from '@/store/providerStore'
|
import { useProviderStore } from '@/store/providerStore'
|
||||||
|
|
||||||
export interface IProviderCardProps {
|
export interface IProviderCardProps {
|
||||||
id: string
|
id: string
|
||||||
providerName: string
|
providerName: string
|
||||||
Icon: string
|
Icon: string
|
||||||
enable: number
|
enable: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProviderCard: FC<IProviderCardProps> = ({
|
const ProviderCard: FC<IProviderCardProps> = ({
|
||||||
providerName,
|
providerName,
|
||||||
Icon,
|
Icon,
|
||||||
id,
|
id,
|
||||||
enable,
|
|
||||||
}: IProviderCardProps) => {
|
}: IProviderCardProps) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const updateProvider = useProviderStore(state => state.updateProvider)
|
const updateProvider = useProviderStore(state => state.updateProvider)
|
||||||
const handleClick = () => {
|
const enabled = useProviderStore(state => state.provider.find(p => p.id === id)?.enabled)
|
||||||
navigate(`/settings/model/${id}`)
|
|
||||||
}
|
const isChecked = enabled === 1
|
||||||
const handleEnable = () => {
|
|
||||||
console.log('enable', enable)
|
const handleToggle = (checked: boolean) => {
|
||||||
|
const allProviders = useProviderStore.getState().provider
|
||||||
|
const provider = allProviders.find(p => p.id === id)
|
||||||
|
if (!provider) return
|
||||||
updateProvider({
|
updateProvider({
|
||||||
id,
|
...provider,
|
||||||
enabled: enable == 1 ? 0 : 1,
|
enabled: checked ? 1 : 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const rawId = useParams()
|
|
||||||
console.log('rawId', rawId)
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const { id: currentId } = useParams()
|
const { id: currentId } = useParams()
|
||||||
const isActive = currentId === id
|
const isActive = currentId === id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
|
||||||
handleClick()
|
|
||||||
}}
|
|
||||||
className={
|
className={
|
||||||
styles.card +
|
styles.card +
|
||||||
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
|
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
|
||||||
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
|
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center text-lg">
|
<div
|
||||||
|
className="flex items-center text-lg"
|
||||||
|
onClick={() => navigate(`/settings/model/${id}`)}
|
||||||
|
>
|
||||||
<div className="flex h-9 w-9 items-center">
|
<div className="flex h-9 w-9 items-center">
|
||||||
<AILogo name={Icon} />
|
<AILogo name={Icon} />
|
||||||
</div>
|
</div>
|
||||||
@@ -53,11 +57,8 @@ const ProviderCard: FC<IProviderCardProps> = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Switch
|
<Switch
|
||||||
onClick={e => {
|
checked={isChecked}
|
||||||
e.preventDefault()
|
onCheckedChange={handleToggle}
|
||||||
handleEnable()
|
|
||||||
}}
|
|
||||||
checked={enable == 1}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ interface AILogoProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
|
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
|
||||||
const Icon = Icons[name as keyof typeof Icons];
|
const Icon = name ? Icons[name as keyof typeof Icons] : undefined;
|
||||||
if (!Icon) {
|
if (!Icon) {
|
||||||
console.error(`❌ 图标组件不存在: ${name}`);
|
if (name && name !== 'custom') {
|
||||||
|
console.warn(`AILogo: 未匹配到图标,使用占位: ${name}`);
|
||||||
|
}
|
||||||
return <span style={{ fontSize: size }}>🚫</span>;
|
return <span style={{ fontSize: size }}>🚫</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||||
import { CheckIcon } from 'lucide-react'
|
import { CheckIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
function Checkbox({ className, checked, onChange, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
const [isChecked, setIsChecked] = useState(checked || false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (checked !== undefined) {
|
||||||
|
setIsChecked(checked);
|
||||||
|
}
|
||||||
|
}, [checked]);
|
||||||
|
|
||||||
|
const handleCheckChange = (newChecked: boolean) => {
|
||||||
|
setIsChecked(newChecked);
|
||||||
|
if (onChange) {
|
||||||
|
onChange({} as React.FormEvent<HTMLButtonElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
@@ -12,6 +28,8 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
|||||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={handleCheckChange}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
function Input({ className, type, value, onChange, ...props }: React.ComponentProps<'input'>) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
@@ -13,6 +13,8 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={onChange}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ export const useTaskPolling = (interval = 3000) => {
|
|||||||
task => task.status != 'SUCCESS' && task.status != 'FAILED'
|
task => task.status != 'SUCCESS' && task.status != 'FAILED'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 无活跃任务时跳过轮询
|
||||||
|
if (pendingTasks.length === 0) return
|
||||||
|
|
||||||
for (const task of pendingTasks) {
|
for (const task of pendingTasks) {
|
||||||
try {
|
try {
|
||||||
console.log('🔄 正在轮询任务:', task.id)
|
|
||||||
const res = await get_task_status(task.id)
|
const res = await get_task_status(task.id)
|
||||||
const { status } = res
|
const { status } = res
|
||||||
|
|
||||||
@@ -47,9 +49,7 @@ export const useTaskPolling = (interval = 3000) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('❌ 任务轮询失败:', e)
|
console.error('❌ 任务轮询失败:', e)
|
||||||
// toast.error(`生成失败 ${e.message || e}`)
|
|
||||||
updateTaskContent(task.id, { status: 'FAILED' })
|
updateTaskContent(task.id, { status: 'FAILED' })
|
||||||
// removeTask(task.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, interval)
|
}, interval)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user