mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-06 20:42:52 +08:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
334
.dockerignore
334
.dockerignore
@@ -1,321 +1,35 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
# Git 和 IDE
|
||||
.git
|
||||
.github
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
BiliNote/pnpm-lock.yaml
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
# Tauri 构建产物(非常大)
|
||||
BillNote_frontend/src-tauri/target
|
||||
BillNote_frontend/src-tauri/bin
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
# 运行时数据
|
||||
backend/data
|
||||
backend/static
|
||||
backend/models
|
||||
backend/logs
|
||||
backend/uploads
|
||||
backend/*.db
|
||||
backend/note_results
|
||||
backend/bin/
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
# 依赖和构建缓存
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
.BiliNote-dev/*
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
!.env.example
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
build/
|
||||
*.tar
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
.idea/
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
# 环境文件
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
/backend/data/*
|
||||
/backend/static/*
|
||||
/backend/note_tasks.db
|
||||
/backend/bin/
|
||||
/backend/logs/
|
||||
/backend/note_results
|
||||
/backend/models
|
||||
/backend/.idea/*
|
||||
/backend/bili_note.db
|
||||
/backend/uploads/*
|
||||
/BiliNote_frontend/.idea/*
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
@@ -4,7 +4,7 @@ FRONTEND_PORT=3015
|
||||
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
|
||||
APP_PORT= 3015 # docker 部署时用
|
||||
# 前端访问后端用 (开发环境使用)
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8483
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
|
||||
VITE_FRONTEND_PORT=3015
|
||||
# 生产环境配置
|
||||
@@ -19,6 +19,6 @@ FFMPEG_BIN_PATH=
|
||||
|
||||
# transcriber 相关配置
|
||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
|
||||
WHISPER_MODEL_SIZE=base
|
||||
WHISPER_MODEL_SIZE=medium
|
||||
|
||||
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo
|
||||
|
||||
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 Desktop App (Python Backend + Tauri Frontend)
|
||||
name: Build & Release Desktop App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # 发布 tag 时触发
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest]
|
||||
include:
|
||||
- platform: macos-latest
|
||||
target: universal-apple-darwin
|
||||
- platform: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 设置 Python 环境
|
||||
# Linux 系统依赖(Tauri 需要)
|
||||
- name: Install Linux Dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
# 设置 Python 环境(带 pip 缓存)
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: backend/requirements.txt
|
||||
|
||||
# 安装 Python 依赖并执行你的 build.sh
|
||||
# 安装 Python 依赖并执行构建
|
||||
- name: Install Python dependencies & Build backend
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -38,30 +53,108 @@ jobs:
|
||||
./backend/build.sh
|
||||
fi
|
||||
|
||||
# 设置 Node 环境 + 安装前端依赖
|
||||
# 设置 pnpm
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
# 设置 Node 环境
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Enable Corepack + Install pnpm
|
||||
- name: Install frontend dependencies
|
||||
working-directory: BillNote_frontend
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install
|
||||
run: pnpm install
|
||||
|
||||
# 设置 Rust 环境
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
# Cargo 缓存
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
BillNote_frontend/src-tauri/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
# 打包 Tauri 应用
|
||||
- name: Build Tauri App
|
||||
working-directory: BillNote_frontend
|
||||
run: pnpm tauri build
|
||||
|
||||
# 可选:上传构建产物
|
||||
- name: Upload Desktop Bundle
|
||||
# 收集产物到统一目录
|
||||
- name: Collect release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-artifacts
|
||||
BUNDLE_DIR="BillNote_frontend/src-tauri/target/release/bundle"
|
||||
|
||||
# macOS: .dmg
|
||||
find "$BUNDLE_DIR" -name "*.dmg" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
# Windows: .msi, .exe (NSIS)
|
||||
find "$BUNDLE_DIR" -name "*.msi" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
# 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
|
||||
with:
|
||||
name: app-${{ matrix.platform }}
|
||||
path: BillNote_frontend/src-tauri/target/release/bundle/
|
||||
name: artifacts-${{ matrix.platform }}
|
||||
path: release-artifacts/
|
||||
|
||||
# 创建 GitHub Release 并上传所有产物
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List all artifacts
|
||||
run: |
|
||||
echo "=== All release artifacts ==="
|
||||
ls -lhR all-artifacts/
|
||||
|
||||
# 创建 Release 并上传产物
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: BiliNote ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: all-artifacts/*
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -320,5 +320,10 @@ cython_debug/
|
||||
/backend/uploads/*
|
||||
/backend/.idea/*
|
||||
/backend/config/*
|
||||
/backend/vector_db/
|
||||
/BiliNote_frontend/.idea/*
|
||||
/BiliNote_frontend/src-tauri/bin/
|
||||
/BiliNote_frontend/src-tauri/bin/
|
||||
|
||||
# FFmpeg 构建文件(不应该提交到仓库)
|
||||
ffmpeg*/
|
||||
ffmpg*/
|
||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
BillNote_frontend/.gitignore
vendored
1
BillNote_frontend/.gitignore
vendored
@@ -22,5 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/pnpm-lock.yaml
|
||||
/src-tauri/bin/
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
# === 前端构建阶段 ===
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# 安装 pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 拷贝前端源码
|
||||
COPY ./BillNote_frontend /app
|
||||
# 先复制 lockfile 利用依赖层缓存
|
||||
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 安装依赖并构建
|
||||
RUN pnpm install && pnpm run build
|
||||
# 再复制源代码并构建
|
||||
COPY ./BillNote_frontend/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
# 删除默认配置(可选)
|
||||
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
|
||||
# 拷贝构建产物
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
18705
BillNote_frontend/package-lock.json
generated
Normal file
18705
BillNote_frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/x": "^2.4.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@lobehub/icons": "^1.97.1",
|
||||
"@lobehub/icons-static-svg": "^1.45.0",
|
||||
@@ -32,6 +33,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.22",
|
||||
"lottie-react": "^2.4.1",
|
||||
"lucide-react": "^0.487.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "BiliNote",
|
||||
"version": "1.8.1",
|
||||
"version": "2.0.0",
|
||||
"identifier": "com.jefferyhuang.bilinote",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import './App.css'
|
||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom'
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
||||
import SettingPage from './pages/SettingPage/index.tsx'
|
||||
import { BrowserRouter, HashRouter, Navigate, Routes } from 'react-router-dom'
|
||||
import { Route } from 'react-router-dom'
|
||||
import Index from '@/pages/Index.tsx'
|
||||
import NotFoundPage from '@/pages/NotFoundPage'
|
||||
import Model from '@/pages/SettingPage/Model.tsx'
|
||||
import Transcriber from '@/pages/SettingPage/transcriber.tsx'
|
||||
import ProviderForm from '@/components/Form/modelForm/Form.tsx'
|
||||
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
||||
import Downloading from '@/components/Lottie/download.tsx'
|
||||
import Prompt from '@/pages/SettingPage/Prompt.tsx'
|
||||
import AboutPage from '@/pages/SettingPage/about.tsx'
|
||||
import Downloader from '@/pages/SettingPage/Downloader.tsx'
|
||||
import DownloaderForm from '@/components/Form/DownloaderForm/Form.tsx'
|
||||
import { useEffect } from 'react'
|
||||
import { systemCheck } from '@/services/system.ts'
|
||||
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||
import { systemCheck } from '@/services/system.ts'
|
||||
import BackendInitDialog from '@/components/BackendInitDialog'
|
||||
import Index from '@/pages/Index.tsx'
|
||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||
|
||||
// 非首屏页面使用 React.lazy 按需加载
|
||||
const SettingPage = lazy(() => import('./pages/SettingPage/index.tsx'))
|
||||
const Model = lazy(() => import('@/pages/SettingPage/Model.tsx'))
|
||||
const ProviderForm = lazy(() => import('@/components/Form/modelForm/Form.tsx'))
|
||||
const AboutPage = lazy(() => import('@/pages/SettingPage/about.tsx'))
|
||||
const Monitor = lazy(() => import('@/pages/SettingPage/Monitor.tsx'))
|
||||
const Downloader = lazy(() => import('@/pages/SettingPage/Downloader.tsx'))
|
||||
const DownloaderForm = lazy(() => import('@/components/Form/DownloaderForm/Form.tsx'))
|
||||
const TranscriberPage = lazy(() => import('@/pages/SettingPage/transcriber.tsx'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
|
||||
function App() {
|
||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||
@@ -43,28 +42,32 @@ function App() {
|
||||
// 后端已初始化,渲染主应用
|
||||
return (
|
||||
<>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="settings" element={<SettingPage />}>
|
||||
<Route index element={<Navigate to="model" replace />} />
|
||||
<Route path="model" element={<Model />}>
|
||||
<Route path="new" element={<ProviderForm isCreate />} />
|
||||
<Route path=":id" element={<ProviderForm />} />
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="settings" element={<SettingPage />}>
|
||||
<Route index element={<Navigate to="model" replace />} />
|
||||
<Route path="model" element={<Model />}>
|
||||
<Route path="new" element={<ProviderForm isCreate />} />
|
||||
<Route path=":id" element={<ProviderForm />} />
|
||||
</Route>
|
||||
<Route path="download" element={<Downloader />}>
|
||||
<Route path=":id" element={<DownloaderForm />} />
|
||||
</Route>
|
||||
<Route path="transcriber" element={<TranscriberPage />} />
|
||||
<Route path="monitor" element={<Monitor />}></Route>
|
||||
<Route path="about" element={<AboutPage />}></Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="download" element={<Downloader />}>
|
||||
<Route path=":id" element={<DownloaderForm />} />
|
||||
</Route>
|
||||
<Route path="about" element={<AboutPage />}></Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { ModelSelector } from '@/components/Form/modelForm/ModelSelector.tsx'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'
|
||||
import { Tags } from 'lucide-react'
|
||||
import { Tag } from 'antd'
|
||||
import { X } from 'lucide-react'
|
||||
import { useModelStore } from '@/store/modelStore'
|
||||
|
||||
// ✅ Provider表单schema
|
||||
@@ -312,12 +312,12 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
{
|
||||
models && models.map(model => {
|
||||
return (
|
||||
<>
|
||||
<Tag onClose={()=>{
|
||||
handelDelete(model.id)
|
||||
}} key={model.id} closable color={'blue'}>
|
||||
{model.model_name}
|
||||
</Tag></>
|
||||
<span key={model.id} className="inline-flex items-center gap-1 rounded-md bg-blue-100 px-2 py-0.5 text-sm text-blue-700">
|
||||
{model.model_name}
|
||||
<button type="button" onClick={() => handelDelete(model.id)} className="hover:text-blue-900">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,47 +4,51 @@ import styles from './index.module.css'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import AILogo from '@/components/Form/modelForm/Icons'
|
||||
import { useProviderStore } from '@/store/providerStore'
|
||||
|
||||
export interface IProviderCardProps {
|
||||
id: string
|
||||
providerName: string
|
||||
Icon: string
|
||||
enable: number
|
||||
}
|
||||
|
||||
const ProviderCard: FC<IProviderCardProps> = ({
|
||||
providerName,
|
||||
Icon,
|
||||
id,
|
||||
enable,
|
||||
}: IProviderCardProps) => {
|
||||
const navigate = useNavigate()
|
||||
const updateProvider = useProviderStore(state => state.updateProvider)
|
||||
const handleClick = () => {
|
||||
navigate(`/settings/model/${id}`)
|
||||
}
|
||||
const handleEnable = () => {
|
||||
console.log('enable', enable)
|
||||
const enabled = useProviderStore(state => state.provider.find(p => p.id === id)?.enabled)
|
||||
|
||||
const isChecked = enabled === 1
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
const allProviders = useProviderStore.getState().provider
|
||||
const provider = allProviders.find(p => p.id === id)
|
||||
if (!provider) return
|
||||
updateProvider({
|
||||
id,
|
||||
enabled: enable == 1 ? 0 : 1,
|
||||
...provider,
|
||||
enabled: checked ? 1 : 0,
|
||||
})
|
||||
}
|
||||
const rawId = useParams()
|
||||
console.log('rawId', rawId)
|
||||
|
||||
// @ts-ignore
|
||||
const { id: currentId } = useParams()
|
||||
const isActive = currentId === id
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
handleClick()
|
||||
}}
|
||||
className={
|
||||
styles.card +
|
||||
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
|
||||
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center text-lg">
|
||||
<div
|
||||
className="flex items-center text-lg"
|
||||
onClick={() => navigate(`/settings/model/${id}`)}
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center">
|
||||
<AILogo name={Icon} />
|
||||
</div>
|
||||
@@ -53,11 +57,8 @@ const ProviderCard: FC<IProviderCardProps> = ({
|
||||
|
||||
<div>
|
||||
<Switch
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
handleEnable()
|
||||
}}
|
||||
checked={enable == 1}
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
function Checkbox({ className, checked, onChange, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
const [isChecked, setIsChecked] = useState(checked || false);
|
||||
|
||||
useEffect(() => {
|
||||
if (checked !== undefined) {
|
||||
setIsChecked(checked);
|
||||
}
|
||||
}, [checked]);
|
||||
|
||||
const handleCheckChange = (newChecked: boolean) => {
|
||||
setIsChecked(newChecked);
|
||||
if (onChange) {
|
||||
onChange({} as React.FormEvent<HTMLButtonElement>);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
@@ -12,6 +28,8 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleCheckChange}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
function Input({ className, type, value, onChange, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
@@ -13,6 +13,8 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
)}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -22,9 +22,11 @@ export const useTaskPolling = (interval = 3000) => {
|
||||
task => task.status != 'SUCCESS' && task.status != 'FAILED'
|
||||
)
|
||||
|
||||
// 无活跃任务时跳过轮询
|
||||
if (pendingTasks.length === 0) return
|
||||
|
||||
for (const task of pendingTasks) {
|
||||
try {
|
||||
console.log('🔄 正在轮询任务:', task.id)
|
||||
const res = await get_task_status(task.id)
|
||||
const { status } = res
|
||||
|
||||
@@ -47,9 +49,7 @@ export const useTaskPolling = (interval = 3000) => {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ 任务轮询失败:', e)
|
||||
// toast.error(`生成失败 ${e.message || e}`)
|
||||
updateTaskContent(task.id, { status: 'FAILED' })
|
||||
// removeTask(task.id)
|
||||
}
|
||||
}
|
||||
}, interval)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { FC } from 'react'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import React, { FC, useRef, useState } from 'react'
|
||||
import { SlidersHorizontal, PanelLeftClose, PanelLeftOpen, History as HistoryIcon } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -7,24 +7,39 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx"
|
||||
import type { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
import logo from '@/assets/icon.svg'
|
||||
|
||||
interface IProps {
|
||||
NoteForm: React.ReactNode
|
||||
Preview: React.ReactNode
|
||||
History: React.ReactNode
|
||||
}
|
||||
|
||||
const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
||||
const [, setShowSettings] = useState(false)
|
||||
const [isLeftCollapsed, setIsLeftCollapsed] = useState(false)
|
||||
const [isMiddleCollapsed, setIsMiddleCollapsed] = useState(false)
|
||||
const leftPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const middlePanelRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
{/* 左边表单 */}
|
||||
<ResizablePanel defaultSize={18} minSize={10} maxSize={35}>
|
||||
<ResizablePanel
|
||||
ref={leftPanelRef}
|
||||
defaultSize={23}
|
||||
minSize={10}
|
||||
maxSize={35}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
onCollapse={() => setIsLeftCollapsed(true)}
|
||||
onExpand={() => setIsLeftCollapsed(false)}
|
||||
>
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||
<header className="flex h-16 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -33,7 +48,22 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => leftPanelRef.current?.collapse()}
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer rounded p-1 hover:bg-neutral-100"
|
||||
>
|
||||
<PanelLeftClose className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>收起工作区</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={() => setShowSettings(true)}>
|
||||
@@ -49,26 +79,91 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
||||
</div>
|
||||
</header>
|
||||
<ScrollArea className="flex-1 overflow-auto">
|
||||
<div className=' p-4' >{NoteForm}</div>
|
||||
<div className="p-4">{NoteForm}</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* 左面板折叠时的展开按钮 */}
|
||||
{isLeftCollapsed && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => leftPanelRef.current?.expand()}
|
||||
className="flex h-full w-8 shrink-0 items-center justify-center border-r border-neutral-200 bg-white hover:bg-neutral-50"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<span>展开工作区</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* 中间历史 */}
|
||||
<ResizablePanel defaultSize={16} minSize={10} maxSize={30}>
|
||||
<ResizablePanel
|
||||
ref={middlePanelRef}
|
||||
defaultSize={16}
|
||||
minSize={10}
|
||||
maxSize={30}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
onCollapse={() => setIsMiddleCollapsed(true)}
|
||||
onExpand={() => setIsMiddleCollapsed(false)}
|
||||
>
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||
<header className="flex h-10 shrink-0 items-center justify-between border-b border-neutral-100 px-3">
|
||||
<span className="text-sm font-medium text-gray-600">生成历史</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => middlePanelRef.current?.collapse()}
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer rounded p-1 hover:bg-neutral-100"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>收起历史</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</header>
|
||||
<ScrollArea className="flex-1 overflow-auto">
|
||||
<div className="">{History}</div>
|
||||
<div>{History}</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* 中间面板折叠时的展开按钮 */}
|
||||
{isMiddleCollapsed && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => middlePanelRef.current?.expand()}
|
||||
className="flex h-full w-8 shrink-0 items-center justify-center border-r border-neutral-200 bg-white hover:bg-neutral-50"
|
||||
>
|
||||
<HistoryIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<span>展开历史</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* 右边预览 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<ResizablePanel defaultSize={61} minSize={30}>
|
||||
<main className="flex h-full flex-col overflow-hidden bg-white p-6">{Preview}</main>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -18,14 +18,15 @@ export const HomePage: FC = () => {
|
||||
useEffect(() => {
|
||||
if (!currentTask) {
|
||||
setStatus('idle')
|
||||
} else if (currentTask.status === 'PENDING') {
|
||||
setStatus('loading')
|
||||
} else if (currentTask.status === 'SUCCESS') {
|
||||
setStatus('success')
|
||||
} else if (currentTask.status === 'FAILED') {
|
||||
setStatus('failed')
|
||||
} else {
|
||||
// PENDING、PARSING、DOWNLOADING、TRANSCRIBING、SUMMARIZING 等所有进行中状态
|
||||
setStatus('loading')
|
||||
}
|
||||
}, [currentTask])
|
||||
}, [currentTask, currentTask?.status])
|
||||
|
||||
// useEffect( () => {
|
||||
// get_task_status('d4e87938-c066-48a0-bbd5-9bec40d53354').then(res=>{
|
||||
|
||||
294
BillNote_frontend/src/pages/HomePage/components/ChatPanel.tsx
Normal file
294
BillNote_frontend/src/pages/HomePage/components/ChatPanel.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Bubble, Sender } from '@ant-design/x'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Trash2, ChevronDown, ChevronUp, BookOpen, UserRound, Bot, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useChatStore } from '@/store/chatStore'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { askQuestion, getChatStatus, indexTask, type ChatSource, type IndexStatus } from '@/services/chat'
|
||||
|
||||
type ChatMode = 'half' | 'full'
|
||||
|
||||
interface ChatPanelProps {
|
||||
taskId: string
|
||||
mode: ChatMode
|
||||
onModeChange: (mode: ChatMode) => void
|
||||
}
|
||||
|
||||
function SourceBadges({ sources }: { sources: ChatSource[] }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (!sources || sources.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-xs text-neutral-400 hover:text-neutral-600"
|
||||
>
|
||||
<BookOpen className="h-3 w-3" />
|
||||
<span>引用来源 ({sources.length})</span>
|
||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{sources.map((s, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs font-normal">
|
||||
{s.source_type === 'markdown'
|
||||
? s.section_title || '笔记'
|
||||
: `${(s.start_time ?? 0).toFixed(0)}s ~ ${(s.end_time ?? 0).toFixed(0)}s`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatPanel({ taskId, mode, onModeChange }: ChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [indexStatus, setIndexStatus] = useState<IndexStatus | null>(null)
|
||||
|
||||
const messages = useChatStore(state => state.chatHistory[taskId]) ?? []
|
||||
const addMessage = useChatStore(state => state.addMessage)
|
||||
const clearChat = useChatStore(state => state.clearChat)
|
||||
|
||||
const currentTaskId = useTaskStore(state => state.currentTaskId)
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const currentTask = useMemo(
|
||||
() => tasks.find(t => t.id === currentTaskId) ?? null,
|
||||
[tasks, currentTaskId],
|
||||
)
|
||||
|
||||
// 检查索引状态,未索引时自动触发,indexing 时轮询
|
||||
useEffect(() => {
|
||||
if (!taskId) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await getChatStatus(taskId)
|
||||
if (cancelled) return
|
||||
setIndexStatus(res.status)
|
||||
|
||||
if (res.status === 'idle') {
|
||||
// 未索引,触发后台索引
|
||||
await indexTask(taskId)
|
||||
if (!cancelled) setIndexStatus('indexing')
|
||||
}
|
||||
|
||||
// indexing 状态持续轮询
|
||||
if (res.status === 'indexing' || res.status === 'idle') {
|
||||
timer = setTimeout(poll, 2000)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setIndexStatus('failed')
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (value: string) => {
|
||||
const question = value.trim()
|
||||
if (!question || loading) return
|
||||
|
||||
const providerId = currentTask?.formData?.provider_id
|
||||
const modelName = currentTask?.formData?.model_name
|
||||
if (!providerId || !modelName) {
|
||||
toast.error('无法获取模型配置,请确认任务已完成')
|
||||
return
|
||||
}
|
||||
|
||||
addMessage(taskId, { role: 'user', content: question })
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const history = messages.map(m => ({ role: m.role, content: m.content }))
|
||||
const res = await askQuestion({
|
||||
task_id: taskId,
|
||||
question,
|
||||
history,
|
||||
provider_id: providerId,
|
||||
model_name: modelName,
|
||||
})
|
||||
addMessage(taskId, {
|
||||
role: 'assistant',
|
||||
content: res.answer,
|
||||
sources: res.sources,
|
||||
})
|
||||
} catch {
|
||||
toast.error('问答请求失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[loading, taskId, currentTask, messages, addMessage],
|
||||
)
|
||||
|
||||
// 转换为 Bubble.List 的数据格式
|
||||
const bubbleItems = useMemo(() => {
|
||||
const items = messages.map((msg, i) => ({
|
||||
key: `msg-${i}`,
|
||||
role: msg.role === 'user' ? ('user' as const) : ('ai' as const),
|
||||
content: msg.content,
|
||||
footer:
|
||||
msg.role === 'assistant' && msg.sources ? (
|
||||
<SourceBadges sources={msg.sources} />
|
||||
) : undefined,
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
items.push({
|
||||
key: 'loading',
|
||||
role: 'ai' as const,
|
||||
content: '思考中...',
|
||||
loading: true,
|
||||
} as any)
|
||||
}
|
||||
|
||||
return items
|
||||
}, [messages, loading])
|
||||
|
||||
// Bubble 角色配置
|
||||
const roles = useMemo(
|
||||
() => ({
|
||||
user: {
|
||||
placement: 'end' as const,
|
||||
avatar: (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-500 text-white">
|
||||
<UserRound className="h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
variant: 'filled' as const,
|
||||
styles: { content: { background: '#3b82f6', color: '#fff' } },
|
||||
},
|
||||
ai: {
|
||||
placement: 'start' as const,
|
||||
avatar: (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-500 text-white">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
variant: 'outlined' as const,
|
||||
contentRender: (content: any) => (
|
||||
<div className="markdown-body prose prose-sm max-w-none prose-p:my-1 prose-li:my-0.5 prose-headings:my-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{typeof content === 'string' ? content : String(content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
if (indexStatus === null || indexStatus === 'indexing' || indexStatus === 'idle') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-neutral-400">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">正在索引笔记内容...</p>
|
||||
<p className="mt-1 text-xs">首次使用需下载 Embedding 模型(约 80MB),请耐心等待</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (indexStatus === 'failed') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<span className="text-sm">索引失败,请重试</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setIndexStatus('indexing')
|
||||
try {
|
||||
await indexTask(taskId)
|
||||
} catch {
|
||||
toast.error('索引请求失败')
|
||||
setIndexStatus('failed')
|
||||
}
|
||||
}}
|
||||
>
|
||||
重新索引
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">AI 问答</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-neutral-400 hover:text-neutral-600"
|
||||
onClick={() => onModeChange(mode === 'half' ? 'full' : 'half')}
|
||||
title={mode === 'half' ? '全屏' : '半屏'}
|
||||
>
|
||||
{mode === 'half' ? (
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-neutral-400 hover:text-red-500"
|
||||
onClick={() => clearChat(taskId)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messages.length === 0 && !loading ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-sm text-neutral-400">
|
||||
<div>
|
||||
<p>针对笔记内容提问</p>
|
||||
<p className="mt-1 text-xs">例如:这个视频的核心观点是什么?</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Bubble.List
|
||||
items={bubbleItems}
|
||||
role={roles}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="border-t px-3 py-2">
|
||||
<Sender
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSend}
|
||||
loading={loading}
|
||||
placeholder="输入你的问题..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, Download, BrainCircuit } from 'lucide-react'
|
||||
import { Copy, Download, BrainCircuit, MessageSquare } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -28,6 +28,8 @@ interface NoteHeaderProps {
|
||||
onDownload: () => void
|
||||
createAt?: string | Date
|
||||
setShowTranscribe: (show: boolean) => void
|
||||
showChat?: false | 'half' | 'full'
|
||||
setShowChat?: (mode: false | 'half' | 'full') => void
|
||||
}
|
||||
|
||||
export function MarkdownHeader({
|
||||
@@ -43,6 +45,8 @@ export function MarkdownHeader({
|
||||
createAt,
|
||||
showTranscribe,
|
||||
setShowTranscribe,
|
||||
showChat,
|
||||
setShowChat,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
}: NoteHeaderProps) {
|
||||
@@ -183,6 +187,24 @@ export function MarkdownHeader({
|
||||
<TooltipContent>原文参照</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{setShowChat && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setShowChat(showChat ? false : 'half')}
|
||||
variant={showChat ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<MessageSquare className="mr-1.5 h-4 w-4" />
|
||||
<span className="text-sm">AI 问答</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>基于笔记内容的 AI 问答</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo, memo, FC } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Copy, Download, ArrowRight, Play, ExternalLink } from 'lucide-react'
|
||||
@@ -16,13 +16,14 @@ import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'github-markdown-css/github-markdown-light.css'
|
||||
import { FC } from 'react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { noteStyles } from '@/constant/note.ts'
|
||||
import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx'
|
||||
import TranscriptViewer from '@/pages/HomePage/components/transcriptViewer.tsx'
|
||||
import MarkmapEditor from '@/pages/HomePage/components/MarkmapComponent.tsx'
|
||||
import ChatPanel from '@/pages/HomePage/components/ChatPanel.tsx'
|
||||
import VideoBanner from '@/pages/HomePage/components/VideoBanner.tsx'
|
||||
|
||||
interface VersionNote {
|
||||
ver_id: string
|
||||
@@ -45,22 +46,249 @@ const steps = [
|
||||
{ label: '保存完成', key: 'SUCCESS' },
|
||||
]
|
||||
|
||||
const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||
const remarkPlugins = [gfm, remarkMath]
|
||||
const rehypePlugins = [rehypeKatex]
|
||||
|
||||
/**
|
||||
* 构建 ReactMarkdown components 对象,baseURL 用于修正图片路径。
|
||||
* 使用函数 + useMemo 避免每次渲染都创建新的函数实例。
|
||||
*/
|
||||
function createMarkdownComponents(baseURL: string) {
|
||||
return {
|
||||
h1: ({ children, ...props }: any) => (
|
||||
<h1
|
||||
className="text-primary my-6 scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-4xl"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }: any) => (
|
||||
<h2
|
||||
className="text-primary mt-10 mb-4 scroll-m-20 border-b pb-2 text-2xl font-semibold tracking-tight first:mt-0"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }: any) => (
|
||||
<h3
|
||||
className="text-primary mt-8 mb-4 scroll-m-20 text-xl font-semibold tracking-tight"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }: any) => (
|
||||
<h4
|
||||
className="text-primary mt-6 mb-2 scroll-m-20 text-lg font-semibold tracking-tight"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children, ...props }: any) => (
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ href, children, ...props }: any) => {
|
||||
const isOriginLink =
|
||||
typeof children[0] === 'string' &&
|
||||
(children[0] as string).startsWith('原片 @')
|
||||
|
||||
if (isOriginLink) {
|
||||
const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/)
|
||||
const timeText = timeMatch ? timeMatch[1] : '原片'
|
||||
|
||||
return (
|
||||
<span className="origin-link my-2 inline-flex">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100"
|
||||
{...props}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
<span>原片({timeText})</span>
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-0.5 font-medium underline underline-offset-4"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{href?.startsWith('http') && (
|
||||
<ExternalLink className="ml-0.5 inline-block h-3 w-3" />
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ node, ...props }: any) => {
|
||||
let src = props.src
|
||||
if (src.startsWith('/')) {
|
||||
src = baseURL + src
|
||||
}
|
||||
props.src = src
|
||||
|
||||
return (
|
||||
<div className="my-8 flex justify-center">
|
||||
<Zoom>
|
||||
<img
|
||||
{...props}
|
||||
className="max-w-full cursor-zoom-in rounded-lg object-cover shadow-md transition-all hover:shadow-lg"
|
||||
style={{ maxHeight: '500px' }}
|
||||
/>
|
||||
</Zoom>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
strong: ({ children, ...props }: any) => (
|
||||
<strong className="text-primary font-bold" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
li: ({ children, ...props }: any) => {
|
||||
const rawText = String(children)
|
||||
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
|
||||
|
||||
if (isFakeHeading) {
|
||||
return (
|
||||
<div className="text-primary my-4 text-lg font-bold">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="my-1" {...props}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
ul: ({ children, ...props }: any) => (
|
||||
<ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }: any) => (
|
||||
<ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
blockquote: ({ children, ...props }: any) => (
|
||||
<blockquote
|
||||
className="border-primary/20 text-muted-foreground mt-6 border-l-4 pl-4 italic"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ inline, className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeContent = String(children).replace(/\n$/, '')
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<div className="group bg-muted relative my-6 overflow-hidden rounded-lg border shadow-sm">
|
||||
<div className="bg-muted text-muted-foreground flex items-center justify-between px-4 py-1.5 text-sm font-medium">
|
||||
<div>{match[1].toUpperCase()}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(codeContent)
|
||||
toast.success('代码已复制')
|
||||
}}
|
||||
className="bg-background/80 hover:bg-background flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
style={codeStyle}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="!bg-muted !m-0 !p-0"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
background: 'transparent',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
table: ({ children, ...props }: any) => (
|
||||
<div className="my-6 w-full overflow-y-auto">
|
||||
<table className="w-full border-collapse text-sm" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, ...props }: any) => (
|
||||
<th
|
||||
className="border-muted-foreground/20 border px-4 py-2 text-left font-medium [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }: any) => (
|
||||
<td
|
||||
className="border-muted-foreground/20 border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
hr: ({ ...props }: any) => (
|
||||
<hr className="border-muted-foreground/20 my-8" {...props} />
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const MarkdownViewer: FC<MarkdownViewerProps> = memo(({ status }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [currentVerId, setCurrentVerId] = useState<string>('')
|
||||
const [selectedContent, setSelectedContent] = useState<string>('')
|
||||
const [modelName, setModelName] = useState<string>('')
|
||||
const [style, setStyle] = useState<string>('')
|
||||
const [createTime, setCreateTime] = useState<string>('')
|
||||
const baseURL = String(import.meta.env.VITE_API_BASE_URL).replace('/api','') || ''
|
||||
// 确保baseURL没有尾部斜杠
|
||||
const baseURL = (String(import.meta.env.VITE_API_BASE_URL || '').replace('/api','') || '').replace(/\/$/, '')
|
||||
const getCurrentTask = useTaskStore.getState().getCurrentTask
|
||||
const currentTask = useTaskStore(state => state.getCurrentTask())
|
||||
const taskStatus = currentTask?.status || 'PENDING'
|
||||
const retryTask = useTaskStore.getState().retryTask
|
||||
const isMultiVersion = Array.isArray(currentTask?.markdown)
|
||||
const [showTranscribe, setShowTranscribe] = useState(false)
|
||||
const [showChat, setShowChat] = useState<false | 'half' | 'full'>(false)
|
||||
const [viewMode, setViewMode] = useState<'map' | 'preview'>('preview')
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
// 缓存 ReactMarkdown components,仅在 baseURL 变化时重建
|
||||
const markdownComponents = useMemo(() => createMarkdownComponents(baseURL), [baseURL])
|
||||
|
||||
// 多版本内容处理
|
||||
useEffect(() => {
|
||||
if (!currentTask) return
|
||||
@@ -159,7 +387,7 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
|
||||
<Idle />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold">输入视频链接并点击“生成笔记”</p>
|
||||
<p className="text-lg font-bold">输入视频链接并点击"生成笔记"</p>
|
||||
<p className="mt-2 text-xs text-neutral-500">支持哔哩哔哩、YouTube 、抖音等视频平台</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,6 +425,8 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||
createAt={createTime}
|
||||
showTranscribe={showTranscribe}
|
||||
setShowTranscribe={setShowTranscribe}
|
||||
showChat={showChat}
|
||||
setShowChat={setShowChat}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
/>
|
||||
@@ -208,6 +438,7 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||
value={selectedContent}
|
||||
onChange={() => {}}
|
||||
height="100%" // 根据需求可以设定百分比或固定高度
|
||||
title={currentTask?.audioMeta?.title || '思维导图'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,253 +446,26 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||
<div className="flex flex-1 overflow-hidden bg-white py-2">
|
||||
{selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? (
|
||||
<>
|
||||
<ScrollArea className="w-full">
|
||||
{showChat === 'full' && currentTask ? (
|
||||
<div className="h-full w-full">
|
||||
<ChatPanel taskId={currentTask.id} mode="full" onModeChange={setShowChat} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="min-w-0 flex-1">
|
||||
<div className="px-2">
|
||||
<VideoBanner
|
||||
audioMeta={currentTask?.audioMeta}
|
||||
videoUrl={currentTask?.formData?.video_url}
|
||||
/>
|
||||
</div>
|
||||
<div className={'markdown-body w-full px-2'}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[gfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
// Headings with improved styling and anchor links
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1
|
||||
className="text-primary my-6 scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-4xl"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2
|
||||
className="text-primary mt-10 mb-4 scroll-m-20 border-b pb-2 text-2xl font-semibold tracking-tight first:mt-0"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3
|
||||
className="text-primary mt-8 mb-4 scroll-m-20 text-xl font-semibold tracking-tight"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4
|
||||
className="text-primary mt-6 mb-2 scroll-m-20 text-lg font-semibold tracking-tight"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Paragraphs with better line height
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Enhanced links with special handling for "原片" links
|
||||
a: ({ href, children, ...props }) => {
|
||||
const isOriginLink =
|
||||
typeof children[0] === 'string' &&
|
||||
(children[0] as string).startsWith('原片 @')
|
||||
|
||||
if (isOriginLink) {
|
||||
const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/)
|
||||
const timeText = timeMatch ? timeMatch[1] : '原片'
|
||||
|
||||
return (
|
||||
<span className="origin-link my-2 inline-flex">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100"
|
||||
{...props}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
<span>原片({timeText})</span>
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Default link styling with external indicator
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-0.5 font-medium underline underline-offset-4"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{href?.startsWith('http') && (
|
||||
<ExternalLink className="ml-0.5 inline-block h-3 w-3" />
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced image with zoom capability
|
||||
img: ({ node, ...props }) =>{
|
||||
|
||||
let src = baseURL +props.src
|
||||
props.src = src
|
||||
|
||||
|
||||
return(
|
||||
|
||||
|
||||
<div className="my-8 flex justify-center">
|
||||
<Zoom>
|
||||
<img
|
||||
{...props}
|
||||
className="max-w-full cursor-zoom-in rounded-lg object-cover shadow-md transition-all hover:shadow-lg"
|
||||
style={{ maxHeight: '500px' }}
|
||||
/>
|
||||
</Zoom>
|
||||
</div>
|
||||
)},
|
||||
|
||||
// Better strong/bold text
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className="text-primary font-bold" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
|
||||
// Enhanced list items with support for "fake headings"
|
||||
li: ({ children, ...props }) => {
|
||||
const rawText = String(children)
|
||||
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
|
||||
|
||||
if (isFakeHeading) {
|
||||
return (
|
||||
<div className="text-primary my-4 text-lg font-bold">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="my-1" {...props}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced unordered lists
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
|
||||
// Enhanced ordered lists
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
|
||||
// Enhanced blockquotes
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote
|
||||
className="border-primary/20 text-muted-foreground mt-6 border-l-4 pl-4 italic"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Enhanced code blocks with syntax highlighting and copy button
|
||||
code: ({ inline, className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeContent = String(children).replace(/\n$/, '')
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<div className="group bg-muted relative my-6 overflow-hidden rounded-lg border shadow-sm">
|
||||
<div className="bg-muted text-muted-foreground flex items-center justify-between px-4 py-1.5 text-sm font-medium">
|
||||
<div>{match[1].toUpperCase()}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(codeContent)
|
||||
toast.success('代码已复制')
|
||||
}}
|
||||
className="bg-background/80 hover:bg-background flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
style={codeStyle}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="!bg-muted !m-0 !p-0"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
background: 'transparent',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline code styling
|
||||
return (
|
||||
<code
|
||||
className="bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced tables
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-6 w-full overflow-y-auto">
|
||||
<table className="w-full border-collapse text-sm" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Table headers
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="border-muted-foreground/20 border px-4 py-2 text-left font-medium [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
|
||||
// Table cells
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="border-muted-foreground/20 border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: ({ ...props }) => (
|
||||
<hr className="border-muted-foreground/20 my-8" {...props} />
|
||||
),
|
||||
}}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{selectedContent}
|
||||
{selectedContent.replace(/^>\s*来源链接:[^\n]*\n*/m, '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -470,6 +474,14 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||
<TranscriptViewer />
|
||||
</div>
|
||||
)}
|
||||
{/* 侧边问答模式:markdown + ChatPanel 各占一半 */}
|
||||
{showChat === 'half' && currentTask && (
|
||||
<div className="ml-2 h-full w-1/2 shrink-0">
|
||||
<ChatPanel taskId={currentTask.id} mode="half" onModeChange={setShowChat} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
@@ -486,6 +498,8 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ status }) => {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
MarkdownViewer.displayName = 'MarkdownViewer'
|
||||
|
||||
export default MarkdownViewer
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Markmap } from 'markmap-view'
|
||||
import { transformer } from '@/lib/markmap.ts'
|
||||
import { Toolbar, ToolbarButton } from 'markmap-toolbar'
|
||||
import { Toolbar } from 'markmap-toolbar'
|
||||
import 'markmap-toolbar/dist/style.css'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
export interface MarkmapEditorProps {
|
||||
/** 要渲染的 Markdown 文本 */
|
||||
@@ -12,9 +13,11 @@ export interface MarkmapEditorProps {
|
||||
/** Toolbar 上要展示的 item id 列表,默认使用 Toolbar.defaultItems */
|
||||
toolbarItems?: string[]
|
||||
/** 自定义按钮列表,会依次注册 */
|
||||
customButtons?: ToolbarButton[]
|
||||
customButtons?: any[]
|
||||
/** 容器 SVG 的高度,默认为 600px */
|
||||
height?: string
|
||||
/** 文档标题,用于导出HTML时的文件名 */
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function MarkmapEditor({
|
||||
@@ -23,9 +26,10 @@ export default function MarkmapEditor({
|
||||
toolbarItems,
|
||||
customButtons = [],
|
||||
height = '600px',
|
||||
title = 'mindmap',
|
||||
}: MarkmapEditorProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const mmRef = useRef<Markmap>()
|
||||
const mmRef = useRef<Markmap | undefined>()
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 用于跟踪是否处于全屏状态
|
||||
@@ -56,6 +60,353 @@ export default function MarkmapEditor({
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// 导出HTML思维导图
|
||||
const exportHtml = () => {
|
||||
try {
|
||||
const { root } = transformer.transform(value)
|
||||
const data = JSON.stringify(root)
|
||||
|
||||
// 创建HTML内容
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title || 'BiliNote思维导图'}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
#mindmap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-view@0.18.10"></script>
|
||||
</head>
|
||||
<body>
|
||||
<svg id="mindmap"></svg>
|
||||
<script>
|
||||
(async () => {
|
||||
const { markmap } = window;
|
||||
const { Markmap } = markmap;
|
||||
const mm = Markmap.create(document.getElementById('mindmap'));
|
||||
mm.setData(${data});
|
||||
mm.fit();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.html`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('导出HTML失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出SVG思维导图(矢量图)
|
||||
const exportSvg = async () => {
|
||||
try {
|
||||
if (!svgRef.current || !mmRef.current) return;
|
||||
|
||||
const svgEl = svgRef.current;
|
||||
const mm = mmRef.current;
|
||||
|
||||
// 先调用fit()确保显示完整的思维导图内容
|
||||
await mm.fit();
|
||||
// 等待渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 克隆SVG以避免修改原始SVG
|
||||
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
|
||||
// 获取SVG内容的实际边界框
|
||||
const gElement = svgEl.querySelector('g');
|
||||
if (gElement) {
|
||||
const bbox = gElement.getBBox();
|
||||
// 添加一些边距
|
||||
const padding = 50;
|
||||
const viewBoxX = bbox.x - padding;
|
||||
const viewBoxY = bbox.y - padding;
|
||||
const viewBoxWidth = bbox.width + padding * 2;
|
||||
const viewBoxHeight = bbox.height + padding * 2;
|
||||
|
||||
// 设置viewBox以确保SVG可以无限缩放
|
||||
clonedSvg.setAttribute('viewBox', `${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}`);
|
||||
// 移除固定尺寸,让SVG根据viewBox自适应
|
||||
clonedSvg.removeAttribute('width');
|
||||
clonedSvg.removeAttribute('height');
|
||||
// 设置默认尺寸为100%,可以在任何容器中自适应
|
||||
clonedSvg.setAttribute('width', '100%');
|
||||
clonedSvg.setAttribute('height', '100%');
|
||||
// 保持宽高比
|
||||
clonedSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
}
|
||||
|
||||
// 设置SVG的背景为白色
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = 'svg { background-color: white; }';
|
||||
clonedSvg.insertBefore(style, clonedSvg.firstChild);
|
||||
|
||||
// 添加白色背景矩形(确保背景在所有查看器中都是白色)
|
||||
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
const viewBox = clonedSvg.getAttribute('viewBox')?.split(' ').map(Number) || [0, 0, 800, 600];
|
||||
bgRect.setAttribute('x', viewBox[0].toString());
|
||||
bgRect.setAttribute('y', viewBox[1].toString());
|
||||
bgRect.setAttribute('width', viewBox[2].toString());
|
||||
bgRect.setAttribute('height', viewBox[3].toString());
|
||||
bgRect.setAttribute('fill', 'white');
|
||||
// 插入到最前面作为背景
|
||||
const firstG = clonedSvg.querySelector('g');
|
||||
if (firstG) {
|
||||
clonedSvg.insertBefore(bgRect, firstG);
|
||||
} else {
|
||||
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
|
||||
}
|
||||
|
||||
// 确保SVG有正确的命名空间
|
||||
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||
|
||||
// 序列化SVG
|
||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||||
|
||||
// 创建下载
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.svg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('导出SVG失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出XMind格式思维导图
|
||||
const exportXMind = async () => {
|
||||
try {
|
||||
const { root } = transformer.transform(value);
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// 解码HTML实体(如 实 -> 实,〹 -> 对应字符)
|
||||
const decodeHtmlEntities = (text: string): string => {
|
||||
if (!text) return text;
|
||||
|
||||
// 首先手动处理十六进制数字实体 &#xHHHH;
|
||||
let decoded = text.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
||||
return String.fromCodePoint(parseInt(hex, 16));
|
||||
});
|
||||
|
||||
// 处理十进制数字实体 &#DDDD;
|
||||
decoded = decoded.replace(/&#(\d+);/g, (_, dec) => {
|
||||
return String.fromCodePoint(parseInt(dec, 10));
|
||||
});
|
||||
|
||||
// 使用textarea处理命名实体(如 & < > 等)
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = decoded;
|
||||
return textarea.value;
|
||||
};
|
||||
|
||||
// 清理HTML标签,只保留纯文本
|
||||
const stripHtml = (html: string): string => {
|
||||
if (!html) return html;
|
||||
// 先解码HTML实体
|
||||
let text = decodeHtmlEntities(html);
|
||||
// 移除HTML标签
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = text;
|
||||
return div.textContent || div.innerText || text;
|
||||
};
|
||||
|
||||
// 将 markmap 节点转换为 XMind 节点格式
|
||||
const convertToXMindNode = (node: any, isRoot = false): any => {
|
||||
const rawTitle = node.content || node.payload?.content || '未命名';
|
||||
const xmindNode: any = {
|
||||
id: generateId(),
|
||||
class: isRoot ? 'topic' : 'topic',
|
||||
title: stripHtml(rawTitle),
|
||||
};
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
xmindNode.children = {
|
||||
attached: node.children.map((child: any) => convertToXMindNode(child, false))
|
||||
};
|
||||
}
|
||||
|
||||
return xmindNode;
|
||||
};
|
||||
|
||||
const rootTopic = convertToXMindNode(root, true);
|
||||
const sheetId = generateId();
|
||||
|
||||
// XMind content.json 结构
|
||||
const content = [{
|
||||
id: sheetId,
|
||||
class: 'sheet',
|
||||
title: stripHtml(title) || '思维导图',
|
||||
rootTopic: rootTopic,
|
||||
topicPositioning: 'fixed'
|
||||
}];
|
||||
|
||||
// XMind metadata.json
|
||||
const metadata = {
|
||||
creator: {
|
||||
name: 'BiliNote',
|
||||
version: '1.0.0'
|
||||
}
|
||||
};
|
||||
|
||||
// XMind manifest.json
|
||||
const manifest = {
|
||||
'file-entries': {
|
||||
'content.json': {},
|
||||
'metadata.json': {}
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 JSZip 创建 .xmind 文件
|
||||
// 直接传入字符串,JSZip会自动处理UTF-8编码
|
||||
const zip = new JSZip();
|
||||
zip.file('content.json', JSON.stringify(content, null, 2));
|
||||
zip.file('metadata.json', JSON.stringify(metadata, null, 2));
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||
|
||||
// 生成 ZIP 并下载
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.xmind`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('导出XMind失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出PNG思维导图
|
||||
const exportPng = async () => {
|
||||
try {
|
||||
if (!svgRef.current || !mmRef.current) return;
|
||||
|
||||
const svgEl = svgRef.current;
|
||||
const mm = mmRef.current;
|
||||
|
||||
// 先调用fit()确保显示完整的思维导图内容
|
||||
await mm.fit();
|
||||
// 等待渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 获取SVG实际尺寸
|
||||
const svgWidth = svgEl.width.baseVal.value || svgEl.clientWidth || 800;
|
||||
const svgHeight = svgEl.height.baseVal.value || svgEl.clientHeight || 600;
|
||||
|
||||
// 设置足够大的缩放比例以确保高清输出
|
||||
const scale = 3;
|
||||
|
||||
// 克隆SVG以避免修改原始SVG
|
||||
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
|
||||
// 设置SVG的背景为白色
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = 'svg { background-color: white; }';
|
||||
clonedSvg.insertBefore(style, clonedSvg.firstChild);
|
||||
|
||||
// 确保SVG有正确的命名空间
|
||||
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
clonedSvg.setAttribute('width', svgWidth.toString());
|
||||
clonedSvg.setAttribute('height', svgHeight.toString());
|
||||
|
||||
// 将SVG转换为Data URI (避免使用Blob URL来解决跨域问题)
|
||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||||
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
|
||||
|
||||
// 创建Canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = svgWidth * scale;
|
||||
canvas.height = svgHeight * scale;
|
||||
|
||||
// 获取上下文并设置白色背景
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('无法获取Canvas上下文');
|
||||
}
|
||||
|
||||
// 设置白色背景
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 创建Image对象
|
||||
const img = new Image();
|
||||
|
||||
// 当图片加载完成后,在Canvas上绘制并导出
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 应用缩放
|
||||
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
|
||||
// 绘制SVG
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 重置变换
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
|
||||
// 将Canvas转换为PNG Blob
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
console.error('无法创建Blob对象');
|
||||
}
|
||||
}, 'image/png');
|
||||
} catch (err) {
|
||||
console.error('Canvas处理失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置图片加载错误处理
|
||||
img.onerror = (error) => {
|
||||
console.error('导出PNG失败(图片加载错误):', error);
|
||||
};
|
||||
|
||||
// 开始加载SVG图像 (使用Data URI而不是Blob URL)
|
||||
img.src = dataUri;
|
||||
|
||||
} catch (error) {
|
||||
console.error('导出PNG失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化 Markmap 实例 + Toolbar
|
||||
useEffect(() => {
|
||||
@@ -82,14 +433,42 @@ export default function MarkmapEditor({
|
||||
}, [value])
|
||||
|
||||
// 文本输入变化回调(如果你自行添加 textarea 编辑区)
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
// const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
// onChange(e.target.value)
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-white">
|
||||
{/* 全屏/退出全屏 按钮 */}
|
||||
<div className="absolute top-2 right-2 z-20 flex space-x-2">
|
||||
<button
|
||||
onClick={exportXMind}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="导出XMind格式"
|
||||
>
|
||||
🧠
|
||||
</button>
|
||||
<button
|
||||
onClick={exportSvg}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="导出SVG矢量图(可无限放大)"
|
||||
>
|
||||
📐
|
||||
</button>
|
||||
<button
|
||||
onClick={exportPng}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="导出PNG图片"
|
||||
>
|
||||
🖼️
|
||||
</button>
|
||||
<button
|
||||
onClick={exportHtml}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="导出HTML(可交互)"
|
||||
>
|
||||
💾
|
||||
</button>
|
||||
{isFullscreen ? (
|
||||
<button
|
||||
onClick={exitFullscreen}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Info, Loader2, Plus } from 'lucide-react'
|
||||
import { message, Alert } from 'antd'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert.tsx'
|
||||
import { generateNote } from '@/services/note.ts'
|
||||
import { uploadFile } from '@/services/upload.ts'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
@@ -43,7 +43,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
/* -------------------- 校验 Schema -------------------- */
|
||||
const formSchema = z
|
||||
.object({
|
||||
video_url: z.string(),
|
||||
video_url: z.string().optional(),
|
||||
platform: z.string().nonempty('请选择平台'),
|
||||
quality: z.enum(['fast', 'medium', 'slow']),
|
||||
screenshot: z.boolean().optional(),
|
||||
@@ -53,28 +53,36 @@ const formSchema = z
|
||||
style: z.string().nonempty('请选择笔记生成风格'),
|
||||
extras: z.string().optional(),
|
||||
video_understanding: z.boolean().optional(),
|
||||
video_interval: z.coerce.number().min(1).max(30).default(4).optional(),
|
||||
video_interval: z.coerce.number().min(1).max(30).default(6).optional(),
|
||||
grid_size: z
|
||||
.tuple([z.coerce.number().min(1).max(10), z.coerce.number().min(1).max(10)])
|
||||
.default([3, 3])
|
||||
.default([2, 2])
|
||||
.optional(),
|
||||
})
|
||||
.superRefine(({ video_url, platform }, ctx) => {
|
||||
if (platform === 'local' || platform === 'douyin') {
|
||||
if (platform === 'local') {
|
||||
if (!video_url) {
|
||||
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(video_url)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error()
|
||||
} catch {
|
||||
ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] })
|
||||
}
|
||||
else {
|
||||
if (!video_url) {
|
||||
ctx.addIssue({ code: 'custom', message: '视频链接不能为空', path: ['video_url'] })
|
||||
}
|
||||
else {
|
||||
try {
|
||||
const url = new URL(video_url)
|
||||
if (!['http:', 'https:'].includes(url.protocol))
|
||||
throw new Error()
|
||||
}
|
||||
catch {
|
||||
ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type NoteFormValues = z.infer<typeof formSchema>
|
||||
export type NoteFormValues = z.infer<typeof formSchema>
|
||||
|
||||
/* -------------------- 可复用子组件 -------------------- */
|
||||
const SectionHeader = ({ title, tip }: { title: string; tip?: string }) => (
|
||||
@@ -136,8 +144,8 @@ const NoteForm = () => {
|
||||
quality: 'medium',
|
||||
model_name: modelList[0]?.model_name || '',
|
||||
style: 'minimal',
|
||||
video_interval: 4,
|
||||
grid_size: [3, 3],
|
||||
video_interval: 6,
|
||||
grid_size: [2, 2],
|
||||
format: [],
|
||||
},
|
||||
})
|
||||
@@ -173,8 +181,8 @@ const NoteForm = () => {
|
||||
screenshot: formData.screenshot ?? false,
|
||||
link: formData.link ?? false,
|
||||
video_understanding: formData.video_understanding ?? false,
|
||||
video_interval: formData.video_interval ?? 4,
|
||||
grid_size: formData.grid_size ?? [3, 3],
|
||||
video_interval: formData.video_interval ?? 6,
|
||||
grid_size: formData.grid_size ?? [2, 2],
|
||||
format: formData.format ?? [],
|
||||
})
|
||||
}, [
|
||||
@@ -202,7 +210,7 @@ const NoteForm = () => {
|
||||
setUploadSuccess(true)
|
||||
} catch (err) {
|
||||
console.error('上传失败:', err)
|
||||
message.error('上传失败,请重试')
|
||||
// message.error('上传失败,请重试')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
@@ -220,13 +228,13 @@ const NoteForm = () => {
|
||||
return
|
||||
}
|
||||
|
||||
message.success('已提交任务')
|
||||
// message.success('已提交任务')
|
||||
const data = await generateNote(payload)
|
||||
addPendingTask(data.task_id, values.platform, payload)
|
||||
}
|
||||
const onInvalid = (errors: FieldErrors<NoteFormValues>) => {
|
||||
console.warn('表单校验失败:', errors)
|
||||
message.error('请完善所有必填项后再提交')
|
||||
// message.error('请完善所有必填项后再提交')
|
||||
}
|
||||
const handleCreateNew = () => {
|
||||
// 🔁 这里清空当前任务状态
|
||||
@@ -297,7 +305,7 @@ const NoteForm = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
<FormMessage style={{ display: 'none' }} />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -314,7 +322,7 @@ const NoteForm = () => {
|
||||
) : (
|
||||
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||||
)}
|
||||
<FormMessage />
|
||||
<FormMessage style={{ display: 'none' }} />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -505,17 +513,11 @@ const NoteForm = () => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
closable
|
||||
type="error"
|
||||
message={
|
||||
<div>
|
||||
<strong>提示:</strong>
|
||||
<p>视频理解功能必须使用多模态模型。</p>
|
||||
</div>
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Alert variant="warning" className="text-sm">
|
||||
<AlertDescription>
|
||||
<strong>提示:</strong>视频理解功能必须使用多模态模型。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* 笔记格式 */}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
import LazyImage from "@/components/LazyImage.tsx";
|
||||
import {FC, useState ,useEffect } from 'react'
|
||||
import {FC, useState, useEffect, useMemo} from 'react'
|
||||
|
||||
interface NoteHistoryProps {
|
||||
onSelect: (taskId: string) => void
|
||||
@@ -24,13 +24,14 @@ interface NoteHistoryProps {
|
||||
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const removeTask = useTaskStore(state => state.removeTask)
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || 'api/'
|
||||
// 确保baseURL没有尾部斜杠
|
||||
const baseURL = (String(import.meta.env.VITE_API_BASE_URL || 'api')).replace(/\/$/, '')
|
||||
const [rawSearch, setRawSearch] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const fuse = new Fuse(tasks, {
|
||||
const fuse = useMemo(() => new Fuse(tasks, {
|
||||
keys: ['audioMeta.title'],
|
||||
threshold: 0.4 // 匹配精度(越低越严格)
|
||||
})
|
||||
}), [tasks])
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (rawSearch === '') return
|
||||
@@ -77,16 +78,15 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||
<div className="flex flex-col gap-2 overflow-hidden">
|
||||
{filteredTasks.map(task => (
|
||||
<div
|
||||
onClick={() => onSelect(task.id)}
|
||||
key={task.id}
|
||||
onClick={() => onSelect(task.id)}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3',
|
||||
selectedId === task.id && 'border-primary bg-primary-light'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn('flex items-center gap-4')}
|
||||
|
||||
>
|
||||
{/* 封面图 */}
|
||||
{task.platform === 'local' ? (
|
||||
@@ -102,7 +102,7 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||
|
||||
src={
|
||||
task.audioMeta.cover_url
|
||||
? baseURL+`/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
|
||||
? `${baseURL}/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
|
||||
: '/placeholder.png'
|
||||
}
|
||||
alt="封面"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { AudioMeta } from '@/store/taskStore'
|
||||
|
||||
interface VideoBannerProps {
|
||||
audioMeta?: AudioMeta
|
||||
videoUrl?: string
|
||||
}
|
||||
|
||||
/** 平台 label 映射 */
|
||||
const platformLabel: Record<string, string> = {
|
||||
bilibili: '哔哩哔哩',
|
||||
youtube: 'YouTube',
|
||||
douyin: '抖音',
|
||||
xiaohongshu: '小红书',
|
||||
}
|
||||
|
||||
export default function VideoBanner({ audioMeta, videoUrl }: VideoBannerProps) {
|
||||
if (!audioMeta) return null
|
||||
|
||||
const rawCover = audioMeta.cover_url
|
||||
// 通过后端代理加载封面,避免跨域/Referrer 限制
|
||||
const apiBase = String(import.meta.env.VITE_API_BASE_URL || 'api').replace(/\/$/, '')
|
||||
const coverUrl = rawCover
|
||||
? `${apiBase}/image_proxy?url=${encodeURIComponent(rawCover)}`
|
||||
: ''
|
||||
const title = audioMeta.title
|
||||
const uploader = audioMeta.raw_info?.uploader || ''
|
||||
const platform = platformLabel[audioMeta.platform] || audioMeta.platform || ''
|
||||
const originalUrl = videoUrl || audioMeta.raw_info?.webpage_url || ''
|
||||
|
||||
return (
|
||||
<div className="relative mb-4 overflow-hidden rounded-lg">
|
||||
{/* 模糊背景封面 */}
|
||||
<div className="absolute inset-0">
|
||||
{coverUrl ? (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover blur-md brightness-[0.4] scale-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-r from-blue-600 to-indigo-700" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容层 */}
|
||||
<div className="relative flex items-center gap-4 px-5 py-4">
|
||||
{/* 封面缩略图 */}
|
||||
{coverUrl && (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-16 w-28 shrink-0 rounded-md object-cover shadow-md"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文字信息 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-base font-bold text-white" title={title}>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
|
||||
{uploader && <span>{uploader}</span>}
|
||||
{uploader && platform && <span className="text-white/40">·</span>}
|
||||
{platform && <span>{platform}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 跳转原视频 */}
|
||||
{originalUrl && (
|
||||
<a
|
||||
href={originalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/25"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
<span>原视频</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import {
|
||||
BotMessageSquare,
|
||||
SquareChevronRight,
|
||||
Captions,
|
||||
HardDriveDownload,
|
||||
Wrench,
|
||||
Info,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import MenuBar, { IMenuProps } from '@/pages/SettingPage/components/menuBar.tsx'
|
||||
|
||||
@@ -16,14 +15,12 @@ const Menu = () => {
|
||||
icon: <BotMessageSquare />,
|
||||
path: '/settings/model',
|
||||
},
|
||||
// TODO :下一版本升级优化
|
||||
// {
|
||||
// id: ' transcriber',
|
||||
// name: '音频转译配置',
|
||||
// icon: <Captions />,
|
||||
// path: '/settings/transcriber',
|
||||
// },
|
||||
// //下载配置
|
||||
{
|
||||
id: 'transcriber',
|
||||
name: '音频转写配置',
|
||||
icon: <Captions />,
|
||||
path: '/settings/transcriber',
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
name: '下载配置',
|
||||
@@ -37,6 +34,12 @@ const Menu = () => {
|
||||
// icon: <SquareChevronRight />,
|
||||
// path: '/settings/prompt',
|
||||
// },
|
||||
{
|
||||
id: 'monitor',
|
||||
name: '部署监控',
|
||||
icon: <Activity />,
|
||||
path: '/settings/monitor',
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
name: '关于',
|
||||
|
||||
241
BillNote_frontend/src/pages/SettingPage/Monitor.tsx
Normal file
241
BillNote_frontend/src/pages/SettingPage/Monitor.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Server,
|
||||
Cpu,
|
||||
AudioLines,
|
||||
Film,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getDeployStatus, DeployStatus } from '@/services/system'
|
||||
|
||||
export default function Monitor() {
|
||||
const [status, setStatus] = useState<DeployStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await getDeployStatus()
|
||||
setStatus(data)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
setError('无法连接到后端服务')
|
||||
setStatus(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
// 自动刷新(每 30 秒)
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
const StatusBadge = ({ ok, label }: { ok: boolean; label?: string }) => (
|
||||
<Badge
|
||||
variant={ok ? 'default' : 'destructive'}
|
||||
className={ok ? 'bg-green-500 hover:bg-green-600' : ''}
|
||||
>
|
||||
{ok ? (
|
||||
<><CheckCircle2 className="mr-1 h-3 w-3" />{label || '正常'}</>
|
||||
) : (
|
||||
<><XCircle className="mr-1 h-3 w-3" />{label || '异常'}</>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full overflow-y-auto bg-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">部署监控</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
实时监控系统各组件运行状态
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{lastUpdated && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
最后更新: {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Backend FastAPI */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<Server className="mr-2 inline h-5 w-5 text-blue-500" />
|
||||
后端 FastAPI
|
||||
</CardTitle>
|
||||
{status && <StatusBadge ok={status.backend.status === 'running'} label="运行中" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">状态:</span>
|
||||
<span className={status.backend.status === 'running' ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
|
||||
{status.backend.status === 'running' ? '运行中' : status.backend.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">端口:</span>
|
||||
<span className="font-mono">{status.backend.port}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CUDA GPU */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<Cpu className="mr-2 inline h-5 w-5 text-green-500" />
|
||||
CUDA GPU
|
||||
</CardTitle>
|
||||
{status && <StatusBadge ok={status.cuda.available} label={status.cuda.available ? '已启用' : '未启用'} />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
{status.cuda.available ? (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">GPU:</span>
|
||||
<span className="font-medium">{status.cuda.gpu_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">CUDA 版本:</span>
|
||||
<span className="font-mono">{status.cuda.version}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
CUDA 不可用,将使用 CPU 模式
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Whisper Model */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<AudioLines className="mr-2 inline h-5 w-5 text-purple-500" />
|
||||
Whisper 模型
|
||||
</CardTitle>
|
||||
{status && <StatusBadge ok={true} label="已配置" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">模型大小:</span>
|
||||
<span className="font-medium">{status.whisper.model_size}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">转写引擎:</span>
|
||||
<span className="font-mono">{status.whisper.transcriber_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* FFmpeg */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<Film className="mr-2 inline h-5 w-5 text-orange-500" />
|
||||
FFmpeg
|
||||
</CardTitle>
|
||||
{status && <StatusBadge ok={status.ffmpeg.available} label={status.ffmpeg.available ? '可用' : '不可用'} />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">状态:</span>
|
||||
<span className={status.ffmpeg.available ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
|
||||
{status.ffmpeg.available ? '已安装' : '未安装'}
|
||||
</span>
|
||||
</div>
|
||||
{!status.ffmpeg.available && (
|
||||
<div className="text-xs text-red-500">
|
||||
请安装 FFmpeg 并添加到系统 PATH
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="mt-8 text-center text-xs text-gray-400">
|
||||
状态每 30 秒自动刷新
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export default function AboutPage() {
|
||||
height={50}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<h1 className="text-4xl font-bold">BiliNote v1.8.1</h1>
|
||||
<h1 className="text-4xl font-bold">BiliNote v2.0.0</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 text-xl italic">
|
||||
AI 视频笔记生成工具 让 AI 为你的视频做笔记
|
||||
|
||||
@@ -1,8 +1,255 @@
|
||||
const Transcriber = () => {
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import {
|
||||
getTranscriberConfig,
|
||||
updateTranscriberConfig,
|
||||
getModelsStatus,
|
||||
downloadModel,
|
||||
TranscriberConfig,
|
||||
ModelStatus,
|
||||
} from '@/services/transcriber'
|
||||
|
||||
const isWhisperType = (type: string) =>
|
||||
type === 'fast-whisper' || type === 'mlx-whisper'
|
||||
|
||||
export default function Transcriber() {
|
||||
const [config, setConfig] = useState<TranscriberConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [selectedType, setSelectedType] = useState('')
|
||||
const [selectedModelSize, setSelectedModelSize] = useState('')
|
||||
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
|
||||
const [mlxModelStatuses, setMlxModelStatuses] = useState<ModelStatus[]>([])
|
||||
const [mlxAvailable, setMlxAvailable] = useState(false)
|
||||
|
||||
const fetchModelsStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await getModelsStatus()
|
||||
setModelStatuses(data.whisper)
|
||||
setMlxModelStatuses(data.mlx_whisper)
|
||||
setMlxAvailable(data.mlx_available)
|
||||
} catch {
|
||||
// 静默失败,不阻塞主流程
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await getTranscriberConfig()
|
||||
setConfig(data)
|
||||
setSelectedType(data.transcriber_type)
|
||||
setSelectedModelSize(data.whisper_model_size)
|
||||
} catch {
|
||||
toast.error('获取转写器配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
fetchModelsStatus()
|
||||
}, [fetchModelsStatus])
|
||||
|
||||
// 有下载中的模型时自动轮询状态
|
||||
useEffect(() => {
|
||||
const hasDownloading =
|
||||
modelStatuses.some(m => m.downloading) || mlxModelStatuses.some(m => m.downloading)
|
||||
if (!hasDownloading) return
|
||||
|
||||
const timer = setInterval(fetchModelsStatus, 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [modelStatuses, mlxModelStatuses, fetchModelsStatus])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: { transcriber_type: string; whisper_model_size?: string } = {
|
||||
transcriber_type: selectedType,
|
||||
}
|
||||
if (isWhisperType(selectedType)) {
|
||||
payload.whisper_model_size = selectedModelSize
|
||||
}
|
||||
await updateTranscriberConfig(payload)
|
||||
toast.success('转写器配置已保存')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (modelSize: string, transcriberType: string) => {
|
||||
try {
|
||||
await downloadModel({ model_size: modelSize, transcriber_type: transcriberType })
|
||||
toast.success(`模型 ${modelSize} 开始下载`)
|
||||
// 立即刷新状态
|
||||
setTimeout(fetchModelsStatus, 1000)
|
||||
} catch {
|
||||
toast.error('下载请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div className="p-6 text-center text-neutral-500">无法加载配置</div>
|
||||
}
|
||||
|
||||
const currentModels = selectedType === 'mlx-whisper' ? mlxModelStatuses : modelStatuses
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center">
|
||||
<h1 className="text-center text-4xl font-bold">Transcriber is under development</h1>
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">音频转写配置</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
选择视频音频转写为文字所使用的引擎,保存后对新任务立即生效
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 转写引擎选择 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AudioLines className="h-5 w-5" />
|
||||
转写引擎
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">转写器类型</label>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-full max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.available_types.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isWhisperType(selectedType) && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Whisper 模型大小</label>
|
||||
<Select value={selectedModelSize} onValueChange={setSelectedModelSize}>
|
||||
<SelectTrigger className="w-full max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.whisper_model_sizes.map(size => {
|
||||
const status = currentModels.find(m => m.model_size === size)
|
||||
return (
|
||||
<SelectItem key={size} value={size}>
|
||||
<span className="flex items-center gap-2">
|
||||
{size}
|
||||
{status?.downloaded && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-400">
|
||||
模型越大精度越高,但速度更慢、占用更多显存
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedType === 'mlx-whisper' && !config.mlx_whisper_available && (
|
||||
<Alert variant="warning" className="text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
MLX Whisper 当前不可用。需要 macOS 平台并安装{' '}
|
||||
<code className="rounded bg-neutral-100 px-1">pip install mlx_whisper</code>,
|
||||
安装后重启后端生效。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={saving || (selectedType === 'mlx-whisper' && !config.mlx_whisper_available)} className="mt-2">
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
保存配置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Whisper 模型管理 */}
|
||||
{isWhisperType(selectedType) && currentModels.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Download className="h-5 w-5" />
|
||||
模型管理
|
||||
<span className="text-sm font-normal text-neutral-400">
|
||||
{selectedType === 'mlx-whisper' ? 'MLX Whisper' : 'Faster Whisper'}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{currentModels.map(model => (
|
||||
<div
|
||||
key={model.model_size}
|
||||
className="flex items-center justify-between rounded-md border px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{model.model_size}</span>
|
||||
{model.downloaded ? (
|
||||
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
|
||||
已下载
|
||||
</Badge>
|
||||
) : model.downloading ? (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
下载中
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">未下载</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!model.downloaded && !model.downloading && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownload(model.model_size, selectedType)}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Transcriber
|
||||
|
||||
44
BillNote_frontend/src/services/chat.ts
Normal file
44
BillNote_frontend/src/services/chat.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatSource {
|
||||
text: string
|
||||
source_type: 'markdown' | 'transcript'
|
||||
section_title?: string
|
||||
start_time?: number
|
||||
end_time?: number
|
||||
}
|
||||
|
||||
export interface AskResponse {
|
||||
answer: string
|
||||
sources: ChatSource[]
|
||||
}
|
||||
|
||||
export type IndexStatus = 'idle' | 'indexing' | 'indexed' | 'failed'
|
||||
|
||||
export interface ChatStatusResponse {
|
||||
indexed: boolean
|
||||
status: IndexStatus
|
||||
}
|
||||
|
||||
export const indexTask = async (taskId: string): Promise<void> => {
|
||||
return await request.post('/chat/index', { task_id: taskId })
|
||||
}
|
||||
|
||||
export const askQuestion = async (data: {
|
||||
task_id: string
|
||||
question: string
|
||||
history: ChatMessage[]
|
||||
provider_id: string
|
||||
model_name: string
|
||||
}): Promise<AskResponse> => {
|
||||
return await request.post('/chat/ask', data, { timeout: 60000 })
|
||||
}
|
||||
|
||||
export const getChatStatus = async (taskId: string): Promise<ChatStatusResponse> => {
|
||||
return await request.get(`/chat/status?task_id=${taskId}`)
|
||||
}
|
||||
@@ -1,5 +1,29 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const systemCheck=async()=>{
|
||||
export const systemCheck = async () => {
|
||||
return await request.get('/sys_health')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export const getDeployStatus = async (): Promise<DeployStatus> => {
|
||||
return await request.get('/deploy_status')
|
||||
}
|
||||
|
||||
|
||||
43
BillNote_frontend/src/services/transcriber.ts
Normal file
43
BillNote_frontend/src/services/transcriber.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface TranscriberConfig {
|
||||
transcriber_type: string
|
||||
whisper_model_size: string
|
||||
available_types: { value: string; label: string }[]
|
||||
whisper_model_sizes: string[]
|
||||
mlx_whisper_available: boolean
|
||||
}
|
||||
|
||||
export interface ModelStatus {
|
||||
model_size: string
|
||||
downloaded: boolean
|
||||
downloading: boolean
|
||||
}
|
||||
|
||||
export interface ModelsStatusResponse {
|
||||
whisper: ModelStatus[]
|
||||
mlx_whisper: ModelStatus[]
|
||||
mlx_available: boolean
|
||||
}
|
||||
|
||||
export const getTranscriberConfig = async (): Promise<TranscriberConfig> => {
|
||||
return await request.get('/transcriber_config')
|
||||
}
|
||||
|
||||
export const updateTranscriberConfig = async (data: {
|
||||
transcriber_type: string
|
||||
whisper_model_size?: string
|
||||
}) => {
|
||||
return await request.post('/transcriber_config', data)
|
||||
}
|
||||
|
||||
export const getModelsStatus = async (): Promise<ModelsStatusResponse> => {
|
||||
return await request.get('/transcriber_models_status')
|
||||
}
|
||||
|
||||
export const downloadModel = async (data: {
|
||||
model_size: string
|
||||
transcriber_type?: string
|
||||
}) => {
|
||||
return await request.post('/transcriber_download', data)
|
||||
}
|
||||
43
BillNote_frontend/src/store/chatStore/index.ts
Normal file
43
BillNote_frontend/src/store/chatStore/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { ChatSource } from '@/services/chat'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
sources?: ChatSource[]
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
chatHistory: Record<string, ChatMessage[]>
|
||||
addMessage: (taskId: string, msg: ChatMessage) => void
|
||||
clearChat: (taskId: string) => void
|
||||
getMessages: (taskId: string) => ChatMessage[]
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
chatHistory: {},
|
||||
|
||||
addMessage: (taskId, msg) =>
|
||||
set(state => ({
|
||||
chatHistory: {
|
||||
...state.chatHistory,
|
||||
[taskId]: [...(state.chatHistory[taskId] || []), msg],
|
||||
},
|
||||
})),
|
||||
|
||||
clearChat: (taskId) =>
|
||||
set(state => {
|
||||
const { [taskId]: _, ...rest } = state.chatHistory
|
||||
return { chatHistory: rest }
|
||||
}),
|
||||
|
||||
getMessages: (taskId) => get().chatHistory[taskId] || [],
|
||||
}),
|
||||
{
|
||||
name: 'bilinote-chat-storage',
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -75,19 +75,19 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
getProviderById: id => get().provider.find(p => p.id === id),
|
||||
updateProvider: async (provider: IProvider) => {
|
||||
try {
|
||||
const existing = get().provider.find(p => p.id === provider.id)
|
||||
const merged = { ...existing, ...provider }
|
||||
|
||||
const data = {
|
||||
...provider,
|
||||
api_key: provider.apiKey,
|
||||
base_url: provider.baseUrl,
|
||||
}
|
||||
const res = await updateProviderById(data)
|
||||
if (res.data.code === 0) {
|
||||
const item = res.data.data
|
||||
console.log('Provider ', item)
|
||||
await get().fetchProviderList()
|
||||
...merged,
|
||||
api_key: merged.apiKey,
|
||||
base_url: merged.baseUrl,
|
||||
}
|
||||
// 拦截器已解包:成功时直接返回 data 部分
|
||||
await updateProviderById(data)
|
||||
await get().fetchProviderList()
|
||||
} catch (error) {
|
||||
console.error('Error fetching provider:', error)
|
||||
console.error('Error updating provider:', error)
|
||||
}
|
||||
},
|
||||
getProviderList: () => get().provider,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { delete_task, generateNote } from '@/services/note.ts'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import toast from 'react-hot-toast'
|
||||
import { get, set, del } from 'idb-keyval'
|
||||
|
||||
|
||||
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
|
||||
@@ -211,6 +212,18 @@ export const useTaskStore = create<TaskStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'task-storage',
|
||||
storage: createJSONStorage(() => ({
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
const value = await get(name)
|
||||
return value ?? null
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await set(name, value)
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await del(name)
|
||||
},
|
||||
})),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd() + '/../')
|
||||
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
|
||||
const envDir = process.env.DOCKER_BUILD ? __dirname : path.resolve(__dirname, '../')
|
||||
const env = loadEnv(mode, envDir)
|
||||
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
const port = env.VITE_FRONTEND_PORT || 3015
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
|
||||
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
|
||||
|
||||
return {
|
||||
base: './',
|
||||
@@ -18,9 +23,21 @@ export default defineConfig(({ mode }) => {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
markdown: ['react-markdown', 'react-syntax-highlighter', 'remark-gfm', 'remark-math', 'rehype-katex'],
|
||||
markmap: ['markmap-lib', 'markmap-view', 'markmap-toolbar', 'markmap-common'],
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: port,
|
||||
allowedHosts: true, // 允许任意域名访问
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiBaseUrl,
|
||||
|
||||
74
CLAUDE.md
Normal file
74
CLAUDE.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
BiliNote is an AI video note generation tool. It extracts content from video links (Bilibili, YouTube, Douyin, Kuaishou, local files) and generates structured Markdown notes using LLM models. Full-stack app with a FastAPI backend, React frontend, and optional Tauri desktop packaging.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (Python 3.11 + FastAPI)
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python main.py # Starts on 0.0.0.0:8483
|
||||
```
|
||||
|
||||
### Frontend (React 19 + Vite + TypeScript)
|
||||
```bash
|
||||
cd BillNote_frontend
|
||||
pnpm install
|
||||
pnpm dev # Dev server on port 3015, proxies /api to backend
|
||||
pnpm build # Production build
|
||||
pnpm lint # ESLint
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker-compose up # Web stack (backend + frontend + nginx)
|
||||
docker-compose -f docker-compose.gpu.yml up # GPU variant
|
||||
```
|
||||
|
||||
### Desktop (Tauri)
|
||||
```bash
|
||||
cd backend && ./build.sh # Build PyInstaller backend binary
|
||||
cd BillNote_frontend && pnpm tauri build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Backend** (`backend/`) — FastAPI app, entry point `main.py`:
|
||||
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`
|
||||
- `app/services/` — Business logic: `note.py` (NoteGenerator orchestrates the full pipeline), `task_serial_executor.py` (task queue)
|
||||
- `app/downloaders/` — Platform adapters (bilibili, youtube, douyin, kuaishou, local) with shared `base.py` interface
|
||||
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`
|
||||
- `app/gpt/` — LLM integration with factory pattern (`gpt_factory.py`), prompt templates (`prompt.py`, `prompt_builder.py`), and `request_chunker.py` for long transcripts
|
||||
- `app/db/` — SQLite + SQLAlchemy: DAO pattern (`provider_dao.py`, `model_dao.py`, `video_task_dao.py`), models in `models/`
|
||||
- `app/utils/` — `response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX)
|
||||
- `events/` (root level) — Blinker signal system for post-processing (e.g., temp file cleanup after transcription)
|
||||
|
||||
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
|
||||
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
|
||||
- `pages/SettingPage/` — LLM provider management, system monitoring, transcriber config
|
||||
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`
|
||||
- `services/` — Axios API clients matching backend routes
|
||||
- `hooks/useTaskPolling.ts` — Polls task status every 3 seconds
|
||||
- `components/ui/` — shadcn/ui (Radix-based) components
|
||||
- Path alias: `@` → `./src`
|
||||
|
||||
**Core Workflow**: User submits URL → task queued → download video → extract audio (FFmpeg) → transcribe (Whisper/Groq/etc) → generate notes (LLM) → frontend polls for completion → display Markdown + mind map.
|
||||
|
||||
## Key Configuration
|
||||
|
||||
- **Ports**: Backend 8483, Frontend dev 3015, Docker maps 3015→80
|
||||
- **Environment**: Root `.env` (copy from `.env.example`). LLM API keys are configured through the UI, not env vars.
|
||||
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
|
||||
- **FFmpeg**: Required system dependency for video/audio processing
|
||||
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir)
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Frontend**: ESLint + Prettier (2 spaces, single quotes, 100 char width, Tailwind plugin). TypeScript strict mode.
|
||||
- **Backend**: Python with type hints. No configured linter. Uses Pydantic models for validation.
|
||||
- **Note**: The frontend directory is named `BillNote_frontend` (not "Bili").
|
||||
112
Dockerfile.complete
Normal file
112
Dockerfile.complete
Normal file
@@ -0,0 +1,112 @@
|
||||
# === 阶段1:构建 Backend ===
|
||||
FROM python:3.11-slim AS backend-builder
|
||||
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN set -ex && \
|
||||
rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/bin:${PATH}"
|
||||
ENV HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
WORKDIR /tmp/backend
|
||||
|
||||
# 先复制 requirements.txt 利用层缓存
|
||||
COPY ./backend/requirements.txt /tmp/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
|
||||
|
||||
COPY ./backend /tmp/backend
|
||||
|
||||
# === 阶段2:构建 Frontend ===
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /tmp/frontend
|
||||
|
||||
# 先复制 package.json 利用依赖层缓存
|
||||
COPY ./BillNote_frontend/package.json ./
|
||||
RUN pnpm install
|
||||
|
||||
COPY ./BillNote_frontend /tmp/frontend
|
||||
|
||||
# 设置环境变量,告诉 vite.config.ts 这是 Docker 构建
|
||||
ENV DOCKER_BUILD=1
|
||||
RUN pnpm run build
|
||||
|
||||
# === 阶段3:完整应用镜像 ===
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
|
||||
# 安装必要的运行时依赖
|
||||
RUN set -ex && \
|
||||
rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg nginx supervisor procps && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/bin:${PATH}"
|
||||
ENV HF_ENDPOINT=https://hf-mirror.com
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# 复制 Python 依赖
|
||||
COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
COPY --from=backend-builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# 复制 backend 代码
|
||||
COPY ./backend /app/backend
|
||||
WORKDIR /app/backend
|
||||
|
||||
# 复制前端静态文件到 nginx
|
||||
COPY --from=frontend-builder /tmp/frontend/dist /usr/share/nginx/html
|
||||
|
||||
# 配置 nginx
|
||||
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 创建 supervisor 配置
|
||||
RUN mkdir -p /var/log/supervisor
|
||||
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -g "daemon off;"
|
||||
stdout_logfile=/var/log/supervisor/nginx.log
|
||||
stderr_logfile=/var/log/supervisor/nginx.log
|
||||
autorestart=true
|
||||
priority=10
|
||||
|
||||
[program:backend]
|
||||
command=python main.py
|
||||
directory=/app/backend
|
||||
stdout_logfile=/var/log/supervisor/backend.log
|
||||
stderr_logfile=/var/log/supervisor/backend.log
|
||||
autorestart=true
|
||||
priority=20
|
||||
environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0"
|
||||
EOF
|
||||
|
||||
# 修改 nginx 配置以使用本地 backend
|
||||
RUN sed -i 's/proxy_pass http:\/\/backend:8483/proxy_pass http:\/\/127.0.0.1:8483/g' /etc/nginx/conf.d/default.conf && \
|
||||
sed -i 's/proxy_pass http:\/\/frontend:80/proxy_pass http:\/\/127.0.0.1:8080/g' /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 启动 supervisor
|
||||
EXPOSE 80
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
109
README.md
109
README.md
@@ -3,17 +3,17 @@
|
||||
<p align="center">
|
||||
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
|
||||
</p>
|
||||
<h1 align="center" > BiliNote v1.8.1</h1>
|
||||
<h1 align="center" > BiliNote v2.0.0</h1>
|
||||
</div>
|
||||
|
||||
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
||||
<img src="https://img.shields.io/badge/frontend-react-blue" />
|
||||
<img src="https://img.shields.io/badge/frontend-react%2019-blue" />
|
||||
<img src="https://img.shields.io/badge/backend-fastapi-green" />
|
||||
<img src="https://img.shields.io/badge/GPT-openai%20%7C%20deepseek%20%7C%20qwen-ff69b4" />
|
||||
<img src="https://img.shields.io/badge/docker-compose-blue" />
|
||||
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" />
|
||||
<img src="https://img.shields.io/badge/status-active-success" />
|
||||
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
|
||||
</p>
|
||||
@@ -22,30 +22,48 @@
|
||||
|
||||
## ✨ 项目简介
|
||||
|
||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。
|
||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。
|
||||
|
||||
## 📝 使用文档
|
||||
详细文档可以查看[这里](https://docs.bilinote.app/)
|
||||
|
||||
## 体验地址
|
||||
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
|
||||
## 📦 Windows 打包版
|
||||
本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.1.1)进行下载。**注意一定要在没有中文路径的环境下运行。**
|
||||
|
||||
## 📦 桌面版下载
|
||||
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
|
||||
|
||||
> Windows 用户请注意:一定要在没有中文路径的环境下运行。
|
||||
|
||||
## 🔧 功能特性
|
||||
|
||||
- 支持多平台:Bilibili、YouTube、本地视频、抖音(后续会加入更多平台)
|
||||
- 支持多平台:Bilibili、YouTube、本地视频、抖音、快手
|
||||
- 支持返回笔记格式选择
|
||||
- 支持笔记风格选择
|
||||
- 支持多模态视频理解
|
||||
- 支持多版本记录保留
|
||||
- 支持自行配置 GPT 大模型
|
||||
- 本地模型音频转写(支持 Fast-Whisper)
|
||||
- 支持自行配置 GPT 大模型(OpenAI、DeepSeek、Qwen 等)
|
||||
- 本地模型音频转写(支持 Fast-Whisper、MLX-Whisper、Groq、BCut)
|
||||
- GPT 大模型总结视频内容
|
||||
- 自动生成结构化 Markdown 笔记
|
||||
- 可选插入截图(自动截取)
|
||||
- 可选内容跳转链接(关联原视频)
|
||||
- 任务记录与历史回看
|
||||
- 基于 RAG 的笔记内容 AI 问答(支持 Function Calling)
|
||||
- 笔记顶部视频封面 Banner 展示
|
||||
- 工作区和生成历史面板支持折叠/展开
|
||||
|
||||
### v2.0.0 新增
|
||||
|
||||
- 基于 RAG 的笔记内容 AI 问答功能,支持半屏/全屏模式
|
||||
- AI 问答支持 Function Calling,模型可主动查询原文数据
|
||||
- RAG 索引支持视频元信息(标题、作者、简介、标签等)
|
||||
- AI 回复支持 Markdown 渲染
|
||||
- 笔记顶部新增视频封面 Banner
|
||||
- 工作区和生成历史面板支持折叠/展开
|
||||
- 笔记开头添加来源链接功能
|
||||
- YouTube 字幕优先获取,有字幕时跳过音频下载
|
||||
- 性能优化与转写器配置改进
|
||||
|
||||
## 📸 截图预览
|
||||

|
||||
@@ -56,7 +74,34 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 克隆仓库
|
||||
### 方式一:Docker 部署(推荐)
|
||||
|
||||
确保已安装 Docker,直接拉取预构建镜像运行:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/jefferyhcool/bilinote:latest
|
||||
|
||||
docker run -d -p 80:80 \
|
||||
-v bilinote-data:/app/backend/data \
|
||||
--name bilinote \
|
||||
ghcr.io/jefferyhcool/bilinote:latest
|
||||
```
|
||||
|
||||
访问:`http://localhost`
|
||||
|
||||
也可以使用 docker-compose 本地构建:
|
||||
|
||||
```bash
|
||||
# 标准部署
|
||||
docker-compose up -d
|
||||
|
||||
# GPU 加速部署(需要 NVIDIA GPU)
|
||||
docker-compose -f docker-compose.gpu.yml up -d
|
||||
```
|
||||
|
||||
### 方式二:源码部署
|
||||
|
||||
#### 1. 克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/JefferyHcool/BiliNote.git
|
||||
@@ -64,7 +109,7 @@ cd BiliNote
|
||||
mv .env.example .env
|
||||
```
|
||||
|
||||
### 2. 启动后端(FastAPI)
|
||||
#### 2. 启动后端(FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
@@ -72,7 +117,7 @@ pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 3. 启动前端(Vite + React)
|
||||
#### 3. 启动前端(Vite + React)
|
||||
|
||||
```bash
|
||||
cd BillNote_frontend
|
||||
@@ -80,11 +125,12 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问:`http://localhost:5173`
|
||||
访问:`http://localhost:3015`
|
||||
|
||||
## ⚙️ 依赖说明
|
||||
|
||||
### 🎬 FFmpeg
|
||||
本项目依赖 ffmpeg 用于音频处理与转码,必须安装:
|
||||
本项目依赖 ffmpeg 用于音频处理与转码,源码部署时必须安装:
|
||||
```bash
|
||||
# Mac (brew)
|
||||
brew install ffmpeg
|
||||
@@ -96,6 +142,8 @@ sudo apt install ffmpeg
|
||||
# 请从官网下载安装:https://ffmpeg.org/download.html
|
||||
```
|
||||
> ⚠️ 若系统无法识别 ffmpeg,请将其加入系统环境变量 PATH
|
||||
>
|
||||
> Docker 部署已内置 FFmpeg,无需额外安装。
|
||||
|
||||
### 🚀 CUDA 加速(可选)
|
||||
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
|
||||
@@ -104,24 +152,43 @@ sudo apt install ffmpeg
|
||||
|
||||
### 🐳 使用 Docker 一键部署
|
||||
|
||||
确保你已安装 Docker 和 Docker Compose:
|
||||
确保你已安装 Docker,然后直接拉取预构建镜像运行:
|
||||
|
||||
[docker 部署](https://github.com/JefferyHcool/bilinote-deploy/blob/master/README.md)
|
||||
```bash
|
||||
# 拉取最新镜像
|
||||
docker pull ghcr.io/jefferyhcool/bilinote:latest
|
||||
|
||||
# 运行容器
|
||||
docker run -d -p 80:80 \
|
||||
-v bilinote-data:/app/backend/data \
|
||||
--name bilinote \
|
||||
ghcr.io/jefferyhcool/bilinote:latest
|
||||
```
|
||||
|
||||
访问:`http://localhost`
|
||||
|
||||
也可以使用 docker-compose 本地构建:
|
||||
|
||||
```bash
|
||||
# 标准部署
|
||||
docker-compose up -d
|
||||
|
||||
# GPU 加速部署(需要 NVIDIA GPU)
|
||||
docker-compose -f docker-compose.gpu.yml up -d
|
||||
```
|
||||
|
||||
## 🧠 TODO
|
||||
|
||||
- [x] 支持抖音及快手等视频平台
|
||||
- [x] 支持前端设置切换 AI 模型切换、语音转文字模型
|
||||
- [x] AI 摘要风格自定义(学术风、口语风、重点提取等)
|
||||
- [ ] 笔记导出为 PDF / Word / Notion
|
||||
- [x] 加入更多模型支持
|
||||
- [x] 加入更多音频转文本模型支持
|
||||
- [x] 基于 RAG 的笔记内容 AI 问答
|
||||
- [ ] 笔记导出为 PDF / Word / Notion
|
||||
|
||||
### Contact and Join-联系和加入社区
|
||||
- BiliNote 交流QQ群:785367111
|
||||
- BiliNote 交流微信群:
|
||||
|
||||
<img src="doc/wechat.png" alt="wechat" style="zoom:33%;" />
|
||||
年会恢复更新以后放出最新社区地址
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y ffmpeg && \
|
||||
apt-get install -y --no-install-recommends ffmpeg curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 确保 PATH 中包含 ffmpeg 路径(可选)
|
||||
ENV PATH="/usr/bin:${PATH}"
|
||||
|
||||
# 设置 Hugging Face 镜像源环境变量
|
||||
ENV HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制 requirements.txt 利用层缓存
|
||||
COPY ./backend/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
|
||||
|
||||
# 再复制应用代码(频繁变动不影响 pip 缓存层)
|
||||
COPY ./backend /app
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
27
backend/Dockerfile.gpu
Normal file
27
backend/Dockerfile.gpu
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
||||
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb https://${APT_MIRROR}/ubuntu jammy main restricted universe multiverse" > /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/ubuntu jammy-updates main restricted universe multiverse" >> /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/ubuntu jammy-security main restricted universe multiverse" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg python3-pip curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制 requirements.txt 利用层缓存
|
||||
COPY ./backend/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt && \
|
||||
pip install --no-cache-dir -i ${PIP_INDEX} 'transformers[torch]>=4.23'
|
||||
|
||||
# 再复制应用代码
|
||||
COPY ./backend /app
|
||||
|
||||
CMD ["python3", "main.py"]
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routers import note, provider, model, config
|
||||
from .routers import note, provider, model, config, chat
|
||||
|
||||
|
||||
|
||||
@@ -10,5 +10,6 @@ def create_app(lifespan) -> FastAPI:
|
||||
app.include_router(provider.router, prefix="/api")
|
||||
app.include_router(model.router,prefix="/api")
|
||||
app.include_router(config.router, prefix="/api")
|
||||
app.include_router(chat.router, prefix="/api")
|
||||
|
||||
return app
|
||||
|
||||
@@ -13,10 +13,19 @@ engine_args = {}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
engine_args["connect_args"] = {"check_same_thread": False}
|
||||
|
||||
_pool_args = {}
|
||||
if not DATABASE_URL.startswith("sqlite"):
|
||||
_pool_args = {
|
||||
"pool_size": int(os.getenv("DB_POOL_SIZE", "10")),
|
||||
"max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")),
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
echo=os.getenv("SQLALCHEMY_ECHO", "false").lower() == "true",
|
||||
**engine_args
|
||||
**engine_args,
|
||||
**_pool_args,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.db.engine import get_db
|
||||
from app.db.models.models import Model
|
||||
from app.db.models.providers import Provider
|
||||
|
||||
|
||||
def get_model_by_provider_and_name(provider_id: int, model_name: str):
|
||||
@@ -58,7 +59,8 @@ def delete_model(model_id: int):
|
||||
def get_all_models():
|
||||
db = next(get_db())
|
||||
try:
|
||||
models = db.query(Model).all()
|
||||
# 只查询启用状态供应商的模型
|
||||
models = db.query(Model).join(Provider, Model.provider_id == Provider.id).filter(Provider.enabled == 1).all()
|
||||
return [
|
||||
{"id": m.id, "provider_id": m.provider_id, "model_name": m.model_name}
|
||||
for m in models
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Optional, Union
|
||||
|
||||
from app.enmus.note_enums import DownloadQuality
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.models.transcriber_model import TranscriptResult
|
||||
from os import getenv
|
||||
QUALITY_MAP = {
|
||||
"fast": "32",
|
||||
@@ -21,7 +22,8 @@ class Downloader(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def download(self, video_url: str, output_dir: str = None,
|
||||
quality: DownloadQuality = "fast", need_video: Optional[bool] = False) -> AudioDownloadResult:
|
||||
quality: DownloadQuality = "fast", need_video: Optional[bool] = False,
|
||||
skip_download: bool = False) -> AudioDownloadResult:
|
||||
'''
|
||||
|
||||
:param need_video:
|
||||
@@ -36,3 +38,15 @@ class Downloader(ABC):
|
||||
def download_video(self, video_url: str,
|
||||
output_dir: Union[str, None] = None) -> str:
|
||||
pass
|
||||
|
||||
def download_subtitles(self, video_url: str, output_dir: str = None,
|
||||
langs: list = None) -> Optional[TranscriptResult]:
|
||||
'''
|
||||
尝试获取平台字幕(人工字幕或自动生成字幕)
|
||||
|
||||
:param video_url: 视频链接
|
||||
:param output_dir: 输出路径
|
||||
:param langs: 优先语言列表,如 ['zh-Hans', 'zh', 'en']
|
||||
:return: TranscriptResult 或 None(无字幕时)
|
||||
'''
|
||||
return None
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from abc import ABC
|
||||
from typing import Union, Optional
|
||||
from typing import Union, Optional, List
|
||||
|
||||
import yt_dlp
|
||||
|
||||
from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
||||
from app.utils.path_helper import get_data_dir
|
||||
from app.utils.url_parser import extract_video_id
|
||||
from app.services.cookie_manager import CookieConfigManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BilibiliDownloader(Downloader, ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._cookie_mgr = CookieConfigManager()
|
||||
self._cookie = self._cookie_mgr.get('bilibili')
|
||||
self._cookiefile = self._write_netscape_cookie_file()
|
||||
|
||||
def _write_netscape_cookie_file(self) -> Optional[str]:
|
||||
"""将 Cookie 写入 Netscape 格式临时文件,返回文件路径(供 yt-dlp cookiefile 使用)"""
|
||||
if not self._cookie:
|
||||
logger.warning("B站 Cookie 未配置,下载可能失败")
|
||||
return None
|
||||
lines = ["# Netscape HTTP Cookie File\n"]
|
||||
for pair in self._cookie.split("; "):
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
lines.append(f".bilibili.com\tTRUE\t/\tFALSE\t0\t{key}\t{value}\n")
|
||||
tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8')
|
||||
tmp.writelines(lines)
|
||||
tmp.close()
|
||||
logger.info("已生成 B站 Netscape Cookie 文件: %s (条目: %d)", tmp.name, len(lines) - 1)
|
||||
return tmp.name
|
||||
|
||||
def download(
|
||||
self,
|
||||
@@ -32,6 +58,7 @@ class BilibiliDownloader(Downloader, ABC):
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
||||
'outtmpl': output_path,
|
||||
'http_headers': {'Referer': 'https://www.bilibili.com'},
|
||||
'postprocessors': [
|
||||
{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
@@ -42,6 +69,8 @@ class BilibiliDownloader(Downloader, ABC):
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
}
|
||||
if self._cookiefile:
|
||||
ydl_opts['cookiefile'] = self._cookiefile
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
@@ -88,10 +117,13 @@ class BilibiliDownloader(Downloader, ABC):
|
||||
ydl_opts = {
|
||||
'format': 'bv*[ext=mp4]/bestvideo+bestaudio/best',
|
||||
'outtmpl': output_path,
|
||||
'http_headers': {'Referer': 'https://www.bilibili.com'},
|
||||
'noplaylist': True,
|
||||
'quiet': False,
|
||||
'merge_output_format': 'mp4', # 确保合并成 mp4
|
||||
}
|
||||
if self._cookiefile:
|
||||
ydl_opts['cookiefile'] = self._cookiefile
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
@@ -111,4 +143,191 @@ class BilibiliDownloader(Downloader, ABC):
|
||||
os.remove(video_path)
|
||||
return f"视频文件已删除: {video_path}"
|
||||
else:
|
||||
return f"视频文件未找到: {video_path}"
|
||||
return f"视频文件未找到: {video_path}"
|
||||
|
||||
def download_subtitles(self, video_url: str, output_dir: str = None,
|
||||
langs: List[str] = None) -> Optional[TranscriptResult]:
|
||||
"""
|
||||
尝试获取B站视频字幕
|
||||
|
||||
:param video_url: 视频链接
|
||||
:param output_dir: 输出路径
|
||||
:param langs: 优先语言列表
|
||||
:return: TranscriptResult 或 None
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
if not output_dir:
|
||||
output_dir = self.cache_data
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
if langs is None:
|
||||
langs = ['zh-Hans', 'zh', 'zh-CN', 'ai-zh', 'en', 'en-US']
|
||||
|
||||
video_id = extract_video_id(video_url, "bilibili")
|
||||
|
||||
ydl_opts = {
|
||||
'writesubtitles': True,
|
||||
'writeautomaticsub': True,
|
||||
'subtitleslangs': langs,
|
||||
'subtitlesformat': 'srt/json3/best', # 支持多种格式
|
||||
'skip_download': True,
|
||||
'outtmpl': os.path.join(output_dir, f'{video_id}.%(ext)s'),
|
||||
'quiet': True,
|
||||
}
|
||||
|
||||
# 通过 CookieConfigManager 注入 B站 Cookie(Netscape cookiefile)
|
||||
if self._cookiefile:
|
||||
ydl_opts['cookiefile'] = self._cookiefile
|
||||
ydl_opts['http_headers'] = {'Referer': 'https://www.bilibili.com'}
|
||||
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
|
||||
# 查找下载的字幕文件
|
||||
subtitles = info.get('requested_subtitles') or {}
|
||||
if not subtitles:
|
||||
logger.info(f"B站视频 {video_id} 没有可用字幕")
|
||||
return None
|
||||
|
||||
# 按优先级查找字幕
|
||||
detected_lang = None
|
||||
sub_info = None
|
||||
for lang in langs:
|
||||
if lang in subtitles:
|
||||
detected_lang = lang
|
||||
sub_info = subtitles[lang]
|
||||
break
|
||||
|
||||
# 如果按优先级没找到,取第一个可用的(排除弹幕)
|
||||
if not detected_lang:
|
||||
for lang, info_item in subtitles.items():
|
||||
if lang != 'danmaku': # 排除弹幕
|
||||
detected_lang = lang
|
||||
sub_info = info_item
|
||||
break
|
||||
|
||||
if not sub_info:
|
||||
logger.info(f"B站视频 {video_id} 没有可用字幕(排除弹幕)")
|
||||
return None
|
||||
|
||||
# 检查是否有内嵌数据(yt-dlp 有时直接返回字幕内容)
|
||||
if 'data' in sub_info and sub_info['data']:
|
||||
logger.info(f"直接从返回数据解析字幕: {detected_lang}")
|
||||
return self._parse_srt_content(sub_info['data'], detected_lang)
|
||||
|
||||
# 查找字幕文件
|
||||
ext = sub_info.get('ext', 'srt')
|
||||
subtitle_file = os.path.join(output_dir, f"{video_id}.{detected_lang}.{ext}")
|
||||
|
||||
if not os.path.exists(subtitle_file):
|
||||
logger.info(f"字幕文件不存在: {subtitle_file}")
|
||||
return None
|
||||
|
||||
# 根据格式解析字幕文件
|
||||
if ext == 'json3':
|
||||
return self._parse_json3_subtitle(subtitle_file, detected_lang)
|
||||
else:
|
||||
with open(subtitle_file, 'r', encoding='utf-8') as f:
|
||||
return self._parse_srt_content(f.read(), detected_lang)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取B站字幕失败: {e}")
|
||||
return None
|
||||
|
||||
def _parse_srt_content(self, srt_content: str, language: str) -> Optional[TranscriptResult]:
|
||||
"""
|
||||
解析 SRT 格式字幕内容
|
||||
|
||||
:param srt_content: SRT 字幕文本内容
|
||||
:param language: 语言代码
|
||||
:return: TranscriptResult
|
||||
"""
|
||||
import re
|
||||
try:
|
||||
segments = []
|
||||
# SRT 格式: 序号\n时间戳\n文本\n\n
|
||||
pattern = r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\n(.*?)(?=\n\n|\n\d+\n|$)'
|
||||
matches = re.findall(pattern, srt_content, re.DOTALL)
|
||||
|
||||
for match in matches:
|
||||
idx, start_time, end_time, text = match
|
||||
text = text.strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# 转换时间格式 00:00:00,000 -> 秒
|
||||
def time_to_seconds(t):
|
||||
parts = t.replace(',', '.').split(':')
|
||||
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
|
||||
|
||||
segments.append(TranscriptSegment(
|
||||
start=time_to_seconds(start_time),
|
||||
end=time_to_seconds(end_time),
|
||||
text=text
|
||||
))
|
||||
|
||||
if not segments:
|
||||
return None
|
||||
|
||||
full_text = ' '.join(seg.text for seg in segments)
|
||||
logger.info(f"成功解析B站SRT字幕,共 {len(segments)} 段")
|
||||
return TranscriptResult(
|
||||
language=language,
|
||||
full_text=full_text,
|
||||
segments=segments,
|
||||
raw={'source': 'bilibili_subtitle', 'format': 'srt'}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"解析SRT字幕失败: {e}")
|
||||
return None
|
||||
|
||||
def _parse_json3_subtitle(self, subtitle_file: str, language: str) -> Optional[TranscriptResult]:
|
||||
"""
|
||||
解析 json3 格式字幕文件
|
||||
|
||||
:param subtitle_file: 字幕文件路径
|
||||
:param language: 语言代码
|
||||
:return: TranscriptResult
|
||||
"""
|
||||
try:
|
||||
with open(subtitle_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
segments = []
|
||||
events = data.get('events', [])
|
||||
|
||||
for event in events:
|
||||
# json3 格式中时间单位是毫秒
|
||||
start_ms = event.get('tStartMs', 0)
|
||||
duration_ms = event.get('dDurationMs', 0)
|
||||
|
||||
# 提取文本
|
||||
segs = event.get('segs', [])
|
||||
text = ''.join(seg.get('utf8', '') for seg in segs).strip()
|
||||
|
||||
if text: # 只添加非空文本
|
||||
segments.append(TranscriptSegment(
|
||||
start=start_ms / 1000.0,
|
||||
end=(start_ms + duration_ms) / 1000.0,
|
||||
text=text
|
||||
))
|
||||
|
||||
if not segments:
|
||||
return None
|
||||
|
||||
full_text = ' '.join(seg.text for seg in segments)
|
||||
|
||||
logger.info(f"成功解析B站字幕,共 {len(segments)} 段")
|
||||
return TranscriptResult(
|
||||
language=language,
|
||||
full_text=full_text,
|
||||
segments=segments,
|
||||
raw={'source': 'bilibili_subtitle', 'file': subtitle_file}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"解析字幕文件失败: {e}")
|
||||
return None
|
||||
@@ -1,14 +1,19 @@
|
||||
import os
|
||||
import logging
|
||||
from abc import ABC
|
||||
from typing import Union, Optional
|
||||
from typing import Union, Optional, List
|
||||
|
||||
import yt_dlp
|
||||
|
||||
from app.downloaders.base import Downloader, DownloadQuality
|
||||
from app.downloaders.youtube_subtitle import YouTubeSubtitleFetcher
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.models.transcriber_model import TranscriptResult
|
||||
from app.utils.path_helper import get_data_dir
|
||||
from app.utils.url_parser import extract_video_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YoutubeDownloader(Downloader, ABC):
|
||||
def __init__(self):
|
||||
@@ -20,12 +25,13 @@ class YoutubeDownloader(Downloader, ABC):
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
quality: DownloadQuality = "fast",
|
||||
need_video:Optional[bool]=False
|
||||
need_video: Optional[bool] = False,
|
||||
skip_download: bool = False,
|
||||
) -> AudioDownloadResult:
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
if not output_dir:
|
||||
output_dir=self.cache_data
|
||||
output_dir = self.cache_data
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
output_path = os.path.join(output_dir, "%(id)s.%(ext)s")
|
||||
@@ -37,15 +43,17 @@ class YoutubeDownloader(Downloader, ABC):
|
||||
'quiet': False,
|
||||
}
|
||||
|
||||
if skip_download:
|
||||
ydl_opts['skip_download'] = True
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
info = ydl.extract_info(video_url, download=not skip_download)
|
||||
video_id = info.get("id")
|
||||
title = info.get("title")
|
||||
duration = info.get("duration", 0)
|
||||
cover_url = info.get("thumbnail")
|
||||
ext = info.get("ext", "m4a") # 兜底用 m4a
|
||||
ext = info.get("ext", "m4a")
|
||||
audio_path = os.path.join(output_dir, f"{video_id}.{ext}")
|
||||
print('os.path.join(output_dir, f"{video_id}.{ext}")',os.path.join(output_dir, f"{video_id}.{ext}"))
|
||||
|
||||
return AudioDownloadResult(
|
||||
file_path=audio_path,
|
||||
@@ -54,8 +62,8 @@ class YoutubeDownloader(Downloader, ABC):
|
||||
cover_url=cover_url,
|
||||
platform="youtube",
|
||||
video_id=video_id,
|
||||
raw_info={'tags':info.get('tags')}, #全部返回会报错
|
||||
video_path=None # ❗音频下载不包含视频路径
|
||||
raw_info={'tags': info.get('tags')},
|
||||
video_path=None,
|
||||
)
|
||||
|
||||
def download_video(
|
||||
@@ -92,3 +100,24 @@ class YoutubeDownloader(Downloader, ABC):
|
||||
raise FileNotFoundError(f"视频文件未找到: {video_path}")
|
||||
|
||||
return video_path
|
||||
|
||||
def download_subtitles(self, video_url: str, output_dir: str = None,
|
||||
langs: List[str] = None) -> Optional[TranscriptResult]:
|
||||
"""
|
||||
通过 YouTube InnerTube API 直接获取字幕(优先人工字幕,其次自动生成)。
|
||||
比 yt_dlp 方式更轻量,无需写临时文件到磁盘。
|
||||
|
||||
:param video_url: 视频链接
|
||||
:param output_dir: 未使用(保留接口兼容)
|
||||
:param langs: 优先语言列表
|
||||
:return: TranscriptResult 或 None
|
||||
"""
|
||||
if langs is None:
|
||||
langs = ['zh-Hans', 'zh', 'zh-CN', 'zh-TW', 'en', 'en-US', 'ja']
|
||||
|
||||
video_id = extract_video_id(video_url, "youtube")
|
||||
fetcher = YouTubeSubtitleFetcher()
|
||||
print(
|
||||
f"尝试获取字幕,video_id={video_id}, langs={langs}"
|
||||
)
|
||||
return fetcher.fetch_subtitles(video_id, langs)
|
||||
|
||||
98
backend/app/downloaders/youtube_subtitle.py
Normal file
98
backend/app/downloaders/youtube_subtitle.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
通过 youtube-transcript-api 获取 YouTube 字幕。
|
||||
优先人工字幕,其次自动生成字幕。不依赖 yt_dlp,无需下载任何文件。
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
|
||||
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class YouTubeSubtitleFetcher:
|
||||
"""通过 youtube-transcript-api 获取 YouTube 字幕。"""
|
||||
|
||||
def __init__(self):
|
||||
self._api = YouTubeTranscriptApi()
|
||||
|
||||
def fetch_subtitles(
|
||||
self,
|
||||
video_id: str,
|
||||
langs: Optional[List[str]] = None,
|
||||
) -> Optional[TranscriptResult]:
|
||||
if langs is None:
|
||||
langs = ["zh-Hans", "zh", "zh-CN", "zh-TW", "en", "en-US", "ja"]
|
||||
|
||||
try:
|
||||
# 1. 列出所有可用字幕
|
||||
transcript_list = self._api.list(video_id)
|
||||
|
||||
available = []
|
||||
for t in transcript_list:
|
||||
available.append(
|
||||
f"{t.language_code}({'auto' if t.is_generated else 'manual'})"
|
||||
)
|
||||
logger.info(f"可用字幕轨道: {', '.join(available)}")
|
||||
|
||||
# 2. 按优先级查找:先人工字幕,再自动字幕
|
||||
transcript = None
|
||||
try:
|
||||
transcript = transcript_list.find_manually_created_transcript(langs)
|
||||
logger.info(f"选中人工字幕: {transcript.language_code} ({transcript.language})")
|
||||
except Exception:
|
||||
try:
|
||||
transcript = transcript_list.find_generated_transcript(langs)
|
||||
logger.info(f"选中自动字幕: {transcript.language_code} ({transcript.language})")
|
||||
except Exception:
|
||||
# 都没匹配,取第一个可用的
|
||||
for t in transcript_list:
|
||||
transcript = t
|
||||
source = "auto" if t.is_generated else "manual"
|
||||
logger.info(f"使用首个可用字幕: {t.language_code} ({source})")
|
||||
break
|
||||
|
||||
if not transcript:
|
||||
logger.info(f"YouTube 视频 {video_id} 没有任何可用字幕")
|
||||
return None
|
||||
|
||||
# 3. 获取字幕内容
|
||||
fetched = transcript.fetch()
|
||||
segments = []
|
||||
for snippet in fetched:
|
||||
text = snippet.get("text", "").strip() if isinstance(snippet, dict) else str(snippet).strip()
|
||||
if not text:
|
||||
continue
|
||||
start = snippet.get("start", 0) if isinstance(snippet, dict) else 0
|
||||
duration = snippet.get("duration", 0) if isinstance(snippet, dict) else 0
|
||||
segments.append(TranscriptSegment(
|
||||
start=float(start),
|
||||
end=float(start) + float(duration),
|
||||
text=text,
|
||||
))
|
||||
|
||||
if not segments:
|
||||
logger.warning(f"YouTube 字幕内容为空: {video_id}")
|
||||
return None
|
||||
|
||||
full_text = " ".join(seg.text for seg in segments)
|
||||
logger.info(f"成功获取 YouTube 字幕,共 {len(segments)} 段")
|
||||
|
||||
return TranscriptResult(
|
||||
language=transcript.language_code,
|
||||
full_text=full_text,
|
||||
segments=segments,
|
||||
raw={
|
||||
"source": "youtube_transcript_api",
|
||||
"language": transcript.language,
|
||||
"language_code": transcript.language_code,
|
||||
"is_generated": transcript.is_generated,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"YouTube 字幕获取失败: {e}")
|
||||
return None
|
||||
@@ -18,12 +18,12 @@ BASE_PROMPT = '''
|
||||
- **不要**将输出包裹在代码块中(例如:```` ```markdown ````,```` ``` ````)。
|
||||
请注意,在生成 Markdown 时,避免将编号标题(如“1. **内容**”)写成有序列表的格式,以免解析错误。
|
||||
|
||||
- 如果要加粗并保留编号,应使用 `1\. **内容**`(加反斜杠),防止被误解析为有序列表。
|
||||
- 如果要加粗并保留编号,应使用 `1\\. **内容**`(加反斜杠),防止被误解析为有序列表。
|
||||
- 或者使用 `## 1. 内容` 的形式作为标题。
|
||||
|
||||
请确保以下格式 **不会出现误渲染**:
|
||||
`1. **xxx**`
|
||||
`1\. **xxx**` 或 `## 1. xxx`
|
||||
`1\\. **xxx**` 或 `## 1. xxx`
|
||||
|
||||
视频分段(格式:开始时间 - 内容):
|
||||
|
||||
@@ -66,4 +66,13 @@ SCREENSHOT='''
|
||||
8. **Screenshot placeholders**: If a section involves **visual demonstrations, code walkthroughs, UI interactions**, or any content where visuals aid understanding, insert a screenshot cue at the end of that section:
|
||||
- Format: `*Screenshot-[mm:ss]`
|
||||
- Only use it when truly helpful.
|
||||
'''
|
||||
'''
|
||||
|
||||
MERGE_PROMPT = '''
|
||||
你将收到多个来自同一视频的 Markdown 笔记片段,请合并成一份完整笔记:
|
||||
- 只做合并与去重,不要发明新内容
|
||||
- 保持原有标题层级与 Markdown 结构
|
||||
- 保留所有 *Content-[mm:ss] 与 *Screenshot-[mm:ss] 标记
|
||||
- 保持中文输出,专有名词保留英文
|
||||
- 不要使用代码块包裹输出
|
||||
'''
|
||||
|
||||
161
backend/app/gpt/request_chunker.py
Normal file
161
backend/app/gpt/request_chunker.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkPayload:
|
||||
segments: list
|
||||
image_urls: list
|
||||
|
||||
|
||||
class RequestChunker:
|
||||
def __init__(self, message_builder: Callable, max_bytes: int, size_estimator: Optional[Callable] = None):
|
||||
self.message_builder = message_builder
|
||||
self.max_bytes = max_bytes
|
||||
self.size_estimator = size_estimator
|
||||
|
||||
def estimate(self, messages) -> int:
|
||||
if self.size_estimator:
|
||||
return self.size_estimator(messages)
|
||||
import json
|
||||
return len(json.dumps(messages, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def _messages_size(self, segments, image_urls, **kwargs) -> int:
|
||||
messages = self.message_builder(segments, image_urls, **kwargs)
|
||||
return self.estimate(messages)
|
||||
|
||||
def _get_text(self, segment) -> str:
|
||||
if isinstance(segment, dict):
|
||||
return segment.get("text", "")
|
||||
return getattr(segment, "text", "")
|
||||
|
||||
def _make_segment(self, segment, text: str):
|
||||
if isinstance(segment, dict):
|
||||
new_seg = dict(segment)
|
||||
new_seg["text"] = text
|
||||
return new_seg
|
||||
if hasattr(segment, "__dict__"):
|
||||
data = dict(segment.__dict__)
|
||||
data["text"] = text
|
||||
return type(segment)(**data)
|
||||
return type(segment)(segment.start, segment.end, text)
|
||||
|
||||
def _split_segment_to_fit(self, segment, **kwargs):
|
||||
text = self._get_text(segment)
|
||||
if not text:
|
||||
raise ValueError("empty segment cannot be split")
|
||||
lo, hi = 1, len(text)
|
||||
best = None
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
candidate = self._make_segment(segment, text[:mid])
|
||||
size = self._messages_size([candidate], [], **kwargs)
|
||||
if size <= self.max_bytes:
|
||||
best = mid
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
if best is None:
|
||||
raise ValueError("single segment too large to fit request")
|
||||
head = self._make_segment(segment, text[:best])
|
||||
tail = self._make_segment(segment, text[best:])
|
||||
return head, tail
|
||||
|
||||
def chunk(self, segments: list, image_urls: list, **kwargs) -> List[ChunkPayload]:
|
||||
segments = list(segments or [])
|
||||
image_urls = list(image_urls or [])
|
||||
if not segments and not image_urls:
|
||||
return []
|
||||
|
||||
chunks: List[ChunkPayload] = []
|
||||
seg_idx = 0
|
||||
|
||||
while seg_idx < len(segments):
|
||||
batch_segments = []
|
||||
while seg_idx < len(segments):
|
||||
candidate = batch_segments + [segments[seg_idx]]
|
||||
size = self._messages_size(candidate, [], **kwargs)
|
||||
if size <= self.max_bytes:
|
||||
batch_segments = candidate
|
||||
seg_idx += 1
|
||||
continue
|
||||
if not batch_segments:
|
||||
head, tail = self._split_segment_to_fit(segments[seg_idx], **kwargs)
|
||||
segments[seg_idx] = head
|
||||
segments.insert(seg_idx + 1, tail)
|
||||
continue
|
||||
break
|
||||
|
||||
if not batch_segments:
|
||||
raise ValueError("unable to fit any content into chunk")
|
||||
|
||||
chunks.append(ChunkPayload(segments=batch_segments, image_urls=[]))
|
||||
|
||||
if not image_urls:
|
||||
return chunks
|
||||
|
||||
if not chunks:
|
||||
chunks = [ChunkPayload(segments=[], image_urls=[])]
|
||||
|
||||
if not segments:
|
||||
for image in image_urls:
|
||||
appended = False
|
||||
for chunk in chunks[-1:]:
|
||||
candidate_images = chunk.image_urls + [image]
|
||||
if self._messages_size(chunk.segments, candidate_images, **kwargs) <= self.max_bytes:
|
||||
chunk.image_urls = candidate_images
|
||||
appended = True
|
||||
break
|
||||
|
||||
if appended:
|
||||
continue
|
||||
|
||||
if self._messages_size([], [image], **kwargs) > self.max_bytes:
|
||||
raise ValueError("single image payload exceeds max_bytes")
|
||||
chunks.append(ChunkPayload(segments=[], image_urls=[image]))
|
||||
return chunks
|
||||
|
||||
chunk_count = len(chunks)
|
||||
total_images = len(image_urls)
|
||||
for idx, image in enumerate(image_urls):
|
||||
preferred_idx = min(chunk_count - 1, (idx * chunk_count) // total_images)
|
||||
placed = False
|
||||
|
||||
for chunk_idx in range(preferred_idx, len(chunks)):
|
||||
chunk = chunks[chunk_idx]
|
||||
candidate_images = chunk.image_urls + [image]
|
||||
if self._messages_size(chunk.segments, candidate_images, **kwargs) <= self.max_bytes:
|
||||
chunk.image_urls = candidate_images
|
||||
placed = True
|
||||
break
|
||||
|
||||
if placed:
|
||||
continue
|
||||
|
||||
if self._messages_size([], [image], **kwargs) > self.max_bytes:
|
||||
raise ValueError("single image payload exceeds max_bytes")
|
||||
chunks.append(ChunkPayload(segments=[], image_urls=[image]))
|
||||
|
||||
return chunks
|
||||
|
||||
def group_texts_by_budget(self, texts: List[str], build_messages: Callable, **kwargs) -> List[List[str]]:
|
||||
groups: List[List[str]] = []
|
||||
idx = 0
|
||||
while idx < len(texts):
|
||||
group: List[str] = []
|
||||
while idx < len(texts):
|
||||
candidate = group + [texts[idx]]
|
||||
try:
|
||||
messages = build_messages(candidate, [], **kwargs)
|
||||
except TypeError:
|
||||
messages = build_messages(candidate, **kwargs)
|
||||
size = self.estimate(messages)
|
||||
if size <= self.max_bytes:
|
||||
group = candidate
|
||||
idx += 1
|
||||
continue
|
||||
if not group:
|
||||
raise ValueError("single text block exceeds max_bytes")
|
||||
break
|
||||
groups.append(group)
|
||||
return groups
|
||||
@@ -1,8 +1,16 @@
|
||||
from app.gpt.base import GPT
|
||||
from app.gpt.prompt_builder import generate_base_prompt
|
||||
from app.models.gpt_model import GPTSource
|
||||
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK
|
||||
import os
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK, MERGE_PROMPT
|
||||
from app.gpt.utils import fix_markdown
|
||||
from app.gpt.request_chunker import RequestChunker
|
||||
from app.models.transcriber_model import TranscriptSegment
|
||||
from datetime import timedelta
|
||||
from typing import List
|
||||
@@ -15,6 +23,12 @@ class UniversalGPT(GPT):
|
||||
self.temperature = temperature
|
||||
self.screenshot = False
|
||||
self.link = False
|
||||
self.max_request_bytes = int(os.getenv("OPENAI_MAX_REQUEST_BYTES", str(45 * 1024 * 1024)))
|
||||
self.checkpoint_dir = Path(os.getenv("NOTE_OUTPUT_DIR", "note_results"))
|
||||
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 初始化时缓存重试配置,避免每次请求重复读取环境变量
|
||||
self._max_retry_attempts = max(1, int(os.getenv("OPENAI_RETRY_ATTEMPTS", "3")))
|
||||
self._retry_base_backoff = float(os.getenv("OPENAI_RETRY_BACKOFF_SECONDS", "1.5"))
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
return str(timedelta(seconds=int(seconds)))[2:]
|
||||
@@ -40,7 +54,7 @@ class UniversalGPT(GPT):
|
||||
)
|
||||
|
||||
# ⛳ 组装 content 数组,支持 text + image_url 混合
|
||||
content = [{"type": "text", "text": content_text}]
|
||||
content: List[dict] = [{"type": "text", "text": content_text}]
|
||||
video_img_urls = kwargs.get('video_img_urls', [])
|
||||
|
||||
for url in video_img_urls:
|
||||
@@ -63,23 +77,231 @@ class UniversalGPT(GPT):
|
||||
def list_models(self):
|
||||
return self.client.models.list()
|
||||
|
||||
def _estimate_messages_bytes(self, messages: list) -> int:
|
||||
import json
|
||||
return len(json.dumps(messages, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def _build_merge_messages(self, partials: list) -> list:
|
||||
merge_text = MERGE_PROMPT + "\n\n" + "\n\n---\n\n".join(partials)
|
||||
return [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": merge_text}]
|
||||
}]
|
||||
|
||||
def _checkpoint_path(self, checkpoint_key: str) -> Path:
|
||||
safe_key = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in checkpoint_key)
|
||||
return self.checkpoint_dir / f"{safe_key}.gpt.checkpoint.json"
|
||||
|
||||
def _build_source_signature(self, source: GPTSource) -> str:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"temperature": self.temperature,
|
||||
"max_request_bytes": self.max_request_bytes,
|
||||
"title": source.title,
|
||||
"tags": source.tags,
|
||||
"format": source._format,
|
||||
"style": source.style,
|
||||
"extras": source.extras,
|
||||
"video_img_urls": source.video_img_urls or [],
|
||||
"segments": [
|
||||
{
|
||||
"start": getattr(seg, "start", None),
|
||||
"end": getattr(seg, "end", None),
|
||||
"text": getattr(seg, "text", "")
|
||||
}
|
||||
for seg in source.segment
|
||||
],
|
||||
}
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
def _load_checkpoint(self, checkpoint_key: str, source_signature: str) -> dict | None:
|
||||
path = self._checkpoint_path(checkpoint_key)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("source_signature") != source_signature:
|
||||
path.unlink(missing_ok=True)
|
||||
return None
|
||||
return data
|
||||
except Exception:
|
||||
path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
def _save_checkpoint(self, checkpoint_key: str, source_signature: str, partials: list, phase: str) -> None:
|
||||
path = self._checkpoint_path(checkpoint_key)
|
||||
data = {
|
||||
"version": 1,
|
||||
"source_signature": source_signature,
|
||||
"phase": phase,
|
||||
"partials": partials,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
tmp_path = path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
|
||||
def _clear_checkpoint(self, checkpoint_key: str) -> None:
|
||||
self._checkpoint_path(checkpoint_key).unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _is_insufficient_quota_error(exc: Exception) -> bool:
|
||||
raw = str(exc)
|
||||
return (
|
||||
"insufficient_user_quota" in raw
|
||||
or "预扣费额度失败" in raw
|
||||
or "insufficient quota" in raw.lower()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable_error(exc: Exception) -> bool:
|
||||
raw = str(exc).lower()
|
||||
retryable_tokens = (
|
||||
"error code: 524",
|
||||
"bad_response_status_code",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"rate limit",
|
||||
"error code: 429",
|
||||
"error code: 500",
|
||||
"error code: 502",
|
||||
"error code: 503",
|
||||
"error code: 504",
|
||||
"apiconnectionerror",
|
||||
"connection error",
|
||||
"service unavailable",
|
||||
)
|
||||
if any(token in raw for token in retryable_tokens):
|
||||
return True
|
||||
|
||||
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
||||
return status in {408, 409, 429, 500, 502, 503, 504, 524}
|
||||
|
||||
def _chat_completion_create(self, messages: list):
|
||||
last_exc = None
|
||||
for attempt in range(self._max_retry_attempts):
|
||||
try:
|
||||
return self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=self.temperature
|
||||
)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt == self._max_retry_attempts - 1 or not self._is_retryable_error(exc):
|
||||
raise
|
||||
sleep_seconds = self._retry_base_backoff * (2 ** attempt)
|
||||
time.sleep(sleep_seconds)
|
||||
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
raise RuntimeError("chat completion failed without exception")
|
||||
|
||||
def _merge_partials(self, partials: list, checkpoint_key: str | None, source_signature: str | None) -> str:
|
||||
def build_messages(texts, *_args, **_kwargs):
|
||||
return self._build_merge_messages(texts)
|
||||
|
||||
merge_chunker = RequestChunker(
|
||||
lambda *_args, **_kwargs: [],
|
||||
self.max_request_bytes,
|
||||
self._estimate_messages_bytes
|
||||
)
|
||||
|
||||
current_partials = list(partials)
|
||||
while len(current_partials) > 1:
|
||||
groups = merge_chunker.group_texts_by_budget(current_partials, build_messages)
|
||||
new_partials = []
|
||||
for group_idx, group in enumerate(groups):
|
||||
messages = build_messages(group)
|
||||
try:
|
||||
response = self._chat_completion_create(messages)
|
||||
except Exception as exc:
|
||||
if checkpoint_key and source_signature:
|
||||
self._save_checkpoint(checkpoint_key, source_signature, current_partials, "merge")
|
||||
raise
|
||||
|
||||
new_partials.append(response.choices[0].message.content.strip())
|
||||
|
||||
if checkpoint_key and source_signature:
|
||||
remaining_partials = []
|
||||
for remaining_group in groups[group_idx + 1:]:
|
||||
remaining_partials.extend(remaining_group)
|
||||
resumable_partials = new_partials + remaining_partials
|
||||
self._save_checkpoint(checkpoint_key, source_signature, resumable_partials, "merge")
|
||||
|
||||
current_partials = new_partials
|
||||
|
||||
return current_partials[0]
|
||||
|
||||
def summarize(self, source: GPTSource) -> str:
|
||||
self.screenshot = source.screenshot
|
||||
self.link = source.link
|
||||
source.segment = self.ensure_segments_type(source.segment)
|
||||
checkpoint_key = source.checkpoint_key
|
||||
source_signature = self._build_source_signature(source) if checkpoint_key else None
|
||||
|
||||
messages = self.create_messages(
|
||||
source.segment,
|
||||
title=source.title,
|
||||
tags=source.tags,
|
||||
video_img_urls=source.video_img_urls,
|
||||
_format=source._format,
|
||||
style=source.style,
|
||||
extras=source.extras
|
||||
)
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=0.7
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
def message_builder(segments, image_urls, **kwargs):
|
||||
return self.create_messages(segments, video_img_urls=image_urls, **kwargs)
|
||||
|
||||
chunker = RequestChunker(message_builder, self.max_request_bytes, self._estimate_messages_bytes)
|
||||
|
||||
try:
|
||||
chunks = chunker.chunk(
|
||||
source.segment,
|
||||
source.video_img_urls or [],
|
||||
title=source.title,
|
||||
tags=source.tags,
|
||||
_format=source._format,
|
||||
style=source.style,
|
||||
extras=source.extras
|
||||
)
|
||||
except ValueError:
|
||||
chunks = chunker.chunk(
|
||||
source.segment,
|
||||
[],
|
||||
title=source.title,
|
||||
tags=source.tags,
|
||||
_format=source._format,
|
||||
style=source.style,
|
||||
extras=source.extras
|
||||
)
|
||||
|
||||
partials = []
|
||||
if checkpoint_key and source_signature:
|
||||
checkpoint = self._load_checkpoint(checkpoint_key, source_signature)
|
||||
if checkpoint and isinstance(checkpoint.get("partials"), list):
|
||||
partials = checkpoint["partials"]
|
||||
|
||||
if len(partials) > len(chunks):
|
||||
partials = []
|
||||
|
||||
for chunk in chunks[len(partials):]:
|
||||
messages = self.create_messages(
|
||||
chunk.segments,
|
||||
title=source.title,
|
||||
tags=source.tags,
|
||||
video_img_urls=chunk.image_urls,
|
||||
_format=source._format,
|
||||
style=source.style,
|
||||
extras=source.extras
|
||||
)
|
||||
try:
|
||||
response = self._chat_completion_create(messages)
|
||||
except Exception as exc:
|
||||
if checkpoint_key and source_signature:
|
||||
self._save_checkpoint(checkpoint_key, source_signature, partials, "summarize")
|
||||
raise
|
||||
|
||||
partials.append(response.choices[0].message.content.strip())
|
||||
if checkpoint_key and source_signature:
|
||||
self._save_checkpoint(checkpoint_key, source_signature, partials, "summarize")
|
||||
|
||||
if len(partials) == 1:
|
||||
if checkpoint_key:
|
||||
self._clear_checkpoint(checkpoint_key)
|
||||
return partials[0]
|
||||
merged = self._merge_partials(partials, checkpoint_key, source_signature)
|
||||
if checkpoint_key:
|
||||
self._clear_checkpoint(checkpoint_key)
|
||||
return merged
|
||||
|
||||
@@ -15,4 +15,5 @@ class GPTSource:
|
||||
extras: Optional[str] = None
|
||||
_format: Optional[list] = None
|
||||
video_img_urls: Optional[list] = None
|
||||
checkpoint_key: Optional[str] = None
|
||||
|
||||
|
||||
101
backend/app/routers/chat.py
Normal file
101
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.chat_service import chat as chat_service
|
||||
from app.services.vector_store import VectorStoreManager
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.response import ResponseWrapper as R
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 索引状态追踪: task_id -> "indexing" | "indexed" | "failed"
|
||||
_index_status: dict[str, str] = {}
|
||||
|
||||
|
||||
class IndexRequest(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class AskRequest(BaseModel):
|
||||
task_id: str
|
||||
question: str
|
||||
history: list[ChatMessage] = []
|
||||
provider_id: str
|
||||
model_name: str
|
||||
|
||||
|
||||
def _do_index(task_id: str):
|
||||
"""后台执行索引任务。"""
|
||||
try:
|
||||
_index_status[task_id] = "indexing"
|
||||
store = VectorStoreManager()
|
||||
store.index_task(task_id)
|
||||
_index_status[task_id] = "indexed"
|
||||
logger.info(f"索引完成: {task_id}")
|
||||
except Exception as e:
|
||||
_index_status[task_id] = "failed"
|
||||
logger.error(f"索引失败: {task_id}, {e}")
|
||||
|
||||
|
||||
@router.post("/chat/index")
|
||||
def index_task(data: IndexRequest, background_tasks: BackgroundTasks):
|
||||
"""触发后台索引,立即返回。"""
|
||||
if _index_status.get(data.task_id) == "indexing":
|
||||
return R.success(msg="正在索引中")
|
||||
|
||||
# 如果已经索引过,直接返回
|
||||
store = VectorStoreManager()
|
||||
if store.is_indexed(data.task_id):
|
||||
_index_status[data.task_id] = "indexed"
|
||||
return R.success(msg="已完成索引")
|
||||
|
||||
_index_status[data.task_id] = "indexing"
|
||||
background_tasks.add_task(_do_index, data.task_id)
|
||||
return R.success(msg="开始索引")
|
||||
|
||||
|
||||
@router.get("/chat/status")
|
||||
def chat_status(task_id: str):
|
||||
"""返回索引状态:idle / indexing / indexed / failed。"""
|
||||
try:
|
||||
# 优先检查内存状态
|
||||
status = _index_status.get(task_id)
|
||||
if status:
|
||||
return R.success(data={"status": status, "indexed": status == "indexed"})
|
||||
|
||||
# 内存没有记录,检查持久化
|
||||
store = VectorStoreManager()
|
||||
indexed = store.is_indexed(task_id)
|
||||
if indexed:
|
||||
_index_status[task_id] = "indexed"
|
||||
return R.success(data={"status": "indexed" if indexed else "idle", "indexed": indexed})
|
||||
except Exception as e:
|
||||
logger.error(f"查询索引状态失败: {e}")
|
||||
return R.success(data={"status": "idle", "indexed": False})
|
||||
|
||||
|
||||
@router.post("/chat/ask")
|
||||
def ask_question(data: AskRequest):
|
||||
"""基于笔记内容的 RAG 问答。"""
|
||||
try:
|
||||
history = [{"role": m.role, "content": m.content} for m in data.history]
|
||||
result = chat_service(
|
||||
task_id=data.task_id,
|
||||
question=data.question,
|
||||
history=history,
|
||||
provider_id=data.provider_id,
|
||||
model_name=data.model_name,
|
||||
)
|
||||
return R.success(data=result)
|
||||
except ValueError as e:
|
||||
return R.error(msg=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Chat 问答失败: {e}", exc_info=True)
|
||||
return R.error(msg=f"问答失败: {str(e)}")
|
||||
@@ -1,13 +1,23 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.utils.response import ResponseWrapper as R
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.path_helper import get_model_dir
|
||||
|
||||
from app.services.cookie_manager import CookieConfigManager
|
||||
from app.services.transcriber_config_manager import TranscriberConfigManager
|
||||
from ffmpeg_helper import ensure_ffmpeg_or_raise
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
cookie_manager = CookieConfigManager()
|
||||
transcriber_config_manager = TranscriberConfigManager()
|
||||
|
||||
|
||||
class CookieUpdateRequest(BaseModel):
|
||||
@@ -32,6 +42,165 @@ def update_cookie(data: CookieUpdateRequest):
|
||||
|
||||
)
|
||||
|
||||
class TranscriberConfigRequest(BaseModel):
|
||||
transcriber_type: str
|
||||
whisper_model_size: Optional[str] = None
|
||||
|
||||
|
||||
AVAILABLE_TRANSCRIBER_TYPES = [
|
||||
{"value": "fast-whisper", "label": "Faster Whisper(本地)"},
|
||||
{"value": "bcut", "label": "必剪(在线)"},
|
||||
{"value": "kuaishou", "label": "快手(在线)"},
|
||||
{"value": "groq", "label": "Groq(在线)"},
|
||||
{"value": "mlx-whisper", "label": "MLX Whisper(仅macOS)"},
|
||||
]
|
||||
|
||||
WHISPER_MODEL_SIZES = ["tiny", "base", "small", "medium", "large-v3", "large-v3-turbo"]
|
||||
|
||||
|
||||
@router.get("/transcriber_config")
|
||||
def get_transcriber_config():
|
||||
from app.transcriber.transcriber_provider import MLX_WHISPER_AVAILABLE
|
||||
|
||||
config = transcriber_config_manager.get_config()
|
||||
return R.success(data={
|
||||
**config,
|
||||
"available_types": AVAILABLE_TRANSCRIBER_TYPES,
|
||||
"whisper_model_sizes": WHISPER_MODEL_SIZES,
|
||||
"mlx_whisper_available": MLX_WHISPER_AVAILABLE,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/transcriber_config")
|
||||
def update_transcriber_config(data: TranscriberConfigRequest):
|
||||
config = transcriber_config_manager.update_config(
|
||||
transcriber_type=data.transcriber_type,
|
||||
whisper_model_size=data.whisper_model_size,
|
||||
)
|
||||
return R.success(data=config)
|
||||
|
||||
|
||||
# ---- Whisper 模型下载状态 & 下载触发 ----
|
||||
|
||||
# 用于跟踪正在进行的下载任务
|
||||
_downloading: dict[str, str] = {} # model_size -> status ("downloading" | "done" | "failed")
|
||||
|
||||
|
||||
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
|
||||
"""检查指定 whisper 模型是否已下载到本地。"""
|
||||
model_dir = get_model_dir(subdir)
|
||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
||||
return Path(model_path).exists()
|
||||
|
||||
|
||||
@router.get("/transcriber_models_status")
|
||||
def get_transcriber_models_status():
|
||||
"""返回所有 whisper 模型的下载状态。"""
|
||||
statuses = []
|
||||
for size in WHISPER_MODEL_SIZES:
|
||||
downloaded = _check_whisper_model_exists(size, "whisper")
|
||||
download_status = _downloading.get(size)
|
||||
statuses.append({
|
||||
"model_size": size,
|
||||
"downloaded": downloaded,
|
||||
"downloading": download_status == "downloading",
|
||||
})
|
||||
|
||||
# 也检查 mlx-whisper(仅 macOS)
|
||||
mlx_available = platform.system() == "Darwin"
|
||||
mlx_statuses = []
|
||||
if mlx_available:
|
||||
for size in WHISPER_MODEL_SIZES:
|
||||
mlx_key = f"mlx-{size}"
|
||||
model_dir = get_model_dir("mlx-whisper")
|
||||
model_path = os.path.join(model_dir, f"mlx-community/whisper-{size}")
|
||||
downloaded = Path(model_path).exists()
|
||||
mlx_statuses.append({
|
||||
"model_size": size,
|
||||
"downloaded": downloaded,
|
||||
"downloading": _downloading.get(mlx_key) == "downloading",
|
||||
})
|
||||
|
||||
return R.success(data={
|
||||
"whisper": statuses,
|
||||
"mlx_whisper": mlx_statuses,
|
||||
"mlx_available": mlx_available,
|
||||
})
|
||||
|
||||
|
||||
class ModelDownloadRequest(BaseModel):
|
||||
model_size: str
|
||||
transcriber_type: str = "fast-whisper" # "fast-whisper" 或 "mlx-whisper"
|
||||
|
||||
|
||||
def _do_download_whisper(model_size: str):
|
||||
"""后台下载 faster-whisper 模型。"""
|
||||
from app.transcriber.whisper import MODEL_MAP
|
||||
from modelscope import snapshot_download
|
||||
|
||||
try:
|
||||
_downloading[model_size] = "downloading"
|
||||
model_dir = get_model_dir("whisper")
|
||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
||||
if Path(model_path).exists():
|
||||
_downloading[model_size] = "done"
|
||||
return
|
||||
repo_id = MODEL_MAP.get(model_size)
|
||||
if not repo_id:
|
||||
_downloading[model_size] = "failed"
|
||||
return
|
||||
logger.info(f"开始下载 whisper 模型: {model_size}")
|
||||
snapshot_download(repo_id, local_dir=model_path)
|
||||
logger.info(f"whisper 模型下载完成: {model_size}")
|
||||
_downloading[model_size] = "done"
|
||||
except Exception as e:
|
||||
logger.error(f"whisper 模型下载失败: {model_size}, {e}")
|
||||
_downloading[model_size] = "failed"
|
||||
|
||||
|
||||
def _do_download_mlx_whisper(model_size: str):
|
||||
"""后台下载 mlx-whisper 模型。"""
|
||||
key = f"mlx-{model_size}"
|
||||
try:
|
||||
_downloading[key] = "downloading"
|
||||
from huggingface_hub import snapshot_download as hf_download
|
||||
|
||||
model_dir = get_model_dir("mlx-whisper")
|
||||
model_name = f"mlx-community/whisper-{model_size}"
|
||||
model_path = os.path.join(model_dir, model_name)
|
||||
if Path(model_path).exists():
|
||||
_downloading[key] = "done"
|
||||
return
|
||||
logger.info(f"开始下载 mlx-whisper 模型: {model_size}")
|
||||
hf_download(model_name, local_dir=model_path, local_dir_use_symlinks=False)
|
||||
logger.info(f"mlx-whisper 模型下载完成: {model_size}")
|
||||
_downloading[key] = "done"
|
||||
except Exception as e:
|
||||
logger.error(f"mlx-whisper 模型下载失败: {model_size}, {e}")
|
||||
_downloading[key] = "failed"
|
||||
|
||||
|
||||
@router.post("/transcriber_download")
|
||||
def download_transcriber_model(data: ModelDownloadRequest, background_tasks: BackgroundTasks):
|
||||
"""触发后台下载指定的 whisper 模型。"""
|
||||
if data.model_size not in WHISPER_MODEL_SIZES:
|
||||
return R.error(msg=f"不支持的模型大小: {data.model_size}")
|
||||
|
||||
if data.transcriber_type == "mlx-whisper":
|
||||
if platform.system() != "Darwin":
|
||||
return R.error(msg="MLX Whisper 仅支持 macOS")
|
||||
key = f"mlx-{data.model_size}"
|
||||
if _downloading.get(key) == "downloading":
|
||||
return R.success(msg="模型正在下载中")
|
||||
background_tasks.add_task(_do_download_mlx_whisper, data.model_size)
|
||||
else:
|
||||
if _downloading.get(data.model_size) == "downloading":
|
||||
return R.success(msg="模型正在下载中")
|
||||
background_tasks.add_task(_do_download_whisper, data.model_size)
|
||||
|
||||
return R.success(msg="模型下载已开始")
|
||||
|
||||
|
||||
@router.get("/sys_health")
|
||||
async def sys_health():
|
||||
try:
|
||||
@@ -42,4 +211,38 @@ async def sys_health():
|
||||
|
||||
@router.get("/sys_check")
|
||||
async def sys_check():
|
||||
return R.success()
|
||||
return R.success()
|
||||
|
||||
|
||||
@router.get("/deploy_status")
|
||||
async def deploy_status():
|
||||
"""返回部署监控所需的所有状态信息"""
|
||||
import torch
|
||||
import os
|
||||
|
||||
# CUDA 状态
|
||||
cuda_available = torch.cuda.is_available()
|
||||
cuda_info = {
|
||||
"available": cuda_available,
|
||||
"version": torch.version.cuda if cuda_available else None,
|
||||
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
|
||||
}
|
||||
|
||||
# Whisper 模型状态(从配置文件读取,与前端设置同步)
|
||||
transcriber_cfg = transcriber_config_manager.get_config()
|
||||
model_size = transcriber_cfg["whisper_model_size"]
|
||||
transcriber_type = transcriber_cfg["transcriber_type"]
|
||||
|
||||
# FFmpeg 状态
|
||||
try:
|
||||
ensure_ffmpeg_or_raise()
|
||||
ffmpeg_ok = True
|
||||
except:
|
||||
ffmpeg_ok = False
|
||||
|
||||
return R.success(data={
|
||||
"backend": {"status": "running", "port": int(os.getenv("BACKEND_PORT", 8483))},
|
||||
"cuda": cuda_info,
|
||||
"whisper": {"model_size": model_size, "transcriber_type": transcriber_type},
|
||||
"ffmpeg": {"available": ffmpeg_ok},
|
||||
})
|
||||
@@ -15,6 +15,7 @@ from app.enmus.exception import NoteErrorEnum
|
||||
from app.enmus.note_enums import DownloadQuality
|
||||
from app.exceptions.note import NoteError
|
||||
from app.services.note import NoteGenerator, logger
|
||||
from app.services.task_serial_executor import task_serial_executor
|
||||
from app.utils.response import ResponseWrapper as R
|
||||
from app.utils.url_parser import extract_video_id
|
||||
from app.validators.video_url_validator import is_supported_video_url
|
||||
@@ -82,28 +83,38 @@ def run_note_task(task_id: str, video_url: str, platform: str, quality: Download
|
||||
if not model_name or not provider_id:
|
||||
raise HTTPException(status_code=400, detail="请选择模型和提供者")
|
||||
|
||||
note = NoteGenerator().generate(
|
||||
video_url=video_url,
|
||||
platform=platform,
|
||||
quality=quality,
|
||||
task_id=task_id,
|
||||
model_name=model_name,
|
||||
provider_id=provider_id,
|
||||
link=link,
|
||||
_format=_format,
|
||||
style=style,
|
||||
extras=extras,
|
||||
screenshot=screenshot
|
||||
, video_understanding=video_understanding,
|
||||
video_interval=video_interval,
|
||||
grid_size=grid_size
|
||||
)
|
||||
def _execute_note_task():
|
||||
return NoteGenerator().generate(
|
||||
video_url=video_url,
|
||||
platform=platform,
|
||||
quality=quality,
|
||||
task_id=task_id,
|
||||
model_name=model_name,
|
||||
provider_id=provider_id,
|
||||
link=link,
|
||||
_format=_format,
|
||||
style=style,
|
||||
extras=extras,
|
||||
screenshot=screenshot,
|
||||
video_understanding=video_understanding,
|
||||
video_interval=video_interval,
|
||||
grid_size=grid_size,
|
||||
)
|
||||
|
||||
logger.info(f"任务进入执行队列 (task_id={task_id})")
|
||||
note = task_serial_executor.run(_execute_note_task)
|
||||
logger.info(f"Note generated: {task_id}")
|
||||
if not note or not note.markdown:
|
||||
logger.warning(f"任务 {task_id} 执行失败,跳过保存")
|
||||
return
|
||||
save_note_to_file(task_id, note)
|
||||
|
||||
# 自动建立向量索引(用于 AI 问答),失败不影响笔记生成
|
||||
try:
|
||||
from app.services.vector_store import VectorStoreManager
|
||||
VectorStoreManager().index_task(task_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"向量索引失败(不影响笔记): {e}")
|
||||
|
||||
|
||||
@router.post('/delete_task')
|
||||
@@ -144,13 +155,14 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
|
||||
if data.task_id:
|
||||
# 如果传了task_id,说明是重试!
|
||||
task_id = data.task_id
|
||||
# 更新之前的状态
|
||||
NoteGenerator()._update_status(task_id, TaskStatus.PENDING)
|
||||
logger.info(f"重试模式,复用已有 task_id={task_id}")
|
||||
else:
|
||||
# 正常新建任务
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 统一先写入 PENDING,表示已进入队列等待串行执行
|
||||
NoteGenerator()._update_status(task_id, TaskStatus.PENDING)
|
||||
|
||||
background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality, data.link,
|
||||
data.screenshot, data.model_name, data.provider_id, data.format, data.style,
|
||||
data.extras, data.video_understanding, data.video_interval, data.grid_size)
|
||||
|
||||
@@ -77,11 +77,14 @@ def update_provider(data: ProviderUpdateRequest):
|
||||
):
|
||||
return R.error(msg='请至少填写一个参数')
|
||||
|
||||
provider_id =ProviderService.update_provider(
|
||||
updated_provider =ProviderService.update_provider(
|
||||
id=data.id,
|
||||
data=dict(data)
|
||||
)
|
||||
return R.success(msg='更新模型供应商成功',data={'id': provider_id})
|
||||
if updated_provider:
|
||||
return R.success(msg='更新模型供应商成功', data=updated_provider)
|
||||
else:
|
||||
return R.error(msg='更新模型供应商失败')
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return R.error(msg=str(e))
|
||||
|
||||
158
backend/app/services/chat_service.py
Normal file
158
backend/app/services/chat_service.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.gpt.gpt_factory import GPTFactory
|
||||
from app.models.model_config import ModelConfig
|
||||
from app.services.provider import ProviderService
|
||||
from app.services.vector_store import VectorStoreManager
|
||||
from app.services.chat_tools import TOOLS, execute_tool
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SYSTEM_PROMPT = """你是一个视频笔记问答助手。你拥有以下能力:
|
||||
|
||||
1. 系统已自动检索了一些相关内容作为初始参考(见下方)
|
||||
2. 你可以调用工具主动查询更多信息:
|
||||
- lookup_transcript: 查询视频原始转录文本(支持按时间、关键词、位置筛选)
|
||||
- get_video_info: 获取视频元信息(标题、作者、简介、标签等)
|
||||
- get_note_content: 获取完整笔记内容
|
||||
|
||||
--- 初始检索内容 ---
|
||||
{context}
|
||||
---
|
||||
|
||||
回答要求:
|
||||
- 如果初始检索内容不足以回答问题,请主动调用工具获取更多信息
|
||||
- 回答关于视频具体原话、细节时,用 lookup_transcript 查询原文
|
||||
- 回答关于作者、标题等基本信息时,用 get_video_info 查询
|
||||
- 请用中文回答,保持简洁准确"""
|
||||
|
||||
|
||||
def _build_context(chunks: list[dict]) -> str:
|
||||
"""将检索到的片段拼接为上下文文本。"""
|
||||
parts = []
|
||||
for chunk in chunks:
|
||||
meta = chunk.get("metadata", {})
|
||||
source_type = meta.get("source_type", "unknown")
|
||||
if source_type == "meta":
|
||||
label = "[视频信息]"
|
||||
elif source_type == "markdown":
|
||||
label = f"[笔记 - {meta.get('section_title', '')}]"
|
||||
else:
|
||||
start = meta.get("start_time", 0)
|
||||
end = meta.get("end_time", 0)
|
||||
label = f"[转录 - {start:.0f}s~{end:.0f}s]"
|
||||
parts.append(f"{label}\n{chunk['text']}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _build_sources(chunks: list[dict]) -> list[dict]:
|
||||
"""从检索片段中提取来源信息。"""
|
||||
sources = []
|
||||
for chunk in chunks:
|
||||
meta = chunk.get("metadata", {})
|
||||
source = {
|
||||
"text": chunk["text"][:200],
|
||||
"source_type": meta.get("source_type", "unknown"),
|
||||
}
|
||||
if meta.get("section_title"):
|
||||
source["section_title"] = meta["section_title"]
|
||||
if meta.get("start_time") is not None:
|
||||
source["start_time"] = meta["start_time"]
|
||||
if meta.get("end_time") is not None:
|
||||
source["end_time"] = meta["end_time"]
|
||||
sources.append(source)
|
||||
return sources
|
||||
|
||||
|
||||
def chat(
|
||||
task_id: str,
|
||||
question: str,
|
||||
history: list[dict],
|
||||
provider_id: str,
|
||||
model_name: str,
|
||||
) -> dict:
|
||||
"""
|
||||
RAG + Tool Calling 问答。
|
||||
1. 向量检索初始上下文
|
||||
2. 调用 LLM(带 tools)
|
||||
3. 如果 LLM 调用了工具,执行工具并将结果返回给 LLM
|
||||
4. 循环直到 LLM 给出最终回答
|
||||
"""
|
||||
vector_store = VectorStoreManager()
|
||||
|
||||
# 1. 检索初始上下文
|
||||
chunks = vector_store.query(task_id, question, n_results=6)
|
||||
context = _build_context(chunks) if chunks else "(未检索到相关内容,请使用工具查询)"
|
||||
sources = _build_sources(chunks) if chunks else []
|
||||
|
||||
# 2. 构建消息
|
||||
system_msg = SYSTEM_PROMPT.format(context=context)
|
||||
messages = [{"role": "system", "content": system_msg}]
|
||||
|
||||
for msg in history[-20:]:
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
|
||||
messages.append({"role": "user", "content": question})
|
||||
|
||||
# 3. 获取 LLM client
|
||||
provider = ProviderService.get_provider_by_id(provider_id)
|
||||
if not provider:
|
||||
raise ValueError(f"未找到模型供应商: {provider_id}")
|
||||
|
||||
config = ModelConfig(
|
||||
api_key=provider["api_key"],
|
||||
base_url=provider["base_url"],
|
||||
model_name=model_name,
|
||||
provider=provider["type"],
|
||||
name=provider["name"],
|
||||
)
|
||||
gpt = GPTFactory.from_config(config)
|
||||
|
||||
logger.info(f"Chat: task_id={task_id}, model={model_name}")
|
||||
|
||||
# 4. Tool calling 循环(最多 3 轮)
|
||||
max_rounds = 3
|
||||
for round_i in range(max_rounds):
|
||||
response = gpt.client.chat.completions.create(
|
||||
model=gpt.model,
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
msg = response.choices[0].message
|
||||
|
||||
# 没有工具调用,直接返回
|
||||
if not msg.tool_calls:
|
||||
return {"answer": msg.content or "", "sources": sources}
|
||||
|
||||
# 处理工具调用
|
||||
messages.append(msg)
|
||||
|
||||
for tool_call in msg.tool_calls:
|
||||
fn_name = tool_call.function.name
|
||||
try:
|
||||
fn_args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
fn_args = {}
|
||||
|
||||
logger.info(f"Tool call [{round_i+1}/{max_rounds}]: {fn_name}({fn_args})")
|
||||
|
||||
result = execute_tool(task_id, fn_name, fn_args)
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": result,
|
||||
})
|
||||
|
||||
# 超过最大轮次,做最后一次不带 tools 的调用
|
||||
response = gpt.client.chat.completions.create(
|
||||
model=gpt.model,
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
return {"answer": response.choices[0].message.content or "", "sources": sources}
|
||||
184
backend/app/services/chat_tools.py
Normal file
184
backend/app/services/chat_tools.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Chat function calling 工具定义与执行。
|
||||
提供给 LLM 调用,用于主动查询视频原文、笔记、元信息。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
NOTE_OUTPUT_DIR = os.getenv("NOTE_OUTPUT_DIR", "note_results")
|
||||
|
||||
|
||||
def _load_note_data(task_id: str) -> Optional[dict]:
|
||||
path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ── 工具定义(OpenAI function calling 格式)──────────────────────
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_transcript",
|
||||
"description": "查询视频原始转录文本。可按时间范围筛选、按关键词搜索、或获取指定位置的内容。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_time": {
|
||||
"type": "number",
|
||||
"description": "起始时间(秒),例如 0 表示视频开头,60 表示第1分钟",
|
||||
},
|
||||
"end_time": {
|
||||
"type": "number",
|
||||
"description": "结束时间(秒),不传则到末尾",
|
||||
},
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": "搜索关键词,返回包含该关键词的转录片段",
|
||||
},
|
||||
"position": {
|
||||
"type": "string",
|
||||
"enum": ["start", "end"],
|
||||
"description": "快捷位置:start=视频开头前30句,end=视频结尾后30句",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_video_info",
|
||||
"description": "获取视频的完整元信息,包括标题、作者、简介、标签、时长、播放量等。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_note_content",
|
||||
"description": "获取 AI 生成的完整笔记内容(Markdown 格式)。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── 工具执行 ──────────────────────────────────────────────────
|
||||
|
||||
def execute_tool(task_id: str, tool_name: str, arguments: dict) -> str:
|
||||
"""执行工具调用,返回结果字符串。"""
|
||||
data = _load_note_data(task_id)
|
||||
if not data:
|
||||
return json.dumps({"error": "笔记数据不存在"}, ensure_ascii=False)
|
||||
|
||||
if tool_name == "lookup_transcript":
|
||||
return _lookup_transcript(data, arguments)
|
||||
elif tool_name == "get_video_info":
|
||||
return _get_video_info(data)
|
||||
elif tool_name == "get_note_content":
|
||||
return _get_note_content(data)
|
||||
else:
|
||||
return json.dumps({"error": f"未知工具: {tool_name}"}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _lookup_transcript(data: dict, args: dict) -> str:
|
||||
segments = data.get("transcript", {}).get("segments", [])
|
||||
if not segments:
|
||||
return json.dumps({"error": "没有转录数据"}, ensure_ascii=False)
|
||||
|
||||
position = args.get("position")
|
||||
start_time = args.get("start_time")
|
||||
end_time = args.get("end_time")
|
||||
keyword = args.get("keyword", "").strip()
|
||||
|
||||
# 快捷位置
|
||||
if position == "start":
|
||||
filtered = segments[:30]
|
||||
elif position == "end":
|
||||
filtered = segments[-30:]
|
||||
else:
|
||||
filtered = segments
|
||||
|
||||
# 时间筛选
|
||||
if start_time is not None:
|
||||
filtered = [s for s in filtered if s.get("end", 0) >= start_time]
|
||||
if end_time is not None:
|
||||
filtered = [s for s in filtered if s.get("start", 0) <= end_time]
|
||||
|
||||
# 关键词筛选
|
||||
if keyword:
|
||||
filtered = [s for s in filtered if keyword.lower() in s.get("text", "").lower()]
|
||||
|
||||
# 限制返回量,避免 token 爆炸
|
||||
if len(filtered) > 50:
|
||||
filtered = filtered[:50]
|
||||
truncated = True
|
||||
else:
|
||||
truncated = False
|
||||
|
||||
result = {
|
||||
"total_segments": len(data.get("transcript", {}).get("segments", [])),
|
||||
"returned": len(filtered),
|
||||
"truncated": truncated,
|
||||
"segments": [
|
||||
{
|
||||
"start": round(s.get("start", 0), 1),
|
||||
"end": round(s.get("end", 0), 1),
|
||||
"text": s.get("text", ""),
|
||||
}
|
||||
for s in filtered
|
||||
],
|
||||
}
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
def _get_video_info(data: dict) -> str:
|
||||
am = data.get("audio_meta", {})
|
||||
raw = am.get("raw_info", {}) or {}
|
||||
|
||||
info = {
|
||||
"title": am.get("title") or raw.get("title", ""),
|
||||
"uploader": raw.get("uploader", ""),
|
||||
"description": raw.get("description", "")[:1000],
|
||||
"tags": raw.get("tags", [])[:20] if isinstance(raw.get("tags"), list) else [],
|
||||
"duration_seconds": am.get("duration", 0),
|
||||
"platform": am.get("platform", ""),
|
||||
"video_id": am.get("video_id", ""),
|
||||
"url": raw.get("webpage_url", ""),
|
||||
"view_count": raw.get("view_count"),
|
||||
"like_count": raw.get("like_count"),
|
||||
"comment_count": raw.get("comment_count"),
|
||||
}
|
||||
# 去除 None 值
|
||||
info = {k: v for k, v in info.items() if v is not None and v != ""}
|
||||
return json.dumps(info, ensure_ascii=False)
|
||||
|
||||
|
||||
def _get_note_content(data: dict) -> str:
|
||||
md = data.get("markdown", "")
|
||||
if isinstance(md, list):
|
||||
# 多版本,取最新
|
||||
md = md[-1].get("content", "") if md else ""
|
||||
# 限制长度
|
||||
if len(md) > 5000:
|
||||
md = md[:5000] + "\n\n... (内容过长已截断)"
|
||||
return json.dumps({"markdown": md}, ensure_ascii=False)
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Union, Any
|
||||
@@ -32,7 +31,8 @@ from app.services.constant import SUPPORT_PLATFORM_MAP
|
||||
from app.services.provider import ProviderService
|
||||
from app.transcriber.base import Transcriber
|
||||
from app.transcriber.transcriber_provider import get_transcriber, _transcribers
|
||||
from app.utils.note_helper import replace_content_markers
|
||||
from app.utils.note_helper import replace_content_markers, prepend_source_link
|
||||
from app.utils.screenshot_marker import extract_screenshot_timestamps
|
||||
from app.utils.status_code import StatusCode
|
||||
from app.utils.video_helper import generate_screenshot
|
||||
from app.utils.video_reader import VideoReader
|
||||
@@ -66,9 +66,11 @@ class NoteGenerator:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.model_size: str = "base"
|
||||
from app.services.transcriber_config_manager import TranscriberConfigManager
|
||||
config_manager = TranscriberConfigManager()
|
||||
self.model_size: str = config_manager.get_whisper_model_size()
|
||||
self.device: Optional[str] = None
|
||||
self.transcriber_type: str = os.getenv("TRANSCRIBER_TYPE", "fast-whisper")
|
||||
self.transcriber_type: str = config_manager.get_transcriber_type()
|
||||
self.transcriber: Transcriber = self._init_transcriber()
|
||||
self.video_path: Optional[Path] = None
|
||||
self.video_img_urls=[]
|
||||
@@ -131,8 +133,46 @@ class NoteGenerator:
|
||||
audio_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_audio.json"
|
||||
transcript_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_transcript.json"
|
||||
markdown_cache_file = NOTE_OUTPUT_DIR / f"{task_id}_markdown.md"
|
||||
print(audio_cache_file)
|
||||
# 1. 下载音频/视频
|
||||
# 1. 获取字幕/转写:优先缓存 → 平台字幕 → 音频转写
|
||||
transcript = None
|
||||
|
||||
# 尝试读取缓存
|
||||
if transcript_cache_file.exists():
|
||||
logger.info(f"检测到转写缓存 ({transcript_cache_file}),尝试读取")
|
||||
try:
|
||||
data = json.loads(transcript_cache_file.read_text(encoding="utf-8"))
|
||||
segments = [TranscriptSegment(**seg) for seg in data.get("segments", [])]
|
||||
transcript = TranscriptResult(
|
||||
language=data.get("language"),
|
||||
full_text=data["full_text"],
|
||||
segments=segments,
|
||||
)
|
||||
logger.info(f"已从缓存加载转写结果,共 {len(segments)} 段")
|
||||
except Exception as e:
|
||||
logger.warning(f"加载转写缓存失败: {e}")
|
||||
|
||||
# 缓存没有,尝试获取平台字幕
|
||||
if transcript is None:
|
||||
logger.info("尝试获取平台字幕(优先于音频下载)...")
|
||||
try:
|
||||
transcript = downloader.download_subtitles(video_url)
|
||||
if transcript and transcript.segments:
|
||||
logger.info(f"成功获取平台字幕,共 {len(transcript.segments)} 段")
|
||||
transcript_cache_file.write_text(
|
||||
json.dumps(asdict(transcript), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
transcript = None
|
||||
logger.info("平台无可用字幕,将下载音频后转写")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取平台字幕失败: {e},将下载音频后转写")
|
||||
transcript = None
|
||||
|
||||
# 2. 下载音频/视频
|
||||
# 有字幕时只提取元信息,不下载音视频文件(除非需要截图/视频理解)
|
||||
has_transcript = transcript is not None
|
||||
need_full_download = not has_transcript or screenshot or video_understanding
|
||||
audio_meta = self._download_media(
|
||||
downloader=downloader,
|
||||
video_url=video_url,
|
||||
@@ -145,14 +185,19 @@ class NoteGenerator:
|
||||
video_understanding=video_understanding,
|
||||
video_interval=video_interval,
|
||||
grid_size=grid_size,
|
||||
skip_download=not need_full_download,
|
||||
)
|
||||
|
||||
# 2. 转写文字
|
||||
transcript = self._transcribe_audio(
|
||||
audio_file=audio_meta.file_path,
|
||||
transcript_cache_file=transcript_cache_file,
|
||||
status_phase=TaskStatus.TRANSCRIBING,
|
||||
)
|
||||
# 3. 如果前面没拿到字幕,走转写流程
|
||||
if transcript is None:
|
||||
transcript = self._get_transcript(
|
||||
downloader=downloader,
|
||||
video_url=video_url,
|
||||
audio_file=audio_meta.file_path,
|
||||
transcript_cache_file=transcript_cache_file,
|
||||
status_phase=TaskStatus.TRANSCRIBING,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# 3. GPT 总结
|
||||
markdown = self._summarize_text(
|
||||
@@ -178,6 +223,8 @@ class NoteGenerator:
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
markdown = prepend_source_link(markdown, str(video_url))
|
||||
|
||||
# 5. 保存记录到数据库
|
||||
self._update_status(task_id, TaskStatus.SAVING)
|
||||
self._save_metadata(video_id=audio_meta.video_id, platform=platform, task_id=task_id)
|
||||
@@ -323,6 +370,7 @@ class NoteGenerator:
|
||||
video_understanding: bool,
|
||||
video_interval: int,
|
||||
grid_size: List[int],
|
||||
skip_download: bool = False,
|
||||
) -> AudioDownloadResult | None:
|
||||
"""
|
||||
1. 检查音频缓存;若不存在,则根据需要下载音频或视频(若需截图/可视化)。
|
||||
@@ -345,34 +393,6 @@ class NoteGenerator:
|
||||
task_id = audio_cache_file.stem.split("_")[0]
|
||||
self._update_status(task_id, status_phase)
|
||||
|
||||
|
||||
|
||||
# 判断是否需要下载视频
|
||||
need_video = screenshot or video_understanding
|
||||
if need_video:
|
||||
try:
|
||||
logger.info("开始下载视频")
|
||||
video_path_str = downloader.download_video(video_url)
|
||||
self.video_path = Path(video_path_str)
|
||||
logger.info(f"视频下载完成:{self.video_path}")
|
||||
|
||||
# 若指定了 grid_size,则生成缩略图
|
||||
if grid_size:
|
||||
self.video_img_urls=VideoReader(
|
||||
video_path=str(self.video_path),
|
||||
grid_size=tuple(grid_size),
|
||||
frame_interval=video_interval,
|
||||
unit_width=1280,
|
||||
unit_height=720,
|
||||
save_quality=90,
|
||||
).run()
|
||||
else:
|
||||
logger.info("未指定 grid_size,跳过缩略图生成")
|
||||
except Exception as exc:
|
||||
logger.error(f"视频下载失败:{exc}")
|
||||
|
||||
self._handle_exception(task_id, exc)
|
||||
raise
|
||||
# 已有缓存,尝试加载
|
||||
if audio_cache_file.exists():
|
||||
logger.info(f"检测到音频缓存 ({audio_cache_file}),直接读取")
|
||||
@@ -381,6 +401,56 @@ class NoteGenerator:
|
||||
return AudioDownloadResult(**data)
|
||||
except Exception as e:
|
||||
logger.warning(f"读取音频缓存失败,将重新下载:{e}")
|
||||
|
||||
# 有字幕且不需要截图/视频理解时,只提取元信息不下载文件
|
||||
if skip_download:
|
||||
logger.info("已有字幕,仅提取视频元信息(不下载音视频)")
|
||||
try:
|
||||
audio = downloader.download(
|
||||
video_url=video_url,
|
||||
quality=quality,
|
||||
output_dir=output_path,
|
||||
need_video=False,
|
||||
skip_download=True,
|
||||
)
|
||||
audio_cache_file.write_text(
|
||||
json.dumps(asdict(audio), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info(f"元信息提取完成 ({audio_cache_file})")
|
||||
return audio
|
||||
except Exception as exc:
|
||||
logger.warning(f"元信息提取失败,将尝试完整下载: {exc}")
|
||||
|
||||
# 判断是否需要下载视频
|
||||
need_video = screenshot or video_understanding
|
||||
if screenshot and not grid_size:
|
||||
grid_size = [2, 2]
|
||||
|
||||
frame_interval = video_interval if video_interval and video_interval > 0 else 6
|
||||
if need_video:
|
||||
try:
|
||||
logger.info("开始下载视频")
|
||||
video_path_str = downloader.download_video(video_url)
|
||||
self.video_path = Path(video_path_str)
|
||||
logger.info(f"视频下载完成:{self.video_path}")
|
||||
|
||||
if grid_size:
|
||||
self.video_img_urls = VideoReader(
|
||||
video_path=str(self.video_path),
|
||||
grid_size=tuple(grid_size),
|
||||
frame_interval=frame_interval,
|
||||
unit_width=960,
|
||||
unit_height=540,
|
||||
save_quality=80,
|
||||
).run()
|
||||
else:
|
||||
logger.info("未指定 grid_size,跳过缩略图生成")
|
||||
except Exception as exc:
|
||||
logger.error(f"视频下载失败:{exc}")
|
||||
self._handle_exception(task_id, exc)
|
||||
raise
|
||||
|
||||
# 下载音频
|
||||
try:
|
||||
logger.info("开始下载音频")
|
||||
@@ -390,7 +460,6 @@ class NoteGenerator:
|
||||
output_dir=output_path,
|
||||
need_video=need_video,
|
||||
)
|
||||
# 缓存 audio 元信息到本地 JSON
|
||||
audio_cache_file.write_text(json.dumps(asdict(audio), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
logger.info(f"音频下载并缓存成功 ({audio_cache_file})")
|
||||
return audio
|
||||
@@ -400,6 +469,62 @@ class NoteGenerator:
|
||||
raise
|
||||
|
||||
|
||||
def _get_transcript(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
video_url: str,
|
||||
audio_file: str,
|
||||
transcript_cache_file: Path,
|
||||
status_phase: TaskStatus,
|
||||
task_id: Optional[str] = None,
|
||||
) -> TranscriptResult | None:
|
||||
"""
|
||||
优先获取平台字幕,没有则 fallback 到音频转写
|
||||
|
||||
:param downloader: 下载器实例
|
||||
:param video_url: 视频链接
|
||||
:param audio_file: 音频文件路径(用于 fallback 转写)
|
||||
:param transcript_cache_file: 缓存文件路径
|
||||
:param status_phase: 状态枚举
|
||||
:param task_id: 任务 ID
|
||||
:return: TranscriptResult 对象
|
||||
"""
|
||||
self._update_status(task_id, status_phase)
|
||||
|
||||
# 已有缓存,直接返回
|
||||
if transcript_cache_file.exists():
|
||||
logger.info(f"检测到转写缓存 ({transcript_cache_file}),尝试读取")
|
||||
try:
|
||||
data = json.loads(transcript_cache_file.read_text(encoding="utf-8"))
|
||||
segments = [TranscriptSegment(**seg) for seg in data.get("segments", [])]
|
||||
return TranscriptResult(language=data.get("language"), full_text=data["full_text"], segments=segments)
|
||||
except Exception as e:
|
||||
logger.warning(f"加载转写缓存失败,将重新获取:{e}")
|
||||
|
||||
# 1. 先尝试获取平台字幕
|
||||
logger.info("尝试获取平台字幕...")
|
||||
try:
|
||||
transcript = downloader.download_subtitles(video_url)
|
||||
if transcript and transcript.segments:
|
||||
logger.info(f"成功获取平台字幕,共 {len(transcript.segments)} 段")
|
||||
# 缓存结果
|
||||
transcript_cache_file.write_text(
|
||||
json.dumps(asdict(transcript), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
return transcript
|
||||
else:
|
||||
logger.info("平台无可用字幕,将使用音频转写")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取平台字幕失败: {e},将使用音频转写")
|
||||
|
||||
# 2. Fallback 到音频转写
|
||||
return self._transcribe_audio(
|
||||
audio_file=audio_file,
|
||||
transcript_cache_file=transcript_cache_file,
|
||||
status_phase=status_phase,
|
||||
)
|
||||
|
||||
def _transcribe_audio(
|
||||
self,
|
||||
audio_file: str,
|
||||
@@ -480,6 +605,7 @@ class NoteGenerator:
|
||||
_format=formats,
|
||||
style=style,
|
||||
extras=extras,
|
||||
checkpoint_key=task_id,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -532,7 +658,7 @@ class NoteGenerator:
|
||||
:param video_path: 本地视频文件路径
|
||||
:return: 替换后的 Markdown 字符串
|
||||
"""
|
||||
matches: List[Tuple[str, int]] = self._extract_screenshot_timestamps(markdown)
|
||||
matches: List[Tuple[str, int]] = extract_screenshot_timestamps(markdown)
|
||||
for idx, (marker, ts) in enumerate(matches):
|
||||
try:
|
||||
img_path = generate_screenshot(str(video_path), str(IMAGE_OUTPUT_DIR), ts, idx)
|
||||
@@ -555,14 +681,7 @@ class NoteGenerator:
|
||||
:param markdown: 原始 Markdown 文本
|
||||
:return: 标记与对应时间戳秒数的列表
|
||||
"""
|
||||
pattern = r"(?:\*Screenshot-(\d{2}):(\d{2})|Screenshot-\[(\d{2}):(\d{2})\])"
|
||||
results: List[Tuple[str, int]] = []
|
||||
for match in re.finditer(pattern, markdown):
|
||||
mm = match.group(1) or match.group(3)
|
||||
ss = match.group(2) or match.group(4)
|
||||
total_seconds = int(mm) * 60 + int(ss)
|
||||
results.append((match.group(0), total_seconds))
|
||||
return results
|
||||
return extract_screenshot_timestamps(markdown)
|
||||
|
||||
def _save_metadata(self, video_id: str, platform: str, task_id: str) -> None:
|
||||
"""
|
||||
@@ -576,4 +695,4 @@ class NoteGenerator:
|
||||
insert_video_task(video_id=video_id, platform=platform, task_id=task_id)
|
||||
logger.info(f"已保存任务记录到数据库 (video_id={video_id}, platform={platform}, task_id={task_id})")
|
||||
except Exception as e:
|
||||
logger.error(f"保存任务记录失败:{e}")
|
||||
logger.error(f"保存任务记录失败:{e}")
|
||||
|
||||
@@ -123,7 +123,12 @@ class ProviderService:
|
||||
filtered_data = {k: v for k, v in data.items() if v is not None and k != 'id'}
|
||||
print('更新模型供应商',filtered_data)
|
||||
update_provider(id, **filtered_data)
|
||||
return id
|
||||
# 获取更新后的供应商信息
|
||||
updated_provider = get_provider_by_id(id)
|
||||
return {
|
||||
'id': id,
|
||||
'enabled': updated_provider.enabled,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print('更新模型供应商失败:',e)
|
||||
|
||||
23
backend/app/services/task_serial_executor.py
Normal file
23
backend/app/services/task_serial_executor.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class ConcurrentTaskExecutor:
|
||||
"""使用线程池并发执行任务,替代原来的串行锁。"""
|
||||
|
||||
def __init__(self, max_workers: int | None = None):
|
||||
self._max_workers = max_workers or int(os.getenv("TASK_MAX_WORKERS", "3"))
|
||||
self._pool = ThreadPoolExecutor(max_workers=self._max_workers)
|
||||
|
||||
def run(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
future: Future = self._pool.submit(fn, *args, **kwargs)
|
||||
return future.result()
|
||||
|
||||
def shutdown(self, wait: bool = True):
|
||||
self._pool.shutdown(wait=wait)
|
||||
|
||||
|
||||
# 保持向后兼容的导出名
|
||||
SerialTaskExecutor = ConcurrentTaskExecutor
|
||||
task_serial_executor = ConcurrentTaskExecutor()
|
||||
58
backend/app/services/transcriber_config_manager.py
Normal file
58
backend/app/services/transcriber_config_manager.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class TranscriberConfigManager:
|
||||
"""管理转写器配置,存储在 JSON 文件中,支持前端动态修改。"""
|
||||
|
||||
def __init__(self, filepath: str = "config/transcriber.json"):
|
||||
self.path = Path(filepath)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read(self) -> Dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return {}
|
||||
try:
|
||||
with self.path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _write(self, data: Dict[str, Any]):
|
||||
with self.path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""获取当前转写器配置,fallback 到环境变量默认值。"""
|
||||
data = self._read()
|
||||
return {
|
||||
"transcriber_type": data.get(
|
||||
"transcriber_type",
|
||||
os.getenv("TRANSCRIBER_TYPE", "fast-whisper"),
|
||||
),
|
||||
"whisper_model_size": data.get(
|
||||
"whisper_model_size",
|
||||
os.getenv("WHISPER_MODEL_SIZE", "medium"),
|
||||
),
|
||||
}
|
||||
|
||||
def update_config(
|
||||
self,
|
||||
transcriber_type: str,
|
||||
whisper_model_size: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""更新转写器配置并持久化。"""
|
||||
data = self._read()
|
||||
data["transcriber_type"] = transcriber_type
|
||||
if whisper_model_size is not None:
|
||||
data["whisper_model_size"] = whisper_model_size
|
||||
self._write(data)
|
||||
return self.get_config()
|
||||
|
||||
def get_transcriber_type(self) -> str:
|
||||
return self.get_config()["transcriber_type"]
|
||||
|
||||
def get_whisper_model_size(self) -> str:
|
||||
return self.get_config()["whisper_model_size"]
|
||||
226
backend/app/services/vector_store.py
Normal file
226
backend/app/services/vector_store.py
Normal file
@@ -0,0 +1,226 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
NOTE_OUTPUT_DIR = os.getenv("NOTE_OUTPUT_DIR", "note_results")
|
||||
VECTOR_DB_DIR = os.getenv("VECTOR_DB_DIR", "vector_db")
|
||||
|
||||
|
||||
def _chunk_markdown(markdown: str) -> list[dict]:
|
||||
"""按 H2/H3 标题拆分 markdown 为语义块。"""
|
||||
sections = re.split(r'(?=^#{2,3}\s)', markdown, flags=re.MULTILINE)
|
||||
chunks = []
|
||||
for section in sections:
|
||||
section = section.strip()
|
||||
if not section or len(section) < 30:
|
||||
continue
|
||||
heading_match = re.match(r'^(#{2,3})\s+(.+)', section)
|
||||
title = heading_match.group(2).strip() if heading_match else "intro"
|
||||
chunks.append({
|
||||
"text": section,
|
||||
"metadata": {"source_type": "markdown", "section_title": title},
|
||||
})
|
||||
return chunks
|
||||
|
||||
|
||||
def _chunk_transcript(segments: list[dict], window_size: int = 15, overlap: int = 3) -> list[dict]:
|
||||
"""将转录 segments 按滑动窗口分组。"""
|
||||
if not segments:
|
||||
return []
|
||||
chunks = []
|
||||
step = max(window_size - overlap, 1)
|
||||
for i in range(0, len(segments), step):
|
||||
window = segments[i:i + window_size]
|
||||
if not window:
|
||||
break
|
||||
text = "\n".join(
|
||||
f"[{seg.get('start', 0):.0f}s] {seg.get('text', '')}" for seg in window
|
||||
)
|
||||
chunks.append({
|
||||
"text": text,
|
||||
"metadata": {
|
||||
"source_type": "transcript",
|
||||
"start_time": window[0].get("start", 0),
|
||||
"end_time": window[-1].get("end", 0),
|
||||
},
|
||||
})
|
||||
return chunks
|
||||
|
||||
|
||||
def _build_meta_chunk(audio_meta: dict) -> list[dict]:
|
||||
"""将视频元信息(标题、作者、描述、标签等)构建为可检索的 chunk。"""
|
||||
if not audio_meta:
|
||||
return []
|
||||
|
||||
raw = audio_meta.get("raw_info", {}) or {}
|
||||
parts = []
|
||||
|
||||
title = audio_meta.get("title") or raw.get("title", "")
|
||||
if title:
|
||||
parts.append(f"视频标题:{title}")
|
||||
|
||||
uploader = raw.get("uploader", "")
|
||||
if uploader:
|
||||
parts.append(f"视频作者/UP主:{uploader}")
|
||||
|
||||
desc = raw.get("description", "")
|
||||
if desc:
|
||||
parts.append(f"视频简介:{desc[:500]}")
|
||||
|
||||
tags = raw.get("tags", [])
|
||||
if tags and isinstance(tags, list):
|
||||
parts.append(f"标签:{', '.join(str(t) for t in tags[:20])}")
|
||||
|
||||
duration = audio_meta.get("duration", 0)
|
||||
if duration:
|
||||
m, s = divmod(int(duration), 60)
|
||||
parts.append(f"视频时长:{m}分{s}秒")
|
||||
|
||||
platform = audio_meta.get("platform", "")
|
||||
if platform:
|
||||
parts.append(f"平台:{platform}")
|
||||
|
||||
url = raw.get("webpage_url", "")
|
||||
if url:
|
||||
parts.append(f"链接:{url}")
|
||||
|
||||
if not parts:
|
||||
return []
|
||||
|
||||
return [{
|
||||
"text": "\n".join(parts),
|
||||
"metadata": {"source_type": "meta"},
|
||||
}]
|
||||
|
||||
|
||||
class VectorStoreManager:
|
||||
"""基于 ChromaDB 的笔记向量存储管理器。"""
|
||||
|
||||
def __init__(self):
|
||||
os.makedirs(VECTOR_DB_DIR, exist_ok=True)
|
||||
self._client = chromadb.PersistentClient(
|
||||
path=VECTOR_DB_DIR,
|
||||
settings=Settings(anonymized_telemetry=False),
|
||||
)
|
||||
|
||||
def _collection_name(self, task_id: str) -> str:
|
||||
"""ChromaDB collection 名称:直接使用 task_id(UUID 格式合法)。"""
|
||||
return task_id
|
||||
|
||||
def index_task(self, task_id: str) -> None:
|
||||
"""读取笔记结果并建立向量索引。"""
|
||||
result_path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json")
|
||||
if not os.path.exists(result_path):
|
||||
logger.warning(f"笔记文件不存在,跳过索引: {result_path}")
|
||||
return
|
||||
|
||||
with open(result_path, "r", encoding="utf-8") as f:
|
||||
note_data = json.load(f)
|
||||
|
||||
markdown = note_data.get("markdown", "")
|
||||
transcript = note_data.get("transcript", {})
|
||||
segments = transcript.get("segments", [])
|
||||
|
||||
audio_meta = note_data.get("audio_meta", {})
|
||||
|
||||
meta_chunks = _build_meta_chunk(audio_meta)
|
||||
md_chunks = _chunk_markdown(markdown)
|
||||
tr_chunks = _chunk_transcript(segments)
|
||||
all_chunks = meta_chunks + md_chunks + tr_chunks
|
||||
|
||||
if not all_chunks:
|
||||
logger.warning(f"笔记内容为空,跳过索引: {task_id}")
|
||||
return
|
||||
|
||||
col_name = self._collection_name(task_id)
|
||||
|
||||
# 删除旧 collection(幂等)
|
||||
try:
|
||||
self._client.delete_collection(col_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
collection = self._client.create_collection(
|
||||
name=col_name,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
documents = [c["text"] for c in all_chunks]
|
||||
metadatas = [c["metadata"] for c in all_chunks]
|
||||
ids = [f"{task_id}_{i}" for i in range(len(all_chunks))]
|
||||
|
||||
collection.add(documents=documents, metadatas=metadatas, ids=ids)
|
||||
logger.info(f"向量索引完成: task_id={task_id}, chunks={len(all_chunks)}")
|
||||
|
||||
def _parse_results(self, results: dict) -> list[dict]:
|
||||
"""将 ChromaDB query 结果转换为 chunk 列表。"""
|
||||
chunks = []
|
||||
if not results or not results.get("documents") or not results["documents"][0]:
|
||||
return chunks
|
||||
for i in range(len(results["documents"][0])):
|
||||
chunks.append({
|
||||
"text": results["documents"][0][i],
|
||||
"metadata": results["metadatas"][0][i] if results["metadatas"] else {},
|
||||
"distance": results["distances"][0][i] if results["distances"] else None,
|
||||
})
|
||||
return chunks
|
||||
|
||||
def query(self, task_id: str, query_text: str, n_results: int = 6) -> list[dict]:
|
||||
"""
|
||||
按固定配额从各来源检索:meta 1 条、markdown 2 条、transcript 3 条,
|
||||
确保三种来源都被召回。
|
||||
"""
|
||||
col_name = self._collection_name(task_id)
|
||||
try:
|
||||
collection = self._client.get_collection(col_name)
|
||||
except Exception:
|
||||
logger.warning(f"Collection 不存在: {col_name}")
|
||||
return []
|
||||
|
||||
all_chunks = []
|
||||
|
||||
# 每种来源的配额
|
||||
quotas = {"meta": 1, "markdown": 2, "transcript": 3}
|
||||
|
||||
for source_type, quota in quotas.items():
|
||||
try:
|
||||
results = collection.query(
|
||||
query_texts=[query_text],
|
||||
n_results=quota,
|
||||
where={"source_type": source_type},
|
||||
)
|
||||
all_chunks.extend(self._parse_results(results))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return all_chunks
|
||||
|
||||
def delete_index(self, task_id: str) -> None:
|
||||
"""删除指定任务的向量索引。"""
|
||||
col_name = self._collection_name(task_id)
|
||||
try:
|
||||
self._client.delete_collection(col_name)
|
||||
logger.info(f"已删除向量索引: {task_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_indexed(self, task_id: str) -> bool:
|
||||
"""检查指定任务是否已建立完整索引(含 meta 信息)。"""
|
||||
col_name = self._collection_name(task_id)
|
||||
try:
|
||||
col = self._client.get_collection(col_name)
|
||||
if col.count() == 0:
|
||||
return False
|
||||
# 检查是否包含 meta chunk,旧索引可能缺失
|
||||
meta = col.get(where={"source_type": "meta"}, limit=1)
|
||||
return len(meta["ids"]) > 0
|
||||
except Exception:
|
||||
return False
|
||||
@@ -17,15 +17,15 @@ class TranscriberType(str, Enum):
|
||||
KUAISHOU = "kuaishou"
|
||||
GROQ = "groq"
|
||||
|
||||
# 仅在 Apple 平台启用 MLX Whisper
|
||||
# 在 Apple 平台尝试导入 MLX Whisper(不再依赖环境变量,支持前端动态切换)
|
||||
MLX_WHISPER_AVAILABLE = False
|
||||
if platform.system() == "Darwin" and os.environ.get("TRANSCRIBER_TYPE") == "mlx-whisper":
|
||||
if platform.system() == "Darwin":
|
||||
try:
|
||||
from app.transcriber.mlx_whisper_transcriber import MLXWhisperTranscriber
|
||||
MLX_WHISPER_AVAILABLE = True
|
||||
logger.info("MLX Whisper 可用,已导入")
|
||||
except ImportError:
|
||||
logger.warning("MLX Whisper 导入失败,可能未安装或平台不支持")
|
||||
logger.warning("MLX Whisper 导入失败,可能未安装 mlx_whisper")
|
||||
|
||||
logger.info('初始化转录服务提供器')
|
||||
|
||||
@@ -97,8 +97,10 @@ def get_transcriber(transcriber_type="fast-whisper", model_size="base", device="
|
||||
|
||||
elif transcriber_enum == TranscriberType.MLX_WHISPER:
|
||||
if not MLX_WHISPER_AVAILABLE:
|
||||
logger.warning("MLX Whisper 不可用,回退到 fast-whisper")
|
||||
return get_whisper_transcriber(whisper_model_size, device=device)
|
||||
raise RuntimeError(
|
||||
"MLX Whisper 不可用:需要 macOS 平台并安装 mlx_whisper 包 (pip install mlx_whisper)。"
|
||||
"请在「音频转写配置」页面切换到其他转写引擎。"
|
||||
)
|
||||
return get_mlx_whisper_transcriber(whisper_model_size)
|
||||
|
||||
elif transcriber_enum == TranscriberType.BCUT:
|
||||
|
||||
@@ -64,7 +64,6 @@ class WhisperTranscriber(Transcriber):
|
||||
model_size_or_path=model_path,
|
||||
device=self.device,
|
||||
compute_type=self.compute_type,
|
||||
cpu_threads=cpu_threads,
|
||||
download_root=model_dir
|
||||
)
|
||||
@staticmethod
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import re
|
||||
|
||||
|
||||
import re
|
||||
def prepend_source_link(markdown: str | None, source_url: str) -> str | None:
|
||||
"""
|
||||
在笔记开头添加来源链接;若首个非空行已包含来源链接,则更新该行并避免重复。
|
||||
"""
|
||||
if markdown is None:
|
||||
return None
|
||||
|
||||
source = (source_url or "").strip()
|
||||
if not source:
|
||||
return markdown
|
||||
|
||||
header = f"> 来源链接:{source}"
|
||||
lines = markdown.splitlines()
|
||||
first_non_empty_idx = None
|
||||
for idx, line in enumerate(lines):
|
||||
if line.strip():
|
||||
first_non_empty_idx = idx
|
||||
break
|
||||
|
||||
if first_non_empty_idx is not None:
|
||||
first_line = lines[first_non_empty_idx].strip()
|
||||
if first_line.startswith("> 来源链接:") or first_line.startswith("来源链接:"):
|
||||
lines[first_non_empty_idx] = header
|
||||
return "\n".join(lines)
|
||||
|
||||
if markdown.strip():
|
||||
return f"{header}\n\n{markdown}"
|
||||
return header
|
||||
|
||||
import re
|
||||
|
||||
def replace_content_markers(markdown: str, video_id: str, platform: str = 'bilibili') -> str:
|
||||
"""
|
||||
@@ -12,17 +38,24 @@ def replace_content_markers(markdown: str, video_id: str, platform: str = 'bilib
|
||||
# 匹配三种形式:*Content-04:16*、Content-04:16、Content-[04:16]
|
||||
pattern = r"(?:\*?)Content-(?:\[(\d{2}):(\d{2})\]|(\d{2}):(\d{2}))"
|
||||
|
||||
safe_video_id = video_id
|
||||
|
||||
def replacer(match):
|
||||
mm = match.group(1) or match.group(3)
|
||||
ss = match.group(2) or match.group(4)
|
||||
total_seconds = int(mm) * 60 + int(ss)
|
||||
|
||||
if platform == 'bilibili':
|
||||
url = f"https://www.bilibili.com/video/{video_id}?t={total_seconds}"
|
||||
video_id = video_id.replace("_p", "?p=")
|
||||
url = f"https://www.bilibili.com/video/{video_id}&t={total_seconds}"
|
||||
parsed_video_id = safe_video_id.replace("_p", "?p=")
|
||||
url = f"https://www.bilibili.com/video/{parsed_video_id}&t={total_seconds}"
|
||||
elif platform == 'youtube':
|
||||
url = f"https://www.youtube.com/watch?v={video_id}&t={total_seconds}s"
|
||||
url = f"https://www.youtube.com/watch?v={safe_video_id}&t={total_seconds}s"
|
||||
elif platform == 'douyin':
|
||||
url = f"https://www.douyin.com/video/{video_id}"
|
||||
url = f"https://www.douyin.com/video/{safe_video_id}"
|
||||
return f"[原片 @ {mm}:{ss}]({url})"
|
||||
else:
|
||||
return f"({mm}:{ss})"
|
||||
@@ -30,3 +63,4 @@ def replace_content_markers(markdown: str, video_id: str, platform: str = 'bilib
|
||||
return f"[原片 @ {mm}:{ss}]({url})"
|
||||
|
||||
return re.sub(pattern, replacer, markdown)
|
||||
|
||||
|
||||
13
backend/app/utils/screenshot_marker.py
Normal file
13
backend/app/utils/screenshot_marker.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
def extract_screenshot_timestamps(markdown: str) -> List[Tuple[str, int]]:
|
||||
pattern = r"(\*?Screenshot-(?:\[(\d{2}):(\d{2})\]|(\d{2}):(\d{2})))"
|
||||
results: List[Tuple[str, int]] = []
|
||||
for match in re.finditer(pattern, markdown):
|
||||
mm = match.group(2) or match.group(4)
|
||||
ss = match.group(3) or match.group(5)
|
||||
total_seconds = int(mm) * 60 + int(ss)
|
||||
results.append((match.group(1), total_seconds))
|
||||
return results
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
|
||||
def extract_video_id(url: str, platform: str) -> Optional[str]:
|
||||
@@ -11,6 +12,12 @@ def extract_video_id(url: str, platform: str) -> Optional[str]:
|
||||
:return: 提取到的视频 ID 或 None
|
||||
"""
|
||||
if platform == "bilibili":
|
||||
# 如果是短链接,则解析真实链接
|
||||
if "b23.tv" in url:
|
||||
resolved_url = resolve_bilibili_short_url(url)
|
||||
if resolved_url:
|
||||
url = resolved_url
|
||||
|
||||
# 匹配 BV号(如 BV1vc411b7Wa)
|
||||
match = re.search(r"BV([0-9A-Za-z]+)", url)
|
||||
return f"BV{match.group(1)}" if match else None
|
||||
@@ -26,3 +33,18 @@ def extract_video_id(url: str, platform: str) -> Optional[str]:
|
||||
return match.group(1) if match else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_bilibili_short_url(short_url: str) -> Optional[str]:
|
||||
"""
|
||||
解析哔哩哔哩短链接以获取真实视频链接
|
||||
|
||||
:param short_url: Bilibili短链接(如"https://b23.tv/xxxxxx")
|
||||
:return: 真实的视频链接或None
|
||||
"""
|
||||
try:
|
||||
response = requests.head(short_url, allow_redirects=True)
|
||||
return response.url
|
||||
except requests.RequestException as e:
|
||||
print(f"Error resolving short URL: {e}")
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import ffmpeg
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -14,6 +16,7 @@ class VideoReader:
|
||||
video_path: str,
|
||||
grid_size=(3, 3),
|
||||
frame_interval=2,
|
||||
dedupe_enabled=True,
|
||||
unit_width=960,
|
||||
unit_height=540,
|
||||
save_quality=90,
|
||||
@@ -23,6 +26,7 @@ class VideoReader:
|
||||
self.video_path = video_path
|
||||
self.grid_size = grid_size
|
||||
self.frame_interval = frame_interval
|
||||
self.dedupe_enabled = dedupe_enabled
|
||||
self.unit_width = unit_width
|
||||
self.unit_height = unit_height
|
||||
self.save_quality = save_quality
|
||||
@@ -31,6 +35,14 @@ class VideoReader:
|
||||
print(f"视频路径:{video_path}",self.frame_dir,self.grid_dir)
|
||||
self.font_path = font_path
|
||||
|
||||
@staticmethod
|
||||
def _calculate_file_md5(file_path: str) -> str:
|
||||
hasher = hashlib.md5()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
def format_time(self, seconds: float) -> str:
|
||||
mm = int(seconds // 60)
|
||||
ss = int(seconds % 60)
|
||||
@@ -43,6 +55,18 @@ class VideoReader:
|
||||
return mm * 60 + ss
|
||||
return float('inf')
|
||||
|
||||
def _extract_single_frame(self, ts: int) -> str | None:
|
||||
"""提取单帧,返回输出路径或 None(失败时)。"""
|
||||
time_label = self.format_time(ts)
|
||||
output_path = os.path.join(self.frame_dir, f"frame_{time_label}.jpg")
|
||||
cmd = ["ffmpeg", "-ss", str(ts), "-i", self.video_path, "-frames:v", "1", "-q:v", "2", "-y", output_path,
|
||||
"-hide_banner", "-loglevel", "error"]
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
return output_path
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
def extract_frames(self, max_frames=1000) -> list[str]:
|
||||
|
||||
try:
|
||||
@@ -50,13 +74,30 @@ class VideoReader:
|
||||
duration = float(ffmpeg.probe(self.video_path)["format"]["duration"])
|
||||
timestamps = [i for i in range(0, int(duration), self.frame_interval)][:max_frames]
|
||||
|
||||
# 并行提取帧
|
||||
max_workers = min(os.cpu_count() or 4, 8, len(timestamps))
|
||||
frame_results: dict[int, str | None] = {}
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(self._extract_single_frame, ts): ts for ts in timestamps}
|
||||
for future in as_completed(futures):
|
||||
ts = futures[future]
|
||||
frame_results[ts] = future.result()
|
||||
|
||||
# 按时间戳顺序整理结果,并进行去重
|
||||
image_paths = []
|
||||
last_hash = None
|
||||
for ts in timestamps:
|
||||
time_label = self.format_time(ts)
|
||||
output_path = os.path.join(self.frame_dir, f"frame_{time_label}.jpg")
|
||||
cmd = ["ffmpeg", "-ss", str(ts), "-i", self.video_path, "-frames:v", "1", "-q:v", "2", "-y", output_path,
|
||||
"-hide_banner", "-loglevel", "error"]
|
||||
subprocess.run(cmd, check=True)
|
||||
output_path = frame_results.get(ts)
|
||||
if not output_path or not os.path.exists(output_path):
|
||||
continue
|
||||
|
||||
if self.dedupe_enabled:
|
||||
frame_hash = self._calculate_file_md5(output_path)
|
||||
if frame_hash == last_hash:
|
||||
os.remove(output_path)
|
||||
continue
|
||||
last_hash = frame_hash
|
||||
|
||||
image_paths.append(output_path)
|
||||
return image_paths
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pydantic import AnyUrl, validator, BaseModel, field_validator
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SUPPORTED_PLATFORMS = {
|
||||
"bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+",
|
||||
@@ -10,6 +11,12 @@ SUPPORTED_PLATFORMS = {
|
||||
|
||||
|
||||
def is_supported_video_url(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
|
||||
# 检查是否为Bilibili的短链接
|
||||
if parsed.netloc == "b23.tv":
|
||||
return True
|
||||
|
||||
for name, pattern in SUPPORTED_PLATFORMS.items():
|
||||
if pattern in ["douyin", "kuaishou"]:
|
||||
if pattern in url:
|
||||
|
||||
@@ -25,6 +25,7 @@ cp .env.example backend/.env
|
||||
# 步骤 2: PyInstaller 打包,直接添加已存在的 .env 文件
|
||||
echo "开始 PyInstaller 打包..."
|
||||
pyinstaller \
|
||||
-y \
|
||||
--name BiliNoteBackend \
|
||||
--paths backend \
|
||||
--distpath ./BillNote_frontend/src-tauri/bin \
|
||||
|
||||
@@ -14,7 +14,17 @@ def check_ffmpeg_exists() -> bool:
|
||||
logger.info(f"FFMPEG_BIN_PATH: {ffmpeg_bin_path}")
|
||||
if ffmpeg_bin_path and os.path.isdir(ffmpeg_bin_path):
|
||||
os.environ["PATH"] = ffmpeg_bin_path + os.pathsep + os.environ.get("PATH", "")
|
||||
logger.info(f"ffmpeg 未配置路径,尝试使用系统路径PATH: {os.environ.get('PATH')}")
|
||||
logger.info(f"使用FFMPEG_BIN_PATH: {ffmpeg_bin_path}")
|
||||
else:
|
||||
# 遍历系统PATH寻找ffmpeg.exe
|
||||
system_path = os.environ.get("PATH", "")
|
||||
path_dirs = system_path.split(os.pathsep)
|
||||
for path_dir in path_dirs:
|
||||
ffmpeg_exe_path = os.path.join(path_dir, "ffmpeg.exe")
|
||||
if os.path.isfile(ffmpeg_exe_path):
|
||||
os.environ["PATH"] = path_dir + os.pathsep + system_path
|
||||
logger.info(f"在系统PATH中找到ffmpeg: {path_dir}")
|
||||
break
|
||||
try:
|
||||
subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||
logger.info("ffmpeg 已安装")
|
||||
@@ -36,4 +46,4 @@ def ensure_ffmpeg_or_raise():
|
||||
"🪟 Windows 推荐:https://www.gyan.dev/ffmpeg/builds/\n"
|
||||
"💡 如果你已安装,请将其路径写入 `.env` 文件,例如:\n"
|
||||
"FFMPEG_BIN_PATH=/your/custom/ffmpeg/bin"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -14,7 +15,7 @@ from app.exceptions.exception_handlers import register_exception_handlers
|
||||
# from app.db.provider_dao import init_provider_table
|
||||
from app.utils.logger import get_logger
|
||||
from app import create_app
|
||||
from app.transcriber.transcriber_provider import get_transcriber
|
||||
from app.services.transcriber_config_manager import TranscriberConfigManager
|
||||
from events import register_handler
|
||||
from ffmpeg_helper import ensure_ffmpeg_or_raise
|
||||
|
||||
@@ -40,7 +41,10 @@ if not os.path.exists(out_dir):
|
||||
async def lifespan(app: FastAPI):
|
||||
register_handler()
|
||||
init_db()
|
||||
get_transcriber(transcriber_type=os.getenv("TRANSCRIBER_TYPE", "fast-whisper"))
|
||||
# 转写器不再在启动时强制初始化,而是在首次生成笔记时按需创建
|
||||
# 如果配置了不可用的类型(如 mlx-whisper 未安装),会在使用时报错而非静默回退
|
||||
_cfg = TranscriberConfigManager().get_config()
|
||||
logger.info(f"当前转写器配置: type={_cfg['transcriber_type']}, model_size={_cfg['whisper_model_size']}")
|
||||
seed_default_providers()
|
||||
yield
|
||||
|
||||
@@ -58,6 +62,7 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
register_exception_handlers(app)
|
||||
app.mount(static_path, StaticFiles(directory=static_dir), name="static")
|
||||
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||
|
||||
@@ -16,6 +16,7 @@ celery==5.5.1
|
||||
certifi==2025.1.31
|
||||
cffi==1.17.1
|
||||
charset-normalizer==3.4.1
|
||||
chromadb>=0.5.0
|
||||
click==8.1.8
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1
|
||||
@@ -123,5 +124,6 @@ weasyprint==65.1
|
||||
webencodings==0.5.1
|
||||
websockets==15.0.1
|
||||
yarl==1.19.0
|
||||
youtube-transcript-api>=1.0.0
|
||||
yt-dlp==2025.3.31
|
||||
zopfli==0.2.3.post1
|
||||
|
||||
1
backend/run.bat
Normal file
1
backend/run.bat
Normal file
@@ -0,0 +1 @@
|
||||
python main.py
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
35
backend/tests/test_note_helper.py
Normal file
35
backend/tests/test_note_helper.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = ROOT / "app" / "utils" / "note_helper.py"
|
||||
spec = importlib.util.spec_from_file_location("note_helper", MODULE_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("note_helper module spec not found")
|
||||
note_helper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(note_helper)
|
||||
|
||||
|
||||
class TestNoteHelper(unittest.TestCase):
|
||||
def test_prepend_source_link_adds_header_at_top(self):
|
||||
source_url = "https://www.bilibili.com/video/BV1xx411c7mD"
|
||||
markdown = "## 标题\n\n内容"
|
||||
|
||||
result = note_helper.prepend_source_link(markdown, source_url)
|
||||
|
||||
self.assertTrue(result.startswith(f"> 来源链接:{source_url}\n\n"))
|
||||
self.assertIn("## 标题", result)
|
||||
|
||||
def test_prepend_source_link_does_not_duplicate_when_header_exists(self):
|
||||
source_url = "https://www.youtube.com/watch?v=abc123"
|
||||
markdown = f"> 来源链接:{source_url}\n\n## 标题\n\n内容"
|
||||
|
||||
result = note_helper.prepend_source_link(markdown, source_url)
|
||||
|
||||
self.assertEqual(result, markdown)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
97
backend/tests/test_request_chunker.py
Normal file
97
backend/tests/test_request_chunker.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = ROOT / "app" / "gpt" / "request_chunker.py"
|
||||
spec = importlib.util.spec_from_file_location("request_chunker", MODULE_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("request_chunker module spec not found")
|
||||
request_chunker = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(request_chunker)
|
||||
RequestChunker = request_chunker.RequestChunker
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummySeg:
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
|
||||
|
||||
def build_messages(segments, image_urls, **_):
|
||||
content = [{"type": "text", "text": "".join(s.text for s in segments)}]
|
||||
for url in image_urls:
|
||||
content.append({"type": "image_url", "image_url": {"url": url, "detail": "auto"}})
|
||||
return [{"role": "user", "content": content}]
|
||||
|
||||
|
||||
def size_estimator(messages):
|
||||
size = 0
|
||||
for part in messages[0]["content"]:
|
||||
if part["type"] == "text":
|
||||
size += len(part["text"])
|
||||
else:
|
||||
size += len(part["image_url"]["url"])
|
||||
return size
|
||||
|
||||
|
||||
class TestRequestChunker(unittest.TestCase):
|
||||
def test_chunk_segments_preserves_order_and_content(self):
|
||||
segments = [
|
||||
DummySeg(0, 1, "aaaa"),
|
||||
DummySeg(1, 2, "bbbb"),
|
||||
DummySeg(2, 3, "cccc"),
|
||||
]
|
||||
chunker = RequestChunker(build_messages, max_bytes=8, size_estimator=size_estimator)
|
||||
chunks = chunker.chunk(segments, [])
|
||||
texts = ["".join(seg.text for seg in c.segments) for c in chunks]
|
||||
self.assertEqual("".join(texts), "aaaabbbbcccc")
|
||||
self.assertTrue(all(texts))
|
||||
|
||||
def test_chunk_images_distributed_across_batches(self):
|
||||
segments = [DummySeg(0, 1, "aa")]
|
||||
images = ["i" * 6, "j" * 6, "k" * 6]
|
||||
chunker = RequestChunker(build_messages, max_bytes=10, size_estimator=size_estimator)
|
||||
chunks = chunker.chunk(segments, images)
|
||||
all_images = [img for c in chunks for img in c.image_urls]
|
||||
self.assertEqual(all_images, images)
|
||||
|
||||
def test_chunk_images_are_not_front_loaded_when_multiple_segment_chunks(self):
|
||||
segments = [
|
||||
DummySeg(0, 1, "aaaaaa"),
|
||||
DummySeg(1, 2, "bbbbbb"),
|
||||
DummySeg(2, 3, "cccccc"),
|
||||
]
|
||||
images = ["11111", "22222", "33333"]
|
||||
chunker = RequestChunker(build_messages, max_bytes=12, size_estimator=size_estimator)
|
||||
chunks = chunker.chunk(segments, images)
|
||||
|
||||
self.assertGreaterEqual(len(chunks), 3)
|
||||
image_counts = [len(c.image_urls) for c in chunks]
|
||||
self.assertGreater(image_counts[1], 0)
|
||||
self.assertGreater(image_counts[2], 0)
|
||||
all_images = [img for c in chunks for img in c.image_urls]
|
||||
self.assertEqual(all_images, images)
|
||||
|
||||
def test_split_oversized_segment(self):
|
||||
segments = [DummySeg(0, 1, "x" * 25)]
|
||||
chunker = RequestChunker(build_messages, max_bytes=10, size_estimator=size_estimator)
|
||||
chunks = chunker.chunk(segments, [])
|
||||
combined = "".join(seg.text for c in chunks for seg in c.segments)
|
||||
self.assertEqual(combined, "x" * 25)
|
||||
|
||||
def test_group_texts_by_budget(self):
|
||||
chunker = RequestChunker(build_messages, max_bytes=10, size_estimator=size_estimator)
|
||||
|
||||
def build_text_messages(texts, *_args, **_kwargs):
|
||||
content = [{"type": "text", "text": "".join(texts)}]
|
||||
return [{"role": "user", "content": content}]
|
||||
|
||||
groups = chunker.group_texts_by_budget(["aaaaa", "bbbbb", "ccccc"], build_text_messages)
|
||||
self.assertEqual(groups, [["aaaaa", "bbbbb"], ["ccccc"]])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
35
backend/tests/test_screenshot_marker.py
Normal file
35
backend/tests/test_screenshot_marker.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = ROOT / "app" / "utils" / "screenshot_marker.py"
|
||||
spec = importlib.util.spec_from_file_location("screenshot_marker", MODULE_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("screenshot_marker module spec not found")
|
||||
screenshot_marker = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(screenshot_marker)
|
||||
extract_screenshot_timestamps = screenshot_marker.extract_screenshot_timestamps
|
||||
|
||||
|
||||
class TestScreenshotMarker(unittest.TestCase):
|
||||
def test_extract_accepts_star_bracket_format(self):
|
||||
markdown = "A\n*Screenshot-[01:02]\nB"
|
||||
matches = extract_screenshot_timestamps(markdown)
|
||||
self.assertEqual(matches, [("*Screenshot-[01:02]", 62)])
|
||||
|
||||
def test_extract_accepts_legacy_formats(self):
|
||||
markdown = "*Screenshot-03:04 and Screenshot-[05:06]"
|
||||
matches = extract_screenshot_timestamps(markdown)
|
||||
self.assertEqual(
|
||||
matches,
|
||||
[
|
||||
("*Screenshot-03:04", 184),
|
||||
("Screenshot-[05:06]", 306),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
42
backend/tests/test_task_serial_executor.py
Normal file
42
backend/tests/test_task_serial_executor.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = ROOT / "app" / "services" / "task_serial_executor.py"
|
||||
spec = importlib.util.spec_from_file_location("task_serial_executor", MODULE_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("task_serial_executor module spec not found")
|
||||
task_serial_executor = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(task_serial_executor)
|
||||
SerialTaskExecutor = task_serial_executor.SerialTaskExecutor
|
||||
|
||||
|
||||
class TestTaskSerialExecutor(unittest.TestCase):
|
||||
def test_executor_runs_tasks_one_by_one(self):
|
||||
executor = SerialTaskExecutor()
|
||||
state_lock = threading.Lock()
|
||||
state = {"active": 0, "peak_active": 0}
|
||||
|
||||
def critical_work():
|
||||
with state_lock:
|
||||
state["active"] += 1
|
||||
state["peak_active"] = max(state["peak_active"], state["active"])
|
||||
time.sleep(0.05)
|
||||
with state_lock:
|
||||
state["active"] -= 1
|
||||
|
||||
threads = [threading.Thread(target=lambda: executor.run(critical_work)) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(state["peak_active"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
147
backend/tests/test_universal_gpt_checkpoint.py
Normal file
147
backend/tests/test_universal_gpt_checkpoint.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _install_stubs():
|
||||
app_mod = types.ModuleType("app")
|
||||
gpt_pkg = types.ModuleType("app.gpt")
|
||||
models_pkg = types.ModuleType("app.models")
|
||||
|
||||
base_mod = types.ModuleType("app.gpt.base")
|
||||
|
||||
class _GPT:
|
||||
pass
|
||||
|
||||
base_mod.GPT = _GPT
|
||||
|
||||
prompt_builder_mod = types.ModuleType("app.gpt.prompt_builder")
|
||||
|
||||
def _generate_base_prompt(**_kwargs):
|
||||
return "prompt"
|
||||
|
||||
prompt_builder_mod.generate_base_prompt = _generate_base_prompt
|
||||
|
||||
prompt_mod = types.ModuleType("app.gpt.prompt")
|
||||
prompt_mod.BASE_PROMPT = ""
|
||||
prompt_mod.AI_SUM = ""
|
||||
prompt_mod.SCREENSHOT = ""
|
||||
prompt_mod.LINK = ""
|
||||
prompt_mod.MERGE_PROMPT = "merge"
|
||||
|
||||
utils_mod = types.ModuleType("app.gpt.utils")
|
||||
|
||||
def _fix_markdown(text):
|
||||
return text
|
||||
|
||||
utils_mod.fix_markdown = _fix_markdown
|
||||
|
||||
request_chunker_mod = types.ModuleType("app.gpt.request_chunker")
|
||||
|
||||
class _RequestChunker:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def group_texts_by_budget(self, texts, _builder, **_kwargs):
|
||||
return [texts]
|
||||
|
||||
request_chunker_mod.RequestChunker = _RequestChunker
|
||||
|
||||
gpt_model_mod = types.ModuleType("app.models.gpt_model")
|
||||
|
||||
class _GPTSource:
|
||||
pass
|
||||
|
||||
gpt_model_mod.GPTSource = _GPTSource
|
||||
|
||||
transcriber_model_mod = types.ModuleType("app.models.transcriber_model")
|
||||
|
||||
class _TranscriptSegment:
|
||||
def __init__(self, **kwargs):
|
||||
self.start = kwargs.get("start", 0)
|
||||
self.end = kwargs.get("end", 0)
|
||||
self.text = kwargs.get("text", "")
|
||||
|
||||
transcriber_model_mod.TranscriptSegment = _TranscriptSegment
|
||||
|
||||
sys.modules.setdefault("app", app_mod)
|
||||
sys.modules.setdefault("app.gpt", gpt_pkg)
|
||||
sys.modules.setdefault("app.models", models_pkg)
|
||||
sys.modules["app.gpt.base"] = base_mod
|
||||
sys.modules["app.gpt.prompt_builder"] = prompt_builder_mod
|
||||
sys.modules["app.gpt.prompt"] = prompt_mod
|
||||
sys.modules["app.gpt.utils"] = utils_mod
|
||||
sys.modules["app.gpt.request_chunker"] = request_chunker_mod
|
||||
sys.modules["app.models.gpt_model"] = gpt_model_mod
|
||||
sys.modules["app.models.transcriber_model"] = transcriber_model_mod
|
||||
|
||||
|
||||
def _load_universal_gpt_class():
|
||||
_install_stubs()
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
module_path = root / "app" / "gpt" / "universal_gpt.py"
|
||||
spec = importlib.util.spec_from_file_location("universal_gpt", module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("universal_gpt module spec not found")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.UniversalGPT
|
||||
|
||||
|
||||
UniversalGPT = _load_universal_gpt_class()
|
||||
|
||||
|
||||
class _FailingCompletions:
|
||||
def create(self, **_kwargs):
|
||||
raise Exception("Error code: 524 - bad_response_status_code")
|
||||
|
||||
|
||||
class _DummyChat:
|
||||
def __init__(self):
|
||||
self.completions = _FailingCompletions()
|
||||
|
||||
|
||||
class _DummyModels:
|
||||
@staticmethod
|
||||
def list():
|
||||
return []
|
||||
|
||||
|
||||
class _DummyClient:
|
||||
def __init__(self):
|
||||
self.chat = _DummyChat()
|
||||
self.models = _DummyModels()
|
||||
|
||||
|
||||
class TestUniversalGPTCheckpoint(unittest.TestCase):
|
||||
def test_merge_524_error_persists_checkpoint(self):
|
||||
original_attempts = os.environ.get("OPENAI_RETRY_ATTEMPTS")
|
||||
os.environ["OPENAI_RETRY_ATTEMPTS"] = "1"
|
||||
gpt = UniversalGPT(_DummyClient(), model="mock-model")
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
gpt.checkpoint_dir = Path(tmp_dir)
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
gpt._merge_partials(["part-a", "part-b"], "task-1", "sig-1")
|
||||
|
||||
checkpoint_path = gpt._checkpoint_path("task-1")
|
||||
self.assertTrue(checkpoint_path.exists())
|
||||
payload = json.loads(checkpoint_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["phase"], "merge")
|
||||
self.assertEqual(payload["partials"], ["part-a", "part-b"])
|
||||
finally:
|
||||
if original_attempts is None:
|
||||
os.environ.pop("OPENAI_RETRY_ATTEMPTS", None)
|
||||
else:
|
||||
os.environ["OPENAI_RETRY_ATTEMPTS"] = original_attempts
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
142
backend/tests/test_video_reader_dedupe.py
Normal file
142
backend/tests/test_video_reader_dedupe.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _install_stubs():
|
||||
app_mod = types.ModuleType("app")
|
||||
utils_pkg = types.ModuleType("app.utils")
|
||||
|
||||
logger_mod = types.ModuleType("app.utils.logger")
|
||||
|
||||
class _Logger:
|
||||
@staticmethod
|
||||
def info(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def warning(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def error(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def _get_logger(_name):
|
||||
return _Logger()
|
||||
|
||||
logger_mod.get_logger = _get_logger
|
||||
|
||||
path_helper_mod = types.ModuleType("app.utils.path_helper")
|
||||
ffmpeg_mod = types.ModuleType("ffmpeg")
|
||||
|
||||
pil_mod = types.ModuleType("PIL")
|
||||
pil_image_mod = types.ModuleType("PIL.Image")
|
||||
pil_draw_mod = types.ModuleType("PIL.ImageDraw")
|
||||
pil_font_mod = types.ModuleType("PIL.ImageFont")
|
||||
|
||||
class _FakeImage:
|
||||
pass
|
||||
|
||||
class _FakeImageDraw:
|
||||
@staticmethod
|
||||
def Draw(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
class _FakeImageFont:
|
||||
@staticmethod
|
||||
def truetype(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def load_default():
|
||||
return None
|
||||
|
||||
pil_image_mod.Image = _FakeImage
|
||||
pil_draw_mod.ImageDraw = _FakeImageDraw
|
||||
pil_font_mod.ImageFont = _FakeImageFont
|
||||
|
||||
def _get_app_dir(name):
|
||||
return name
|
||||
|
||||
path_helper_mod.get_app_dir = _get_app_dir
|
||||
ffmpeg_mod.probe = lambda *_args, **_kwargs: {"format": {"duration": "0"}}
|
||||
|
||||
sys.modules.setdefault("app", app_mod)
|
||||
sys.modules.setdefault("app.utils", utils_pkg)
|
||||
sys.modules["PIL"] = pil_mod
|
||||
sys.modules["PIL.Image"] = pil_image_mod
|
||||
sys.modules["PIL.ImageDraw"] = pil_draw_mod
|
||||
sys.modules["PIL.ImageFont"] = pil_font_mod
|
||||
sys.modules["ffmpeg"] = ffmpeg_mod
|
||||
sys.modules["app.utils.logger"] = logger_mod
|
||||
sys.modules["app.utils.path_helper"] = path_helper_mod
|
||||
|
||||
|
||||
def _load_video_reader_module():
|
||||
_install_stubs()
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
module_path = root / "app" / "utils" / "video_reader.py"
|
||||
spec = importlib.util.spec_from_file_location("video_reader", module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError("video_reader module spec not found")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
video_reader_module = _load_video_reader_module()
|
||||
VideoReader = video_reader_module.VideoReader
|
||||
|
||||
|
||||
def _make_fake_ffmpeg_runner(colors_by_second):
|
||||
def _runner(cmd, check=True):
|
||||
output_path = next((arg for arg in cmd if isinstance(arg, str) and arg.endswith(".jpg")), None)
|
||||
if output_path is None:
|
||||
raise AssertionError("Output path not found in ffmpeg cmd")
|
||||
match = re.search(r"frame_(\d{2})_(\d{2})\.jpg$", output_path)
|
||||
if match is None:
|
||||
raise AssertionError("Unexpected output path")
|
||||
sec = int(match.group(1)) * 60 + int(match.group(2))
|
||||
payload = colors_by_second[sec]
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(payload)
|
||||
return 0
|
||||
|
||||
return _runner
|
||||
|
||||
|
||||
class TestVideoReaderDeduplicateFrames(unittest.TestCase):
|
||||
def test_extract_frames_skips_adjacent_duplicates_when_enabled(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
frame_dir = pathlib.Path(tmp_dir) / "frames"
|
||||
grid_dir = pathlib.Path(tmp_dir) / "grids"
|
||||
reader = VideoReader(
|
||||
video_path="dummy.mp4",
|
||||
frame_interval=1,
|
||||
frame_dir=str(frame_dir),
|
||||
grid_dir=str(grid_dir),
|
||||
)
|
||||
|
||||
fake_colors = {
|
||||
0: b"frame-a",
|
||||
1: b"frame-a",
|
||||
2: b"frame-b",
|
||||
3: b"frame-b",
|
||||
}
|
||||
|
||||
with patch.object(video_reader_module.ffmpeg, "probe", return_value={"format": {"duration": "4"}}), \
|
||||
patch.object(video_reader_module.subprocess, "run", side_effect=_make_fake_ffmpeg_runner(fake_colors)):
|
||||
paths = reader.extract_frames(max_frames=10)
|
||||
|
||||
names = [pathlib.Path(p).name for p in paths]
|
||||
self.assertEqual(names, ["frame_00_00.jpg", "frame_00_02.jpg"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
47
docker-compose.gpu.yml
Normal file
47
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
services:
|
||||
backend:
|
||||
container_name: bilinote-backend
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile.gpu
|
||||
args:
|
||||
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
||||
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BACKEND_PORT=${BACKEND_PORT}
|
||||
- BACKEND_HOST=${BACKEND_HOST}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
expose:
|
||||
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: "nvidia"
|
||||
count: "all"
|
||||
capabilities: ["gpu"]
|
||||
|
||||
frontend:
|
||||
container_name: bilinote-frontend
|
||||
build:
|
||||
context: .
|
||||
dockerfile: BillNote_frontend/Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "80" # 不暴露给宿主机,只供 nginx 访问
|
||||
|
||||
nginx:
|
||||
container_name: bilinote-nginx
|
||||
image: nginx:1.25-alpine
|
||||
ports:
|
||||
- "${APP_PORT}:80"
|
||||
volumes:
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
@@ -5,6 +5,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
args:
|
||||
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
||||
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -14,6 +17,14 @@ services:
|
||||
- ./backend:/app
|
||||
expose:
|
||||
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
|
||||
restart: on-failure:3
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_PORT}/api/sys_health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
mem_limit: 4g
|
||||
|
||||
frontend:
|
||||
container_name: bilinote-frontend
|
||||
@@ -24,6 +35,8 @@ services:
|
||||
- .env
|
||||
expose:
|
||||
- "80" # 不暴露给宿主机,只供 nginx 访问
|
||||
restart: on-failure:3
|
||||
mem_limit: 512m
|
||||
|
||||
nginx:
|
||||
container_name: bilinote-nginx
|
||||
@@ -33,5 +46,9 @@ services:
|
||||
volumes:
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
restart: on-failure:3
|
||||
mem_limit: 256m
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
client_max_body_size 10G;
|
||||
|
||||
# gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# 所有非 /api 请求全部代理给 frontend 容器
|
||||
location / {
|
||||
proxy_pass http://frontend:80;
|
||||
@@ -8,15 +16,16 @@ server {
|
||||
|
||||
# 所有 /api 请求代理给 backend 容器
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_pass http://backend:8483;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
proxy_pass http://backend:8000/static/;
|
||||
|
||||
location /static/ {
|
||||
proxy_pass http://backend:8483/static/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user