mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-12 19:40:31 +08:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5900a9026 | ||
|
|
63577aa1aa | ||
|
|
2e69d1179b | ||
|
|
7e5be46cda | ||
|
|
604cdefa15 | ||
|
|
ff91f74bef | ||
|
|
9bbae2c0c4 | ||
|
|
5b5bf802af | ||
|
|
ecc2e56246 | ||
|
|
d8470bacbc | ||
|
|
0af2efb4de | ||
|
|
721bda5280 | ||
|
|
a928e0e38f | ||
|
|
1329390f98 | ||
|
|
b117ab9f71 | ||
|
|
c4abaf4e60 | ||
|
|
50f0816dab | ||
|
|
9a64a2da8e | ||
|
|
2bb69d1581 | ||
|
|
e89090bed0 | ||
|
|
edf2083d71 | ||
|
|
f6d299ce48 | ||
|
|
ed1ee0a151 | ||
|
|
a7c717abbd | ||
|
|
799ab64a28 | ||
|
|
c0837e0132 | ||
|
|
c9497b502c | ||
|
|
1aea86a8d6 | ||
|
|
9237cac9c3 | ||
|
|
f97ab0b7bc | ||
|
|
ac72cc6d6e | ||
|
|
7358cd0123 | ||
|
|
80f081613b | ||
|
|
26e23d0f2c | ||
|
|
234e3b9d2a | ||
|
|
1d93d1c5f5 | ||
|
|
c19d462505 | ||
|
|
64882e6a77 | ||
|
|
f32a6944d1 | ||
|
|
c5c84a8ec7 | ||
|
|
c4413c66a1 | ||
|
|
26ee15ce28 | ||
|
|
29fa3d9540 | ||
|
|
61cb4ec9fa | ||
|
|
a46880f169 | ||
|
|
c187dce5cb | ||
|
|
424c7f84e2 | ||
|
|
f6a9af4658 | ||
|
|
3ff7086491 | ||
|
|
f583f3cc8c | ||
|
|
2e7fe8d3a8 | ||
|
|
1e055c3068 | ||
|
|
b2af0e4e53 | ||
|
|
3d0838ba72 | ||
|
|
ee58a65bcd | ||
|
|
cfc3053be8 |
28
.commitlintrc.json
Normal file
28
.commitlintrc.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"],
|
||||
"rules": {
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
[
|
||||
"feat",
|
||||
"fix",
|
||||
"docs",
|
||||
"style",
|
||||
"refactor",
|
||||
"perf",
|
||||
"test",
|
||||
"build",
|
||||
"ci",
|
||||
"chore",
|
||||
"ui",
|
||||
"revert"
|
||||
]
|
||||
],
|
||||
"subject-case": [0],
|
||||
"subject-full-stop": [0],
|
||||
"header-max-length": [1, "always", 100],
|
||||
"body-max-line-length": [0],
|
||||
"footer-max-line-length": [0]
|
||||
}
|
||||
}
|
||||
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: 上报一些bug
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: JefferyHcool
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
name: 🐛 Bug 反馈
|
||||
about: 提交一个 Bug 报告,帮助我们改进
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**版本说明**
|
||||
|
||||
请说明的你的版本号
|
||||
|
||||
**部署方式**
|
||||
|
||||
使用的是什么方式部署(代码环境部署,docker部署,桌面端,在线预览)
|
||||
|
||||
**描述问题**
|
||||
清晰、简明地描述你遇到的问题是什么。
|
||||
|
||||
**复现步骤**
|
||||
复现该问题的步骤:
|
||||
|
||||
1. 进入页面 '...'
|
||||
2. 点击 '...'
|
||||
3. 滚动到 '...'
|
||||
4. 出现错误
|
||||
|
||||
**预期行为**
|
||||
清晰、简明地描述你本来预期发生的行为。
|
||||
|
||||
**截图**
|
||||
如果适用,请添加截图以帮助说明问题。
|
||||
|
||||
**桌面端(请补充以下信息)**
|
||||
|
||||
- 操作系统:例如 Windows / macOS / Ubuntu
|
||||
- 浏览器:例如 Chrome、Safari
|
||||
|
||||
**其他补充信息**
|
||||
请补充任何其他相关信息。
|
||||
93
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
93
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: 🐛 Bug 报告
|
||||
description: 报告一个可复现的问题
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢反馈。请尽量提供完整的复现路径与日志,便于排查。
|
||||
⚠️ **不要**贴 API key、SESSDATA、密钥等敏感信息。
|
||||
- type: dropdown
|
||||
id: workspace
|
||||
attributes:
|
||||
label: 受影响的工作区
|
||||
multiple: true
|
||||
options:
|
||||
- 后端 (backend/)
|
||||
- Web 前端 (BillNote_frontend/)
|
||||
- 浏览器插件 (BillNote_extension/)
|
||||
- Tauri 桌面端
|
||||
- 文档 / 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 版本
|
||||
description: BiliNote 版本号(README 顶部,例如 v2.1.0)
|
||||
placeholder: v2.1.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: deploy
|
||||
attributes:
|
||||
label: 部署方式
|
||||
options:
|
||||
- 源码运行
|
||||
- Docker (docker-compose.yml)
|
||||
- Docker GPU (docker-compose.gpu.yml)
|
||||
- 桌面端安装包 (Tauri Release)
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 一步步说明如何触发问题
|
||||
placeholder: |
|
||||
1. 打开 ...
|
||||
2. 点击 ...
|
||||
3. 看到 ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际行为
|
||||
description: 含错误信息、截图、录屏均可
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: env
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 操作系统、Python 版本、Node 版本、浏览器(如适用)
|
||||
placeholder: |
|
||||
- OS: macOS 14.5 / Windows 11 / Ubuntu 22.04
|
||||
- Python: 3.11.6
|
||||
- Node: 20.18.0
|
||||
- Browser: Chrome 124(如涉及插件/前端)
|
||||
- GPU: 无 / NVIDIA 4070(如涉及 fast-whisper / video understanding)
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 日志 / 堆栈
|
||||
description: 后端 console、前端 DevTools、扩展 background 页都可以贴
|
||||
render: text
|
||||
- type: checkboxes
|
||||
id: pre-checks
|
||||
attributes:
|
||||
label: 提交前自查
|
||||
options:
|
||||
- label: 我已搜索过 [Issues](https://github.com/JefferyHcool/BiliNote/issues?q=),确认不是重复问题
|
||||
required: true
|
||||
- label: 我提供的日志中**不**包含 API key、cookie、SESSDATA 等敏感信息
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📖 文档与常见问题
|
||||
url: https://docs.bilinote.app/
|
||||
about: 安装与配置遇到问题,先看一下文档
|
||||
- name: 💬 提问 / 讨论
|
||||
url: https://github.com/JefferyHcool/BiliNote/discussions
|
||||
about: 用法咨询、想法征集请发到 Discussions(不是 bug 才用 Issues)
|
||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: ✨ 功能建议
|
||||
description: 提议新功能或改进
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 想解决什么问题?
|
||||
description: 描述你遇到的实际场景或痛点。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: 建议方案
|
||||
description: 期望的功能或交互。可附草图 / 示例。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 备选方案
|
||||
description: 你考虑过哪些其他做法?为什么没采用?
|
||||
- type: dropdown
|
||||
id: workspace
|
||||
attributes:
|
||||
label: 涉及的工作区
|
||||
multiple: true
|
||||
options:
|
||||
- 后端 (backend/)
|
||||
- Web 前端 (BillNote_frontend/)
|
||||
- 浏览器插件 (BillNote_extension/)
|
||||
- Tauri 桌面端
|
||||
- 不确定
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 其他补充
|
||||
description: 关联 issue、参考资料、产品截图等
|
||||
29
.github/ISSUE_TEMPLATE/新增功能建议.md
vendored
29
.github/ISSUE_TEMPLATE/新增功能建议.md
vendored
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: 新增功能建议
|
||||
about: 一些新的功能建议
|
||||
title: "[FEATHURE]"
|
||||
labels: enhancement
|
||||
assignees: JefferyHcool
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
name: ✨ 功能请求
|
||||
about: 提出一个新的功能建议
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**这个功能请求是否与某个问题相关?请描述**
|
||||
清晰简要地描述问题是什么。例如:每次遇到 [...] 都让我感到很沮丧。
|
||||
|
||||
**描述你希望实现的解决方案**
|
||||
清晰简要地描述你希望发生的事情。
|
||||
|
||||
**描述你考虑过的备选方案**
|
||||
清晰简要地描述你考虑过的其他解决方案或功能。
|
||||
|
||||
**其他补充信息**
|
||||
请在此添加关于功能请求的其他上下文或截图。
|
||||
39
.github/pull_request_template.md
vendored
Normal file
39
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
PR 标题请遵循 type(scope): subject 格式,例如:
|
||||
feat(extension): 侧边栏接入思维导图
|
||||
fix(bilibili): 修正字幕优先链路在未登录态下的回退
|
||||
分支命名 / 提交规范见 CONTRIBUTING.md。
|
||||
-->
|
||||
|
||||
## 改动概述
|
||||
|
||||
<!-- 一句话说清这个 PR 做了什么 -->
|
||||
|
||||
## 为什么
|
||||
|
||||
<!-- 背景、关联 issue(Fixes #xxx / Refs #xxx)、用户场景 -->
|
||||
|
||||
## 做了什么
|
||||
|
||||
<!-- 关键文件、关键决策。可贴关键片段或截图 -->
|
||||
|
||||
## 测试方式
|
||||
|
||||
- [ ] `pnpm typecheck && pnpm build`(前端 / 插件)通过
|
||||
- [ ] `python -m py_compile <文件>` 或本地 backend 启动验证(后端)通过
|
||||
- [ ] 手动验证步骤:
|
||||
<!-- 描述如何复现验证;UI 改动请附截图 / 录屏 -->
|
||||
|
||||
## 回归风险
|
||||
|
||||
<!-- 影响面、可能受波及的功能、是否需要前后端 / 配置 同步部署 -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] 分支命名遵循 [CONTRIBUTING.md §3](../CONTRIBUTING.md#3-分支命名)(`feature/*` / `fix/*` / `release/*` / `hotfix/*`)
|
||||
- [ ] base 分支正确(常规改动 → `develop`;线上紧急 → `master`;发版 → 见 §4.3)
|
||||
- [ ] Commit message 遵循 `type(scope): subject` 格式([CONTRIBUTING.md §5.1](../CONTRIBUTING.md#51-commit-message-格式))
|
||||
- [ ] 已自测核心流程
|
||||
- [ ] 已更新相关文档(`README.md` / `CHANGELOG.md` / `CLAUDE.md` / 模块 README,如适用)
|
||||
- [ ] 未夹带 secrets / `.env` / 大型二进制
|
||||
- [ ] 单 PR 不跨多个工作区做无关改动
|
||||
30
.github/workflows/commitlint.yml
vendored
Normal file
30
.github/workflows/commitlint.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Commit Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
name: Lint commit messages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run commitlint
|
||||
uses: wagoid/commitlint-github-action@v6
|
||||
with:
|
||||
configFile: .commitlintrc.json
|
||||
failOnWarnings: false
|
||||
helpURL: https://github.com/JefferyHcool/BiliNote/blob/develop/CONTRIBUTING.md#5-提交规范
|
||||
16
.github/workflows/main.yml
vendored
16
.github/workflows/main.yml
vendored
@@ -13,8 +13,6 @@ jobs:
|
||||
include:
|
||||
- platform: macos-latest
|
||||
target: universal-apple-darwin
|
||||
- platform: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
@@ -24,13 +22,6 @@ jobs:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Linux 系统依赖(Tauri 需要)
|
||||
- name: Install Linux Dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
# 设置 Python 环境(带 pip 缓存)
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -54,10 +45,12 @@ jobs:
|
||||
fi
|
||||
|
||||
# 设置 pnpm
|
||||
# 不能用 'latest':pnpm 11+ 要求 Node 22+,与下方 Node 20 不兼容(ERR_UNKNOWN_BUILTIN_MODULE)。
|
||||
# lockfile 是 pnpm 9 生成;统一 pin 到 9.15.0
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 'latest'
|
||||
version: '9.15.0'
|
||||
|
||||
# 设置 Node 环境
|
||||
- name: Set up Node.js
|
||||
@@ -103,9 +96,6 @@ jobs:
|
||||
# Windows: .msi, .exe (NSIS)
|
||||
find "$BUNDLE_DIR" -name "*.msi" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
# Linux: .deb, .AppImage
|
||||
find "$BUNDLE_DIR" -name "*.deb" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
find "$BUNDLE_DIR" -name "*.AppImage" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
|
||||
echo "=== Collected artifacts ==="
|
||||
ls -lh release-artifacts/
|
||||
|
||||
115
.github/workflows/release-extension.yml
vendored
Normal file
115
.github/workflows/release-extension.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
name: Release Extension
|
||||
|
||||
# 在 v* tag push 时触发,构建插件并把产物挂到对应 GitHub Release。
|
||||
# 商店上传仍走人工(详见 RELEASING.md);如果将来配齐了商店 API secrets,
|
||||
# 把本文件末尾注释的 publish-* job 解开就是自动发布。
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & attach to release
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: BillNote_extension
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
cache-dependency-path: BillNote_extension/pnpm-lock.yaml
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Pack zip (Chrome / Edge upload format)
|
||||
run: pnpm pack:zip
|
||||
|
||||
- name: Pack xpi (Firefox Add-ons)
|
||||
run: pnpm pack:xpi
|
||||
|
||||
- name: Pack crx (self-host sideload)
|
||||
# crx 需要稳定 key.pem 才能保持插件 ID 不变;CI 没有就跳过,不阻塞主流程。
|
||||
# 想生成稳定 crx:把 key 存到 secret EXTENSION_CRX_KEY,下面解开几行。
|
||||
run: |
|
||||
# if [ -n "${{ secrets.EXTENSION_CRX_KEY }}" ]; then
|
||||
# echo "${{ secrets.EXTENSION_CRX_KEY }}" > key.pem
|
||||
# pnpm pack:crx
|
||||
# else
|
||||
pnpm pack:crx || true
|
||||
# fi
|
||||
continue-on-error: true
|
||||
|
||||
- name: Rename artifacts with version suffix
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
[ -f extension.zip ] && mv extension.zip "bilinote-extension-${VERSION}.zip"
|
||||
[ -f extension.xpi ] && mv extension.xpi "bilinote-extension-${VERSION}.xpi"
|
||||
[ -f extension.crx ] && mv extension.crx "bilinote-extension-${VERSION}.crx"
|
||||
ls -la *.zip *.xpi *.crx 2>/dev/null || true
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
BillNote_extension/bilinote-extension-*.zip
|
||||
BillNote_extension/bilinote-extension-*.xpi
|
||||
BillNote_extension/bilinote-extension-*.crx
|
||||
fail_on_unmatched_files: false
|
||||
generate_release_notes: false
|
||||
|
||||
# ---------- 商店自动发布(默认禁用,配齐 secrets 后可启用) ----------
|
||||
#
|
||||
# publish-chrome:
|
||||
# needs: build
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/download-artifact@v4
|
||||
# - uses: mnao305/chrome-extension-upload@v5
|
||||
# with:
|
||||
# file-path: BillNote_extension/bilinote-extension-${{ github.ref_name }}.zip
|
||||
# extension-id: ${{ secrets.CHROME_EXTENSION_ID }}
|
||||
# client-id: ${{ secrets.CHROME_CLIENT_ID }}
|
||||
# client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
|
||||
# refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
|
||||
#
|
||||
# publish-edge:
|
||||
# needs: build
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: wdzeng/edge-addon@v2
|
||||
# with:
|
||||
# product-id: ${{ secrets.EDGE_PRODUCT_ID }}
|
||||
# zip-path: BillNote_extension/bilinote-extension-${{ github.ref_name }}.zip
|
||||
# client-id: ${{ secrets.EDGE_CLIENT_ID }}
|
||||
# api-key: ${{ secrets.EDGE_API_KEY }}
|
||||
#
|
||||
# publish-firefox:
|
||||
# needs: build
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: trmcnvn/firefox-addon@v3
|
||||
# with:
|
||||
# uuid: ${{ secrets.FIREFOX_ADDON_UUID }}
|
||||
# xpi: BillNote_extension/bilinote-extension-${{ github.ref_name }}.xpi
|
||||
# api-key: ${{ secrets.FIREFOX_API_KEY }}
|
||||
# api-secret: ${{ secrets.FIREFOX_API_SECRET }}
|
||||
@@ -70,6 +70,7 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
|
||||
// B 站:先在浏览器里抓字幕(带本地登录态 cookie),随提交带过去
|
||||
const prefetched = platform === 'bilibili' ? await fetchBilibiliSubtitle(url) : null
|
||||
|
||||
const formats = settings.formats || []
|
||||
try {
|
||||
const res = await fetch(`${backend}/api/generate_note`, {
|
||||
method: 'POST',
|
||||
@@ -80,13 +81,15 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
|
||||
quality: settings.quality,
|
||||
provider_id: settings.providerId,
|
||||
model_name: settings.modelName,
|
||||
screenshot: settings.screenshot,
|
||||
link: settings.link,
|
||||
// backend 同时接受 format 数组与 screenshot/link 单独布尔;从 formats 派生保持单一真相源
|
||||
format: [...formats],
|
||||
screenshot: formats.includes('screenshot'),
|
||||
link: formats.includes('link'),
|
||||
style: settings.style || undefined,
|
||||
format: [
|
||||
...(settings.screenshot ? ['screenshot'] : []),
|
||||
...(settings.link ? ['link'] : []),
|
||||
],
|
||||
extras: settings.extras || undefined,
|
||||
video_understanding: settings.video_understanding || undefined,
|
||||
video_interval: settings.video_understanding ? settings.video_interval : undefined,
|
||||
grid_size: settings.video_understanding ? settings.grid_size : undefined,
|
||||
prefetched_transcript: prefetched ?? undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -7,9 +7,14 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
providerId: '',
|
||||
modelName: '',
|
||||
quality: 'medium',
|
||||
formats: ['toc', 'summary'],
|
||||
screenshot: false,
|
||||
link: false,
|
||||
style: '',
|
||||
style: 'minimal',
|
||||
extras: '',
|
||||
video_understanding: false,
|
||||
video_interval: 6,
|
||||
grid_size: [2, 2],
|
||||
}
|
||||
|
||||
export const MAX_TASKS = 30
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface GenerateRequest {
|
||||
format?: string[]
|
||||
style?: string
|
||||
extras?: string
|
||||
video_understanding?: boolean
|
||||
video_interval?: number
|
||||
grid_size?: [number, number]
|
||||
// 客户端在浏览器里直接抓到的字幕,跳过后端的 download_subtitles + 音频转写
|
||||
prefetched_transcript?: {
|
||||
language: string
|
||||
@@ -78,14 +81,52 @@ export interface TaskRecord {
|
||||
result?: NoteResult
|
||||
}
|
||||
|
||||
// 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐
|
||||
export type NoteStyle =
|
||||
| 'minimal' | 'detailed' | 'academic' | 'tutorial'
|
||||
| 'xiaohongshu' | 'life_journal' | 'task_oriented'
|
||||
| 'business' | 'meeting_minutes'
|
||||
|
||||
// 与 backend/app/gpt/prompt_builder.py note_formats 一一对齐
|
||||
export type NoteFormat = 'toc' | 'link' | 'screenshot' | 'summary'
|
||||
|
||||
export const NOTE_STYLES: Array<{ value: NoteStyle, label: string }> = [
|
||||
{ value: 'minimal', label: '精简' },
|
||||
{ value: 'detailed', label: '详细' },
|
||||
{ value: 'tutorial', label: '教程' },
|
||||
{ value: 'academic', label: '学术' },
|
||||
{ value: 'xiaohongshu', label: '小红书' },
|
||||
{ value: 'life_journal', label: '生活向' },
|
||||
{ value: 'task_oriented', label: '任务导向' },
|
||||
{ value: 'business', label: '商业风格' },
|
||||
{ value: 'meeting_minutes', label: '会议纪要' },
|
||||
]
|
||||
|
||||
export const NOTE_FORMATS: Array<{ value: NoteFormat, label: string }> = [
|
||||
{ value: 'toc', label: '目录' },
|
||||
{ value: 'summary', label: 'AI 总结' },
|
||||
{ value: 'screenshot', label: '原片截图' },
|
||||
{ value: 'link', label: '原片跳转' },
|
||||
]
|
||||
|
||||
export interface Settings {
|
||||
backendUrl: string
|
||||
providerId: string
|
||||
modelName: string
|
||||
quality: Quality
|
||||
// 输出 format 的 toggle 集合(screenshot / link 与下方两个布尔保持联动)
|
||||
formats: NoteFormat[]
|
||||
screenshot: boolean
|
||||
link: boolean
|
||||
style: string
|
||||
style: NoteStyle
|
||||
extras: string
|
||||
// 多模态视频理解:抽帧拼图喂给视觉模型,提升画面相关问题的回答质量
|
||||
// 要求所选 model 是视觉模型(如 gpt-4o / gemini / claude-opus 系列),文字模型会忽略图片
|
||||
video_understanding: boolean
|
||||
// 抽帧间隔(秒),范围 1-30,默认 6
|
||||
video_interval: number
|
||||
// 拼图网格 [rows, cols],每张拼图最多 rows*cols 帧。默认 [2,2]
|
||||
grid_size: [number, number]
|
||||
}
|
||||
|
||||
export interface ProviderUpdatePayload {
|
||||
|
||||
@@ -3,9 +3,16 @@ import { onMounted, ref } from 'vue'
|
||||
import { getProviders, ping } from '~/logic/api'
|
||||
import { settings, settingsReady } from '~/logic/storage'
|
||||
import { getModelsByProvider } from '~/logic/api'
|
||||
import type { Model, Provider } from '~/logic/types'
|
||||
import { NOTE_FORMATS, NOTE_STYLES, type Model, type NoteFormat, type Provider } from '~/logic/types'
|
||||
import { watch } from 'vue'
|
||||
|
||||
function toggleFormat(value: NoteFormat, checked: boolean) {
|
||||
const cur = settings.value.formats || []
|
||||
settings.value.formats = checked
|
||||
? Array.from(new Set([...cur, value]))
|
||||
: cur.filter(v => v !== value)
|
||||
}
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const models = ref<Model[]>([])
|
||||
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
|
||||
@@ -128,13 +135,67 @@ onMounted(async () => {
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<input v-model="settings.style" class="input" placeholder="留空使用默认">
|
||||
<select v-model="settings.style" class="input">
|
||||
<option v-for="s in NOTE_STYLES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">输出形式(与 web 端 NoteForm 对齐)</span>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-2">
|
||||
<label v-for="f in NOTE_FORMATS" :key="f.value" class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(settings.formats || []).includes(f.value)"
|
||||
@change="toggleFormat(f.value, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">额外提示词(追加到 prompt 末尾)</span>
|
||||
<textarea
|
||||
v-model="settings.extras"
|
||||
class="input resize-y"
|
||||
rows="3"
|
||||
placeholder="例如:重点关注游戏开发部分;保留所有专业术语原文"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">视频理解(多模态)</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
启用后会按抽帧间隔截取视频帧拼成网格图,连同字幕一起喂给视觉模型,提升画面相关问题的回答质量。
|
||||
<strong class="text-amber-700">需要选择视觉模型</strong>(GPT-4o / Gemini / Claude 等),文字模型会忽略图片。
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="settings.video_understanding" type="checkbox">
|
||||
启用视频理解
|
||||
</label>
|
||||
<div v-if="settings.video_understanding" class="grid grid-cols-3 gap-3 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">抽帧间隔(秒, 1-30)</span>
|
||||
<input v-model.number="settings.video_interval" type="number" min="1" max="30" class="input">
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图行 (1-10)</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[0] ?? 2"
|
||||
type="number" min="1" max="10" class="input"
|
||||
@input="settings.grid_size = [Number(($event.target as HTMLInputElement).value) || 2, settings.grid_size?.[1] ?? 2]"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图列 (1-10)</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[1] ?? 2"
|
||||
type="number" min="1" max="10" class="input"
|
||||
@input="settings.grid_size = [settings.grid_size?.[0] ?? 2, Number(($event.target as HTMLInputElement).value) || 2]"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { detectPlatform } from '~/logic/platform'
|
||||
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
|
||||
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
|
||||
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||
import type { TaskRecord } from '~/logic/types'
|
||||
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
|
||||
|
||||
const tabUrl = ref<string>('')
|
||||
const tabTitle = ref<string>('')
|
||||
@@ -67,19 +67,22 @@ async function start() {
|
||||
try {
|
||||
// B 站:在用户浏览器里直接抓字幕(带本地登录态 cookie),跳过后端的 download_subtitles 与音频转写
|
||||
const prefetched = platform.value === 'bilibili' ? await fetchBilibiliSubtitle(tabUrl.value) : null
|
||||
const formats = settings.value.formats || []
|
||||
const { task_id } = await generateNote({
|
||||
video_url: tabUrl.value,
|
||||
platform: platform.value!,
|
||||
quality: settings.value.quality,
|
||||
provider_id: settings.value.providerId,
|
||||
model_name: settings.value.modelName,
|
||||
screenshot: settings.value.screenshot,
|
||||
link: settings.value.link,
|
||||
// backend VideoRequest 同时接受 format 数组与 screenshot/link 单独布尔,从 formats 派生保持单一真相源
|
||||
format: [...formats],
|
||||
screenshot: formats.includes('screenshot'),
|
||||
link: formats.includes('link'),
|
||||
style: settings.value.style || undefined,
|
||||
format: [
|
||||
...(settings.value.screenshot ? ['screenshot'] : []),
|
||||
...(settings.value.link ? ['link'] : []),
|
||||
],
|
||||
extras: settings.value.extras || undefined,
|
||||
video_understanding: settings.value.video_understanding || undefined,
|
||||
video_interval: settings.value.video_understanding ? settings.value.video_interval : undefined,
|
||||
grid_size: settings.value.video_understanding ? settings.value.grid_size : undefined,
|
||||
prefetched_transcript: prefetched ?? undefined,
|
||||
})
|
||||
activeTaskId.value = task_id
|
||||
@@ -108,6 +111,13 @@ function openOptions() {
|
||||
browser.runtime.openOptionsPage()
|
||||
}
|
||||
|
||||
function toggleFormat(value: NoteFormat, checked: boolean) {
|
||||
const cur = settings.value.formats || []
|
||||
settings.value.formats = checked
|
||||
? Array.from(new Set([...cur, value]))
|
||||
: cur.filter(v => v !== value)
|
||||
}
|
||||
|
||||
async function openSidePanel() {
|
||||
// 只能在用户操作触发的同步上下文里调,且需要明确的 tabId
|
||||
try {
|
||||
@@ -176,7 +186,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<fieldset class="border rounded p-2 flex flex-col gap-2" :disabled="!supported || submitting">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">画质</span>
|
||||
<select v-model="settings.quality" class="border rounded px-1 py-0.5">
|
||||
@@ -185,14 +195,76 @@ onUnmounted(() => {
|
||||
<option value="slow">高质</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-1 mt-4">
|
||||
<input v-model="settings.screenshot" type="checkbox"> 截图
|
||||
</label>
|
||||
<label class="flex items-center gap-1 mt-4">
|
||||
<input v-model="settings.link" type="checkbox"> 跳转
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<select v-model="settings.style" class="border rounded px-1 py-0.5">
|
||||
<option v-for="s in NOTE_STYLES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-gray-600">输出形式</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<label v-for="f in NOTE_FORMATS" :key="f.value" class="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(settings.formats || []).includes(f.value)"
|
||||
@change="toggleFormat(f.value, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="text-xs">
|
||||
<summary class="cursor-pointer text-gray-500">高级</summary>
|
||||
<label class="flex flex-col gap-1 mt-2">
|
||||
<span class="text-gray-600">额外提示词(追加到 prompt 末尾)</span>
|
||||
<textarea
|
||||
v-model="settings.extras"
|
||||
class="border rounded px-1 py-1 resize-y"
|
||||
rows="2"
|
||||
placeholder="例如:重点关注游戏开发部分;保留所有专业术语原文"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 mt-2">
|
||||
<input v-model="settings.video_understanding" type="checkbox">
|
||||
<span class="text-gray-600">启用视频理解(抽帧拼图喂视觉模型)</span>
|
||||
</label>
|
||||
<div v-if="settings.video_understanding" class="grid grid-cols-3 gap-2 mt-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">抽帧间隔(秒)</span>
|
||||
<input
|
||||
v-model.number="settings.video_interval"
|
||||
type="number" min="1" max="30"
|
||||
class="border rounded px-1 py-0.5"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图行</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[0] ?? 2"
|
||||
type="number" min="1" max="10"
|
||||
class="border rounded px-1 py-0.5"
|
||||
@input="settings.grid_size = [Number(($event.target as HTMLInputElement).value) || 2, settings.grid_size?.[1] ?? 2]"
|
||||
>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">拼图列</span>
|
||||
<input
|
||||
:value="settings.grid_size?.[1] ?? 2"
|
||||
type="number" min="1" max="10"
|
||||
class="border rounded px-1 py-0.5"
|
||||
@input="settings.grid_size = [settings.grid_size?.[0] ?? 2, Number(($event.target as HTMLInputElement).value) || 2]"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="settings.video_understanding" class="text-amber-700 mt-1">
|
||||
⚠ 需要选择视觉模型(GPT-4o / Gemini / Claude 等),文字模型会忽略图片
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<div class="text-xs text-gray-600">
|
||||
<span v-if="settings.providerId && settings.modelName">
|
||||
模型:{{ settings.modelName }}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# === 前端构建阶段 ===
|
||||
FROM node:18-alpine AS builder
|
||||
# Tailwind v4 / Vite 6 需要 Node 20+,alpine + pnpm 会按 lockfile 拉 musl native binary。
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# pnpm pin 到 9.x:lockfile 是 v9 生成;pnpm 11 要求 Node 22+ 与 node:20 不兼容
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
10810
BillNote_frontend/pnpm-lock.yaml
generated
Normal file
10810
BillNote_frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,14 @@
|
||||
use tauri::{Manager, Emitter};
|
||||
use tauri::{Manager, Emitter, State};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::process::CommandEvent;
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use std::env;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use serde::Serialize;
|
||||
|
||||
// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程
|
||||
struct SidecarHandle(Mutex<Option<CommandChild>>);
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -18,77 +24,31 @@ pub fn run() {
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().expect("无法获取当前可执行文件路径");
|
||||
let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录");
|
||||
|
||||
// 收集所有系统环境变量
|
||||
let mut all_env_vars = HashMap::new();
|
||||
for (key, value) in env::vars() {
|
||||
all_env_vars.insert(key, value);
|
||||
// 安装路径诊断:PyInstaller sidecar 在含非 ASCII / 空格的路径下经常炸(README 已警告但缺主动防御)
|
||||
// 命中时把诊断信息 emit 给前端,由顶端横幅展示,不阻断启动
|
||||
let diag = analyze_install_path(&exe_path);
|
||||
if diag.path_has_non_ascii || diag.path_has_space || !diag.parent_writable {
|
||||
let app_handle = app.handle().clone();
|
||||
// 等前端首屏挂载好 listener;setup 阶段 window 已存在但 React 还没 render
|
||||
// 用独立线程 + 标准 sleep,不引入 tokio 依赖
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.emit("backend-warning", &diag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 增强 PATH 环境变量,添加常见的二进制路径
|
||||
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
|
||||
let additional_paths = get_additional_binary_paths();
|
||||
let enhanced_path = enhance_path_variable(¤t_path, &additional_paths);
|
||||
all_env_vars.insert("PATH".to_string(), enhanced_path);
|
||||
|
||||
// 打印一些关键环境变量用于调试
|
||||
println!("Enhanced PATH: {}", all_env_vars.get("PATH").unwrap_or(&"Not found".to_string()));
|
||||
println!("Total environment variables: {}", all_env_vars.len());
|
||||
|
||||
// 检查 ffmpeg 是否在 PATH 中可用
|
||||
check_ffmpeg_availability();
|
||||
|
||||
// 启动 Python 后端侧车
|
||||
let mut sidecar_command = app.shell().sidecar("BiliNoteBackend").unwrap();
|
||||
|
||||
// 设置所有环境变量到 sidecar
|
||||
for (key, value) in &all_env_vars {
|
||||
sidecar_command = sidecar_command.env(key, value);
|
||||
}
|
||||
|
||||
let (mut rx, _child) = sidecar_command
|
||||
.current_dir(sidecar_dir)
|
||||
.spawn()
|
||||
.expect("Failed to spawn sidecar");
|
||||
|
||||
// 获取主窗口句柄用于发送事件
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 读取诸如 stdout 之类的事件
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
let output = String::from_utf8_lossy(&line);
|
||||
println!("Backend stdout: {}", output);
|
||||
|
||||
// 发送到前端
|
||||
window
|
||||
.emit("backend-message", Some(format!("'{}'", output)))
|
||||
.expect("failed to emit event");
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
let error = String::from_utf8_lossy(&line);
|
||||
eprintln!("Backend stderr: {}", error);
|
||||
|
||||
window
|
||||
.emit("backend-error", Some(format!("'{}'", error)))
|
||||
.expect("failed to emit event");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
println!("Backend terminated with code: {:?}", payload.code);
|
||||
window
|
||||
.emit("backend-terminated", Some(payload.code))
|
||||
.expect("failed to emit event");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("Backend event: {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// 启动 Sidecar 并把 child handle 存到 state,方便后续 restart_backend_sidecar 使用
|
||||
let child = spawn_backend_sidecar(app.handle()).map_err(|e| {
|
||||
eprintln!("Sidecar 启动失败: {}", e);
|
||||
e
|
||||
})?;
|
||||
app.manage(SidecarHandle(Mutex::new(Some(child))));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -96,7 +56,9 @@ pub fn run() {
|
||||
get_system_env_vars,
|
||||
find_executable_path,
|
||||
run_command_with_env,
|
||||
test_ffmpeg_access
|
||||
test_ffmpeg_access,
|
||||
get_install_path_diagnostics,
|
||||
restart_backend_sidecar
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
@@ -268,6 +230,150 @@ async fn test_ffmpeg_access() -> Result<String, String> {
|
||||
run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await
|
||||
}
|
||||
|
||||
// 启动后端 Sidecar:负责装环境变量、spawn、挂 stdout/stderr/terminated 监听并 emit 给前端。
|
||||
// 第一次启动 + restart_backend_sidecar 都走这里,保持单一启动路径。
|
||||
fn spawn_backend_sidecar(app_handle: &tauri::AppHandle) -> Result<CommandChild, String> {
|
||||
let exe_path = env::current_exe().map_err(|e| format!("无法获取可执行文件路径: {}", e))?;
|
||||
let sidecar_dir = exe_path
|
||||
.parent()
|
||||
.ok_or("无法获取可执行文件父目录")?
|
||||
.to_path_buf();
|
||||
|
||||
// 收集所有系统环境变量并增强 PATH(含 ffmpeg 常见安装位置)
|
||||
let mut all_env_vars = HashMap::new();
|
||||
for (key, value) in env::vars() {
|
||||
all_env_vars.insert(key, value);
|
||||
}
|
||||
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
|
||||
let additional_paths = get_additional_binary_paths();
|
||||
let enhanced_path = enhance_path_variable(¤t_path, &additional_paths);
|
||||
all_env_vars.insert("PATH".to_string(), enhanced_path);
|
||||
|
||||
let mut sidecar_command = app_handle
|
||||
.shell()
|
||||
.sidecar("BiliNoteBackend")
|
||||
.map_err(|e| format!("找不到 BiliNoteBackend sidecar: {}", e))?;
|
||||
for (key, value) in &all_env_vars {
|
||||
sidecar_command = sidecar_command.env(key, value);
|
||||
}
|
||||
|
||||
let (mut rx, child) = sidecar_command
|
||||
.current_dir(sidecar_dir)
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn sidecar 失败: {}", e))?;
|
||||
|
||||
// 异步监听 stdout / stderr / terminated 事件,转发到前端 webview
|
||||
let app_handle_for_listener = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
// window 句柄每次重新取,允许窗口关闭重开
|
||||
let window = app_handle_for_listener.get_webview_window("main");
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
let output = String::from_utf8_lossy(&line).to_string();
|
||||
println!("Backend stdout: {}", output);
|
||||
if let Some(w) = window {
|
||||
let _ = w.emit("backend-message", Some(output));
|
||||
}
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
let error = String::from_utf8_lossy(&line).to_string();
|
||||
eprintln!("Backend stderr: {}", error);
|
||||
if let Some(w) = window {
|
||||
let _ = w.emit("backend-error", Some(error));
|
||||
}
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
println!("Backend terminated with code: {:?}", payload.code);
|
||||
if let Some(w) = window {
|
||||
let _ = w.emit("backend-terminated", Some(payload.code));
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("Backend event: {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
// 重启 sidecar:杀旧 child,spawn 新 child,回写到 state。
|
||||
#[tauri::command]
|
||||
fn restart_backend_sidecar(
|
||||
state: State<'_, SidecarHandle>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
// 1. 拿出旧 child 并 kill(kill 失败也继续,可能进程已经退了)
|
||||
{
|
||||
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
|
||||
if let Some(child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
// 2. 重新 spawn
|
||||
let new_child = spawn_backend_sidecar(&app)?;
|
||||
{
|
||||
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
|
||||
*guard = Some(new_child);
|
||||
}
|
||||
// 3. emit 一个事件让前端知道已重启
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.emit("backend-restarted", ());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 安装路径诊断:PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸;
|
||||
// 父目录不可写时模型 / 配置 / 日志也无法落盘
|
||||
#[derive(Serialize, Clone)]
|
||||
struct InstallPathDiagnostics {
|
||||
exe_path: String,
|
||||
path_has_non_ascii: bool,
|
||||
path_has_space: bool,
|
||||
parent_writable: bool,
|
||||
platform: String,
|
||||
}
|
||||
|
||||
fn analyze_install_path(exe_path: &Path) -> InstallPathDiagnostics {
|
||||
let path_str = exe_path.to_string_lossy().to_string();
|
||||
// 不在 ASCII 范围内的字符(中文 / 日文 / 西里尔等都会命中 PyInstaller 路径解析坑)
|
||||
let has_non_ascii = path_str.chars().any(|c| !c.is_ascii());
|
||||
// 空格本身在 Windows shell 引号场景偶尔出问题,且 macOS path 里也偶尔触发 sidecar 启动失败
|
||||
let has_space = path_str.contains(' ');
|
||||
// 父目录可写:PyInstaller 解压 _internal/、写日志、写配置都需要这个
|
||||
let parent = exe_path.parent();
|
||||
let parent_writable = parent
|
||||
.and_then(|p| {
|
||||
let probe = p.join(".bilinote_write_probe");
|
||||
match std::fs::write(&probe, b"x") {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Some(true)
|
||||
}
|
||||
Err(_) => Some(false),
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
InstallPathDiagnostics {
|
||||
exe_path: path_str,
|
||||
path_has_non_ascii: has_non_ascii,
|
||||
path_has_space: has_space,
|
||||
parent_writable,
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri 命令:让前端按需重新查询诊断结果(比如用户卸载到新目录后重启)
|
||||
#[tauri::command]
|
||||
fn get_install_path_diagnostics() -> InstallPathDiagnostics {
|
||||
let exe_path = env::current_exe().unwrap_or_default();
|
||||
analyze_install_path(&exe_path)
|
||||
}
|
||||
|
||||
// 可选:添加一个函数来动态更新 sidecar 的环境变量
|
||||
#[tauri::command]
|
||||
async fn update_sidecar_environment(
|
||||
|
||||
@@ -5,11 +5,23 @@ import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
||||
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||
import { systemCheck } from '@/services/system.ts'
|
||||
import BackendInitDialog from '@/components/BackendInitDialog'
|
||||
import StartupBanner from '@/components/SystemDiagnostic/StartupBanner'
|
||||
import BackendHealthIndicator from '@/components/BackendHealth/BackendHealthIndicator'
|
||||
import Index from '@/pages/Index.tsx'
|
||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||
|
||||
// 非首屏页面使用 React.lazy 按需加载
|
||||
const Onboarding = lazy(() => import('@/pages/Onboarding'))
|
||||
const SettingPage = lazy(() => import('./pages/SettingPage/index.tsx'))
|
||||
|
||||
// 桌面端首启引导守卫:未完成 onboarding 时强制跳到 /onboarding
|
||||
function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
// 仅在 Tauri 桌面端拦截;纯 web 端不打扰用户
|
||||
if (!isTauri) return <>{children}</>
|
||||
if (localStorage.getItem('bilinote-onboarded') !== '1') return <Navigate to="/onboarding" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
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'))
|
||||
@@ -34,6 +46,7 @@ function App() {
|
||||
if (!initialized) {
|
||||
return (
|
||||
<>
|
||||
<StartupBanner />
|
||||
<BackendInitDialog open={loading} />
|
||||
</>
|
||||
)
|
||||
@@ -42,10 +55,13 @@ function App() {
|
||||
// 后端已初始化,渲染主应用
|
||||
return (
|
||||
<>
|
||||
<StartupBanner />
|
||||
<BackendHealthIndicator />
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />}>
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route path="/" element={<OnboardingGuard><Index /></OnboardingGuard>}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="settings" element={<SettingPage />}>
|
||||
<Route index element={<Navigate to="model" replace />} />
|
||||
|
||||
BIN
BillNote_frontend/src/assets/wechat.png
Normal file
BIN
BillNote_frontend/src/assets/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useBackendEvents } from './useBackendEvents'
|
||||
import BackendLogPanel from './BackendLogPanel'
|
||||
|
||||
// 健康度判定:
|
||||
// - 绿:sidecar running 且 /sys_health 通
|
||||
// - 黄:sidecar running 但 /sys_health 失败 (ffmpeg 缺等)
|
||||
// - 红:sidecar terminated 或 /sys_health 连续 3 次失败
|
||||
|
||||
type Health = 'green' | 'yellow' | 'red' | 'unknown'
|
||||
|
||||
const HEALTH_POLL_MS = 5000
|
||||
const SYS_HEALTH_PATH = '/api/sys_health'
|
||||
|
||||
function backendBase(): string {
|
||||
// 与 services/request.ts 用的一致
|
||||
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
|
||||
return (fromEnv ?? '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const BackendHealthIndicator = () => {
|
||||
const { status, isTauri, exitCode, logs, restart, copyLogs } = useBackendEvents()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [healthCheckFailures, setHealthCheckFailures] = useState(0)
|
||||
const [lastHealthOk, setLastHealthOk] = useState<boolean | null>(null)
|
||||
|
||||
// 仅在 Tauri 环境挂指示器;纯 web 用户由 useCheckBackend 接管
|
||||
useEffect(() => {
|
||||
if (!isTauri) return
|
||||
let mounted = true
|
||||
|
||||
async function ping() {
|
||||
try {
|
||||
const res = await fetch(`${backendBase()}${SYS_HEALTH_PATH}`)
|
||||
const ok = res.ok
|
||||
if (!mounted) return
|
||||
if (ok) {
|
||||
setHealthCheckFailures(0)
|
||||
setLastHealthOk(true)
|
||||
}
|
||||
else {
|
||||
setHealthCheckFailures(c => c + 1)
|
||||
setLastHealthOk(false)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if (!mounted) return
|
||||
setHealthCheckFailures(c => c + 1)
|
||||
setLastHealthOk(false)
|
||||
}
|
||||
}
|
||||
|
||||
ping()
|
||||
const t = setInterval(ping, HEALTH_POLL_MS)
|
||||
return () => {
|
||||
mounted = false
|
||||
clearInterval(t)
|
||||
}
|
||||
}, [isTauri])
|
||||
|
||||
if (!isTauri) return null
|
||||
|
||||
const health: Health = (() => {
|
||||
if (status === 'terminated') return 'red'
|
||||
if (healthCheckFailures >= 3) return 'red'
|
||||
if (lastHealthOk === false) return 'yellow'
|
||||
if (lastHealthOk === true) return 'green'
|
||||
return 'unknown'
|
||||
})()
|
||||
|
||||
const colorMap: Record<Health, string> = {
|
||||
green: 'bg-green-500',
|
||||
yellow: 'bg-amber-500',
|
||||
red: 'bg-red-500',
|
||||
unknown: 'bg-gray-400',
|
||||
}
|
||||
|
||||
const labelMap: Record<Health, string> = {
|
||||
green: '后端运行正常',
|
||||
yellow: '后端运行中(部分检查未通过)',
|
||||
red: status === 'terminated' ? `后端已退出 (code=${exitCode ?? 'unknown'})` : '后端无响应',
|
||||
unknown: '后端状态未知',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="fixed right-3 bottom-3 z-[9998] flex items-center gap-2 rounded-full border bg-white px-3 py-1.5 text-xs shadow hover:shadow-md"
|
||||
title={labelMap[health]}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${colorMap[health]}${health === 'red' || health === 'yellow' ? ' animate-pulse' : ''}`} />
|
||||
<span className="text-gray-700">后端</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<BackendLogPanel
|
||||
status={status}
|
||||
exitCode={exitCode}
|
||||
logs={logs}
|
||||
health={health}
|
||||
onRestart={restart}
|
||||
onCopyLogs={copyLogs}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackendHealthIndicator
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { LogEntry, BackendStatus } from './useBackendEvents'
|
||||
|
||||
interface Props {
|
||||
status: BackendStatus
|
||||
exitCode: number | null
|
||||
logs: LogEntry[]
|
||||
health: 'green' | 'yellow' | 'red' | 'unknown'
|
||||
onRestart: () => Promise<void>
|
||||
onCopyLogs: () => Promise<boolean>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const BackendLogPanel = ({ status, exitCode, logs, health, onRestart, onCopyLogs, onClose }: Props) => {
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 新日志进来自动滚到底
|
||||
useEffect(() => {
|
||||
if (scrollRef.current)
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}, [logs])
|
||||
|
||||
async function handleRestart() {
|
||||
setRestarting(true)
|
||||
try { await onRestart() }
|
||||
catch { /* errors already in log via useBackendEvents */ }
|
||||
finally { setRestarting(false) }
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const ok = await onCopyLogs()
|
||||
setCopied(ok)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 半透明遮罩 */}
|
||||
<div className="fixed inset-0 z-[9998] bg-black/20" onClick={onClose} />
|
||||
|
||||
<aside className="fixed right-0 bottom-0 top-0 z-[9999] flex w-[480px] max-w-[90vw] flex-col border-l bg-white shadow-2xl">
|
||||
<header className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">后端运行状态</h2>
|
||||
<div className="mt-0.5 text-xs text-gray-500">
|
||||
{status === 'terminated'
|
||||
? `已退出(退出码 ${exitCode ?? 'unknown'})`
|
||||
: health === 'red'
|
||||
? '运行中但无响应'
|
||||
: health === 'yellow'
|
||||
? '运行中,部分系统检查未通过'
|
||||
: '运行正常'}
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded p-1 text-gray-500 hover:bg-gray-100" onClick={onClose}>✕</button>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<button
|
||||
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={restarting}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
{restarting ? '重启中…' : '重启后端'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? '已复制 ✓' : '复制日志'}
|
||||
</button>
|
||||
<span className="ml-auto text-xs text-gray-400">
|
||||
最近 {logs.length} 行
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto bg-gray-900 p-3 font-mono text-xs text-gray-100"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-gray-500 italic">暂无日志输出</div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div
|
||||
key={`${l.ts}-${i}`}
|
||||
className={`whitespace-pre-wrap break-all leading-snug ${l.level === 'error' ? 'text-red-300' : 'text-gray-100'}`}
|
||||
>
|
||||
<span className="mr-2 text-gray-500">
|
||||
{new Date(l.ts).toISOString().slice(11, 19)}
|
||||
</span>
|
||||
{l.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="border-t px-4 py-2 text-xs text-gray-500">
|
||||
后端进程退出 / 无响应时,先点「重启后端」;仍不行复制日志去 issue 反馈。
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackendLogPanel
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// 桌面端 Sidecar 健康度。监听 Tauri 侧的 backend-message / backend-error /
|
||||
// backend-terminated / backend-restarted 事件,把 stdout/stderr 缓冲成 ring buffer,
|
||||
// 同时维护进程运行状态。
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
export type LogLevel = 'info' | 'error'
|
||||
|
||||
export interface LogEntry {
|
||||
level: LogLevel
|
||||
text: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
export type BackendStatus = 'running' | 'terminated'
|
||||
|
||||
const MAX_LOG_LINES = 200
|
||||
|
||||
interface BackendEvents {
|
||||
status: BackendStatus
|
||||
exitCode: number | null
|
||||
logs: LogEntry[]
|
||||
/** 调 Tauri 命令重启 sidecar */
|
||||
restart: () => Promise<void>
|
||||
/** 复制全部日志到剪贴板 */
|
||||
copyLogs: () => Promise<boolean>
|
||||
isTauri: boolean
|
||||
}
|
||||
|
||||
export function useBackendEvents(): BackendEvents {
|
||||
const [status, setStatus] = useState<BackendStatus>('running')
|
||||
const [exitCode, setExitCode] = useState<number | null>(null)
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
// 用 ref 持有最新 logs 数组,append 时不被闭包陷阱卡到旧值
|
||||
const logsRef = useRef<LogEntry[]>([])
|
||||
|
||||
function append(entry: LogEntry) {
|
||||
const next = logsRef.current.concat(entry)
|
||||
if (next.length > MAX_LOG_LINES)
|
||||
next.splice(0, next.length - MAX_LOG_LINES)
|
||||
logsRef.current = next
|
||||
setLogs(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return
|
||||
|
||||
let unlisteners: Array<() => void> = []
|
||||
|
||||
;(async () => {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
|
||||
const offMsg = await listen<string>('backend-message', event => {
|
||||
append({ level: 'info', text: stripQuotes(event.payload), ts: Date.now() })
|
||||
})
|
||||
const offErr = await listen<string>('backend-error', event => {
|
||||
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() })
|
||||
})
|
||||
const offTerm = await listen<number | null>('backend-terminated', event => {
|
||||
setStatus('terminated')
|
||||
setExitCode(event.payload ?? null)
|
||||
append({
|
||||
level: 'error',
|
||||
text: `[Backend terminated] code=${event.payload ?? 'unknown'}`,
|
||||
ts: Date.now(),
|
||||
})
|
||||
})
|
||||
const offRestart = await listen('backend-restarted', () => {
|
||||
setStatus('running')
|
||||
setExitCode(null)
|
||||
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
|
||||
})
|
||||
|
||||
unlisteners = [offMsg, offErr, offTerm, offRestart]
|
||||
})()
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach(fn => fn())
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function restart() {
|
||||
if (!isTauri) return
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
await invoke('restart_backend_sidecar')
|
||||
}
|
||||
catch (e) {
|
||||
append({ level: 'error', text: `[Restart failed] ${(e as Error).message ?? e}`, ts: Date.now() })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLogs() {
|
||||
const text = logsRef.current
|
||||
.map(l => `${new Date(l.ts).toISOString().slice(11, 19)} ${l.level === 'error' ? 'E' : 'I'} ${l.text}`)
|
||||
.join('\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { status, exitCode, logs, restart, copyLogs, isTauri }
|
||||
}
|
||||
|
||||
// Rust 早期版本 emit 时把 stdout 包了一层 '...',新版本已经直接 emit 原文。
|
||||
// 这里做兼容:去掉外层单引号(如果有的话)。
|
||||
function stripQuotes(s: string): string {
|
||||
if (typeof s !== 'string') return String(s)
|
||||
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'"))
|
||||
return s.slice(1, -1)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 桌面端启动诊断横幅。监听 Tauri 侧 emit 的 backend-warning / backend-error / backend-terminated。
|
||||
// 只在 Tauri 环境生效;纯 web 环境(无 window.__TAURI_INTERNALS__)下静默不挂载。
|
||||
|
||||
type Severity = 'info' | 'warning' | 'error'
|
||||
|
||||
interface DiagnosticPayload {
|
||||
exe_path?: string
|
||||
path_has_non_ascii?: boolean
|
||||
path_has_space?: boolean
|
||||
parent_writable?: boolean
|
||||
platform?: string
|
||||
}
|
||||
|
||||
interface BannerState {
|
||||
severity: Severity
|
||||
title: string
|
||||
detail: string
|
||||
payload?: DiagnosticPayload
|
||||
dismissible: boolean
|
||||
}
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
function describeWarning(payload: DiagnosticPayload): { title: string; detail: string } {
|
||||
const parts: string[] = []
|
||||
if (payload.path_has_non_ascii) {
|
||||
parts.push('安装路径包含非 ASCII 字符(中文 / 日文等)')
|
||||
}
|
||||
if (payload.path_has_space) {
|
||||
parts.push('安装路径包含空格')
|
||||
}
|
||||
if (payload.parent_writable === false) {
|
||||
parts.push('安装目录不可写(缺少权限或只读)')
|
||||
}
|
||||
return {
|
||||
title: '检测到可能导致后端启动失败的安装路径',
|
||||
detail:
|
||||
`${parts.join(';')}。\n` +
|
||||
'建议把 BiliNote 重新安装到一个纯英文、无空格、可写的路径下(如 C:\\BiliNote\\ 或 /Applications/)。\n' +
|
||||
`当前路径:${payload.exe_path || '未知'}`,
|
||||
}
|
||||
}
|
||||
|
||||
const StartupBanner = () => {
|
||||
const [banner, setBanner] = useState<BannerState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return
|
||||
|
||||
let unlisteners: Array<() => void> = []
|
||||
|
||||
;(async () => {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
|
||||
const offWarning = await listen<DiagnosticPayload>('backend-warning', event => {
|
||||
const { title, detail } = describeWarning(event.payload || {})
|
||||
setBanner({
|
||||
severity: 'warning',
|
||||
title,
|
||||
detail,
|
||||
payload: event.payload,
|
||||
dismissible: true,
|
||||
})
|
||||
})
|
||||
|
||||
const offTerminated = await listen<number | null>('backend-terminated', event => {
|
||||
setBanner({
|
||||
severity: 'error',
|
||||
title: '后端进程已退出',
|
||||
detail: `退出码:${event.payload ?? '未知'}。打开「部署监控」或重启应用以恢复。`,
|
||||
dismissible: false,
|
||||
})
|
||||
})
|
||||
|
||||
// backend-error 是 sidecar stderr,量大噪音多,这里不直接展示,留给 P2 的日志面板。
|
||||
unlisteners = [offWarning, offTerminated]
|
||||
})()
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach(fn => fn())
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!banner) return null
|
||||
|
||||
const colorByLevel: Record<Severity, string> = {
|
||||
info: 'bg-blue-50 border-blue-300 text-blue-900',
|
||||
warning: 'bg-amber-50 border-amber-300 text-amber-900',
|
||||
error: 'bg-red-50 border-red-300 text-red-900',
|
||||
}
|
||||
|
||||
const iconByLevel: Record<Severity, string> = {
|
||||
info: 'ℹ️',
|
||||
warning: '⚠️',
|
||||
error: '✕',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed left-0 right-0 top-0 z-[9999] flex items-start gap-3 border-b px-4 py-2 text-sm shadow-sm ${colorByLevel[banner.severity]}`}
|
||||
>
|
||||
<span className="text-lg">{iconByLevel[banner.severity]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{banner.title}</div>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words font-sans text-xs opacity-90">
|
||||
{banner.detail}
|
||||
</pre>
|
||||
</div>
|
||||
{banner.dismissible && (
|
||||
<button
|
||||
className="shrink-0 rounded px-2 py-0.5 text-xs hover:bg-black/10"
|
||||
onClick={() => setBanner(null)}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StartupBanner
|
||||
265
BillNote_frontend/src/pages/Onboarding/index.tsx
Normal file
265
BillNote_frontend/src/pages/Onboarding/index.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { addProvider, addModel, getProviderList, testConnection } from '@/services/model'
|
||||
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
|
||||
import logo from '@/assets/icon.svg'
|
||||
|
||||
// 桌面端首启 4 步引导。完成后写 localStorage('bilinote-onboarded') = '1',路由守卫不再拦。
|
||||
//
|
||||
// 1. 后端连通性自检
|
||||
// 2. LLM 供应商 + 模型(最简:只引导填一个 OpenAI-兼容供应商 + 一个 model 名)
|
||||
// 3. 转写引擎选择(推荐 Groq 在线,避开本地模型下载坑)
|
||||
// 4. (可选)Cookie 同步说明(仅当用户关注 B 站等需要登录态的平台时)
|
||||
|
||||
const ONBOARD_KEY = 'bilinote-onboarded'
|
||||
|
||||
export function isOnboarded(): boolean {
|
||||
return localStorage.getItem(ONBOARD_KEY) === '1'
|
||||
}
|
||||
|
||||
function markOnboarded() {
|
||||
localStorage.setItem(ONBOARD_KEY, '1')
|
||||
}
|
||||
|
||||
const Onboarding = () => {
|
||||
const navigate = useNavigate()
|
||||
const [step, setStep] = useState(1)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// step 1
|
||||
const [pinging, setPinging] = useState(false)
|
||||
const [backendOk, setBackendOk] = useState<boolean | null>(null)
|
||||
|
||||
// step 2
|
||||
const [providerName, setProviderName] = useState('OpenAI')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1')
|
||||
const [modelName, setModelName] = useState('gpt-4o-mini')
|
||||
const [providerId, setProviderId] = useState<string | null>(null)
|
||||
const [savingProvider, setSavingProvider] = useState(false)
|
||||
|
||||
// step 3
|
||||
const [transcriberType, setTranscriberType] = useState<string>('groq')
|
||||
const [savingTranscriber, setSavingTranscriber] = useState(false)
|
||||
|
||||
function next() {
|
||||
setError('')
|
||||
setStep(s => s + 1)
|
||||
}
|
||||
function prev() {
|
||||
setError('')
|
||||
setStep(s => Math.max(1, s - 1))
|
||||
}
|
||||
|
||||
// step 1: ping 后端
|
||||
useEffect(() => {
|
||||
if (step !== 1) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setPinging(true)
|
||||
try {
|
||||
await getProviderList()
|
||||
if (!cancelled) setBackendOk(true)
|
||||
}
|
||||
catch {
|
||||
if (!cancelled) setBackendOk(false)
|
||||
}
|
||||
finally {
|
||||
if (!cancelled) setPinging(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [step])
|
||||
|
||||
async function saveProvider() {
|
||||
setError('')
|
||||
if (!apiKey.trim()) { setError('请填 API Key'); return }
|
||||
if (!baseUrl.trim()) { setError('请填 API 地址'); return }
|
||||
if (!providerName.trim()) { setError('请填供应商名'); return }
|
||||
if (!modelName.trim()) { setError('请填模型名'); return }
|
||||
setSavingProvider(true)
|
||||
try {
|
||||
// 复用桌面 web 端的 add_provider;type 必须是 'custom'(backend 强制)
|
||||
const res: any = await addProvider({
|
||||
name: providerName.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
base_url: baseUrl.trim(),
|
||||
type: 'custom',
|
||||
logo: 'custom',
|
||||
})
|
||||
const newId = (res?.data ?? res) as string | undefined
|
||||
if (!newId) throw new Error('后端未返回 provider id')
|
||||
setProviderId(newId)
|
||||
|
||||
// 加一个默认 model
|
||||
await addModel({ provider_id: newId, model_name: modelName.trim() })
|
||||
|
||||
// 测试连通
|
||||
try { await testConnection({ id: newId }) }
|
||||
catch (e: any) {
|
||||
// 测试失败不阻断流程,让用户自己决定继续
|
||||
console.warn('测试连接失败:', e?.message ?? e)
|
||||
}
|
||||
next()
|
||||
}
|
||||
catch (e: any) {
|
||||
setError(`保存失败:${e?.message ?? e}`)
|
||||
}
|
||||
finally {
|
||||
setSavingProvider(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTranscriber() {
|
||||
setError('')
|
||||
setSavingTranscriber(true)
|
||||
try {
|
||||
// fast-whisper / mlx-whisper 需指定 model size;在线 (groq/bcut/kuaishou) 不用
|
||||
const needsSize = transcriberType === 'fast-whisper' || transcriberType === 'mlx-whisper'
|
||||
await updateTranscriberConfig({
|
||||
transcriber_type: transcriberType,
|
||||
...(needsSize ? { whisper_model_size: 'tiny' } : {}),
|
||||
} as any)
|
||||
next()
|
||||
}
|
||||
catch (e: any) {
|
||||
setError(`保存失败:${e?.message ?? e}`)
|
||||
}
|
||||
finally {
|
||||
setSavingTranscriber(false)
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
markOnboarded()
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-pink-50 p-6">
|
||||
<div className="w-full max-w-xl rounded-xl border bg-white p-6 shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<img src={logo} alt="logo" className="h-10 w-10" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">欢迎使用 BiliNote</h1>
|
||||
<p className="text-xs text-gray-500">几步配置后就可以开始把视频转笔记。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="mb-5 flex items-center gap-2 text-xs text-gray-500">
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full border ${step >= s ? 'border-blue-600 bg-blue-600 text-white' : 'border-gray-300 bg-white text-gray-400'}`}
|
||||
>{s}</div>
|
||||
{s < 4 && <div className={`h-px w-8 ${step > s ? 'bg-blue-600' : 'bg-gray-300'}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold">第 1 步 · 后端连通性</h2>
|
||||
<p className="text-sm text-gray-600">桌面端会自动启动 Python 后端进程。检查连通中…</p>
|
||||
{pinging && <div className="text-sm text-gray-500">检测中…</div>}
|
||||
{backendOk === true && <div className="rounded bg-green-50 p-2 text-sm text-green-700">✓ 后端已就绪</div>}
|
||||
{backendOk === false && (
|
||||
<div className="rounded bg-red-50 p-2 text-sm text-red-700">
|
||||
✗ 暂时连不上后端。可能正在初始化(首次启动会下载依赖),等 1-2 分钟再试。
|
||||
右下角的「后端」状态点会持续监控。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={!backendOk} onClick={next}>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold">第 2 步 · 模型供应商</h2>
|
||||
<p className="text-sm text-gray-600">填一个 OpenAI 兼容供应商:DeepSeek / Qwen / Claude / 自托管 / OpenAI 都行。</p>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-gray-600">供应商名(自取)</span>
|
||||
<input className="input border rounded px-2 py-1" value={providerName} onChange={e => setProviderName(e.target.value)} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-gray-600">API 地址</span>
|
||||
<input className="input border rounded px-2 py-1" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-gray-600">API Key</span>
|
||||
<input type="password" className="input border rounded px-2 py-1" value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-gray-600">模型名(如 gpt-4o-mini / deepseek-chat / qwen-turbo)</span>
|
||||
<input className="input border rounded px-2 py-1" value={modelName} onChange={e => setModelName(e.target.value)} />
|
||||
</label>
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
<div className="flex gap-2 justify-between">
|
||||
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}>上一步</button>
|
||||
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={savingProvider} onClick={saveProvider}>
|
||||
{savingProvider ? '保存中…' : '保存并下一步'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold">第 3 步 · 音频转写引擎</h2>
|
||||
<p className="text-sm text-gray-600">把视频音频转成文字。<strong>推荐在线引擎</strong>,避免本地下载 ~600MB 的模型。</p>
|
||||
<div className="grid gap-2">
|
||||
{[
|
||||
{ value: 'groq', title: 'Groq(在线,推荐)', desc: '注册 https://groq.com/ 拿免费 key;速度快、英文语料佳。无需本地模型。' },
|
||||
{ value: 'bcut', title: '必剪(在线,免登)', desc: '免登,中文表现好;偶尔限流。' },
|
||||
{ value: 'kuaishou', title: '快手(在线,免登)', desc: '与必剪类似,备选。' },
|
||||
{ value: 'fast-whisper', title: 'Faster Whisper(本地)', desc: '完全离线但首次需下载 ~75MB(tiny)至 ~3GB(large-v3)的模型。CPU 慢。' },
|
||||
].map(opt => (
|
||||
<label key={opt.value} className={`flex gap-3 p-3 rounded border cursor-pointer ${transcriberType === opt.value ? 'border-blue-600 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="radio" name="transcriber" value={opt.value} checked={transcriberType === opt.value} onChange={e => setTranscriberType(e.target.value)} />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{opt.title}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{opt.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
<div className="flex gap-2 justify-between">
|
||||
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}>上一步</button>
|
||||
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50" disabled={savingTranscriber} onClick={saveTranscriber}>
|
||||
{savingTranscriber ? '保存中…' : '保存并下一步'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold">第 4 步 · Cookie 同步(可选)</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
想总结 <strong>B 站 / 抖音 / 快手</strong> 等需要登录态的平台时,需要把浏览器 cookie 复制到「下载配置」页。
|
||||
<br />
|
||||
YouTube 一般不需要 cookie。先跳过也没问题,到时再去配。
|
||||
</p>
|
||||
<div className="rounded bg-gray-50 p-3 text-xs text-gray-600">
|
||||
提示:插件版(<a className="text-blue-600 underline" href="https://github.com/JefferyHcool/BiliNote/tree/develop/BillNote_extension" target="_blank" rel="noreferrer">BillNote_extension</a>)支持一键 cookie 同步;桌面版需手动复制。
|
||||
</div>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<button className="text-sm text-gray-500 hover:text-gray-800" onClick={prev}>上一步</button>
|
||||
<button className="px-4 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={finish}>
|
||||
完成,进入 BiliNote
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Onboarding
|
||||
@@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Github, Star, ExternalLink, Download } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import logo from '@/assets/icon.svg'
|
||||
import wechatQr from '@/assets/wechat.png'
|
||||
|
||||
export default function AboutPage() {
|
||||
const images = [
|
||||
@@ -197,14 +198,10 @@ export default function AboutPage() {
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">联系和加入社区</h2>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<div className="text-center">
|
||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流 QQ 群</h3>
|
||||
<p className="text-lg font-medium">785367111</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流微信群</h3>
|
||||
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
|
||||
<img src={'https://common-1304618721.cos.ap-chengdu.myqcloud.com/wechat.png'} />
|
||||
<img src={wechatQr} alt="BiliNote 交流微信群" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,28 @@ export default function Transcriber() {
|
||||
}, [modelStatuses, mlxModelStatuses, fetchModelsStatus])
|
||||
|
||||
const handleSave = async () => {
|
||||
// 切到本地 whisper 引擎且选了未下载的模型时,提前 confirm,避免用户保存后到首次任务才发现要下 GB 级模型
|
||||
if (isWhisperType(selectedType)) {
|
||||
const pool = selectedType === 'mlx-whisper' ? mlxModelStatuses : modelStatuses
|
||||
const target = pool.find(m => m.model_size === selectedModelSize)
|
||||
if (target && !target.downloaded && !target.downloading) {
|
||||
const sizeHint: Record<string, string> = {
|
||||
'tiny': '~75MB',
|
||||
'base': '~150MB',
|
||||
'small': '~500MB',
|
||||
'medium': '~1.5GB',
|
||||
'large-v3': '~3GB',
|
||||
'large-v3-turbo': '~1.6GB',
|
||||
}
|
||||
const ok = window.confirm(
|
||||
`选择 ${selectedType} / ${selectedModelSize} 后,首次转写时会下载该模型(${sizeHint[selectedModelSize] || '体积未知'})。\n` +
|
||||
`网络较差时容易中断;推荐改用 Groq / 必剪 / 快手 等在线引擎。\n\n` +
|
||||
'继续保存吗?',
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: { transcriber_type: string; whisper_model_size?: string } = {
|
||||
|
||||
104
CHANGELOG.md
104
CHANGELOG.md
@@ -2,6 +2,110 @@
|
||||
|
||||
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [2.2.2] - 2026-05-09
|
||||
|
||||
补 v2.2.1 漏掉的 Tauri 桌面端 build 修复。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 桌面端 Tauri 构建失败:v2.2.1 的 hotfix 只修了 Docker 镜像构建里的 pnpm 版本,`main.yml` 的 `pnpm/action-setup@v4 with: version: 'latest'` 没改,于是桌面端 build 仍然在 `Install frontend dependencies` 步报 `ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`(pnpm 11 要求 Node 22+,但 main.yml 用的 node 20)。pin 到 `9.15.0`,与 Docker 侧一致。
|
||||
|
||||
## [2.2.1] - 2026-05-09
|
||||
|
||||
补 v2.2.0 ghcr.io 镜像构建失败。
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker 镜像构建失败:`v2.2.0` tag 触发的 ghcr.io 推送在 frontend-builder 第 5/7 步 `pnpm install --frozen-lockfile` 报 `ERR_UNKNOWN_BUILTIN_MODULE`。根因:`corepack prepare pnpm@latest` 拉到了 pnpm 11.0.9,而 pnpm 11 要求 Node 22+,跟我们的 `node:20-alpine` 不兼容。
|
||||
- `Dockerfile.complete` 与 `BillNote_frontend/Dockerfile` 的 pnpm 版本 pin 到 `9.15.0`(lockfile 由 pnpm 9 生成,匹配 Node 20)
|
||||
|
||||
## [2.2.0] - 2026-05-09
|
||||
|
||||
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
|
||||
|
||||
### Added — 浏览器插件
|
||||
|
||||
- 笔记选项与 web 端 NoteForm 完整对齐:
|
||||
- `style` 由自由文本改成 9 个预设下拉(minimal / detailed / academic / tutorial / xiaohongshu / life_journal / task_oriented / business / meeting_minutes),与 backend `prompt_builder.note_styles` 严格匹配(之前自由文本不命中 enum 等于没传——隐性 bug)
|
||||
- `format` 完整 4 个 checkbox(toc / link / screenshot / summary,原来只有 screenshot/link)
|
||||
- `extras` 文本框:拼接到 prompt 末尾的 ad-hoc 提示
|
||||
- 多模态视频理解:`video_understanding` 开关 + `video_interval`(1-30 秒)+ `grid_size`([r,c],1-10),抽帧拼图喂视觉模型,提示需选视觉模型才生效
|
||||
|
||||
### Added — 桌面客户端
|
||||
|
||||
- **首启 4 步引导**(`/onboarding`):后端连通性自检 → LLM 供应商 + 模型 → 转写引擎选择(默认推荐 Groq)→ Cookie 同步说明。完成后 `localStorage('bilinote-onboarded')` 标记,纯 web 端不打扰
|
||||
- **Sidecar 健康度面板**:右下角浮动状态点(绿/黄/红,5s 轮询 `/sys_health`),点开抽屉看最近 200 行后端日志、一键重启后端(新增 Tauri command `restart_backend_sidecar`)、复制日志
|
||||
- **启动期路径诊断**:Tauri `setup` 中检测安装路径含非 ASCII / 含空格 / 父目录不可写时,emit `backend-warning` 让前端顶端横幅显式告警,主动暴露 README 长期文字警告但无防御的"中文路径"等坑
|
||||
|
||||
### Changed
|
||||
|
||||
- Whisper 默认模型 size 从 `medium`(~1.5GB)改为 `tiny`(~75MB):新装用户没主动设置时不再卡在首次大模型下载;高精度可在「音频转写配置」页主动切
|
||||
- 切到 `fast-whisper` / `mlx-whisper` 且当前 size 未下载时,「音频转写配置」页保存前 confirm 体积提示,并推荐改用在线引擎
|
||||
- Tauri sidecar 启动逻辑抽出 `spawn_backend_sidecar()`;child handle 存进 `SidecarHandle` state 以支持后续 restart
|
||||
- sidecar stdout/stderr emit 时不再用 `format!("'{}'", ...)` 包引号,原文直传(前端 hook 兼容旧格式兜底剥引号)
|
||||
|
||||
### Fixed
|
||||
|
||||
- WhisperTranscriber 在半成品模型目录上死循环报 `Unable to open file 'model.bin'`:判定从「目录存在」改为「`model.bin` 落盘」,半成品目录会被识别并重新下载(PR `fix/backend-deploy-resilience`)
|
||||
- `/api/deploy_status` 在没装 torch 的部署上 `ModuleNotFoundError: No module named 'torch'` 500:torch 改 try/except,未装时返回 `{available: false, torch_installed: false}`;transcriber 配置 + ffmpeg 也都裹 try,单项失败不再打死整个监控页(同上 PR)
|
||||
- `routers/config._check_whisper_model_exists` 同步改用 `model.bin` 判定,避免「已下载」状态在监控页误报
|
||||
|
||||
## [2.1.4] - 2026-05-07
|
||||
|
||||
CI 工程化修复,无运行时行为变化。
|
||||
|
||||
### Internal
|
||||
|
||||
- 桌面端 Tauri 构建矩阵去掉 Linux(`ubuntu-22.04 / x86_64-unknown-linux-gnu`)。Linux 桌面端构建持续 17m+,且无对应分发渠道;Linux 用户继续可以走 Docker 镜像 (`ghcr.io/jefferyhcool/bilinote`)
|
||||
- commitlint workflow 去掉无效的 `firstParent` input(wagoid/commitlint-github-action@v6 不支持,被忽略并打 warn)
|
||||
- 规范 release merge commit 标题:`chore(release): vX.Y.Z`(合 master)/ `chore(release): merge release/X.Y.Z back into develop`(回灌 develop),让 commitlint 能正确识别。`RELEASING.md` §3 与 `CONTRIBUTING.md` §6.3 同步更新
|
||||
|
||||
## [2.1.3] - 2026-05-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- DeepSeek 等非多模态供应商被 400 拒绝(issue #282):`UniversalGPT.create_messages` 与 `_build_merge_messages` 此前**无条件**把 content 拼成 OpenAI 多模态数组 `[{"type":"text",...}]`,DeepSeek `deepseek-chat` 等模型不识别 `image_url` 变体直接报 `invalid_request_error`。`GPTFactory.from_config` 一律实例化 `UniversalGPT`,所以问题覆盖**所有**通过模型设置页接入的非多模态供应商,不止 DeepSeek。
|
||||
- 现按 `video_img_urls` 是否非空切换 content 形态:有图保留多模态数组(视觉模型不退化),无图退回 string。合并阶段历来不带图,统一改 string。
|
||||
- 与同包内 `deepseek_gpt.py` / `openai_gpt.py` / `qwen_gpt.py` 的 message builder 行为对齐。
|
||||
- 新增 `backend/tests/test_universal_gpt_content_format.py` 6 个 case 回归覆盖(含 `image_url` 字面 not-in JSON 断言)。
|
||||
|
||||
感谢 @voidborne-d 的修复(#345)。
|
||||
|
||||
## [2.1.2] - 2026-05-07
|
||||
|
||||
补 v2.1.1 上 ghcr.io 镜像构建失败的坑。
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker 镜像构建失败:v2.1.1 tag 触发的 ghcr.io 推送在 frontend-builder 第 7/7 步 `pnpm run build` 挂掉(vite `loadConfigFromBundledFile` 加载 `@tailwindcss/vite` plugin 时 1.5s 内异常退出)。
|
||||
- `Dockerfile.complete` 与 `BillNote_frontend/Dockerfile` 升 `node:18-alpine` → `node:20-alpine`:Tailwind v4 已不再支持 Node 18,Vite 6 也推荐 Node 20+
|
||||
- `Dockerfile.complete` 的 frontend 阶段同时复制 `pnpm-lock.yaml` 并改用 `--frozen-lockfile`,杜绝每次构建重解析 semver 拉到比本地新的 native dep
|
||||
- `BillNote_frontend/pnpm-lock.yaml` 强制入库(之前一直未提交,导致 CI / 本地依赖图持续漂移)
|
||||
- README 联系社区段补上微信群二维码(之前只写"年会恢复更新以后放出最新社区地址")
|
||||
|
||||
## [2.1.1] - 2026-05-07
|
||||
|
||||
工程化与文档收尾,无运行时行为变化。
|
||||
|
||||
### Added
|
||||
|
||||
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — 贡献指南,落地简化 Git Flow(master + develop + 短生命周期分支)+ 提交规范 + 合并规范
|
||||
- [`RELEASING.md`](./RELEASING.md) — 发版手册(Release Manager 视角),含 release/* 流程 + 各商店人工上传步骤 + 自动发布所需 secrets
|
||||
- `.github/ISSUE_TEMPLATE/{config,bug_report,feature_request}.yml` — 表单形式的 issue 模板,按工作区分类
|
||||
- `.github/pull_request_template.md` — PR 模板,把 CONTRIBUTING §5.2 落成 checklist
|
||||
- `.commitlintrc.json` + `.github/workflows/commitlint.yml` — commitlint CI(PR + push develop/master 时校验,自定义 type 白名单,兼容中文 subject)
|
||||
- `.github/workflows/release-extension.yml` — `v*` tag push 时自动构建插件 .zip / .xpi / .crx 并挂到对应 GitHub Release(商店自动发布以注释形式预留)
|
||||
|
||||
### Changed
|
||||
|
||||
- 关于页二维码改为 `import @/assets/wechat.png`,不再依赖腾讯云 COS CDN,更新只需替换文件 + 跑构建
|
||||
- 群聊 QR 替换为最新版本(`doc/wechat.png` + `BillNote_frontend/src/assets/wechat.png`)
|
||||
|
||||
### Removed
|
||||
|
||||
- 关于页 QQ 群联系方式(号 785367111,已不再活跃维护)
|
||||
- 旧版 `.md` 格式 issue 模板(被新 yml 表单模板取代)
|
||||
|
||||
## [2.1.0] - 2026-05-07
|
||||
|
||||
本次发布的主线是**浏览器插件**和 **B 站字幕优先链路**。配合一些后端 / 前端体验修复。
|
||||
|
||||
341
CONTRIBUTING.md
Normal file
341
CONTRIBUTING.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# 贡献指南
|
||||
|
||||
欢迎为 BiliNote 贡献代码。本文档约定分支管理、提交规范、合并流程。新贡献者请通读一遍后再开 PR。
|
||||
|
||||
> 关联文档
|
||||
> - [README.md](./README.md):项目概览、快速开始
|
||||
> - [CLAUDE.md](./CLAUDE.md):仓库结构 + 各 workspace 开发命令
|
||||
> - [CHANGELOG.md](./CHANGELOG.md):版本变更记录
|
||||
> - [RELEASING.md](./RELEASING.md):发版执行手册(Release Manager 视角)
|
||||
|
||||
---
|
||||
|
||||
## 1. 仓库结构与工作区
|
||||
|
||||
本仓库为多工作区单体仓:
|
||||
|
||||
| 路径 | 内容 | 主要命令 |
|
||||
|---|---|---|
|
||||
| `backend/` | Python 3.11 + FastAPI | `pip install -r requirements.txt && python main.py` |
|
||||
| `BillNote_frontend/` | React 19 + Vite | `pnpm install && pnpm dev` |
|
||||
| `BillNote_extension/` | Vue 3 + Vite + WebExtension MV3 | `pnpm install && pnpm dev` |
|
||||
|
||||
详细结构与开发命令见 [CLAUDE.md](./CLAUDE.md)。提交时**单 PR 不要跨多个工作区做无关改动**,便于评审与回滚。
|
||||
|
||||
---
|
||||
|
||||
## 2. 分支模型
|
||||
|
||||
采用简化 Git Flow:稳定主干 `master` + 长期开发集成 `develop` + 短生命周期业务分支。
|
||||
|
||||
| 分支类型 | 命名 | 长期保留 | 创建来源 | 合并去向 | 用途 |
|
||||
|---|---|---|---|---|---|
|
||||
| 生产主干 | `master` | ✅ | 仓库默认分支 | — | 始终保持可发布状态;只接收 `release/*` 与 `hotfix/*` 合入 |
|
||||
| 开发主干 | `develop` | ✅ | `master` | `release/*` 回灌后 | 日常需求集成;常规开发都从这里起 |
|
||||
| 功能分支 | `feature/*` | ❌ | `develop` | `develop` | 新功能 / 需求开发 |
|
||||
| 修复分支 | `fix/*` | ❌ | `develop` | `develop` | 开发期间发现的缺陷修复(非线上问题) |
|
||||
| 发布分支 | `release/*` | ❌ | `develop` | `master` + `develop` | 版本冻结、回归、发版准备 |
|
||||
| 热修复 | `hotfix/*` | ❌ | `master` | `master` + `develop` | 线上紧急问题 |
|
||||
|
||||
### 基本原则
|
||||
|
||||
- `master` 只保存**已发布代码**,不接受日常开发提交。
|
||||
- `develop` 是**唯一**长期开发集成分支。
|
||||
- 所有业务开发**必须**用短生命周期分支,禁止长期占用个人开发分支。
|
||||
- 一个分支只承载一个需求或一类明确的修复事项。
|
||||
|
||||
---
|
||||
|
||||
## 3. 分支命名
|
||||
|
||||
### 命名格式
|
||||
|
||||
```
|
||||
feature/<scope>-<事项>
|
||||
fix/<scope>-<事项>
|
||||
release/<版本号>
|
||||
hotfix/<scope>-<事项>
|
||||
```
|
||||
|
||||
`<scope>` 优先用代码 scope(与 commit message scope 对齐,见 §5),常用:
|
||||
|
||||
- `extension` — 浏览器插件(`BillNote_extension/`)
|
||||
- `frontend` — Web 前端(`BillNote_frontend/`)
|
||||
- `backend` — Python 后端(`backend/`)
|
||||
- `bilibili` / `youtube` / `douyin` / `kuaishou` — 平台特定改动
|
||||
- `transcriber` — 音频转写
|
||||
- `gpt` / `chat` — LLM / RAG
|
||||
- `docker` / `ci` — 构建与发布
|
||||
- `docs` — 文档
|
||||
|
||||
### 命名示例
|
||||
|
||||
```bash
|
||||
# 功能开发
|
||||
feature/extension-side-panel
|
||||
feature/youtube-subtitle-innertube
|
||||
feature/backend-rag-chat
|
||||
|
||||
# 开发期修复
|
||||
fix/extension-task-status-unwrap
|
||||
fix/bilibili-cookie-injection
|
||||
fix/mlx-whisper-repo-id
|
||||
|
||||
# 发版
|
||||
release/2.1.0
|
||||
release/2.2.0
|
||||
|
||||
# 线上热修
|
||||
hotfix/backend-cors-regex
|
||||
hotfix/frontend-provider-switch
|
||||
```
|
||||
|
||||
### 命名要求
|
||||
|
||||
- 全小写字母 / 数字 / 中划线
|
||||
- `<事项>` 要表达**具体行为**,避免 `test` / `update` / `temp` / `wip` 这类无意义名
|
||||
- `release/<版本号>` 必须与实际 tag 一致(如 `release/2.1.0` ↔ `v2.1.0`)
|
||||
|
||||
---
|
||||
|
||||
## 4. 标准协作流程
|
||||
|
||||
### 4.1 日常需求开发
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/<scope>-<事项>
|
||||
|
||||
# … 开发 + 自测 + commit …
|
||||
|
||||
git push -u origin feature/<scope>-<事项>
|
||||
# 在 GitHub 上发起 PR:base = develop,compare = 你的分支
|
||||
```
|
||||
|
||||
合并通过且 PR closed 后,**删除本地与远端分支**:
|
||||
|
||||
```bash
|
||||
git branch -d feature/<scope>-<事项>
|
||||
git push origin --delete feature/<scope>-<事项>
|
||||
```
|
||||
|
||||
### 4.2 开发期缺陷修复
|
||||
|
||||
提测或联调中发现问题时,从 `develop` 切 `fix/*`。**不要在原 `feature/*` 上长期叠加零散修复**。
|
||||
|
||||
适用场景:
|
||||
- 已合入 `develop` 后被测试打回的问题
|
||||
- 多个功能集成后暴露的兼容性问题
|
||||
- 非线上环境问题
|
||||
|
||||
### 4.3 版本发布
|
||||
|
||||
```bash
|
||||
# 1. 从 develop 切 release
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout -b release/<版本号>
|
||||
|
||||
# 2. 在 release 分支上:更新 README 版本号、写 CHANGELOG.md、必要的小修
|
||||
git commit -am "docs: <版本号> CHANGELOG + README 版本"
|
||||
git push -u origin release/<版本号>
|
||||
|
||||
# 3. 进入冻结期,PR base=master 合并;同时 PR base=develop 回灌
|
||||
# 4. master 上打 tag
|
||||
git checkout master && git pull
|
||||
git tag -a v<版本号> -m "BiliNote v<版本号>" && git push origin v<版本号>
|
||||
|
||||
# 5. release 分支已合入两边,删除
|
||||
git push origin --delete release/<版本号>
|
||||
```
|
||||
|
||||
要点:
|
||||
- **冻结期内 `release/*` 不再合入新需求**,只允许修复发布缺陷。
|
||||
- 发版窗口期内的新需求继续基于 `develop` 开发,**不进入当前 `release/*`**。
|
||||
- 发布完成后必须把 `release/*` 同步回 `develop`,避免漏修。
|
||||
|
||||
### 4.4 线上紧急修复
|
||||
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
git checkout -b hotfix/<scope>-<事项>
|
||||
# … 修 + commit …
|
||||
# PR base=master,合入后立刻打 patch tag(如 v2.1.1)发版
|
||||
# 同一改动同时 PR base=develop 回灌
|
||||
```
|
||||
|
||||
要点:
|
||||
- `hotfix/*` 仅处理**线上阻断性 / 高优先级**缺陷。
|
||||
- 非紧急问题不得绕过 `develop` 直接走热修流程。
|
||||
- 若当前存在 `release/*` 即将发布,需评估是否同步到对应 release,避免修复丢失。
|
||||
|
||||
---
|
||||
|
||||
## 5. 提交规范
|
||||
|
||||
### 5.1 Commit message 格式
|
||||
|
||||
> CI 已接入 [commitlint](https://commitlint.js.org)([`.commitlintrc.json`](./.commitlintrc.json) + [`.github/workflows/commitlint.yml`](./.github/workflows/commitlint.yml))。
|
||||
> PR 上所有 commit 都会被校验,type 不在白名单时合并按钮会被卡。
|
||||
|
||||
```
|
||||
type(scope): subject
|
||||
```
|
||||
|
||||
例:
|
||||
|
||||
```
|
||||
feat(extension): 侧边栏接入思维导图(markmap)与 RAG 问答
|
||||
fix(bilibili): 修正字幕优先链路在未登录态下的回退
|
||||
docs(contributing): 新增贡献指南
|
||||
chore(ci): 优化 docker 构建缓存
|
||||
```
|
||||
|
||||
#### type
|
||||
|
||||
| type | 说明 |
|
||||
|---|---|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | 缺陷修复 |
|
||||
| `docs` | 文档变更 |
|
||||
| `style` | 代码风格调整(不影响行为) |
|
||||
| `refactor` | 重构(非 feat / 非 fix) |
|
||||
| `perf` | 性能优化 |
|
||||
| `test` | 测试增删 |
|
||||
| `build` | 构建系统 / 依赖变更 |
|
||||
| `ci` | CI 配置变更 |
|
||||
| `chore` | 杂项(不归入以上类别) |
|
||||
| `ui` | 界面 / 交互层调整(仓库内既有用法) |
|
||||
| `revert` | 回滚 |
|
||||
|
||||
#### scope
|
||||
|
||||
与 §3 的分支 scope 保持一致:`extension` / `frontend` / `backend` / `bilibili` / `youtube` / `douyin` / `kuaishou` / `transcriber` / `gpt` / `chat` / `docker` / `ci` / `docs` 等。
|
||||
|
||||
#### subject
|
||||
|
||||
- 用中文或英文都可以,**保持一种风格**。
|
||||
- 用现在时陈述("新增 X" / "修复 Y" / "Add X" / "Fix Y")。
|
||||
- 首字母不大写,结尾不加句号。
|
||||
- 单行控制在 72 字符以内;如需详细说明,正文与标题之间空一行。
|
||||
|
||||
### 5.2 PR 标题与正文
|
||||
|
||||
- **PR 标题**沿用 commit message 格式,描述本次 PR 的总体改动。
|
||||
- **PR 正文**应包含:
|
||||
- 改动的"为什么"(背景 / issue / 用户场景)
|
||||
- 改动的"做了什么"(关键文件、关键决策)
|
||||
- **测试方式**(如何验证、覆盖了哪些 case)
|
||||
- **回归风险**与影响面
|
||||
- 是否需要后端 / 前端 / 配置同步部署
|
||||
|
||||
---
|
||||
|
||||
## 6. 合并规范
|
||||
|
||||
### 6.1 合并前要求
|
||||
|
||||
- 合并前必须**先同步**目标分支最新代码(`git pull --rebase` 或在 PR 上点 "Update branch")。
|
||||
- 合并前必须完成**自测**,确保核心流程可用。
|
||||
- 后端改动需 `python -m py_compile` 至少通过;前端 / 插件改动需 `pnpm typecheck && pnpm build` 通过。
|
||||
- **冲突由分支负责人解决**,不得留给评审人或发版人员。
|
||||
|
||||
### 6.2 评审
|
||||
|
||||
- 默认通过 PR 合并,**不允许**绕过 PR 直接 push 到 `master` 或 `develop`。
|
||||
- 评审人至少关注:业务影响、回归风险、是否夹带无关改动、目录归属是否合理。
|
||||
- 修文档 / 改注释这种小变更允许 1 人评审通过;改业务逻辑 / 协议 / 共享模块至少 2 人评审。
|
||||
|
||||
### 6.3 合并方式
|
||||
|
||||
- `feature/*` / `fix/*` 合入 `develop`:推荐 **Squash and merge**,保持 develop 历史线性。
|
||||
- `release/*` 合入 `master` 与回灌 `develop`:使用 **Merge commit (--no-ff)**,保留发版结构。
|
||||
· merge commit 标题用 `chore(release): vX.Y.Z`(合 master)或 `chore(release): merge release/X.Y.Z back into develop`(回灌 develop),保证 commitlint 通过。
|
||||
- `hotfix/*` 同上 release。
|
||||
|
||||
### 6.4 合并后
|
||||
|
||||
- 短期分支合并完成后**必须删除**(远端 + 本地)。
|
||||
- 已完成的分支不得继续承接新需求;如需后续迭代请重新基于最新目标分支开新分支。
|
||||
|
||||
---
|
||||
|
||||
## 7. Git 钩子注意事项
|
||||
|
||||
`BillNote_extension/` 早期使用 `simple-git-hooks` 的 `postinstall`,会在仓库根目录 `.git/hooks/pre-commit` 注入 `pnpm lint-staged`,但仓库根没有 `package.json`,导致**任何 commit 都被钩子卡死**。**已在 v2.1.0 起移除**该 postinstall 配置。
|
||||
|
||||
如果你机器上还残留旧版本装下来的 hook:
|
||||
|
||||
```bash
|
||||
# 一次性清理
|
||||
rm .git/hooks/pre-commit
|
||||
|
||||
# 或临时绕过本次 commit
|
||||
SKIP_SIMPLE_GIT_HOOKS=1 git commit -m "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 版本号与 CHANGELOG
|
||||
|
||||
- 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/):`MAJOR.MINOR.PATCH`
|
||||
- `MAJOR`:破坏性 API 变更或重大重构
|
||||
- `MINOR`:新增特性、向后兼容
|
||||
- `PATCH`:bug 修复、向后兼容
|
||||
- 每次发版**必须更新 [CHANGELOG.md](./CHANGELOG.md)**,按 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式分类(Added / Changed / Fixed / Removed / Security / Internal)。
|
||||
- 发版同时更新 `README.md` 顶部的版本号与"v\<版本号\> 新增"摘要段。
|
||||
- `master` 上打 tag 形如 `vX.Y.Z`,注释中包含本次发布主线 + 引用 `CHANGELOG.md`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 禁止事项
|
||||
|
||||
- ❌ 直接在 `master` 上开发、提交、修复普通问题
|
||||
- ❌ 新增长期 `dev-*` / `wip-*` / `<姓名>-dev` 个人分支作为日常协作分支
|
||||
- ❌ 一个分支同时承载多个需求 / 多个缺陷 / 跨版本内容
|
||||
- ❌ 未评审、未自测、未通过基础校验直接合并
|
||||
- ❌ `release/*` 冻结后继续混入新需求
|
||||
- ❌ `hotfix/*` 只合入 `master` 而不回灌 `develop`
|
||||
- ❌ 提交 / 仓库内包含密钥、API key、`.env` 等敏感文件
|
||||
- ❌ 提交 `node_modules/` / `dist/` / `extension/dist/` / `__pycache__/` / 大型二进制(参考各级 `.gitignore`)
|
||||
|
||||
---
|
||||
|
||||
## 10. 历史分支迁移
|
||||
|
||||
仓库历史上存在多条已合并但未删除的分支(见 `git branch -a`)。即日起:
|
||||
|
||||
- 不再创建 `dev-*` / `<姓名>-*` 个人分支
|
||||
- 已合入主干的旧分支,由发版人统一清理
|
||||
- 未完成需求应尽快迁移到符合 §3 命名规范的新分支
|
||||
|
||||
---
|
||||
|
||||
## 11. 推荐流程图
|
||||
|
||||
```text
|
||||
master ← hotfix/* (线上紧急修复)
|
||||
↑ ↑
|
||||
│ │
|
||||
release/* ← develop ← feature/* (功能开发)
|
||||
│ ↑
|
||||
└───────────┘
|
||||
fix/* (开发期修复)
|
||||
回灌
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 执行口径速查
|
||||
|
||||
| 场景 | 流程 |
|
||||
|---|---|
|
||||
| 新功能 | `develop` → `feature/*` → `develop` |
|
||||
| 提测后发现问题 | `develop` → `fix/*` → `develop` |
|
||||
| 版本发布 | `develop` → `release/*` → `master` + `develop`;打 tag |
|
||||
| 线上紧急故障 | `master` → `hotfix/*` → `master` + `develop`;打 patch tag |
|
||||
| 发版期内新需求 | 基于 `develop` 开 `feature/*`,**不**进入当前 `release/*` |
|
||||
|
||||
---
|
||||
|
||||
如有改进建议,欢迎开 PR 修订本文档(`docs(contributing): ...`)。
|
||||
@@ -26,15 +26,22 @@ RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
|
||||
COPY ./backend /tmp/backend
|
||||
|
||||
# === 阶段2:构建 Frontend ===
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
# Node 18-alpine 跑不动 Tailwind v4 / Vite 6(前者要求 Node 20+,后者推荐 Node 20+),
|
||||
# 升到 node:20-alpine。alpine 走 musl,pnpm 会按 lockfile 拉 *-linux-x64-musl native binary。
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# pnpm 版本 pin 到 9 系列:
|
||||
# - lockfile (BillNote_frontend/pnpm-lock.yaml) 是 lockfileVersion '9.0',由 pnpm 9 生成
|
||||
# - pnpm 11+ 要求 Node 22+,与 node:20 不兼容(ERR_UNKNOWN_BUILTIN_MODULE)
|
||||
# - 不用 @latest 避免上游 pnpm 升级悄悄破坏 CI
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /tmp/frontend
|
||||
|
||||
# 先复制 package.json 利用依赖层缓存
|
||||
COPY ./BillNote_frontend/package.json ./
|
||||
RUN pnpm install
|
||||
# 先复制 package.json + lockfile 利用依赖层缓存
|
||||
# --frozen-lockfile 保证 CI 与本地开发依赖版本一致,杜绝 semver 漂移引入的破坏性升级
|
||||
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ./BillNote_frontend /tmp/frontend
|
||||
|
||||
|
||||
49
README.md
49
README.md
@@ -3,7 +3,7 @@
|
||||
<p align="center">
|
||||
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
|
||||
</p>
|
||||
<h1 align="center" > BiliNote v2.1.0</h1>
|
||||
<h1 align="center" > BiliNote v2.2.2</h1>
|
||||
</div>
|
||||
|
||||
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
|
||||
@@ -53,6 +53,46 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y
|
||||
- 笔记顶部视频封面 Banner 展示
|
||||
- 工作区和生成历史面板支持折叠/展开
|
||||
|
||||
### v2.2.2 修订
|
||||
|
||||
- 修复 v2.2.0 桌面端 Tauri 构建失败(main.yml 的 pnpm 版本没 pin,pnpm 11 不兼容 Node 20)
|
||||
|
||||
### v2.2.1 修订
|
||||
|
||||
- 修复 v2.2.0 ghcr.io 镜像构建失败(pnpm@latest 拉到 11,与 Node 20 不兼容;pin 到 pnpm 9.15.0)
|
||||
|
||||
### v2.2.0 新增
|
||||
|
||||
- **浏览器插件**笔记选项与 web 端完整对齐:style 9 个预设下拉、format 4 个 checkbox、extras 文本框、多模态视频理解开关
|
||||
- **桌面客户端**首启 4 步引导(连通自检 → 供应商/模型 → 转写引擎 → Cookie 提示)
|
||||
- **桌面客户端**右下角后端运行状态指示,点开看日志、一键重启
|
||||
- **桌面客户端**启动期主动检测中文 / 空格 / 不可写安装路径,弹横幅告警
|
||||
- Whisper 默认 size 从 medium(~1.5GB)改为 tiny(~75MB);切大模型时显式 confirm
|
||||
- 修:whisper 半成品模型目录死循环;`/deploy_status` 在没装 torch 的部署 500
|
||||
- 详见 [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
### v2.1.4 修订
|
||||
|
||||
- CI:桌面端 Tauri 构建去掉 Linux(17m+ 慢线退役;Linux 用户继续走 Docker 镜像)
|
||||
- CI:commitlint workflow 修复 + 规范 release merge commit 标题约定
|
||||
|
||||
### v2.1.3 修订
|
||||
|
||||
- 修复 DeepSeek 等非多模态供应商被 400 拒绝的问题(issue #282):`UniversalGPT` 的 message builder 按是否带图切换 string / 多模态数组形态
|
||||
- 感谢 @voidborne-d (#345)
|
||||
|
||||
### v2.1.2 修订
|
||||
|
||||
- 修复 v2.1.1 触发的 ghcr.io Docker 镜像构建失败(Node 18 + Tailwind v4 不兼容、缺 lockfile)
|
||||
- README 补上微信群二维码
|
||||
|
||||
### v2.1.1 修订
|
||||
|
||||
- 工程化与文档收尾:CONTRIBUTING.md / RELEASING.md / issue + PR 模板 / commitlint CI / 插件发版工作流
|
||||
- 关于页群聊二维码:换成最新版,改为 import 本地资源,不再依赖 CDN
|
||||
- 关于页移除 QQ 群入口(仅保留微信群)
|
||||
- 详见 [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
### v2.1.0 新增
|
||||
|
||||
- 浏览器插件(Chrome / Edge / Firefox MV3)—— 工具栏 popup、视频页悬浮按钮、右键菜单、侧边栏(Markdown / 思维导图 / AI 问答)四件套
|
||||
@@ -198,7 +238,12 @@ docker-compose -f docker-compose.gpu.yml up -d
|
||||
- [ ] 笔记导出为 PDF / Word / Notion
|
||||
|
||||
### Contact and Join-联系和加入社区
|
||||
年会恢复更新以后放出最新社区地址
|
||||
|
||||
扫码加入 BiliNote 交流微信群(如二维码失效,请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈):
|
||||
|
||||
<p align="center">
|
||||
<img src="./doc/wechat.png" alt="BiliNote 交流微信群" width="240" />
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
161
RELEASING.md
Normal file
161
RELEASING.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 发版手册(Release Manager)
|
||||
|
||||
本文档面向**发版执行者**,覆盖从 `develop` 切发版到产物上架商店的完整步骤。日常分支与提交规范见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 流程总览
|
||||
|
||||
```
|
||||
develop ──→ release/X.Y.Z ──→ PR ─→ master ──→ 打 tag vX.Y.Z
|
||||
│ │ │
|
||||
└──→ PR 回灌 ──→ develop └──→ CI 自动构建插件产物 + 挂到 GitHub Release
|
||||
↓
|
||||
人工上传商店(Chrome/Edge/Firefox)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 切发布分支
|
||||
|
||||
```bash
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout -b release/X.Y.Z
|
||||
```
|
||||
|
||||
版本号遵循 [SemVer](https://semver.org/lang/zh-CN/):`MAJOR.MINOR.PATCH`。
|
||||
|
||||
## 2. 写 CHANGELOG,更新版本号
|
||||
|
||||
在 `release/X.Y.Z` 上:
|
||||
|
||||
- 编辑 [`CHANGELOG.md`](./CHANGELOG.md),新增 `## [X.Y.Z] - YYYY-MM-DD` 段,按 Keep a Changelog 分类(Added / Changed / Fixed / Removed / Security / Internal)
|
||||
- 编辑 [`README.md`](./README.md) 顶部标题中的版本号 + 新增"vX.Y.Z 新增"摘要段
|
||||
- 重大变更也同步更新 [`CLAUDE.md`](./CLAUDE.md)
|
||||
|
||||
```bash
|
||||
git commit -am "docs: vX.Y.Z CHANGELOG + README 版本"
|
||||
git push -u origin release/X.Y.Z
|
||||
```
|
||||
|
||||
## 3. 合并到 master + 回灌 develop
|
||||
|
||||
在 GitHub 上发起两个 PR:
|
||||
|
||||
| PR | base | 合并方式 | 合并后 commit 标题 |
|
||||
|---|---|---|---|
|
||||
| `release/X.Y.Z` → `master` | `master` | **Merge commit (--no-ff)** | `chore(release): vX.Y.Z` |
|
||||
| `release/X.Y.Z` → `develop` | `develop` | **Merge commit (--no-ff)** | `chore(release): merge release/X.Y.Z back into develop` |
|
||||
|
||||
> ⚠️ Merge commit 的标题**必须**符合 `type(scope): subject` 格式(commitlint 在 push 到 master/develop 时会校验)。
|
||||
> 历史上用过 `Release vX.Y.Z` 这种形式,会被 commitlint 报 `type-empty` / `subject-empty`。
|
||||
|
||||
`master` 分支保护要求 review 通过。回灌 `develop` 是为了把发版冻结期内的小修同步回来。
|
||||
|
||||
## 4. 打 tag
|
||||
|
||||
```bash
|
||||
git checkout master && git pull origin master
|
||||
git tag -a vX.Y.Z -m "BiliNote vX.Y.Z
|
||||
|
||||
主线:
|
||||
- ...
|
||||
|
||||
详见 CHANGELOG.md"
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
push tag **会自动触发 [`.github/workflows/release-extension.yml`](.github/workflows/release-extension.yml)**:构建插件并把 `.zip` / `.xpi` / `.crx` 挂到对应 GitHub Release。
|
||||
|
||||
## 5. 创建 GitHub Release(如果还没有)
|
||||
|
||||
CI 默认会创建 / 更新 `vX.Y.Z` 对应的 Release。如果你想自己写 release notes:
|
||||
|
||||
1. 打开 https://github.com/JefferyHcool/BiliNote/releases/new
|
||||
2. Tag: 选 `vX.Y.Z`
|
||||
3. Title: `vX.Y.Z`
|
||||
4. Body: 直接贴 [`CHANGELOG.md`](./CHANGELOG.md) 的对应段
|
||||
5. CI 跑完后 Release 页面会自动出现 `bilinote-extension-X.Y.Z.zip` / `.xpi` / `.crx`
|
||||
|
||||
## 6. 上传到各商店(人工)
|
||||
|
||||
商店审核普遍 1-3 个工作日。建议**先上 Chrome → Edge → Firefox**(Edge 接受同一份 zip)。
|
||||
|
||||
### Chrome Web Store
|
||||
|
||||
1. https://chrome.google.com/webstore/devconsole
|
||||
2. 选 BiliNote → 左侧 **Package** → **Upload new package**
|
||||
3. 上传 `bilinote-extension-X.Y.Z.zip`
|
||||
4. 检查 listing(描述 / 图标 / 截图无变化可保持),点 **Submit for review**
|
||||
|
||||
### Microsoft Edge Add-ons
|
||||
|
||||
1. https://partner.microsoft.com/dashboard/microsoftedge
|
||||
2. 选 BiliNote → **New submission**
|
||||
3. 上传同一份 `.zip`(Edge Add-ons 与 Chrome 完全兼容 MV3)
|
||||
4. 提交审核
|
||||
|
||||
### Firefox Add-ons (AMO)
|
||||
|
||||
1. https://addons.mozilla.org/developers/
|
||||
2. 选 BiliNote → **Upload New Version**
|
||||
3. 上传 `bilinote-extension-X.Y.Z.xpi`
|
||||
4. 选择"在 AMO 公开"或"自托管"
|
||||
5. 提交审核
|
||||
|
||||
### 桌面端 (Tauri)
|
||||
|
||||
仓库已有 GitHub Actions 在 `v*` tag 时构建桌面端安装包并自动挂到 GitHub Release,无需额外操作。
|
||||
|
||||
## 7. 清理
|
||||
|
||||
```bash
|
||||
# release 分支已合到 master 与 develop,删掉
|
||||
git push origin --delete release/X.Y.Z
|
||||
git branch -d release/X.Y.Z
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自动发布到商店(可选)
|
||||
|
||||
`.github/workflows/release-extension.yml` 末尾有三段商店自动发布的 job 注释。要启用:
|
||||
|
||||
1. 在 https://github.com/JefferyHcool/BiliNote/settings/secrets/actions 加 secrets:
|
||||
|
||||
| 商店 | 需要的 secret |
|
||||
|---|---|
|
||||
| Chrome | `CHROME_EXTENSION_ID`、`CHROME_CLIENT_ID`、`CHROME_CLIENT_SECRET`、`CHROME_REFRESH_TOKEN` |
|
||||
| Edge | `EDGE_PRODUCT_ID`、`EDGE_CLIENT_ID`、`EDGE_API_KEY` |
|
||||
| Firefox | `FIREFOX_ADDON_UUID`、`FIREFOX_API_KEY`、`FIREFOX_API_SECRET` |
|
||||
|
||||
2. 解开 workflow 文件末尾的 `publish-chrome` / `publish-edge` / `publish-firefox` job 注释。
|
||||
3. 推 tag 时即自动发布。
|
||||
|
||||
> Chrome 各 secret 的获取方式:[chrome-webstore-upload-cli 文档](https://github.com/fregante/chrome-webstore-upload-cli/blob/main/How%20to%20generate%20Google%20API%20keys.md)
|
||||
> Edge:[Edge Add-ons API](https://learn.microsoft.com/en-us/microsoft-edge/extensions-chromium/publish/api/using-addons-api)
|
||||
> Firefox:https://addons.mozilla.org/en-US/developers/addon/api/key/
|
||||
|
||||
---
|
||||
|
||||
## 紧急 hotfix 发版
|
||||
|
||||
线上紧急问题不走 `release/*`,走 `hotfix/*`:
|
||||
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
git checkout -b hotfix/<scope>-<事项>
|
||||
# … 修复 ...
|
||||
# PR base=master 合入;同时 PR base=develop 回灌
|
||||
```
|
||||
|
||||
合入 master 后通常打 patch tag(如 `v2.1.1`),CI 流程同上。
|
||||
|
||||
---
|
||||
|
||||
## 历史发布快查
|
||||
|
||||
| Version | Date | Tag |
|
||||
|---|---|---|
|
||||
| 2.1.0 | 2026-05-07 | [`v2.1.0`](https://github.com/JefferyHcool/BiliNote/releases/tag/v2.1.0) |
|
||||
| 2.0.0 | (上游 web 端 v2.0.0) | [`v2.0.0`](https://github.com/JefferyHcool/BiliNote/releases/tag/v2.0.0) |
|
||||
@@ -53,20 +53,26 @@ class UniversalGPT(GPT):
|
||||
extras=kwargs.get('extras'),
|
||||
)
|
||||
|
||||
# ⛳ 组装 content 数组,支持 text + image_url 混合
|
||||
content: List[dict] = [{"type": "text", "text": content_text}]
|
||||
video_img_urls = kwargs.get('video_img_urls', [])
|
||||
|
||||
for url in video_img_urls:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": url,
|
||||
"detail": "auto"
|
||||
}
|
||||
})
|
||||
content: list[dict] | str
|
||||
if video_img_urls:
|
||||
# 有截图时走 OpenAI 多模态 content 数组(text + image_url)
|
||||
content = [{"type": "text", "text": content_text}]
|
||||
for url in video_img_urls:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": url,
|
||||
"detail": "auto"
|
||||
}
|
||||
})
|
||||
else:
|
||||
# 纯文本场景退回 string content:DeepSeek deepseek-chat 等非多模态模型
|
||||
# 不识别 [{"type":"text",...}] 数组形态,会返回 invalid_request_error
|
||||
# (issue #282)。OpenAI 规范本身也允许 content 为 string。
|
||||
content = content_text
|
||||
|
||||
# 正确格式:整体包在一个 message 里,role + content array
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": content
|
||||
@@ -83,9 +89,10 @@ class UniversalGPT(GPT):
|
||||
|
||||
def _build_merge_messages(self, partials: list) -> list:
|
||||
merge_text = MERGE_PROMPT + "\n\n" + "\n\n---\n\n".join(partials)
|
||||
# 合并阶段没有图片,直接用 string content 兼容非多模态模型(issue #282)
|
||||
return [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": merge_text}]
|
||||
"content": merge_text
|
||||
}]
|
||||
|
||||
def _checkpoint_path(self, checkpoint_key: str) -> Path:
|
||||
|
||||
@@ -25,7 +25,12 @@ class TranscriberConfigManager:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""获取当前转写器配置,fallback 到环境变量默认值。"""
|
||||
"""获取当前转写器配置,fallback 到环境变量默认值。
|
||||
|
||||
whisper 默认 size 从 'medium' (~1.5GB) 改为 'tiny' (~75MB):
|
||||
新装用户没主动设置时不应该被首次下载卡住。想要更高精度可在「音频转写配置」
|
||||
页主动切换。
|
||||
"""
|
||||
data = self._read()
|
||||
return {
|
||||
"transcriber_type": data.get(
|
||||
@@ -34,7 +39,7 @@ class TranscriberConfigManager:
|
||||
),
|
||||
"whisper_model_size": data.get(
|
||||
"whisper_model_size",
|
||||
os.getenv("WHISPER_MODEL_SIZE", "medium"),
|
||||
os.getenv("WHISPER_MODEL_SIZE", "tiny"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
189
backend/tests/test_universal_gpt_content_format.py
Normal file
189
backend/tests/test_universal_gpt_content_format.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""issue #282 回归测试:UniversalGPT 拼装 content 时按是否有图片切换 string / array 形态。
|
||||
|
||||
DeepSeek deepseek-chat 等非多模态模型只接受 ``content`` 为字符串,旧实现无条件
|
||||
emit ``[{"type":"text","text":...}]`` 导致 ``invalid_request_error``。
|
||||
"""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
|
||||
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_BODY"
|
||||
|
||||
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_HEAD"
|
||||
|
||||
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_content_format", 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 _DummyClient:
|
||||
"""create_messages 不会真的调用 client,给个空壳即可。"""
|
||||
|
||||
|
||||
def _make_gpt():
|
||||
return UniversalGPT(_DummyClient(), model="deepseek-chat")
|
||||
|
||||
|
||||
class TestCreateMessagesContentFormat(unittest.TestCase):
|
||||
"""覆盖 create_messages 在不同 video_img_urls 输入下的输出形态。"""
|
||||
|
||||
def test_no_images_emits_string_content(self):
|
||||
"""无图片时 content 为 str(DeepSeek / 非多模态模型可解析)。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt.create_messages(segments=[])
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0]["role"], "user")
|
||||
self.assertIsInstance(messages[0]["content"], str)
|
||||
self.assertEqual(messages[0]["content"], "PROMPT_BODY")
|
||||
|
||||
def test_empty_image_list_emits_string_content(self):
|
||||
"""显式传入空列表也要走纯文本分支,避免图片字段误触发。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt.create_messages(segments=[], video_img_urls=[])
|
||||
|
||||
self.assertIsInstance(messages[0]["content"], str)
|
||||
|
||||
def test_with_images_emits_multimodal_array(self):
|
||||
"""有图片时保留多模态 array 形态,确保多模态模型功能不退化。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt.create_messages(
|
||||
segments=[],
|
||||
video_img_urls=["https://example.com/a.jpg", "https://example.com/b.jpg"],
|
||||
)
|
||||
|
||||
content = messages[0]["content"]
|
||||
self.assertIsInstance(content, list)
|
||||
self.assertEqual(len(content), 3) # 1 text + 2 images
|
||||
self.assertEqual(content[0], {"type": "text", "text": "PROMPT_BODY"})
|
||||
self.assertEqual(content[1]["type"], "image_url")
|
||||
self.assertEqual(content[1]["image_url"]["url"], "https://example.com/a.jpg")
|
||||
self.assertEqual(content[1]["image_url"]["detail"], "auto")
|
||||
self.assertEqual(content[2]["image_url"]["url"], "https://example.com/b.jpg")
|
||||
|
||||
def test_no_image_url_field_when_no_images(self):
|
||||
"""纯文本响应里不应该出现 image_url 关键字 —— 这是触发 DeepSeek 400 的根因。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt.create_messages(segments=[])
|
||||
|
||||
import json
|
||||
serialized = json.dumps(messages, ensure_ascii=False)
|
||||
self.assertNotIn("image_url", serialized)
|
||||
|
||||
|
||||
class TestBuildMergeMessagesContentFormat(unittest.TestCase):
|
||||
"""合并阶段从不带图片,应该统一走 string content 路径。"""
|
||||
|
||||
def test_merge_messages_use_string_content(self):
|
||||
"""否则长视频 chunk 后的合并阶段还会复现 issue #282 错误。"""
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt._build_merge_messages(["partial-A", "partial-B"])
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0]["role"], "user")
|
||||
self.assertIsInstance(messages[0]["content"], str)
|
||||
self.assertIn("MERGE_HEAD", messages[0]["content"])
|
||||
self.assertIn("partial-A", messages[0]["content"])
|
||||
self.assertIn("partial-B", messages[0]["content"])
|
||||
|
||||
def test_merge_messages_no_image_url_field(self):
|
||||
gpt = _make_gpt()
|
||||
|
||||
messages = gpt._build_merge_messages(["x"])
|
||||
|
||||
import json
|
||||
serialized = json.dumps(messages, ensure_ascii=False)
|
||||
self.assertNotIn("image_url", serialized)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
BIN
doc/wechat.png
BIN
doc/wechat.png
Binary file not shown.
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 11 KiB |
Reference in New Issue
Block a user