Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddaa0eef92 | ||
|
|
e41a3e27eb | ||
|
|
fd18aa2955 | ||
|
|
0a93911f3e | ||
|
|
d0760bcbbc | ||
|
|
2fc558e00a | ||
|
|
a83642e602 | ||
|
|
9bc3b2960b | ||
|
|
095d772c7d | ||
|
|
2e3fda7df4 | ||
|
|
2ea95b2fad | ||
|
|
e78b687096 | ||
|
|
9d1a7cd699 | ||
|
|
f2d8ece0c1 | ||
|
|
7bc4b0114e | ||
|
|
f7ea6f72d9 | ||
|
|
905dbcce47 | ||
|
|
ebdb254fc6 | ||
|
|
1eb213e215 | ||
|
|
4425239717 | ||
|
|
64a0400792 | ||
|
|
58d992f28f | ||
|
|
db556b8991 | ||
|
|
717df2af7b | ||
|
|
b431db545a | ||
|
|
25face4b67 | ||
|
|
edfd6e4765 | ||
|
|
b53cafda5a | ||
|
|
adda5fd240 | ||
|
|
3e28f1fe38 | ||
|
|
bffa285cd0 | ||
|
|
b740e70068 | ||
|
|
261c95cf12 | ||
|
|
1cc7f38e14 | ||
|
|
7fffd6873b | ||
|
|
7b927db363 | ||
|
|
c42ceaaa32 | ||
|
|
177ee4ba3a | ||
|
|
aae17abf9a | ||
|
|
33d44e32d2 | ||
|
|
ce58cb9352 | ||
|
|
2043d89288 | ||
|
|
56e075253a | ||
|
|
f1b091b846 | ||
|
|
37f7ee6e15 | ||
|
|
41f17592c2 | ||
|
|
88d25f8cc1 | ||
|
|
de630dadb3 | ||
|
|
7b5e6099e8 | ||
|
|
bb9a70eee2 | ||
|
|
e9d4740cc7 | ||
|
|
c5900a9026 | ||
|
|
63577aa1aa | ||
|
|
ec33ae35ed | ||
|
|
2e69d1179b | ||
|
|
7e5be46cda | ||
|
|
0742387235 |
35
.env.example
@@ -1,12 +1,28 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# BiliNote 环境变量示例
|
||||||
|
# Docker 部署:cp .env.example .env,按需修改,然后 docker-compose up --build -d
|
||||||
|
#
|
||||||
|
# 注意区分两类变量:
|
||||||
|
# 1) VITE_* 是【构建时】变量,会被烘进前端 JS bundle。改完必须
|
||||||
|
# docker-compose build frontend && docker-compose up -d 才会生效,
|
||||||
|
# 只 docker-compose restart 不行。
|
||||||
|
# 2) 其他后端变量是【运行时】变量,改完 docker-compose up -d 即可。
|
||||||
|
#
|
||||||
|
# 提醒:LLM API key 不要写在这里!请部署完成后,从前端「模型供应商」页面录入,
|
||||||
|
# 这些 key 会保存到 SQLite 数据库(./backend/bili_note.db)并随容器持久化。
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
# 通用端口配置
|
# 通用端口配置
|
||||||
BACKEND_PORT=8483 # 后端端口
|
BACKEND_PORT=8483 # 后端端口
|
||||||
FRONTEND_PORT=3015
|
FRONTEND_PORT=3015
|
||||||
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
|
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
|
||||||
APP_PORT= 3015 # docker 部署时用
|
APP_PORT=3015 # docker 部署时对外暴露端口(浏览器访问的端口)
|
||||||
# 前端访问后端用 (开发环境使用)
|
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
# 前端访问后端用(开发环境直连;Docker 部署下走 nginx 代理,此值仅作回退)
|
||||||
|
VITE_API_BASE_URL=http://127.0.0.1:8483
|
||||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
|
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
|
||||||
VITE_FRONTEND_PORT=3015
|
VITE_FRONTEND_PORT=3015
|
||||||
|
|
||||||
# 生产环境配置
|
# 生产环境配置
|
||||||
ENV=production
|
ENV=production
|
||||||
STATIC=/static
|
STATIC=/static
|
||||||
@@ -14,11 +30,16 @@ OUT_DIR=./static/screenshots
|
|||||||
NOTE_OUTPUT_DIR=note_results
|
NOTE_OUTPUT_DIR=note_results
|
||||||
IMAGE_BASE_URL=/static/screenshots
|
IMAGE_BASE_URL=/static/screenshots
|
||||||
DATA_DIR=data
|
DATA_DIR=data
|
||||||
# FFMPEG 配置
|
|
||||||
|
# FFMPEG 配置(Docker 镜像已内置 ffmpeg,留空即可;自建/桌面端可填绝对路径)
|
||||||
FFMPEG_BIN_PATH=
|
FFMPEG_BIN_PATH=
|
||||||
|
|
||||||
# transcriber 相关配置
|
# 转写器配置
|
||||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
|
# TRANSCRIBER_TYPE 可选:fast-whisper / bcut / kuaishou / mlx-whisper(仅 Apple Silicon) / groq
|
||||||
WHISPER_MODEL_SIZE=medium
|
TRANSCRIBER_TYPE=fast-whisper
|
||||||
|
# WHISPER_MODEL_SIZE 默认 tiny (~75MB),首次启动快;想要更高识别质量可在前端
|
||||||
|
# 「音频转写配置」页切到 base/small/medium/large。直接在这里改大尺寸会触发
|
||||||
|
# 首次启动下载 ~1.5GB 文件,慢网络或 4GB 内存的容器容易 OOM。
|
||||||
|
WHISPER_MODEL_SIZE=tiny
|
||||||
|
|
||||||
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo
|
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo
|
||||||
|
|||||||
15
.github/workflows/docker-build.yml
vendored
@@ -42,6 +42,16 @@ jobs:
|
|||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Resolve app version
|
||||||
|
id: app-version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "version=" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build and Push Docker Image
|
- name: Build and Push Docker Image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -50,6 +60,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
VITE_APP_VERSION=${{ steps.app-version.outputs.version }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -66,6 +78,9 @@ jobs:
|
|||||||
echo "Run the container:"
|
echo "Run the container:"
|
||||||
echo " docker run -d -p 80:80 \\"
|
echo " docker run -d -p 80:80 \\"
|
||||||
echo " -v bilinote-data:/app/backend/data \\"
|
echo " -v bilinote-data:/app/backend/data \\"
|
||||||
|
echo " -v bilinote-config:/app/backend/config \\"
|
||||||
|
echo " -v bilinote-static:/app/backend/static \\"
|
||||||
|
echo " -v bilinote-models:/app/backend/models \\"
|
||||||
echo " --name bilinote \\"
|
echo " --name bilinote \\"
|
||||||
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
17
.github/workflows/main.yml
vendored
@@ -45,10 +45,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 设置 pnpm
|
# 设置 pnpm
|
||||||
|
# 不能用 'latest':pnpm 11+ 要求 Node 22+,与下方 Node 20 不兼容(ERR_UNKNOWN_BUILTIN_MODULE)。
|
||||||
|
# lockfile 是 pnpm 9 生成;统一 pin 到 9.15.0
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 'latest'
|
version: '9.15.0'
|
||||||
|
|
||||||
# 设置 Node 环境
|
# 设置 Node 环境
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
@@ -77,6 +79,19 @@ jobs:
|
|||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
|
||||||
restore-keys: ${{ runner.os }}-cargo-
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
# 从 tag 注入版本号到 tauri.conf.json:Tauri 取该文件的静态 version 作为
|
||||||
|
# 产物版本,不同步的话构建产物会恒为 conf 里写死的值(此前的 2.0.0)。
|
||||||
|
# github.ref_name 形如 v2.3.2,去掉前缀 v。workflow_dispatch(无 tag)时跳过,保留静态值。
|
||||||
|
- name: Sync version from tag
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
working-directory: BillNote_frontend
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "Injecting version $VERSION into tauri.conf.json"
|
||||||
|
node -e "const f='src-tauri/tauri.conf.json'; const fs=require('fs'); const j=JSON.parse(fs.readFileSync(f,'utf8')); j.version=process.argv[1]; fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');" "$VERSION"
|
||||||
|
node -e "console.log('tauri.conf.json version =', require('./src-tauri/tauri.conf.json').version)"
|
||||||
|
|
||||||
# 打包 Tauri 应用
|
# 打包 Tauri 应用
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
working-directory: BillNote_frontend
|
working-directory: BillNote_frontend
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Settings, TaskRecord } from '~/logic/types'
|
|||||||
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
|
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
|
||||||
import { detectPlatform } from '~/logic/platform'
|
import { detectPlatform } from '~/logic/platform'
|
||||||
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||||
|
import { normalizeVideoTitle } from '~/logic/task-display'
|
||||||
|
|
||||||
// only on dev mode
|
// only on dev mode
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
@@ -56,8 +57,9 @@ async function upsertTask(record: TaskRecord) {
|
|||||||
|
|
||||||
// ---------- 启动任务 ----------
|
// ---------- 启动任务 ----------
|
||||||
|
|
||||||
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
|
async function startTask(url: string, title?: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
|
||||||
const platform = detectPlatform(url)
|
const platform = detectPlatform(url)
|
||||||
|
const displayTitle = normalizeVideoTitle(title)
|
||||||
if (!platform)
|
if (!platform)
|
||||||
return { ok: false, error: '当前链接不是支持的视频平台' }
|
return { ok: false, error: '当前链接不是支持的视频平台' }
|
||||||
|
|
||||||
@@ -107,6 +109,7 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
|
|||||||
message: '已提交',
|
message: '已提交',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
title: displayTitle,
|
||||||
})
|
})
|
||||||
return { ok: true, taskId: body.data.task_id }
|
return { ok: true, taskId: body.data.task_id }
|
||||||
}
|
}
|
||||||
@@ -129,8 +132,8 @@ async function openSidePanelInTab(tabId?: number) {
|
|||||||
|
|
||||||
// ---------- 消息桥 ----------
|
// ---------- 消息桥 ----------
|
||||||
|
|
||||||
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
|
onMessage<{ url: string; title?: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
|
||||||
const result = await startTask(data.url)
|
const result = await startTask(data.url, data.title)
|
||||||
// 成功就把侧边栏拉起来给用户看进度
|
// 成功就把侧边栏拉起来给用户看进度
|
||||||
if (result.ok)
|
if (result.ok)
|
||||||
await openSidePanelInTab(sender?.tabId)
|
await openSidePanelInTab(sender?.tabId)
|
||||||
@@ -168,7 +171,7 @@ browser.contextMenus?.onClicked.addListener(async (info, tab) => {
|
|||||||
const url = info.linkUrl || tab?.url
|
const url = info.linkUrl || tab?.url
|
||||||
if (!url)
|
if (!url)
|
||||||
return
|
return
|
||||||
const result = await startTask(url)
|
const result = await startTask(url, tab?.title)
|
||||||
if (result.ok)
|
if (result.ok)
|
||||||
await openSidePanelInTab(tab?.id)
|
await openSidePanelInTab(tab?.id)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,32 +1,181 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Transformer } from 'markmap-lib'
|
import { Transformer } from 'markmap-lib'
|
||||||
import { Markmap } from 'markmap-view'
|
import { Markmap } from 'markmap-view'
|
||||||
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
|
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
|
||||||
|
|
||||||
const props = defineProps<{ markdown: string }>()
|
const props = defineProps<{ markdown: string }>()
|
||||||
|
|
||||||
|
const wrapRef = ref<HTMLDivElement | null>(null)
|
||||||
const svgRef = ref<SVGSVGElement | null>(null)
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
let mm: Markmap | null = null
|
let mm: Markmap | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
const transformer = new Transformer()
|
const transformer = new Transformer()
|
||||||
|
const MIN_EXPORT_FONT_PX = 256
|
||||||
|
const MIN_EXPORT_WIDTH = 12800
|
||||||
|
const MAX_EXPORT_SCALE = 24
|
||||||
|
const MAX_CANVAS_SIDE = 32767
|
||||||
|
|
||||||
function render() {
|
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||||
if (!svgRef.value)
|
return new Promise((resolve, reject) => {
|
||||||
return
|
canvas.toBlob((blob) => {
|
||||||
const md = absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))
|
if (blob)
|
||||||
const { root } = transformer.transform(md)
|
resolve(blob)
|
||||||
if (!mm)
|
else
|
||||||
mm = Markmap.create(svgRef.value, undefined, root)
|
reject(new Error('导出思维导图图片失败'))
|
||||||
else
|
}, 'image/png')
|
||||||
mm.setData(root).then(() => mm?.fit())
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(render)
|
function createSvgElement<K extends keyof SVGElementTagNameMap>(tag: K): SVGElementTagNameMap[K] {
|
||||||
|
return document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSvgForCanvas(svg: SVGSVGElement): SVGSVGElement {
|
||||||
|
const cloned = svg.cloneNode(true) as SVGSVGElement
|
||||||
|
|
||||||
|
cloned.querySelectorAll('image').forEach(el => el.remove())
|
||||||
|
cloned.querySelectorAll('foreignObject').forEach((foreignObject) => {
|
||||||
|
const textContent = foreignObject.textContent?.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!textContent) {
|
||||||
|
foreignObject.remove()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = Number(foreignObject.getAttribute('x') || 0)
|
||||||
|
const y = Number(foreignObject.getAttribute('y') || 0)
|
||||||
|
const height = Number(foreignObject.getAttribute('height') || 20)
|
||||||
|
const text = createSvgElement('text')
|
||||||
|
text.setAttribute('x', String(x))
|
||||||
|
text.setAttribute('y', String(y + height / 2))
|
||||||
|
text.setAttribute('dominant-baseline', 'middle')
|
||||||
|
text.setAttribute('font-size', '14')
|
||||||
|
text.setAttribute('font-family', 'Arial, "Microsoft YaHei", sans-serif')
|
||||||
|
text.setAttribute('fill', '#333')
|
||||||
|
text.textContent = textContent
|
||||||
|
foreignObject.replaceWith(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExportFontSize(svg: SVGSVGElement): number {
|
||||||
|
const text = svg.querySelector('text, foreignObject')
|
||||||
|
if (!text)
|
||||||
|
return 14
|
||||||
|
|
||||||
|
const fontSize = Number.parseFloat(getComputedStyle(text).fontSize || '')
|
||||||
|
if (Number.isFinite(fontSize) && fontSize > 0)
|
||||||
|
return fontSize
|
||||||
|
|
||||||
|
const attrSize = Number.parseFloat(text.getAttribute('font-size') || '')
|
||||||
|
return Number.isFinite(attrSize) && attrSize > 0 ? attrSize : 14
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMindmapNoise(md: string): string {
|
||||||
|
return absolutizeMarkdownImages(stripSourceLink(md || ''))
|
||||||
|
// 笔记里的截图/封面图片在思维导图中会被当作超大 SVG foreignObject,
|
||||||
|
// 容易把导图挤成截图里那种“只剩半框/一条竖线”的效果。导图只保留文字层级。
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||||
|
.replace(/<img\b[^>]*>/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fit() {
|
||||||
|
await nextTick()
|
||||||
|
requestAnimationFrame(() => mm?.fit())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
if (!svgRef.value)
|
||||||
|
return
|
||||||
|
const { root } = transformer.transform(stripMindmapNoise(props.markdown))
|
||||||
|
if (!mm)
|
||||||
|
mm = Markmap.create(svgRef.value, { autoFit: true }, root)
|
||||||
|
else
|
||||||
|
await mm.setData(root)
|
||||||
|
await fit()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toPngBlob(): Promise<Blob> {
|
||||||
|
await fit()
|
||||||
|
await nextTick()
|
||||||
|
if (!svgRef.value)
|
||||||
|
throw new Error('思维导图尚未渲染完成')
|
||||||
|
|
||||||
|
const svg = svgRef.value
|
||||||
|
const bbox = svg.getBBox()
|
||||||
|
const padding = 48
|
||||||
|
const x = Math.floor(bbox.x - padding)
|
||||||
|
const y = Math.floor(bbox.y - padding)
|
||||||
|
const width = Math.max(Math.ceil(bbox.width + padding * 2), 1)
|
||||||
|
const height = Math.max(Math.ceil(bbox.height + padding * 2), 1)
|
||||||
|
const cloned = sanitizeSvgForCanvas(svg)
|
||||||
|
|
||||||
|
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||||
|
cloned.setAttribute('width', String(width))
|
||||||
|
cloned.setAttribute('height', String(height))
|
||||||
|
cloned.setAttribute('viewBox', `${x} ${y} ${width} ${height}`)
|
||||||
|
cloned.insertAdjacentHTML('afterbegin', `<rect width="100%" height="100%" fill="#fff"/>`)
|
||||||
|
|
||||||
|
const svgText = new XMLSerializer().serializeToString(cloned)
|
||||||
|
const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.decoding = 'async'
|
||||||
|
img.src = url
|
||||||
|
await img.decode()
|
||||||
|
|
||||||
|
// 不写死某个导出宽度:按导图内容和文字字号动态反推 PNG 倍率。
|
||||||
|
// 目标是让导出的正文至少有 MIN_EXPORT_FONT_PX 像素高,小图自动放大,
|
||||||
|
// 大图则按内容尺寸导出;同时限制最大边长,避免复杂导图撑爆内存。
|
||||||
|
const fontScale = MIN_EXPORT_FONT_PX / getExportFontSize(svg)
|
||||||
|
const widthScale = MIN_EXPORT_WIDTH / width
|
||||||
|
const rawScale = Math.max(window.devicePixelRatio || 1, fontScale, widthScale)
|
||||||
|
const sideLimitScale = Math.min(MAX_CANVAS_SIDE / width, MAX_CANVAS_SIDE / height)
|
||||||
|
const scale = Math.max(1, Math.min(rawScale, MAX_EXPORT_SCALE, sideLimitScale))
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = Math.ceil(width * scale)
|
||||||
|
canvas.height = Math.ceil(height * scale)
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error('当前浏览器不支持 Canvas 导出')
|
||||||
|
ctx.fillStyle = '#fff'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.scale(scale, scale)
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
return await canvasToBlob(canvas)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toPngBlob,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
render()
|
||||||
|
if (wrapRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => fit())
|
||||||
|
resizeObserver.observe(wrapRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
mm?.destroy()
|
||||||
|
mm = null
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.markdown, render)
|
watch(() => props.markdown, render)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full bg-white rounded border overflow-hidden">
|
<div ref="wrapRef" class="w-full h-full min-h-[360px] bg-white rounded border overflow-hidden">
|
||||||
<svg ref="svgRef" class="w-full h-full" />
|
<svg ref="svgRef" class="w-full h-full min-h-[360px]" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ async function trigger() {
|
|||||||
const res = await sendMessage('bilinote-start', {
|
const res = await sendMessage('bilinote-start', {
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
platform,
|
platform,
|
||||||
|
title: document.title,
|
||||||
}, 'background')
|
}, 'background')
|
||||||
const ok = res && (res as any).ok
|
const ok = res && (res as any).ok
|
||||||
toast.value = ok
|
toast.value = ok
|
||||||
|
|||||||
21
BillNote_extension/src/logic/task-display.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { TaskRecord } from './types'
|
||||||
|
|
||||||
|
const SITE_SUFFIX_RE = /\s*[-_—–||]\s*(哔哩哔哩|bilibili|youtube|抖音|douyin|快手|kuaishou)\s*$/i
|
||||||
|
|
||||||
|
export function normalizeVideoTitle(title: string | undefined | null): string | undefined {
|
||||||
|
const value = title?.trim()
|
||||||
|
if (!value)
|
||||||
|
return undefined
|
||||||
|
return value
|
||||||
|
.replace(SITE_SUFFIX_RE, '')
|
||||||
|
.trim() || value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskDisplayTitle(task: TaskRecord | undefined | null, fallbackTitle?: string): string {
|
||||||
|
if (!task)
|
||||||
|
return normalizeVideoTitle(fallbackTitle) || ''
|
||||||
|
return normalizeVideoTitle((task.result?.audio_meta as { title?: string } | undefined)?.title)
|
||||||
|
|| normalizeVideoTitle(task.title)
|
||||||
|
|| normalizeVideoTitle(fallbackTitle)
|
||||||
|
|| task.videoUrl
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ export interface TaskRecord {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
result?: NoteResult
|
result?: NoteResult
|
||||||
|
// 从浏览器 tab.title 抓取,任务完成前用来替代 videoUrl 显示
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐
|
// 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/
|
|||||||
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
|
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
|
||||||
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||||
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
|
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
|
||||||
|
import { getTaskDisplayTitle, normalizeVideoTitle } from '~/logic/task-display'
|
||||||
|
|
||||||
const tabUrl = ref<string>('')
|
const tabUrl = ref<string>('')
|
||||||
const tabTitle = ref<string>('')
|
const tabTitle = ref<string>('')
|
||||||
@@ -43,6 +44,7 @@ async function poll(taskId: string) {
|
|||||||
createdAt: activeTask.value?.createdAt ?? Date.now(),
|
createdAt: activeTask.value?.createdAt ?? Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
result: res.result ?? activeTask.value?.result,
|
result: res.result ?? activeTask.value?.result,
|
||||||
|
title: activeTask.value?.title || normalizeVideoTitle(tabTitle.value),
|
||||||
})
|
})
|
||||||
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||||
pollTimer = setTimeout(() => poll(taskId), 3000)
|
pollTimer = setTimeout(() => poll(taskId), 3000)
|
||||||
@@ -94,6 +96,7 @@ async function start() {
|
|||||||
message: '已提交',
|
message: '已提交',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
title: normalizeVideoTitle(tabTitle.value),
|
||||||
})
|
})
|
||||||
poll(task_id)
|
poll(task_id)
|
||||||
// 提交后顺手把侧边栏拉起来,免得用户来回切窗口
|
// 提交后顺手把侧边栏拉起来,免得用户来回切窗口
|
||||||
@@ -142,7 +145,7 @@ function selectTask(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeCover = computed(() => activeTask.value?.result?.audio_meta?.cover_url as string | undefined)
|
const activeCover = computed(() => activeTask.value?.result?.audio_meta?.cover_url as string | undefined)
|
||||||
const activeTitle = computed(() => (activeTask.value?.result?.audio_meta?.title as string | undefined) || tabTitle.value)
|
const activeTitle = computed(() => getTaskDisplayTitle(activeTask.value, tabTitle.value))
|
||||||
|
|
||||||
function fmtTime(ts?: number) {
|
function fmtTime(ts?: number) {
|
||||||
if (!ts)
|
if (!ts)
|
||||||
@@ -177,8 +180,8 @@ onUnmounted(() => {
|
|||||||
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
|
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 truncate" :title="tabUrl">
|
<div class="text-xs text-gray-500 truncate" :title="normalizeVideoTitle(tabTitle) || tabUrl">
|
||||||
{{ tabUrl || '当前没有打开的标签页' }}
|
{{ normalizeVideoTitle(tabTitle) || tabUrl || '当前没有打开的标签页' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
|
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
|
||||||
@@ -331,8 +334,8 @@ onUnmounted(() => {
|
|||||||
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
|
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
|
||||||
@click="selectTask(t.taskId)"
|
@click="selectTask(t.taskId)"
|
||||||
>
|
>
|
||||||
<span class="truncate flex-1" :title="t.videoUrl">
|
<span class="truncate flex-1" :title="getTaskDisplayTitle(t)">
|
||||||
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
|
{{ getTaskDisplayTitle(t) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
|
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { getTaskStatus, resolveImageUrl } from '~/logic/api'
|
import { getTaskStatus, resolveImageUrl } from '~/logic/api'
|
||||||
import { tasks, tasksReady, settingsReady, upsertTask } from '~/logic/storage'
|
import { tasks, tasksReady, settingsReady, upsertTask } from '~/logic/storage'
|
||||||
import type { TaskRecord } from '~/logic/types'
|
import type { TaskRecord } from '~/logic/types'
|
||||||
|
import { getTaskDisplayTitle } from '~/logic/task-display'
|
||||||
|
|
||||||
type ViewMode = 'markdown' | 'mindmap' | 'chat'
|
type ViewMode = 'markdown' | 'mindmap' | 'chat'
|
||||||
|
|
||||||
const activeTaskId = ref<string>('')
|
const activeTaskId = ref<string>('')
|
||||||
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
|
const successMsg = ref('')
|
||||||
const viewMode = ref<ViewMode>('markdown')
|
const viewMode = ref<ViewMode>('markdown')
|
||||||
const showHistory = ref(false)
|
const showHistory = ref(false)
|
||||||
|
const mindMapRef = ref<{ toPngBlob: () => Promise<Blob> } | null>(null)
|
||||||
|
|
||||||
const isDone = computed(() => activeTask.value?.status === 'SUCCESS')
|
const isDone = computed(() => activeTask.value?.status === 'SUCCESS')
|
||||||
const isFailed = computed(() => activeTask.value?.status === 'FAILED')
|
const isFailed = computed(() => activeTask.value?.status === 'FAILED')
|
||||||
@@ -41,6 +44,7 @@ async function poll(taskId: string) {
|
|||||||
message: res.message,
|
message: res.message,
|
||||||
result: res.result ?? cur.result,
|
result: res.result ?? cur.result,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
title: cur.title || getTaskDisplayTitle(cur),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||||
@@ -74,11 +78,19 @@ async function copyMarkdown() {
|
|||||||
await navigator.clipboard.writeText(md)
|
await navigator.clipboard.writeText(md)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeFilename(name: string): string {
|
||||||
|
return (name || 'bilinote')
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 120) || 'bilinote'
|
||||||
|
}
|
||||||
|
|
||||||
function downloadMarkdown() {
|
function downloadMarkdown() {
|
||||||
const md = activeTask.value?.result?.markdown
|
const md = activeTask.value?.result?.markdown
|
||||||
if (!md)
|
if (!md)
|
||||||
return
|
return
|
||||||
const title = (activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || 'bilinote'
|
const title = safeFilename(getTaskDisplayTitle(activeTask.value))
|
||||||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
|
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
@@ -88,8 +100,44 @@ function downloadMarkdown() {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTitle = computed(() =>
|
async function copyMindMapImage() {
|
||||||
(activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || activeTask.value?.videoUrl || '')
|
try {
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
const blob = await mindMapRef.value?.toPngBlob()
|
||||||
|
if (!blob)
|
||||||
|
return
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ [blob.type]: blob }),
|
||||||
|
])
|
||||||
|
successMsg.value = '思维导图图片已复制'
|
||||||
|
setTimeout(() => { successMsg.value = '' }, 2000)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message || '复制思维导图图片失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadMindMapImage() {
|
||||||
|
try {
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = ''
|
||||||
|
const blob = await mindMapRef.value?.toPngBlob()
|
||||||
|
if (!blob)
|
||||||
|
return
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${safeFilename(getTaskDisplayTitle(activeTask.value))}.png`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message || '下载思维导图图片失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTitle = computed(() => getTaskDisplayTitle(activeTask.value))
|
||||||
|
|
||||||
const activeCover = computed(() =>
|
const activeCover = computed(() =>
|
||||||
(activeTask.value?.result?.audio_meta as { cover_url?: string } | undefined)?.cover_url)
|
(activeTask.value?.result?.audio_meta as { cover_url?: string } | undefined)?.cover_url)
|
||||||
@@ -140,8 +188,8 @@ onUnmounted(() => {
|
|||||||
:class="{ 'bg-white border': t.taskId === activeTaskId }"
|
:class="{ 'bg-white border': t.taskId === activeTaskId }"
|
||||||
@click="selectTask(t.taskId)"
|
@click="selectTask(t.taskId)"
|
||||||
>
|
>
|
||||||
<span class="truncate flex-1" :title="t.videoUrl">
|
<span class="truncate flex-1" :title="getTaskDisplayTitle(t)">
|
||||||
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
|
{{ getTaskDisplayTitle(t) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-gray-400 shrink-0">{{ STAGE_LABELS[t.status] || t.status }}</span>
|
<span class="text-gray-400 shrink-0">{{ STAGE_LABELS[t.status] || t.status }}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -151,6 +199,9 @@ onUnmounted(() => {
|
|||||||
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-1 break-words bg-red-50 shrink-0">
|
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-1 break-words bg-red-50 shrink-0">
|
||||||
{{ errorMsg }}
|
{{ errorMsg }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="successMsg" class="text-xs text-green-700 px-3 py-1 break-words bg-green-50 shrink-0">
|
||||||
|
{{ successMsg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
|
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
|
||||||
还没有任务。在视频页点悬浮按钮、在 popup 提交,或右键菜单选「用 BiliNote 总结」。
|
还没有任务。在视频页点悬浮按钮、在 popup 提交,或右键菜单选「用 BiliNote 总结」。
|
||||||
@@ -170,7 +221,7 @@ onUnmounted(() => {
|
|||||||
class="text-sm font-medium leading-tight line-clamp-1 break-all flex-1 min-w-0 hover:text-blue-600"
|
class="text-sm font-medium leading-tight line-clamp-1 break-all flex-1 min-w-0 hover:text-blue-600"
|
||||||
:href="activeTask.videoUrl"
|
:href="activeTask.videoUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
:title="activeTask.videoUrl"
|
:title="activeTitle || activeTask.videoUrl"
|
||||||
>{{ activeTitle }}</a>
|
>{{ activeTitle }}</a>
|
||||||
<span
|
<span
|
||||||
v-if="isDone"
|
v-if="isDone"
|
||||||
@@ -224,6 +275,18 @@ onUnmounted(() => {
|
|||||||
title="下载 .md"
|
title="下载 .md"
|
||||||
@click="downloadMarkdown"
|
@click="downloadMarkdown"
|
||||||
>下载</button>
|
>下载</button>
|
||||||
|
<button
|
||||||
|
v-if="viewMode === 'mindmap'"
|
||||||
|
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
|
||||||
|
title="复制思维导图图片"
|
||||||
|
@click="copyMindMapImage"
|
||||||
|
>复制</button>
|
||||||
|
<button
|
||||||
|
v-if="viewMode === 'mindmap'"
|
||||||
|
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
|
||||||
|
title="下载思维导图 PNG"
|
||||||
|
@click="downloadMindMapImage"
|
||||||
|
>下载</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区:占满剩余空间 -->
|
<!-- 内容区:占满剩余空间 -->
|
||||||
@@ -236,6 +299,7 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
<MindMap
|
<MindMap
|
||||||
v-else-if="isDone && activeTask.result?.markdown && viewMode === 'mindmap'"
|
v-else-if="isDone && activeTask.result?.markdown && viewMode === 'mindmap'"
|
||||||
|
ref="mindMapRef"
|
||||||
:markdown="activeTask.result.markdown"
|
:markdown="activeTask.result.markdown"
|
||||||
class="h-full"
|
class="h-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
# === 前端构建阶段 ===
|
# === 前端构建阶段 ===
|
||||||
# Tailwind v4 / Vite 6 需要 Node 20+,alpine + pnpm 会按 lockfile 拉 musl native binary。
|
# Tailwind v4 / Vite 6 需要 Node 20+,alpine + pnpm 会按 lockfile 拉 musl native binary。
|
||||||
FROM node:20-alpine AS builder
|
# BASE_REGISTRY 默认 docker.io,国内拉不到可换 daocloud / 阿里云镜像:
|
||||||
|
# docker-compose build --build-arg BASE_REGISTRY=docker.m.daocloud.io
|
||||||
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
FROM ${BASE_REGISTRY}/library/node:20-alpine AS builder
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
# 可由发布 workflow 从 git tag 注入,用于前端 About 页展示版本;未传时由 Vite 回退读取 tauri.conf.json。
|
||||||
|
ARG VITE_APP_VERSION=
|
||||||
|
ENV VITE_APP_VERSION=${VITE_APP_VERSION}
|
||||||
|
|
||||||
|
# pnpm pin 到 9.x:lockfile 是 v9 生成;pnpm 11 要求 Node 22+ 与 node:20 不兼容
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -15,7 +23,9 @@ COPY ./BillNote_frontend/ ./
|
|||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
||||||
FROM nginx:1.25-alpine
|
# 重新声明 ARG —— buildkit 跨阶段不自动继承
|
||||||
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
FROM ${BASE_REGISTRY}/library/nginx:1.25-alpine
|
||||||
|
|
||||||
RUN rm -rf /etc/nginx/conf.d/default.conf
|
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||||
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.9",
|
"@radix-ui/react-tabs": "^1.1.9",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@tauri-apps/plugin-shell": "~2.2.2",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
|
"@tauri-apps/plugin-shell": "~2.3.5",
|
||||||
"@uiw/react-markdown-preview": "^5.1.3",
|
"@uiw/react-markdown-preview": "^5.1.3",
|
||||||
"antd": "^5.24.8",
|
"antd": "^5.24.8",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
"react-router-dom": "^7.5.1",
|
"react-router-dom": "^7.5.1",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"rehype-katex": "^6.0.2",
|
"rehype-katex": "^6.0.2",
|
||||||
|
"rehype-slug": "5.1.0",
|
||||||
"remark-gfm": "3.0.1",
|
"remark-gfm": "3.0.1",
|
||||||
"remark-math": "^5.1.1",
|
"remark-math": "^5.1.1",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
|
|||||||
189
BillNote_frontend/pnpm-lock.yaml
generated
@@ -13,7 +13,7 @@ importers:
|
|||||||
version: 2.4.0(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 2.4.0(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
|
version: 5.4.0(react-hook-form@7.72.0(react@19.2.4))
|
||||||
'@lobehub/icons':
|
'@lobehub/icons':
|
||||||
specifier: ^1.97.1
|
specifier: ^1.97.1
|
||||||
version: 1.98.0(@babel/core@7.29.0)(@types/mdast@4.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.98.0(@babel/core@7.29.0)(@types/mdast@4.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(micromark-util-types@2.0.2)(micromark@4.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -52,13 +52,16 @@ importers:
|
|||||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.3
|
specifier: ^4.1.3
|
||||||
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
|
version: 4.2.2(vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
|
||||||
|
'@tauri-apps/api':
|
||||||
|
specifier: ^2.11.0
|
||||||
|
version: 2.11.0
|
||||||
'@tauri-apps/plugin-shell':
|
'@tauri-apps/plugin-shell':
|
||||||
specifier: ~2.2.2
|
specifier: ~2.3.5
|
||||||
version: 2.2.2
|
version: 2.3.5
|
||||||
'@uiw/react-markdown-preview':
|
'@uiw/react-markdown-preview':
|
||||||
specifier: ^5.1.3
|
specifier: ^5.1.3
|
||||||
version: 5.1.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 5.2.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
antd:
|
antd:
|
||||||
specifier: ^5.24.8
|
specifier: ^5.24.8
|
||||||
version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -73,13 +76,13 @@ importers:
|
|||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
fuse.js:
|
fuse.js:
|
||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 7.1.0
|
version: 7.3.0
|
||||||
github-markdown-css:
|
github-markdown-css:
|
||||||
specifier: ^5.8.1
|
specifier: ^5.8.1
|
||||||
version: 5.9.0
|
version: 5.9.0
|
||||||
idb-keyval:
|
idb-keyval:
|
||||||
specifier: ^6.2.2
|
specifier: ^6.2.2
|
||||||
version: 6.2.2
|
version: 6.2.4
|
||||||
jszip:
|
jszip:
|
||||||
specifier: ^3.10.1
|
specifier: ^3.10.1
|
||||||
version: 3.10.1
|
version: 3.10.1
|
||||||
@@ -146,6 +149,9 @@ importers:
|
|||||||
rehype-katex:
|
rehype-katex:
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
rehype-slug:
|
||||||
|
specifier: 5.1.0
|
||||||
|
version: 5.1.0
|
||||||
remark-gfm:
|
remark-gfm:
|
||||||
specifier: 3.0.1
|
specifier: 3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
@@ -166,7 +172,7 @@ importers:
|
|||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0
|
version: 11.1.1
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.2
|
specifier: ^3.24.2
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -194,7 +200,7 @@ importers:
|
|||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
|
version: 4.7.0(vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
|
||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.21
|
specifier: ^10.4.21
|
||||||
version: 10.4.27(postcss@8.5.8)
|
version: 10.4.27(postcss@8.5.8)
|
||||||
@@ -224,7 +230,7 @@ importers:
|
|||||||
version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.7.3)
|
version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.7.3)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.2.0
|
specifier: ^6.2.0
|
||||||
version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
|
version: 6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -729,8 +735,8 @@ packages:
|
|||||||
react: ^16 || ^17 || ^18 || ^19
|
react: ^16 || ^17 || ^18 || ^19
|
||||||
react-dom: ^16 || ^17 || ^18 || ^19
|
react-dom: ^16 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
'@hookform/resolvers@5.2.2':
|
'@hookform/resolvers@5.4.0':
|
||||||
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
|
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react-hook-form: ^7.55.0
|
react-hook-form: ^7.55.0
|
||||||
|
|
||||||
@@ -1587,8 +1593,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1':
|
'@tauri-apps/api@2.11.0':
|
||||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||||
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
|
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
|
||||||
@@ -1666,8 +1672,8 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@tauri-apps/plugin-shell@2.2.2':
|
'@tauri-apps/plugin-shell@2.3.5':
|
||||||
resolution: {integrity: sha512-fg9XKWfzRQsN8p+Zrk82WeHvXFvGVnG0/mTlujQdLWNnO5cM6WD9qCrHbFytScVS+WhmRAkuypQPcxeKKl3VBg==}
|
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
@@ -1783,6 +1789,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/estree@1.0.9':
|
||||||
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16':
|
'@types/geojson@7946.0.16':
|
||||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
@@ -1907,8 +1916,8 @@ packages:
|
|||||||
'@uiw/copy-to-clipboard@1.0.20':
|
'@uiw/copy-to-clipboard@1.0.20':
|
||||||
resolution: {integrity: sha512-IFQhS62CLNon1YgYJTEzXR2N3WVXg7V1FaBRDLMlzU6JY5X6Hr3OPAcw4WNoKcz2XcFD6XCgwEjlsmj+JA0mWA==}
|
resolution: {integrity: sha512-IFQhS62CLNon1YgYJTEzXR2N3WVXg7V1FaBRDLMlzU6JY5X6Hr3OPAcw4WNoKcz2XcFD6XCgwEjlsmj+JA0mWA==}
|
||||||
|
|
||||||
'@uiw/react-markdown-preview@5.1.5':
|
'@uiw/react-markdown-preview@5.2.1':
|
||||||
resolution: {integrity: sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg==}
|
resolution: {integrity: sha512-JjvcHveT6glhlJYJx1XGBZij6wkw+VwREV6Z6m/GpsjPPdLjF1x8nlPBSB/ATyUF4lD7C8ttMkCqVH9N9XMgEA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8.0'
|
react: '>=16.8.0'
|
||||||
react-dom: '>=16.8.0'
|
react-dom: '>=16.8.0'
|
||||||
@@ -1916,6 +1925,9 @@ packages:
|
|||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
|
'@ungap/structured-clone@1.3.1':
|
||||||
|
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||||
|
|
||||||
'@upsetjs/venn.js@2.0.0':
|
'@upsetjs/venn.js@2.0.0':
|
||||||
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
|
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
|
||||||
|
|
||||||
@@ -2686,8 +2698,8 @@ packages:
|
|||||||
function-bind@1.1.2:
|
function-bind@1.1.2:
|
||||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
fuse.js@7.1.0:
|
fuse.js@7.3.0:
|
||||||
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
|
resolution: {integrity: sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2:
|
gensync@1.0.0-beta.2:
|
||||||
@@ -2791,9 +2803,15 @@ packages:
|
|||||||
hast-util-from-parse5@8.0.3:
|
hast-util-from-parse5@8.0.3:
|
||||||
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
|
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
|
||||||
|
|
||||||
|
hast-util-has-property@2.0.1:
|
||||||
|
resolution: {integrity: sha512-X2+RwZIMTMKpXUzlotatPzWj8bspCymtXH3cfG3iQKV+wPF53Vgaqxi/eLqGck0wKq1kS9nvoB1wchbCPEL8sg==}
|
||||||
|
|
||||||
hast-util-has-property@3.0.0:
|
hast-util-has-property@3.0.0:
|
||||||
resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==}
|
resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==}
|
||||||
|
|
||||||
|
hast-util-heading-rank@2.1.1:
|
||||||
|
resolution: {integrity: sha512-iAuRp+ESgJoRFJbSyaqsfvJDY6zzmFoEnL1gtz1+U8gKtGGj1p0CVlysuUAUjq95qlZESHINLThwJzNGmgGZxA==}
|
||||||
|
|
||||||
hast-util-heading-rank@3.0.0:
|
hast-util-heading-rank@3.0.0:
|
||||||
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
|
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
|
||||||
|
|
||||||
@@ -2830,6 +2848,9 @@ packages:
|
|||||||
hast-util-to-parse5@8.0.1:
|
hast-util-to-parse5@8.0.1:
|
||||||
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
|
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
|
||||||
|
|
||||||
|
hast-util-to-string@2.0.0:
|
||||||
|
resolution: {integrity: sha512-02AQ3vLhuH3FisaMM+i/9sm4OXGSq1UhOOCpTLLQtHdL3tZt7qil69r8M8iDkZYyC0HCFylcYoP+8IO7ddta1A==}
|
||||||
|
|
||||||
hast-util-to-string@3.0.1:
|
hast-util-to-string@3.0.1:
|
||||||
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
|
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
|
||||||
|
|
||||||
@@ -2880,8 +2901,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
idb-keyval@6.2.2:
|
idb-keyval@6.2.4:
|
||||||
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
resolution: {integrity: sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
@@ -4225,12 +4246,6 @@ packages:
|
|||||||
'@types/react': '>=16'
|
'@types/react': '>=16'
|
||||||
react: '>=16'
|
react: '>=16'
|
||||||
|
|
||||||
react-markdown@9.0.3:
|
|
||||||
resolution: {integrity: sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw==}
|
|
||||||
peerDependencies:
|
|
||||||
'@types/react': '>=18'
|
|
||||||
react: '>=18'
|
|
||||||
|
|
||||||
react-medium-image-zoom@5.4.1:
|
react-medium-image-zoom@5.4.1:
|
||||||
resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==}
|
resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4369,8 +4384,8 @@ packages:
|
|||||||
regex@6.1.0:
|
regex@6.1.0:
|
||||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||||
|
|
||||||
rehype-attr@3.0.3:
|
rehype-attr@4.0.0:
|
||||||
resolution: {integrity: sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw==}
|
resolution: {integrity: sha512-tANn9EmhG4mEZlNdDDRKuS0OXPDvc6P6OjJ1yApzOjIdCvKNLiuU2HdMSLTpiVi3D/FyLK6B+ZZ8PYtRxiGg7Q==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
rehype-autolink-headings@7.1.0:
|
rehype-autolink-headings@7.1.0:
|
||||||
@@ -4405,6 +4420,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==}
|
resolution: {integrity: sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
rehype-slug@5.1.0:
|
||||||
|
resolution: {integrity: sha512-Gf91dJoXneiorNEnn+Phx97CO7oRMrpi+6r155tTxzGuLtm+QrI4cTwCa9e1rtePdL4i9tSO58PeSS6HWfgsiw==}
|
||||||
|
|
||||||
rehype-slug@6.0.0:
|
rehype-slug@6.0.0:
|
||||||
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
|
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
|
||||||
|
|
||||||
@@ -4819,12 +4837,12 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
uuid@11.1.0:
|
uuid@11.1.1:
|
||||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uuid@13.0.0:
|
uuid@13.0.2:
|
||||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uvu@0.5.6:
|
uvu@0.5.6:
|
||||||
@@ -4853,8 +4871,8 @@ packages:
|
|||||||
vfile@6.0.3:
|
vfile@6.0.3:
|
||||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||||
|
|
||||||
vite@6.4.1:
|
vite@6.4.2:
|
||||||
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
|
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
|
||||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5531,7 +5549,7 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
'@hookform/resolvers@5.2.2(react-hook-form@7.72.0(react@19.2.4))':
|
'@hookform/resolvers@5.4.0(react-hook-form@7.72.0(react@19.2.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/utils': 0.3.0
|
'@standard-schema/utils': 0.3.0
|
||||||
react-hook-form: 7.72.0(react@19.2.4)
|
react-hook-form: 7.72.0(react@19.2.4)
|
||||||
@@ -5722,7 +5740,7 @@ snapshots:
|
|||||||
unified: 11.0.5
|
unified: 11.0.5
|
||||||
url-join: 5.0.0
|
url-join: 5.0.0
|
||||||
use-merge-value: 1.2.0(react@19.2.4)
|
use-merge-value: 1.2.0(react@19.2.4)
|
||||||
uuid: 13.0.0
|
uuid: 13.0.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- '@types/mdast'
|
- '@types/mdast'
|
||||||
@@ -6453,14 +6471,14 @@ snapshots:
|
|||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
|
|
||||||
'@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
|
'@tailwindcss/vite@4.2.2(vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tailwindcss/node': 4.2.2
|
'@tailwindcss/node': 4.2.2
|
||||||
'@tailwindcss/oxide': 4.2.2
|
'@tailwindcss/oxide': 4.2.2
|
||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
|
vite: 6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1': {}
|
'@tauri-apps/api@2.11.0': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||||
optional: true
|
optional: true
|
||||||
@@ -6509,9 +6527,9 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-shell@2.2.2':
|
'@tauri-apps/plugin-shell@2.3.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6661,6 +6679,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16': {}
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/hast@2.3.10':
|
'@types/hast@2.3.10':
|
||||||
@@ -6808,14 +6828,14 @@ snapshots:
|
|||||||
|
|
||||||
'@uiw/copy-to-clipboard@1.0.20': {}
|
'@uiw/copy-to-clipboard@1.0.20': {}
|
||||||
|
|
||||||
'@uiw/react-markdown-preview@5.1.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
'@uiw/react-markdown-preview@5.2.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.2
|
||||||
'@uiw/copy-to-clipboard': 1.0.20
|
'@uiw/copy-to-clipboard': 1.0.20
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
react-markdown: 9.0.3(@types/react@19.2.14)(react@19.2.4)
|
react-markdown: 10.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||||
rehype-attr: 3.0.3
|
rehype-attr: 4.0.0
|
||||||
rehype-autolink-headings: 7.1.0
|
rehype-autolink-headings: 7.1.0
|
||||||
rehype-ignore: 2.0.3
|
rehype-ignore: 2.0.3
|
||||||
rehype-prism-plus: 2.0.0
|
rehype-prism-plus: 2.0.0
|
||||||
@@ -6831,6 +6851,8 @@ snapshots:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
|
'@ungap/structured-clone@1.3.1': {}
|
||||||
|
|
||||||
'@upsetjs/venn.js@2.0.0':
|
'@upsetjs/venn.js@2.0.0':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
d3-selection: 3.0.0
|
d3-selection: 3.0.0
|
||||||
@@ -6843,7 +6865,7 @@ snapshots:
|
|||||||
'@use-gesture/core': 10.3.1
|
'@use-gesture/core': 10.3.1
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
|
'@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||||
@@ -6851,7 +6873,7 @@ snapshots:
|
|||||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.17.0
|
react-refresh: 0.17.0
|
||||||
vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
|
vite: 6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -7627,7 +7649,7 @@ snapshots:
|
|||||||
|
|
||||||
estree-util-attach-comments@3.0.0:
|
estree-util-attach-comments@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
estree-util-build-jsx@3.0.1:
|
estree-util-build-jsx@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7739,7 +7761,7 @@ snapshots:
|
|||||||
|
|
||||||
function-bind@1.1.2: {}
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
fuse.js@7.1.0: {}
|
fuse.js@7.3.0: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
@@ -7868,10 +7890,16 @@ snapshots:
|
|||||||
vfile-location: 5.0.3
|
vfile-location: 5.0.3
|
||||||
web-namespaces: 2.0.1
|
web-namespaces: 2.0.1
|
||||||
|
|
||||||
|
hast-util-has-property@2.0.1: {}
|
||||||
|
|
||||||
hast-util-has-property@3.0.0:
|
hast-util-has-property@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
|
hast-util-heading-rank@2.1.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 2.3.10
|
||||||
|
|
||||||
hast-util-heading-rank@3.0.0:
|
hast-util-heading-rank@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -7931,7 +7959,7 @@ snapshots:
|
|||||||
|
|
||||||
hast-util-to-estree@3.1.3:
|
hast-util-to-estree@3.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
'@types/estree-jsx': 1.0.5
|
'@types/estree-jsx': 1.0.5
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
comma-separated-tokens: 2.0.3
|
comma-separated-tokens: 2.0.3
|
||||||
@@ -7966,7 +7994,7 @@ snapshots:
|
|||||||
|
|
||||||
hast-util-to-jsx-runtime@2.3.6:
|
hast-util-to-jsx-runtime@2.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
comma-separated-tokens: 2.0.3
|
comma-separated-tokens: 2.0.3
|
||||||
@@ -7994,6 +8022,10 @@ snapshots:
|
|||||||
web-namespaces: 2.0.1
|
web-namespaces: 2.0.1
|
||||||
zwitch: 2.0.4
|
zwitch: 2.0.4
|
||||||
|
|
||||||
|
hast-util-to-string@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 2.3.10
|
||||||
|
|
||||||
hast-util-to-string@3.0.1:
|
hast-util-to-string@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -8067,7 +8099,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
idb-keyval@6.2.2: {}
|
idb-keyval@6.2.4: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
@@ -8687,7 +8719,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
'@ungap/structured-clone': 1.3.0
|
'@ungap/structured-clone': 1.3.1
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
micromark-util-sanitize-uri: 2.0.1
|
micromark-util-sanitize-uri: 2.0.1
|
||||||
trim-lines: 3.0.1
|
trim-lines: 3.0.1
|
||||||
@@ -8757,7 +8789,7 @@ snapshots:
|
|||||||
roughjs: 4.6.6
|
roughjs: 4.6.6
|
||||||
stylis: 4.3.6
|
stylis: 4.3.6
|
||||||
ts-dedent: 2.2.0
|
ts-dedent: 2.2.0
|
||||||
uuid: 11.1.0
|
uuid: 11.1.1
|
||||||
|
|
||||||
micromark-core-commonmark@1.1.0:
|
micromark-core-commonmark@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8954,7 +8986,7 @@ snapshots:
|
|||||||
|
|
||||||
micromark-extension-mdx-expression@3.0.1:
|
micromark-extension-mdx-expression@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
micromark-factory-mdx-expression: 2.0.3
|
micromark-factory-mdx-expression: 2.0.3
|
||||||
micromark-factory-space: 2.0.1
|
micromark-factory-space: 2.0.1
|
||||||
@@ -8965,7 +8997,7 @@ snapshots:
|
|||||||
|
|
||||||
micromark-extension-mdx-jsx@3.0.2:
|
micromark-extension-mdx-jsx@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
estree-util-is-identifier-name: 3.0.0
|
estree-util-is-identifier-name: 3.0.0
|
||||||
micromark-factory-mdx-expression: 2.0.3
|
micromark-factory-mdx-expression: 2.0.3
|
||||||
@@ -8982,7 +9014,7 @@ snapshots:
|
|||||||
|
|
||||||
micromark-extension-mdxjs-esm@3.0.0:
|
micromark-extension-mdxjs-esm@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
micromark-core-commonmark: 2.0.3
|
micromark-core-commonmark: 2.0.3
|
||||||
micromark-util-character: 2.1.1
|
micromark-util-character: 2.1.1
|
||||||
@@ -9031,7 +9063,7 @@ snapshots:
|
|||||||
|
|
||||||
micromark-factory-mdx-expression@2.0.3:
|
micromark-factory-mdx-expression@2.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
micromark-factory-space: 2.0.1
|
micromark-factory-space: 2.0.1
|
||||||
micromark-util-character: 2.1.1
|
micromark-util-character: 2.1.1
|
||||||
@@ -9147,7 +9179,7 @@ snapshots:
|
|||||||
|
|
||||||
micromark-util-events-to-acorn@2.0.3:
|
micromark-util-events-to-acorn@2.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
estree-util-visit: 2.0.0
|
estree-util-visit: 2.0.0
|
||||||
@@ -9933,23 +9965,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
react-markdown@9.0.3(@types/react@19.2.14)(react@19.2.4):
|
|
||||||
dependencies:
|
|
||||||
'@types/hast': 3.0.4
|
|
||||||
'@types/react': 19.2.14
|
|
||||||
devlop: 1.1.0
|
|
||||||
hast-util-to-jsx-runtime: 2.3.6
|
|
||||||
html-url-attributes: 3.0.1
|
|
||||||
mdast-util-to-hast: 13.2.1
|
|
||||||
react: 19.2.4
|
|
||||||
remark-parse: 11.0.0
|
|
||||||
remark-rehype: 11.1.2
|
|
||||||
unified: 11.0.5
|
|
||||||
unist-util-visit: 5.1.0
|
|
||||||
vfile: 6.0.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
react-medium-image-zoom@5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
react-medium-image-zoom@5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -10075,7 +10090,7 @@ snapshots:
|
|||||||
|
|
||||||
recma-parse@1.0.0:
|
recma-parse@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.9
|
||||||
esast-util-from-js: 2.0.1
|
esast-util-from-js: 2.0.1
|
||||||
unified: 11.0.5
|
unified: 11.0.5
|
||||||
vfile: 6.0.3
|
vfile: 6.0.3
|
||||||
@@ -10117,7 +10132,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
rehype-attr@3.0.3:
|
rehype-attr@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
unified: 11.0.5
|
unified: 11.0.5
|
||||||
unist-util-visit: 5.0.0
|
unist-util-visit: 5.0.0
|
||||||
@@ -10198,6 +10213,16 @@ snapshots:
|
|||||||
unified: 11.0.5
|
unified: 11.0.5
|
||||||
unist-util-visit: 5.1.0
|
unist-util-visit: 5.1.0
|
||||||
|
|
||||||
|
rehype-slug@5.1.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 2.3.10
|
||||||
|
github-slugger: 2.0.0
|
||||||
|
hast-util-has-property: 2.0.1
|
||||||
|
hast-util-heading-rank: 2.1.1
|
||||||
|
hast-util-to-string: 2.0.0
|
||||||
|
unified: 10.1.2
|
||||||
|
unist-util-visit: 4.1.2
|
||||||
|
|
||||||
rehype-slug@6.0.0:
|
rehype-slug@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -10693,9 +10718,9 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
uuid@11.1.0: {}
|
uuid@11.1.1: {}
|
||||||
|
|
||||||
uuid@13.0.0: {}
|
uuid@13.0.2: {}
|
||||||
|
|
||||||
uvu@0.5.6:
|
uvu@0.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10738,7 +10763,7 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.0.3
|
vfile-message: 4.0.3
|
||||||
|
|
||||||
vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3):
|
vite@6.4.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
|||||||
961
BillNote_frontend/src-tauri/Cargo.lock
generated
@@ -15,14 +15,16 @@ name = "app_lib"
|
|||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.2.0", features = [] }
|
# tauri-build / tauri crate 与 @tauri-apps/api 大版本必须对齐(CLI 在 build 前会校验)。
|
||||||
|
# @tauri-apps/api 已升 2.10(commit bb9a70e),这里同步到 2.x 最新让 cargo 解析到匹配版本。
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.5.0", features = ["devtools"] }
|
tauri = { version = "2", features = ["devtools"] }
|
||||||
tauri-plugin-log = "2.0.0-rc"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
|
|
||||||
[package.metadata.tauri.bundle.macOS]
|
[package.metadata.tauri.bundle.macOS]
|
||||||
|
|||||||
@@ -3,10 +3,19 @@ use tauri_plugin_shell::ShellExt;
|
|||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
// Sidecar 启动期内前端不该看到「加载中」无限转。
|
||||||
|
// 总等待上限 = 启动期 PyInstaller 解压 + uvicorn bind 时间的最坏估计,
|
||||||
|
// 实测 macOS / Windows 慢盘大概 5-20s,设 45s 留余量但不至于让用户绝望。
|
||||||
|
const BACKEND_STARTUP_TIMEOUT_SECS: u64 = 45;
|
||||||
|
const BACKEND_DEFAULT_PORT: u16 = 8483;
|
||||||
|
|
||||||
// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程
|
// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程
|
||||||
struct SidecarHandle(Mutex<Option<CommandChild>>);
|
struct SidecarHandle(Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
@@ -50,6 +59,10 @@ pub fn run() {
|
|||||||
})?;
|
})?;
|
||||||
app.manage(SidecarHandle(Mutex::new(Some(child))));
|
app.manage(SidecarHandle(Mutex::new(Some(child))));
|
||||||
|
|
||||||
|
// 启动 ready probe:异步轮询本地 BACKEND_PORT 是否在监听,
|
||||||
|
// 解决前端 useCheckBackend 在 PyInstaller 解压期瞎猜后端起没起的问题。
|
||||||
|
spawn_backend_ready_probe(app.handle().clone());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -60,8 +73,33 @@ pub fn run() {
|
|||||||
get_install_path_diagnostics,
|
get_install_path_diagnostics,
|
||||||
restart_backend_sidecar
|
restart_backend_sidecar
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while building tauri application")
|
||||||
|
// 用 build()+run() 拿到 RunEvent 流,关键诉求:app 退出前必须 kill 掉 PyInstaller
|
||||||
|
// sidecar,否则它会变成持有 8483 端口的孤儿进程,下次启动 BiliNote 直接 bind 失败。
|
||||||
|
// 之前漏掉这一步导致用户 PID 96739 那种「上次没关干净 → 这次起不来」的死循环。
|
||||||
|
.run(|app_handle, event| {
|
||||||
|
match event {
|
||||||
|
// ExitRequested 在用户 Cmd-Q / 点关闭 / Dock 退出时触发,先于实际进程结束。
|
||||||
|
// Exit 是兜底——任何走到 Tauri 主循环结束的路径都会经过它。
|
||||||
|
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
|
||||||
|
kill_backend_sidecar(app_handle);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭期统一杀 sidecar,take() 把 child 从 state 拿走避免重复 kill。
|
||||||
|
fn kill_backend_sidecar(app_handle: &tauri::AppHandle) {
|
||||||
|
if let Some(state) = app_handle.try_state::<SidecarHandle>() {
|
||||||
|
if let Ok(mut guard) = state.0.lock() {
|
||||||
|
if let Some(child) = guard.take() {
|
||||||
|
eprintln!("[shutdown] killing backend sidecar before app exit");
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取额外的二进制路径
|
// 获取额外的二进制路径
|
||||||
@@ -306,6 +344,12 @@ fn restart_backend_sidecar(
|
|||||||
state: State<'_, SidecarHandle>,
|
state: State<'_, SidecarHandle>,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// 0. 先告诉前端「我们要重启了」。前端可以借此忽略接下来 N 秒内的 backend-terminated
|
||||||
|
// 事件——那是我们主动 kill 老 sidecar 的副作用,不是真异常。否则会出现:
|
||||||
|
// terminated 事件延迟到达 → 覆盖掉 'running' 状态 → 面板永远显示「已退出」。
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.emit("backend-restarting", ());
|
||||||
|
}
|
||||||
// 1. 拿出旧 child 并 kill(kill 失败也继续,可能进程已经退了)
|
// 1. 拿出旧 child 并 kill(kill 失败也继续,可能进程已经退了)
|
||||||
{
|
{
|
||||||
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
|
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
|
||||||
@@ -323,9 +367,88 @@ fn restart_backend_sidecar(
|
|||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.emit("backend-restarted", ());
|
let _ = window.emit("backend-restarted", ());
|
||||||
}
|
}
|
||||||
|
// 4. 重启后同样起一次 ready probe,让前端能及时退出失败态
|
||||||
|
spawn_backend_ready_probe(app);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后端就绪探测:异步轮询 GET /api/sys_check,要求 HTTP 200 才算就绪。
|
||||||
|
//
|
||||||
|
// 旧实现只做 TcpStream::connect_timeout——但端口被另一个孤儿 sidecar 占着时也会
|
||||||
|
// 连得通,导致 emit('backend-ready') 误判:前端进入主界面,但真正的新 sidecar
|
||||||
|
// 没 bind 上立刻就死,banner 永远停在「后端进程已退出」。
|
||||||
|
//
|
||||||
|
// 真发一个 HTTP 请求拿 200 才算「这是我们的后端在响应」。
|
||||||
|
fn spawn_backend_ready_probe(app: tauri::AppHandle) {
|
||||||
|
let port: u16 = env::var("BACKEND_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(BACKEND_DEFAULT_PORT);
|
||||||
|
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().expect("invalid backend addr");
|
||||||
|
let timeout = Duration::from_secs(BACKEND_STARTUP_TIMEOUT_SECS);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let start = Instant::now();
|
||||||
|
let probe_interval = Duration::from_millis(500);
|
||||||
|
loop {
|
||||||
|
if probe_sys_check(&addr) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.emit("backend-ready", port);
|
||||||
|
println!("Backend ready on port {} after {:?}", port, start.elapsed());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if start.elapsed() >= timeout {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let payload = format!(
|
||||||
|
"后端在 {}s 内 /api/sys_check 未返回 200,疑似启动失败或端口 {} 被其他进程占用",
|
||||||
|
timeout.as_secs(),
|
||||||
|
port
|
||||||
|
);
|
||||||
|
let _ = window.emit("backend-startup-timeout", payload);
|
||||||
|
eprintln!(
|
||||||
|
"Backend startup timeout: /api/sys_check did not return 200 on 127.0.0.1:{} after {:?}",
|
||||||
|
port,
|
||||||
|
start.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(probe_interval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 极简 HTTP/1.0 GET /api/sys_check —— 用 std::net 手写避免引 reqwest/ureq 的重依赖。
|
||||||
|
// 任何错都视为「还没就绪」,下次 tick 再试。
|
||||||
|
fn probe_sys_check(addr: &SocketAddr) -> bool {
|
||||||
|
let connect_timeout = Duration::from_millis(800);
|
||||||
|
let rw_timeout = Duration::from_millis(1500);
|
||||||
|
let mut stream = match TcpStream::connect_timeout(addr, connect_timeout) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let _ = stream.set_read_timeout(Some(rw_timeout));
|
||||||
|
let _ = stream.set_write_timeout(Some(rw_timeout));
|
||||||
|
// HTTP/1.0 + Connection: close 让服务端发完响应就关,免去 chunked / keep-alive 解析
|
||||||
|
let req = format!(
|
||||||
|
"GET /api/sys_check HTTP/1.0\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n",
|
||||||
|
addr.port()
|
||||||
|
);
|
||||||
|
if stream.write_all(req.as_bytes()).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 只要 status line,64 字节够了
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n = match stream.read(&mut buf) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let head = std::str::from_utf8(&buf[..n]).unwrap_or("");
|
||||||
|
// 兼容 HTTP/1.0 / 1.1 起始行
|
||||||
|
head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
|
||||||
|
}
|
||||||
|
|
||||||
// 安装路径诊断:PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸;
|
// 安装路径诊断:PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸;
|
||||||
// 父目录不可写时模型 / 配置 / 日志也无法落盘
|
// 父目录不可写时模型 / 配置 / 日志也无法落盘
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "BiliNote",
|
"productName": "BiliNote",
|
||||||
"version": "2.0.0",
|
"version": "2.4.0",
|
||||||
"identifier": "com.jefferyhuang.bilinote",
|
"identifier": "com.jefferyhuang.bilinote",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import './App.css'
|
import './App.css'
|
||||||
import { lazy, Suspense, useEffect } from 'react'
|
import { lazy, Suspense, useEffect } from 'react'
|
||||||
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter, HashRouter, Navigate, Routes, Route } from 'react-router-dom'
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
||||||
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||||
import { systemCheck } from '@/services/system.ts'
|
import { systemCheck } from '@/services/system.ts'
|
||||||
@@ -33,7 +33,7 @@ const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||||
const { loading, initialized } = useCheckBackend()
|
const { loading, initialized, failed, lastError, retry } = useCheckBackend()
|
||||||
|
|
||||||
// 在后端初始化完成后执行系统检查
|
// 在后端初始化完成后执行系统检查
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,22 +42,31 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [initialized])
|
}, [initialized])
|
||||||
|
|
||||||
// 如果后端还未初始化,显示初始化对话框
|
// 如果后端还未初始化,显示初始化对话框(loading 或 failed 都展示,由 dialog 内部决定渲染哪一态)
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StartupBanner />
|
<StartupBanner />
|
||||||
<BackendInitDialog open={loading} />
|
<BackendInitDialog
|
||||||
|
open={loading}
|
||||||
|
failed={failed}
|
||||||
|
lastError={lastError}
|
||||||
|
onRetry={retry}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 桌面端使用 HashRouter 避免刷新 404;Web 端继续使用 BrowserRouter
|
||||||
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
const Router = isTauri ? HashRouter : BrowserRouter
|
||||||
|
|
||||||
// 后端已初始化,渲染主应用
|
// 后端已初始化,渲染主应用
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StartupBanner />
|
<StartupBanner />
|
||||||
<BackendHealthIndicator />
|
<BackendHealthIndicator />
|
||||||
<BrowserRouter>
|
<Router>
|
||||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
@@ -81,7 +90,7 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</BrowserRouter>
|
</Router>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import BackendLogPanel from './BackendLogPanel'
|
|||||||
type Health = 'green' | 'yellow' | 'red' | 'unknown'
|
type Health = 'green' | 'yellow' | 'red' | 'unknown'
|
||||||
|
|
||||||
const HEALTH_POLL_MS = 5000
|
const HEALTH_POLL_MS = 5000
|
||||||
const SYS_HEALTH_PATH = '/api/sys_health'
|
// 路径不带 /api/,因为 backendBase() 已经把它包进 baseURL 了(同 axios 实例的语义)。
|
||||||
|
// 之前写 '/api/sys_health' + base='http://host/api' = 双 /api → 一直 404。
|
||||||
|
const SYS_HEALTH_PATH = '/sys_health'
|
||||||
|
|
||||||
function backendBase(): string {
|
function backendBase(): string {
|
||||||
// 与 services/request.ts 用的一致
|
// 与 utils/request.ts 的 baseURL 计算保持一致:env 没设走 '/api' 兜底。
|
||||||
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
|
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
|
||||||
return (fromEnv ?? '').replace(/\/$/, '')
|
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackendHealthIndicator = () => {
|
const BackendHealthIndicator = () => {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export function useBackendEvents(): BackendEvents {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
// 用 ref 持有最新 logs 数组,append 时不被闭包陷阱卡到旧值
|
// 用 ref 持有最新 logs 数组,append 时不被闭包陷阱卡到旧值
|
||||||
const logsRef = useRef<LogEntry[]>([])
|
const logsRef = useRef<LogEntry[]>([])
|
||||||
|
// 主动重启期:Rust 在 kill 老 sidecar 前会 emit 'backend-restarting'。
|
||||||
|
// 期间到达的 'backend-terminated' 是我们自己造成的,不要污染状态。
|
||||||
|
const ignoreNextTerminatedRef = useRef(false)
|
||||||
|
|
||||||
function append(entry: LogEntry) {
|
function append(entry: LogEntry) {
|
||||||
const next = logsRef.current.concat(entry)
|
const next = logsRef.current.concat(entry)
|
||||||
@@ -58,7 +61,23 @@ export function useBackendEvents(): BackendEvents {
|
|||||||
const offErr = await listen<string>('backend-error', event => {
|
const offErr = await listen<string>('backend-error', event => {
|
||||||
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() })
|
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() })
|
||||||
})
|
})
|
||||||
|
const offRestarting = await listen('backend-restarting', () => {
|
||||||
|
// 紧接着到达的 terminated 是我们主动 kill 老 sidecar 引发的,跳过 3s
|
||||||
|
ignoreNextTerminatedRef.current = true
|
||||||
|
setTimeout(() => { ignoreNextTerminatedRef.current = false }, 3000)
|
||||||
|
append({ level: 'info', text: '[Backend restarting]', ts: Date.now() })
|
||||||
|
})
|
||||||
const offTerm = await listen<number | null>('backend-terminated', event => {
|
const offTerm = await listen<number | null>('backend-terminated', event => {
|
||||||
|
// 主动重启窗口内的 terminated 是预期副作用,仅记日志、不改状态
|
||||||
|
if (ignoreNextTerminatedRef.current) {
|
||||||
|
ignoreNextTerminatedRef.current = false
|
||||||
|
append({
|
||||||
|
level: 'info',
|
||||||
|
text: `[Backend terminated, restart in progress] code=${event.payload ?? 'unknown'}`,
|
||||||
|
ts: Date.now(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
setStatus('terminated')
|
setStatus('terminated')
|
||||||
setExitCode(event.payload ?? null)
|
setExitCode(event.payload ?? null)
|
||||||
append({
|
append({
|
||||||
@@ -73,7 +92,7 @@ export function useBackendEvents(): BackendEvents {
|
|||||||
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
|
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
|
||||||
})
|
})
|
||||||
|
|
||||||
unlisteners = [offMsg, offErr, offTerm, offRestart]
|
unlisteners = [offMsg, offErr, offRestarting, offTerm, offRestart]
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,13 +1,141 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Loader2, AlertTriangle, RotateCcw, Clipboard } from 'lucide-react'
|
||||||
|
import { useBackendEvents } from '@/components/BackendHealth/useBackendEvents'
|
||||||
|
|
||||||
|
// 失败态预览里最多展示几行 stderr。比这还多就请用户去 copyLogs() 拷出来。
|
||||||
|
const STDERR_PREVIEW_LINES = 6
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** 加载中:显示转圈对话框 */
|
||||||
open: boolean
|
open: boolean
|
||||||
|
/** 启动失败:显示错误 + 重启/复制日志按钮 */
|
||||||
|
failed?: boolean
|
||||||
|
/** 失败原因(来自 useCheckBackend.lastError 或 Tauri 事件 payload) */
|
||||||
|
lastError?: string | null
|
||||||
|
/** 重新走一遍 useCheckBackend 的轮询(不重启 sidecar) */
|
||||||
|
onRetry?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackendInitDialog({ open }: Props) {
|
// 加载中 + 启动失败两个状态合并在一个 dialog 里。
|
||||||
|
// 失败态比加载态更紧急:用户能看到具体原因 + 一键重启 + 一键复制日志去 issue,
|
||||||
|
// 而不是面对一个永远转圈的对话框。
|
||||||
|
function BackendInitDialog({ open, failed = false, lastError = null, onRetry }: Props) {
|
||||||
|
const { isTauri, restart, copyLogs, logs } = useBackendEvents()
|
||||||
|
const [restarting, setRestarting] = useState(false)
|
||||||
|
const [copyResult, setCopyResult] = useState<'idle' | 'ok' | 'fail'>('idle')
|
||||||
|
|
||||||
|
// 从 ring buffer 里挑最后几行 stderr —— 它们比 lastError(hook 自己总结的那句)信息密度更高,
|
||||||
|
// 通常 Python traceback 的最后一行就是真正的错误类型 + 消息
|
||||||
|
const stderrPreview = useMemo(() => {
|
||||||
|
if (!failed || !logs?.length) return []
|
||||||
|
return logs
|
||||||
|
.filter((l) => l.level === 'error')
|
||||||
|
.slice(-STDERR_PREVIEW_LINES)
|
||||||
|
.map((l) => l.text)
|
||||||
|
}, [failed, logs])
|
||||||
|
|
||||||
|
// 任一态需要展示就保持 dialog 开着,关掉只在两个 flag 都熄灭时发生
|
||||||
|
const isOpen = open || failed
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
setRestarting(true)
|
||||||
|
try {
|
||||||
|
if (isTauri) await restart()
|
||||||
|
onRetry?.()
|
||||||
|
} catch {
|
||||||
|
// restart 内部已经 append 到 log,这里不再 toast
|
||||||
|
} finally {
|
||||||
|
setRestarting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const ok = await copyLogs()
|
||||||
|
setCopyResult(ok ? 'ok' : 'fail')
|
||||||
|
setTimeout(() => setCopyResult('idle'), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
后端启动失败
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 mt-2 text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{lastError || '后端在预计时间内未就绪。'}
|
||||||
|
</p>
|
||||||
|
{stderrPreview.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
后端最近 {stderrPreview.length} 行错误日志
|
||||||
|
<span className="opacity-60">(完整日志请用「复制启动日志」)</span>:
|
||||||
|
</p>
|
||||||
|
<pre className="max-h-32 overflow-auto rounded bg-zinc-900 px-2 py-1.5 font-mono text-[11px] leading-snug text-red-200">
|
||||||
|
{stderrPreview.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<p>常见原因:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 pl-1">
|
||||||
|
<li>安装路径含中文 / 空格(PyInstaller 在这种路径下经常起不来)</li>
|
||||||
|
<li>没装 ffmpeg / 端口 8483 被占用</li>
|
||||||
|
<li>首次启动时 whisper 模型下载未完成</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRestart}
|
||||||
|
disabled={restarting}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{restarting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{isTauri ? (restarting ? '重启中…' : '重启后端') : '重试'}
|
||||||
|
</Button>
|
||||||
|
{isTauri && (
|
||||||
|
<Button size="sm" variant="outline" onClick={handleCopy} className="gap-1.5">
|
||||||
|
<Clipboard className="w-4 h-4" />
|
||||||
|
{copyResult === 'ok'
|
||||||
|
? '已复制 ✓'
|
||||||
|
: copyResult === 'fail'
|
||||||
|
? '复制失败'
|
||||||
|
: '复制启动日志'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground pt-2">
|
||||||
|
仍然无法解决?复制日志去
|
||||||
|
<a
|
||||||
|
href="https://github.com/JefferyHcool/BiliNote/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
>
|
||||||
|
GitHub Issues
|
||||||
|
</a>
|
||||||
|
反馈。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认加载态
|
||||||
return (
|
return (
|
||||||
<Dialog open={open}>
|
<Dialog open={isOpen}>
|
||||||
<DialogContent className="text-center">
|
<DialogContent className="text-center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center justify-center gap-2">
|
<DialogTitle className="flex items-center justify-center gap-2">
|
||||||
@@ -15,9 +143,12 @@ interface Props {
|
|||||||
后端正在初始化中…
|
后端正在初始化中…
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-muted-foreground mt-2">请稍候,系统正在启动后端服务,出现报错属于正常现象</p>
|
<p className="text-muted-foreground mt-2">
|
||||||
|
请稍候,系统正在启动后端服务。首次启动可能需要 10-30 秒解压依赖。
|
||||||
|
</p>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default BackendInitDialog
|
|
||||||
|
export default BackendInitDialog
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { getProxyConfig, updateProxyConfig } from '@/services/proxy'
|
||||||
|
|
||||||
|
// 全局代理配置:作用于 LLM API + 转写 API(Groq 等)+ yt-dlp 视频下载。
|
||||||
|
// 国内访问 OpenAI / Groq / YouTube 基本都要靠它。
|
||||||
|
const ProxyConfig = () => {
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [effective, setEffective] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await getProxyConfig()
|
||||||
|
setEnabled(cfg.enabled)
|
||||||
|
setUrl(cfg.url)
|
||||||
|
setEffective(cfg.effective)
|
||||||
|
} catch {
|
||||||
|
/* 拦截器已 toast */
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (enabled && !url.trim()) {
|
||||||
|
toast.error('请填写代理地址,或关闭代理开关')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const cfg = await updateProxyConfig({ enabled, url: url.trim() })
|
||||||
|
setEnabled(cfg.enabled)
|
||||||
|
setUrl(cfg.url)
|
||||||
|
setEffective(cfg.effective)
|
||||||
|
toast.success('代理配置已保存')
|
||||||
|
} catch {
|
||||||
|
/* 拦截器已 toast */
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-xs text-gray-400">加载代理配置…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// env 兜底:配置没开但 effective 有值,说明来自 HTTP_PROXY 环境变量
|
||||||
|
const fromEnv = !enabled && !!effective
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded border border-neutral-200 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">全局代理</span>
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
作用于 AI 模型接口、转写接口(Groq 等)、YouTube 下载。
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
placeholder="http://127.0.0.1:7890"
|
||||||
|
value={url}
|
||||||
|
disabled={!enabled}
|
||||||
|
onChange={e => setUrl(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
{fromEnv && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
当前生效(来自环境变量):{effective}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{enabled && effective && (
|
||||||
|
<p className="text-xs text-green-600">当前生效:{effective}</p>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? '保存中…' : '保存代理配置'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProxyConfig
|
||||||
@@ -41,21 +41,22 @@ const ProviderCard: FC<IProviderCardProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
styles.card +
|
styles.card +
|
||||||
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
|
' flex h-14 cursor-pointer items-center justify-between rounded border border-[#f3f3f3] p-2' +
|
||||||
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
|
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
|
||||||
}
|
}
|
||||||
|
// 整行可点跳转到对应供应商编辑页(之前 onClick 只挂在 icon+名字那一小块 div 上,
|
||||||
|
// 名字和开关之间的空白区域点不动)
|
||||||
|
onClick={() => navigate(`/settings/model/${id}`)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center text-lg">
|
||||||
className="flex items-center text-lg"
|
|
||||||
onClick={() => navigate(`/settings/model/${id}`)}
|
|
||||||
>
|
|
||||||
<div className="flex h-9 w-9 items-center">
|
<div className="flex h-9 w-9 items-center">
|
||||||
<AILogo name={Icon} />
|
<AILogo name={Icon} />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold">{providerName}</div>
|
<div className="font-semibold">{providerName}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Switch 自己的点击不应该冒泡触发整行跳转 */}
|
||||||
|
<div onClick={e => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
|
|||||||
@@ -74,8 +74,17 @@ const StartupBanner = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 后端被「重启后端」按钮拉起来后 / Rust ready-probe 检测到新 sidecar 真的就绪后,
|
||||||
|
// 自动清掉 terminated 横幅。之前 dismissible:false + 没自动清逻辑 = banner 永远卡。
|
||||||
|
const offRestarted = await listen('backend-restarted', () => {
|
||||||
|
setBanner(b => (b?.severity === 'error' ? null : b))
|
||||||
|
})
|
||||||
|
const offReady = await listen('backend-ready', () => {
|
||||||
|
setBanner(b => (b?.severity === 'error' ? null : b))
|
||||||
|
})
|
||||||
|
|
||||||
// backend-error 是 sidecar stderr,量大噪音多,这里不直接展示,留给 P2 的日志面板。
|
// backend-error 是 sidecar stderr,量大噪音多,这里不直接展示,留给 P2 的日志面板。
|
||||||
unlisteners = [offWarning, offTerminated]
|
unlisteners = [offWarning, offTerminated, offRestarted, offReady]
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,52 +1,156 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import request from '@/utils/request'
|
|
||||||
|
|
||||||
const MAX_RETRIES = 3
|
// 后端就绪检测的几个时间常量
|
||||||
const RETRY_INTERVAL = 10000 // 10秒
|
// - 总等待上限 60s:超过这个时间没就绪就切「启动失败」UI,
|
||||||
|
// 不再像旧实现 while(true) 无限转
|
||||||
|
// - 轮询间隔 2s:比旧的 10s 更敏感,桌面端 sidecar 5-15s 解压期内能尽快感知就绪
|
||||||
|
// - 单次请求超时 5s,避免连接 hang 拖到下一轮
|
||||||
|
const TOTAL_TIMEOUT_MS = 60_000
|
||||||
|
const POLL_INTERVAL_MS = 2_000
|
||||||
|
const PROBE_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
export const useCheckBackend = () => {
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [initialized, setInitialized] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
// 直接用 fetch 而非 utils/request 的共享 axios:那个 axios 装了全局 toast 拦截器,
|
||||||
let retries = 0
|
// 启动期每次 /sys_check 失败都会弹一个红色 toast,2s 一次轮询会叠出十几个。
|
||||||
|
function getBackendBase(): string {
|
||||||
|
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
|
||||||
|
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
const check = async () => {
|
async function probeSysCheck(): Promise<boolean> {
|
||||||
try {
|
const url = `${getBackendBase()}/sys_check`
|
||||||
await request.get('/sys_check')
|
const ctrl = new AbortController()
|
||||||
setInitialized(true)
|
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||||
setLoading(false)
|
try {
|
||||||
} catch {
|
const res = await fetch(url, { signal: ctrl.signal })
|
||||||
if (retries === 0) {
|
if (!res.ok) return false
|
||||||
// 第一次失败时开始显示加载状态
|
const json = await res.json().catch(() => null)
|
||||||
setLoading(true)
|
return json?.code === 0
|
||||||
}
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
clearTimeout(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (retries < MAX_RETRIES) {
|
interface Status {
|
||||||
retries++
|
loading: boolean
|
||||||
setTimeout(check, RETRY_INTERVAL)
|
initialized: boolean
|
||||||
} else {
|
failed: boolean
|
||||||
// 达到重试上限,继续轮询直到后端就绪
|
lastError: string | null
|
||||||
waitUntilBackendReady()
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitUntilBackendReady = async () => {
|
interface BackendCheck extends Status {
|
||||||
while (true) {
|
retry: () => void
|
||||||
try {
|
}
|
||||||
await request.get('/sys_health')
|
|
||||||
setInitialized(true)
|
|
||||||
setLoading(false)
|
|
||||||
break
|
|
||||||
} catch {
|
|
||||||
await new Promise(res => setTimeout(res, RETRY_INTERVAL))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
check()
|
const initialStatus: Status = {
|
||||||
|
loading: true,
|
||||||
|
initialized: false,
|
||||||
|
failed: false,
|
||||||
|
lastError: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端就绪检测。
|
||||||
|
*
|
||||||
|
* 三路信号汇聚:
|
||||||
|
* 1. HTTP 轮询 /sys_check —— 所有平台通用
|
||||||
|
* 2. Tauri 'backend-ready' 事件 —— 桌面端 sidecar 探测器先于 HTTP 一步触达
|
||||||
|
* 3. Tauri 'backend-terminated' / 'backend-startup-timeout' 事件 —— sidecar 死了或超时
|
||||||
|
* 立即进失败态,不再继续轮询(旧实现的 while(true) 就是死在这里)
|
||||||
|
*
|
||||||
|
* 任何一路报「ready」即成功;任何一路报「失败」立即停掉所有轮询。
|
||||||
|
*/
|
||||||
|
export const useCheckBackend = (): BackendCheck => {
|
||||||
|
const [status, setStatus] = useState<Status>(initialStatus)
|
||||||
|
// tick 用来强制重启 useEffect(retry 时 +1),不引入 ref 互斥逻辑的复杂性
|
||||||
|
const [tick, setTick] = useState(0)
|
||||||
|
// 标记当前 effect 是否已 settle(避免后到的事件覆盖已确定的成功/失败态)
|
||||||
|
const settledRef = useRef(false)
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
settledRef.current = false
|
||||||
|
setStatus(initialStatus)
|
||||||
|
setTick((t) => t + 1)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { loading, initialized }
|
useEffect(() => {
|
||||||
}
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pollTimerId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let cancelled = false
|
||||||
|
const tauriUnsubs: Array<() => void> = []
|
||||||
|
|
||||||
|
const markReady = () => {
|
||||||
|
if (cancelled || settledRef.current) return
|
||||||
|
settledRef.current = true
|
||||||
|
setStatus({ loading: false, initialized: true, failed: false, lastError: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const markFailed = (msg: string) => {
|
||||||
|
if (cancelled || settledRef.current) return
|
||||||
|
settledRef.current = true
|
||||||
|
setStatus({ loading: false, initialized: false, failed: true, lastError: msg })
|
||||||
|
}
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled || settledRef.current) return
|
||||||
|
const ok = await probeSysCheck()
|
||||||
|
if (cancelled || settledRef.current) return
|
||||||
|
if (ok) {
|
||||||
|
markReady()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 单次失败不报 toast、不抛错,继续轮询
|
||||||
|
setStatus((s) => ({ ...s, lastError: '后端尚未响应' }))
|
||||||
|
pollTimerId = setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总超时兜底
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
markFailed(`后端 ${TOTAL_TIMEOUT_MS / 1000}s 内未就绪,请检查后端日志或重启`)
|
||||||
|
}, TOTAL_TIMEOUT_MS)
|
||||||
|
|
||||||
|
// 桌面端订阅 Tauri 事件(动态 import 避免 web 端打包报错)
|
||||||
|
if (isTauri) {
|
||||||
|
import('@tauri-apps/api/event')
|
||||||
|
.then(async ({ listen }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const offReady = await listen<number>('backend-ready', () => markReady())
|
||||||
|
const offTimeout = await listen<string>('backend-startup-timeout', (e) => {
|
||||||
|
markFailed(typeof e.payload === 'string' ? e.payload : '后端启动超时')
|
||||||
|
})
|
||||||
|
const offTerm = await listen<number | null>('backend-terminated', (e) => {
|
||||||
|
const code = e.payload
|
||||||
|
markFailed(`后端进程已退出 (code=${code ?? 'unknown'})`)
|
||||||
|
})
|
||||||
|
tauriUnsubs.push(offReady, offTimeout, offTerm)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// 拿不到 @tauri-apps/api/event 不致命,继续走 HTTP 轮询
|
||||||
|
console.warn('[useCheckBackend] 无法订阅 Tauri 事件:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立刻开始第一轮轮询
|
||||||
|
poll()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
|
if (pollTimerId) clearTimeout(pollTimerId)
|
||||||
|
tauriUnsubs.forEach((off) => {
|
||||||
|
try {
|
||||||
|
off()
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [tick])
|
||||||
|
|
||||||
|
return { ...status, retry }
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'react-medium-image-zoom/dist/styles.css'
|
|||||||
import gfm from 'remark-gfm'
|
import gfm from 'remark-gfm'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
import rehypeKatex from 'rehype-katex'
|
import rehypeKatex from 'rehype-katex'
|
||||||
|
import rehypeSlug from 'rehype-slug'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import 'github-markdown-css/github-markdown-light.css'
|
import 'github-markdown-css/github-markdown-light.css'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||||
@@ -47,7 +48,7 @@ const steps = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const remarkPlugins = [gfm, remarkMath]
|
const remarkPlugins = [gfm, remarkMath]
|
||||||
const rehypePlugins = [rehypeKatex]
|
const rehypePlugins = [rehypeKatex, rehypeSlug]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建 ReactMarkdown components 对象,baseURL 用于修正图片路径。
|
* 构建 ReactMarkdown components 对象,baseURL 用于修正图片路径。
|
||||||
@@ -117,6 +118,51 @@ function createMarkdownComponents(baseURL: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理笔记内部锚点链接(如目录跳转)
|
||||||
|
if (href?.startsWith('#')) {
|
||||||
|
const handleAnchorClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const id = decodeURIComponent(href.slice(1))
|
||||||
|
|
||||||
|
// 1. 优先精确匹配 id
|
||||||
|
let target = document.getElementById(id)
|
||||||
|
|
||||||
|
// 2. 精确失败时按 heading 文本模糊匹配
|
||||||
|
// LLM 生成的目录锚点可能和 heading 实际文本不完全一致
|
||||||
|
//(例如 heading 带 *Content-[00:00]* 后缀,目录链接里没有)
|
||||||
|
if (!target) {
|
||||||
|
const normalize = (s: string) =>
|
||||||
|
s.replace(/[-::\s*\[\]]/g, '').toLowerCase()
|
||||||
|
const search = normalize(id)
|
||||||
|
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||||
|
for (const h of headings) {
|
||||||
|
const text = h.textContent || ''
|
||||||
|
if (normalize(text).includes(search) || search.includes(normalize(text))) {
|
||||||
|
target = h
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
} else {
|
||||||
|
toast.error('未找到对应章节')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
onClick={handleAnchorClick}
|
||||||
|
className="text-primary hover:text-primary/80 inline-flex items-center gap-0.5 font-medium underline underline-offset-4"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
|
|||||||
@@ -5,6 +5,171 @@ import { Toolbar } from 'markmap-toolbar'
|
|||||||
import 'markmap-toolbar/dist/style.css'
|
import 'markmap-toolbar/dist/style.css'
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
|
|
||||||
|
const MIN_EXPORT_FONT_PX = 256
|
||||||
|
const MIN_EXPORT_WIDTH = 12800
|
||||||
|
const WEB_EXPORT_SCALE_FACTOR = 0.34
|
||||||
|
const MAX_EXPORT_SCALE = 24
|
||||||
|
const MAX_CANVAS_SIDE = 32767
|
||||||
|
const MAX_CANVAS_PIXELS = 268000000
|
||||||
|
|
||||||
|
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob)
|
||||||
|
} else {
|
||||||
|
reject(new Error('无法创建PNG图片'))
|
||||||
|
}
|
||||||
|
}, 'image/png')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSvgElement<K extends keyof SVGElementTagNameMap>(tag: K): SVGElementTagNameMap[K] {
|
||||||
|
return document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSvgForCanvas(svg: SVGSVGElement): SVGSVGElement {
|
||||||
|
const cloned = svg.cloneNode(true) as SVGSVGElement
|
||||||
|
|
||||||
|
// markmap 会在 SVG 的顶层 <g> 上写入当前预览视口的 pan/zoom transform。
|
||||||
|
// 导出时我们按内容 bbox 裁剪,如果保留这个视口 transform,会产生双重偏移,
|
||||||
|
// 导致图片内容跑到角落并留下大片空白。这里只移除顶层视口 transform,
|
||||||
|
// 保留内部节点自身的布局 transform。
|
||||||
|
cloned.querySelector(':scope > g')?.removeAttribute('transform')
|
||||||
|
|
||||||
|
cloned.querySelectorAll('image').forEach(el => el.remove())
|
||||||
|
cloned.querySelectorAll('foreignObject').forEach((foreignObject) => {
|
||||||
|
const textContent = foreignObject.textContent?.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!textContent) {
|
||||||
|
foreignObject.remove()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = Number(foreignObject.getAttribute('x') || 0)
|
||||||
|
const y = Number(foreignObject.getAttribute('y') || 0)
|
||||||
|
const height = Number(foreignObject.getAttribute('height') || 20)
|
||||||
|
const text = createSvgElement('text')
|
||||||
|
text.setAttribute('x', String(x))
|
||||||
|
text.setAttribute('y', String(y + height / 2))
|
||||||
|
text.setAttribute('dominant-baseline', 'middle')
|
||||||
|
text.setAttribute('font-size', '14')
|
||||||
|
text.setAttribute('font-family', 'Arial, "Microsoft YaHei", sans-serif')
|
||||||
|
text.setAttribute('fill', '#333')
|
||||||
|
text.textContent = textContent
|
||||||
|
foreignObject.replaceWith(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExportFontSize(svg: SVGSVGElement): number {
|
||||||
|
const text = svg.querySelector('text, foreignObject')
|
||||||
|
if (!text) return 14
|
||||||
|
|
||||||
|
const fontSize = Number.parseFloat(getComputedStyle(text).fontSize || '')
|
||||||
|
if (Number.isFinite(fontSize) && fontSize > 0) return fontSize
|
||||||
|
|
||||||
|
const attrSize = Number.parseFloat(text.getAttribute('font-size') || '')
|
||||||
|
return Number.isFinite(attrSize) && attrSize > 0 ? attrSize : 14
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMindmapBounds(svg: SVGSVGElement) {
|
||||||
|
const target = svg.querySelector('g') || svg
|
||||||
|
const bbox = target.getBBox()
|
||||||
|
const padding = 50
|
||||||
|
return {
|
||||||
|
x: Math.floor(bbox.x - padding),
|
||||||
|
y: Math.floor(bbox.y - padding),
|
||||||
|
width: Math.max(Math.ceil(bbox.width + padding * 2), 1),
|
||||||
|
height: Math.max(Math.ceil(bbox.height + padding * 2), 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMindmapImages(markdown: string) {
|
||||||
|
return (markdown || '')
|
||||||
|
// 思维导图只保留文字结构,图片节点会让预览排版和 PNG 导出效果都很差。
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||||
|
.replace(/<img\b[^>]*>/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformMindmap(markdown: string) {
|
||||||
|
return transformer.transform(stripMindmapImages(markdown))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExportSvg(svgEl: SVGSVGElement) {
|
||||||
|
const bounds = getMindmapBounds(svgEl)
|
||||||
|
const clonedSvg = sanitizeSvgForCanvas(svgEl)
|
||||||
|
|
||||||
|
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||||
|
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
|
||||||
|
clonedSvg.setAttribute('width', String(bounds.width))
|
||||||
|
clonedSvg.setAttribute('height', String(bounds.height))
|
||||||
|
clonedSvg.setAttribute('viewBox', `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`)
|
||||||
|
clonedSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
||||||
|
|
||||||
|
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
||||||
|
bgRect.setAttribute('x', String(bounds.x))
|
||||||
|
bgRect.setAttribute('y', String(bounds.y))
|
||||||
|
bgRect.setAttribute('width', String(bounds.width))
|
||||||
|
bgRect.setAttribute('height', String(bounds.height))
|
||||||
|
bgRect.setAttribute('fill', 'white')
|
||||||
|
const firstG = clonedSvg.querySelector('g')
|
||||||
|
clonedSvg.insertBefore(bgRect, firstG || clonedSvg.firstChild)
|
||||||
|
|
||||||
|
return { clonedSvg, ...bounds }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportSvgToPngBlob(svgEl: SVGSVGElement): Promise<Blob> {
|
||||||
|
const { clonedSvg, width, height } = createExportSvg(svgEl)
|
||||||
|
const svgData = new XMLSerializer().serializeToString(clonedSvg)
|
||||||
|
const svgUrl = URL.createObjectURL(new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.decoding = 'async'
|
||||||
|
img.src = svgUrl
|
||||||
|
await img.decode()
|
||||||
|
|
||||||
|
// 按导图内容尺寸和字号动态反推 PNG 倍率,而不是按预览容器或固定倍率导出。
|
||||||
|
const fontScale = MIN_EXPORT_FONT_PX / getExportFontSize(svgEl)
|
||||||
|
const widthScale = MIN_EXPORT_WIDTH / width
|
||||||
|
const rawScale = Math.max(window.devicePixelRatio || 1, fontScale, widthScale)
|
||||||
|
const sideLimitScale = Math.min(MAX_CANVAS_SIDE / width, MAX_CANVAS_SIDE / height)
|
||||||
|
const pixelLimitScale = Math.sqrt(MAX_CANVAS_PIXELS / (width * height))
|
||||||
|
const baseScale = Math.min(rawScale, MAX_EXPORT_SCALE, sideLimitScale, pixelLimitScale)
|
||||||
|
const scale = Math.max(1, baseScale * WEB_EXPORT_SCALE_FACTOR)
|
||||||
|
|
||||||
|
let currentScale = scale
|
||||||
|
let lastError: unknown
|
||||||
|
while (currentScale >= 1) {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = Math.ceil(width * currentScale)
|
||||||
|
canvas.height = Math.ceil(height * currentScale)
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('无法获取Canvas上下文')
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFFFFF'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.setTransform(currentScale, 0, 0, currentScale, 0, 0)
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||||
|
|
||||||
|
return await canvasToBlob(canvas)
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
currentScale = Math.floor(currentScale / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || new Error('导出PNG失败')
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(svgUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarkmapEditorProps {
|
export interface MarkmapEditorProps {
|
||||||
/** 要渲染的 Markdown 文本 */
|
/** 要渲染的 Markdown 文本 */
|
||||||
value: string
|
value: string
|
||||||
@@ -34,6 +199,13 @@ export default function MarkmapEditor({
|
|||||||
|
|
||||||
// 用于跟踪是否处于全屏状态
|
// 用于跟踪是否处于全屏状态
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
const [pngAction, setPngAction] = useState<'idle' | 'exporting' | 'copying'>('idle')
|
||||||
|
const [pngMessage, setPngMessage] = useState('')
|
||||||
|
|
||||||
|
const showPngMessage = (message: string) => {
|
||||||
|
setPngMessage(message)
|
||||||
|
window.setTimeout(() => setPngMessage(''), 2500)
|
||||||
|
}
|
||||||
|
|
||||||
// 监听全屏状态变化
|
// 监听全屏状态变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,7 +236,7 @@ export default function MarkmapEditor({
|
|||||||
// 导出HTML思维导图
|
// 导出HTML思维导图
|
||||||
const exportHtml = () => {
|
const exportHtml = () => {
|
||||||
try {
|
try {
|
||||||
const { root } = transformer.transform(value)
|
const { root } = transformMindmap(value)
|
||||||
const data = JSON.stringify(root)
|
const data = JSON.stringify(root)
|
||||||
|
|
||||||
// 创建HTML内容
|
// 创建HTML内容
|
||||||
@@ -202,7 +374,7 @@ export default function MarkmapEditor({
|
|||||||
// 导出XMind格式思维导图
|
// 导出XMind格式思维导图
|
||||||
const exportXMind = async () => {
|
const exportXMind = async () => {
|
||||||
try {
|
try {
|
||||||
const { root } = transformer.transform(value);
|
const { root } = transformMindmap(value);
|
||||||
|
|
||||||
// 生成唯一ID
|
// 生成唯一ID
|
||||||
const generateId = () => Math.random().toString(36).substring(2, 15);
|
const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||||
@@ -311,100 +483,44 @@ export default function MarkmapEditor({
|
|||||||
try {
|
try {
|
||||||
if (!svgRef.current || !mmRef.current) return;
|
if (!svgRef.current || !mmRef.current) return;
|
||||||
|
|
||||||
const svgEl = svgRef.current;
|
setPngAction('exporting');
|
||||||
const mm = mmRef.current;
|
setPngMessage('正在生成高清 PNG…');
|
||||||
|
const blob = await exportSvgToPngBlob(svgRef.current);
|
||||||
// 先调用fit()确保显示完整的思维导图内容
|
const url = URL.createObjectURL(blob);
|
||||||
await mm.fit();
|
const a = document.createElement('a');
|
||||||
// 等待渲染完成
|
a.href = url;
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
a.download = `${title || 'mindmap'}.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
// 获取SVG实际尺寸
|
a.click();
|
||||||
const svgWidth = svgEl.width.baseVal.value || svgEl.clientWidth || 800;
|
document.body.removeChild(a);
|
||||||
const svgHeight = svgEl.height.baseVal.value || svgEl.clientHeight || 600;
|
URL.revokeObjectURL(url);
|
||||||
|
showPngMessage('PNG 已开始下载');
|
||||||
// 设置足够大的缩放比例以确保高清输出
|
|
||||||
const scale = 3;
|
|
||||||
|
|
||||||
// 克隆SVG以避免修改原始SVG
|
|
||||||
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
|
|
||||||
|
|
||||||
// 设置SVG的背景为白色
|
|
||||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
||||||
style.textContent = 'svg { background-color: white; }';
|
|
||||||
clonedSvg.insertBefore(style, clonedSvg.firstChild);
|
|
||||||
|
|
||||||
// 确保SVG有正确的命名空间
|
|
||||||
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
||||||
clonedSvg.setAttribute('width', svgWidth.toString());
|
|
||||||
clonedSvg.setAttribute('height', svgHeight.toString());
|
|
||||||
|
|
||||||
// 将SVG转换为Data URI (避免使用Blob URL来解决跨域问题)
|
|
||||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
|
||||||
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
|
|
||||||
const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
|
|
||||||
|
|
||||||
// 创建Canvas
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = svgWidth * scale;
|
|
||||||
canvas.height = svgHeight * scale;
|
|
||||||
|
|
||||||
// 获取上下文并设置白色背景
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('无法获取Canvas上下文');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置白色背景
|
|
||||||
ctx.fillStyle = '#FFFFFF';
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// 创建Image对象
|
|
||||||
const img = new Image();
|
|
||||||
|
|
||||||
// 当图片加载完成后,在Canvas上绘制并导出
|
|
||||||
img.onload = () => {
|
|
||||||
try {
|
|
||||||
// 应用缩放
|
|
||||||
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
||||||
|
|
||||||
// 绘制SVG
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
|
|
||||||
// 重置变换
|
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
||||||
|
|
||||||
// 将Canvas转换为PNG Blob
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (blob) {
|
|
||||||
// 创建下载链接
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${title || 'mindmap'}.png`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} else {
|
|
||||||
console.error('无法创建Blob对象');
|
|
||||||
}
|
|
||||||
}, 'image/png');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Canvas处理失败:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 设置图片加载错误处理
|
|
||||||
img.onerror = (error) => {
|
|
||||||
console.error('导出PNG失败(图片加载错误):', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 开始加载SVG图像 (使用Data URI而不是Blob URL)
|
|
||||||
img.src = dataUri;
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('导出PNG失败:', error);
|
console.error('导出PNG失败:', error);
|
||||||
|
showPngMessage('导出 PNG 失败,请查看控制台');
|
||||||
|
} finally {
|
||||||
|
setPngAction('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复制PNG思维导图
|
||||||
|
const copyPng = async () => {
|
||||||
|
try {
|
||||||
|
if (!svgRef.current || !mmRef.current) return;
|
||||||
|
|
||||||
|
setPngAction('copying');
|
||||||
|
setPngMessage('正在复制高清 PNG…');
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'image/png': exportSvgToPngBlob(svgRef.current),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
showPngMessage('PNG 已复制');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制PNG失败:', error);
|
||||||
|
showPngMessage('复制 PNG 失败,请查看控制台');
|
||||||
|
} finally {
|
||||||
|
setPngAction('idle');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -428,7 +544,7 @@ export default function MarkmapEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mm = mmRef.current
|
const mm = mmRef.current
|
||||||
if (!mm) return
|
if (!mm) return
|
||||||
const { root } = transformer.transform(value)
|
const { root } = transformMindmap(value)
|
||||||
mm.setData(root).then(() => mm.fit())
|
mm.setData(root).then(() => mm.fit())
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
@@ -459,8 +575,17 @@ export default function MarkmapEditor({
|
|||||||
onClick={exportPng}
|
onClick={exportPng}
|
||||||
className="rounded p-1 hover:bg-gray-200"
|
className="rounded p-1 hover:bg-gray-200"
|
||||||
title="导出PNG图片"
|
title="导出PNG图片"
|
||||||
|
disabled={pngAction !== 'idle'}
|
||||||
>
|
>
|
||||||
🖼️
|
{pngAction === 'exporting' ? '⏳' : '🖼️'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={copyPng}
|
||||||
|
className="rounded p-1 hover:bg-gray-200"
|
||||||
|
title="复制PNG图片"
|
||||||
|
disabled={pngAction !== 'idle'}
|
||||||
|
>
|
||||||
|
{pngAction === 'copying' ? '⏳' : '📋'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={exportHtml}
|
onClick={exportHtml}
|
||||||
@@ -483,6 +608,11 @@ export default function MarkmapEditor({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{pngMessage && (
|
||||||
|
<div className="absolute top-11 right-2 z-20 rounded bg-white/95 px-2 py-1 text-xs text-gray-600 shadow">
|
||||||
|
{pngMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 如果需要编辑区,就自己加一个 <textarea> 并把 handleChange 绑上 */}
|
{/* 如果需要编辑区,就自己加一个 <textarea> 并把 handleChange 绑上 */}
|
||||||
{/* <textarea value={value} onChange={handleChange} className="mb-2 p-2 border rounded" /> */}
|
{/* <textarea value={value} onChange={handleChange} className="mb-2 p-2 border rounded" /> */}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea.tsx'
|
|||||||
import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts'
|
import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts'
|
||||||
import { fetchModels } from '@/services/model.ts'
|
import { fetchModels } from '@/services/model.ts'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
/* -------------------- 校验 Schema -------------------- */
|
/* -------------------- 校验 Schema -------------------- */
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
@@ -229,8 +230,25 @@ const NoteForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// message.success('已提交任务')
|
// message.success('已提交任务')
|
||||||
const data = await generateNote(payload)
|
try {
|
||||||
addPendingTask(data.task_id, values.platform, payload)
|
const data = await generateNote(payload)
|
||||||
|
addPendingTask(data.task_id, values.platform, payload)
|
||||||
|
} catch (e: any) {
|
||||||
|
// 就绪门禁:本地转写模型还没下载好。后端返回 reason='transcriber_model_not_ready',
|
||||||
|
// 引导用户去「设置 → 音频转写配置」下载,而不是留一个静默失败的任务。
|
||||||
|
if (e?.data?.reason === 'transcriber_model_not_ready') {
|
||||||
|
const downloading = e?.data?.downloading
|
||||||
|
toast.error(
|
||||||
|
downloading
|
||||||
|
? '转写模型正在下载中,请稍候再提交'
|
||||||
|
: '转写模型尚未下载,请先去「音频转写配置」页下载',
|
||||||
|
)
|
||||||
|
if (!downloading) navigate('/settings/transcriber')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 其余错误:axios 拦截器已经弹过 toast,这里只兜底不让 promise 变成未处理 rejection
|
||||||
|
console.error('提交任务失败:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const onInvalid = (errors: FieldErrors<NoteFormValues>) => {
|
const onInvalid = (errors: FieldErrors<NoteFormValues>) => {
|
||||||
console.warn('表单校验失败:', errors)
|
console.warn('表单校验失败:', errors)
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { addProvider, addModel, getProviderList, testConnection } from '@/services/model'
|
import { addProvider, addModel, testConnection, getProviderList, updateProviderById } from '@/services/model'
|
||||||
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
|
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
|
||||||
import logo from '@/assets/icon.svg'
|
import logo from '@/assets/icon.svg'
|
||||||
|
|
||||||
|
// 后端 R.error / ProviderError 的形状是 { code, msg, data },没有 .message。
|
||||||
|
// 直接 ${e} 会渲染成 [object Object],这里统一抽取可读文案。
|
||||||
|
function errText(e: any): string {
|
||||||
|
if (!e) return '未知错误'
|
||||||
|
if (typeof e === 'string') return e
|
||||||
|
return e.msg || e.message || JSON.stringify(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
|
||||||
|
// 后端连通性自检不走共享 axios(会弹 toast),用裸 fetch 避免启动期 toast 叠堆
|
||||||
|
function getBackendBase(): string {
|
||||||
|
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
|
||||||
|
return ((fromEnv && fromEnv.length > 0) ? fromEnv : '/api').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
async function pingBackend(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getBackendBase()}/sys_check`)
|
||||||
|
if (!res.ok) return false
|
||||||
|
const json = await res.json().catch(() => null)
|
||||||
|
return json?.code === 0
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 桌面端首启 4 步引导。完成后写 localStorage('bilinote-onboarded') = '1',路由守卫不再拦。
|
// 桌面端首启 4 步引导。完成后写 localStorage('bilinote-onboarded') = '1',路由守卫不再拦。
|
||||||
//
|
//
|
||||||
// 1. 后端连通性自检
|
// 1. 后端连通性自检
|
||||||
@@ -52,24 +79,52 @@ const Onboarding = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// step 1: ping 后端
|
// step 1: ping 后端
|
||||||
|
// 关键点:旧实现 useEffect 只在 step===1 时 ping 一次。失败后 backendOk=false 永远卡死,
|
||||||
|
// 即便后端随后就绪了也不会刷新。现在改成:
|
||||||
|
// - 手动重试按钮调用 doPing
|
||||||
|
// - Tauri backend-ready / backend-restarted 事件触发 doPing
|
||||||
|
// - 初次失败后 2s 自动再 ping 一次(覆盖 sidecar 慢热场景)
|
||||||
|
const doPing = useCallback(async () => {
|
||||||
|
setPinging(true)
|
||||||
|
const ok = await pingBackend()
|
||||||
|
setBackendOk(ok)
|
||||||
|
setPinging(false)
|
||||||
|
return ok
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 1) return
|
if (step !== 1) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let offReady: (() => void) | null = null
|
||||||
|
let offRestarted: (() => void) | null = null
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
setPinging(true)
|
const ok = await doPing()
|
||||||
try {
|
if (cancelled) return
|
||||||
await getProviderList()
|
if (!ok) {
|
||||||
if (!cancelled) setBackendOk(true)
|
// 后端可能正在解压/启动,2s 后再试一次
|
||||||
|
timerId = setTimeout(() => { if (!cancelled) doPing() }, 2000)
|
||||||
}
|
}
|
||||||
catch {
|
|
||||||
if (!cancelled) setBackendOk(false)
|
// 桌面端订阅 Tauri 事件:后端真正就绪 / 重启完成时立刻再检查一次
|
||||||
}
|
if (isTauri) {
|
||||||
finally {
|
try {
|
||||||
if (!cancelled) setPinging(false)
|
const { listen } = await import('@tauri-apps/api/event')
|
||||||
|
offReady = await listen('backend-ready', () => { if (!cancelled) doPing() })
|
||||||
|
offRestarted = await listen('backend-restarted', () => { if (!cancelled) doPing() })
|
||||||
|
}
|
||||||
|
catch { /* 拿不到事件 API 不致命 */ }
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [step])
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (timerId) clearTimeout(timerId)
|
||||||
|
offReady?.()
|
||||||
|
offRestarted?.()
|
||||||
|
}
|
||||||
|
}, [step, doPing])
|
||||||
|
|
||||||
async function saveProvider() {
|
async function saveProvider() {
|
||||||
setError('')
|
setError('')
|
||||||
@@ -79,31 +134,61 @@ const Onboarding = () => {
|
|||||||
if (!modelName.trim()) { setError('请填模型名'); return }
|
if (!modelName.trim()) { setError('请填模型名'); return }
|
||||||
setSavingProvider(true)
|
setSavingProvider(true)
|
||||||
try {
|
try {
|
||||||
// 复用桌面 web 端的 add_provider;type 必须是 'custom'(backend 强制)
|
const name = providerName.trim()
|
||||||
const res: any = await addProvider({
|
let pid: string | undefined
|
||||||
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
|
// 后端 seed_default_providers() 会预置 OpenAI / DeepSeek / Qwen 等同名供应商,
|
||||||
await addModel({ provider_id: newId, model_name: modelName.trim() })
|
// 直接 add_provider 撞名会报「供应商名称已存在」。所以:撞名时改为
|
||||||
|
// 「找到那个已存在的同名供应商 → 更新它的 key / base_url」而不是新建。
|
||||||
|
// 这些调用都带 silent:true —— 撞名是预期内的,不弹全局红 toast。
|
||||||
|
try {
|
||||||
|
const res: any = await addProvider({
|
||||||
|
name,
|
||||||
|
api_key: apiKey.trim(),
|
||||||
|
base_url: baseUrl.trim(),
|
||||||
|
type: 'custom',
|
||||||
|
logo: 'custom',
|
||||||
|
}, { silent: true })
|
||||||
|
pid = (res?.data ?? res) as string | undefined
|
||||||
|
if (!pid) throw new Error('后端未返回 provider id')
|
||||||
|
}
|
||||||
|
catch (addErr: any) {
|
||||||
|
const msg = errText(addErr)
|
||||||
|
if (!msg.includes('已存在')) throw addErr
|
||||||
|
// 撞名:复用已存在的同名供应商
|
||||||
|
const list: any[] = (await getProviderList({ silent: true })) || []
|
||||||
|
const existing = list.find(p => p?.name === name)
|
||||||
|
if (!existing?.id) throw new Error(`供应商「${name}」已存在但无法定位,请换个名字`)
|
||||||
|
pid = existing.id
|
||||||
|
await updateProviderById({
|
||||||
|
id: pid,
|
||||||
|
api_key: apiKey.trim(),
|
||||||
|
base_url: baseUrl.trim(),
|
||||||
|
enabled: 1,
|
||||||
|
}, { silent: true })
|
||||||
|
}
|
||||||
|
|
||||||
// 测试连通
|
setProviderId(pid!)
|
||||||
try { await testConnection({ id: newId }) }
|
|
||||||
|
// 加一个默认 model(同名 model 已存在时后端会报错,这里也容错)
|
||||||
|
try {
|
||||||
|
await addModel({ provider_id: pid!, model_name: modelName.trim() }, { silent: true })
|
||||||
|
}
|
||||||
|
catch (modelErr: any) {
|
||||||
|
const msg = errText(modelErr)
|
||||||
|
if (!msg.includes('已存在')) throw modelErr
|
||||||
|
// 模型已存在,直接继续
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试连通(失败不阻断流程,让用户自己决定继续)
|
||||||
|
try { await testConnection({ id: pid!, model: modelName.trim() }, { silent: true }) }
|
||||||
catch (e: any) {
|
catch (e: any) {
|
||||||
// 测试失败不阻断流程,让用户自己决定继续
|
console.warn('测试连接失败:', errText(e))
|
||||||
console.warn('测试连接失败:', e?.message ?? e)
|
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
catch (e: any) {
|
catch (e: any) {
|
||||||
setError(`保存失败:${e?.message ?? e}`)
|
setError(`保存失败:${errText(e)}`)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setSavingProvider(false)
|
setSavingProvider(false)
|
||||||
@@ -123,7 +208,7 @@ const Onboarding = () => {
|
|||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
catch (e: any) {
|
catch (e: any) {
|
||||||
setError(`保存失败:${e?.message ?? e}`)
|
setError(`保存失败:${errText(e)}`)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setSavingTranscriber(false)
|
setSavingTranscriber(false)
|
||||||
@@ -171,6 +256,15 @@ const Onboarding = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
|
{backendOk !== true && (
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
disabled={pinging}
|
||||||
|
onClick={doPing}
|
||||||
|
>
|
||||||
|
{pinging ? '检测中…' : '重新检测'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<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 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>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Provider from '@/components/Form/modelForm/Provider.tsx'
|
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
import Options from '@/components/Form/DownloaderForm/Options.tsx'
|
import Options from '@/components/Form/DownloaderForm/Options.tsx'
|
||||||
|
import ProxyConfig from '@/components/Form/DownloaderForm/ProxyConfig.tsx'
|
||||||
const Downloader = () => {
|
const Downloader = () => {
|
||||||
return (
|
return (
|
||||||
<div className={'flex h-full bg-white'}>
|
<div className={'flex h-full bg-white'}>
|
||||||
<div className={'flex-1/5 border-r border-neutral-200 p-2'}>
|
<div className={'flex flex-1/5 flex-col gap-3 overflow-y-auto border-r border-neutral-200 p-2'}>
|
||||||
|
<ProxyConfig />
|
||||||
<Options></Options>
|
<Options></Options>
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex-4/5'}>
|
<div className={'flex-4/5'}>
|
||||||
|
|||||||
@@ -174,7 +174,11 @@ export default function Monitor() {
|
|||||||
<AudioLines className="mr-2 inline h-5 w-5 text-purple-500" />
|
<AudioLines className="mr-2 inline h-5 w-5 text-purple-500" />
|
||||||
Whisper 模型
|
Whisper 模型
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{status && <StatusBadge ok={true} label="已配置" />}
|
{status && (() => {
|
||||||
|
const isLocal = status.whisper.transcriber_type === 'fast-whisper' || status.whisper.transcriber_type === 'mlx-whisper'
|
||||||
|
if (!isLocal) return <StatusBadge ok={true} label="在线引擎" />
|
||||||
|
return <StatusBadge ok={status.whisper.downloaded} label={status.whisper.downloaded ? '已下载' : '未下载'} />
|
||||||
|
})()}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && !status ? (
|
{loading && !status ? (
|
||||||
@@ -192,6 +196,14 @@ export default function Monitor() {
|
|||||||
<span className="text-muted-foreground">转写引擎:</span>
|
<span className="text-muted-foreground">转写引擎:</span>
|
||||||
<span className="font-mono">{status.whisper.transcriber_type}</span>
|
<span className="font-mono">{status.whisper.transcriber_type}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(status.whisper.transcriber_type === 'fast-whisper' || status.whisper.transcriber_type === 'mlx-whisper') && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">本地下载:</span>
|
||||||
|
<span className={status.whisper.downloaded ? 'font-medium text-green-600' : 'font-medium text-amber-600'}>
|
||||||
|
{status.whisper.downloaded ? '已就绪' : '未下载(首次转写会触发下载)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { Github, Star, ExternalLink, Download } from 'lucide-react'
|
import { Github, Star, ExternalLink, Download } from 'lucide-react'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||||
import logo from '@/assets/icon.svg'
|
import logo from '@/assets/icon.svg'
|
||||||
import wechatQr from '@/assets/wechat.png'
|
|
||||||
|
// 二维码统一走 GitHub 在线地址:后续在仓库替换图片后,已打包的 App 会自动更新,无需重新发版
|
||||||
|
const GITHUB_DOC_RAW = 'https://raw.githubusercontent.com/JefferyHcool/BiliNote/master/doc'
|
||||||
|
const wechatQr = `${GITHUB_DOC_RAW}/wechat-gzh.png`
|
||||||
|
const remoteInstallQr = `${GITHUB_DOC_RAW}/remote-install-wechat.png`
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
|
const appVersion = __APP_VERSION__
|
||||||
const images = [
|
const images = [
|
||||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504102850.png',
|
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504102850.png',
|
||||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103028.png',
|
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103028.png',
|
||||||
@@ -27,7 +32,7 @@ export default function AboutPage() {
|
|||||||
height={50}
|
height={50}
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-4xl font-bold">BiliNote v2.0.0</h1>
|
<h1 className="text-4xl font-bold">BiliNote v{appVersion}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mb-6 text-xl italic">
|
<p className="text-muted-foreground mb-6 text-xl italic">
|
||||||
AI 视频笔记生成工具 让 AI 为你的视频做笔记
|
AI 视频笔记生成工具 让 AI 为你的视频做笔记
|
||||||
@@ -197,12 +202,28 @@ export default function AboutPage() {
|
|||||||
<section className="mb-16">
|
<section className="mb-16">
|
||||||
<h2 className="mb-8 text-center text-3xl font-bold">联系和加入社区</h2>
|
<h2 className="mb-8 text-center text-3xl font-bold">联系和加入社区</h2>
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-3xl">
|
||||||
<div className="flex flex-col items-center justify-center gap-8">
|
<div className="flex flex-col items-center justify-center gap-10 md:flex-row md:items-start">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流微信群</h3>
|
<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">
|
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
|
||||||
<img src={wechatQr} alt="BiliNote 交流微信群" className="h-full w-full object-contain" />
|
<img src={wechatQr} alt="BiliNote 公众号" className="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-3 text-sm">扫描公众号,回复「交流群」获取二维码</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="mb-3 text-xl font-semibold">BiliNote AI笔记系统一对一搭建服务</h3>
|
||||||
|
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
|
||||||
|
<img
|
||||||
|
src={remoteInstallQr}
|
||||||
|
alt="BiliNote AI笔记系统一对一搭建服务"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-3 text-sm">
|
||||||
|
专人一对一远程协助,从部署到上手全程陪跑
|
||||||
|
<br />
|
||||||
|
扫码加微信,备注「搭建服务」即可咨询
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle } from 'lucide-react'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle, Plus, Trash2, Boxes } from 'lucide-react'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
getTranscriberConfig,
|
getTranscriberConfig,
|
||||||
updateTranscriberConfig,
|
updateTranscriberConfig,
|
||||||
getModelsStatus,
|
getModelsStatus,
|
||||||
downloadModel,
|
downloadModel,
|
||||||
|
addWhisperModel,
|
||||||
|
deleteWhisperModel,
|
||||||
TranscriberConfig,
|
TranscriberConfig,
|
||||||
ModelStatus,
|
ModelStatus,
|
||||||
} from '@/services/transcriber'
|
} from '@/services/transcriber'
|
||||||
@@ -33,6 +36,19 @@ export default function Transcriber() {
|
|||||||
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
|
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
|
||||||
const [mlxModelStatuses, setMlxModelStatuses] = useState<ModelStatus[]>([])
|
const [mlxModelStatuses, setMlxModelStatuses] = useState<ModelStatus[]>([])
|
||||||
const [mlxAvailable, setMlxAvailable] = useState(false)
|
const [mlxAvailable, setMlxAvailable] = useState(false)
|
||||||
|
// 自定义模型表单
|
||||||
|
const [newModelName, setNewModelName] = useState('')
|
||||||
|
const [newModelTarget, setNewModelTarget] = useState('')
|
||||||
|
const [addingModel, setAddingModel] = useState(false)
|
||||||
|
|
||||||
|
// 重新拉取配置(不重置用户当前的选择),用于增删自定义模型后刷新下拉与列表
|
||||||
|
const reloadConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setConfig(await getTranscriberConfig())
|
||||||
|
} catch {
|
||||||
|
// 静默
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchModelsStatus = useCallback(async () => {
|
const fetchModelsStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +139,41 @@ export default function Transcriber() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddCustomModel = async () => {
|
||||||
|
const name = newModelName.trim()
|
||||||
|
const target = newModelTarget.trim()
|
||||||
|
if (!name || !target) {
|
||||||
|
toast.error('请填写模型名称和 HF repo_id / 本地路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAddingModel(true)
|
||||||
|
try {
|
||||||
|
await addWhisperModel({ name, target })
|
||||||
|
toast.success(`已添加自定义模型 ${name}`)
|
||||||
|
setNewModelName('')
|
||||||
|
setNewModelTarget('')
|
||||||
|
await reloadConfig()
|
||||||
|
await fetchModelsStatus()
|
||||||
|
} catch {
|
||||||
|
// 后端的具体错误(如重名)已由请求拦截器 toast,这里不重复提示
|
||||||
|
} finally {
|
||||||
|
setAddingModel(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteCustomModel = async (name: string) => {
|
||||||
|
try {
|
||||||
|
await deleteWhisperModel(name)
|
||||||
|
toast.success(`已删除自定义模型 ${name}`)
|
||||||
|
// 删的正好是当前选中的,回退到 tiny,避免选中一个不存在的名称
|
||||||
|
if (selectedModelSize === name) setSelectedModelSize('tiny')
|
||||||
|
await reloadConfig()
|
||||||
|
await fetchModelsStatus()
|
||||||
|
} catch {
|
||||||
|
// 拦截器已提示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
@@ -272,6 +323,97 @@ export default function Transcriber() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 自定义 Whisper 模型(仅 fast-whisper:名称不符合内置 Systran 约定的模型在此登记映射) */}
|
||||||
|
{selectedType === 'fast-whisper' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Boxes className="h-5 w-5" />
|
||||||
|
自定义模型
|
||||||
|
<span className="text-sm font-normal text-neutral-400">
|
||||||
|
登记名称不符合内置约定的模型
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Alert className="text-sm">
|
||||||
|
<AlertDescription>
|
||||||
|
填 <strong>HF repo_id</strong>(如{' '}
|
||||||
|
<code className="rounded bg-neutral-100 px-1">Systran/faster-whisper-large-v3</code>
|
||||||
|
,会自动下载)或<strong>本地模型目录</strong>(如{' '}
|
||||||
|
<code className="rounded bg-neutral-100 px-1">/app/backend/models/my-whisper</code>
|
||||||
|
,目录内需含 <code className="rounded bg-neutral-100 px-1">model.bin</code>,下载会跳过)。
|
||||||
|
添加后即可在上方「模型大小」下拉中选用。Docker 部署请把模型目录挂载进容器(见 README 的{' '}
|
||||||
|
<code className="rounded bg-neutral-100 px-1">models</code> 卷)。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{config.whisper_custom_models &&
|
||||||
|
Object.keys(config.whisper_custom_models).length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(config.whisper_custom_models).map(([name, target]) => {
|
||||||
|
const status = modelStatuses.find(m => m.model_size === name)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
{name}
|
||||||
|
{status?.downloaded && (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
)}
|
||||||
|
{status?.downloading && (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-neutral-400" title={target}>
|
||||||
|
{target}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-500 hover:text-red-600"
|
||||||
|
onClick={() => handleDeleteCustomModel(name)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-400">还没有自定义模型</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="模型名称(自定义,如 my-large-v3)"
|
||||||
|
value={newModelName}
|
||||||
|
onChange={e => setNewModelName(e.target.value)}
|
||||||
|
className="sm:max-w-[220px]"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="HF repo_id 或本地路径"
|
||||||
|
value={newModelTarget}
|
||||||
|
onChange={e => setNewModelTarget(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleAddCustomModel} disabled={addingModel}>
|
||||||
|
{addingModel ? (
|
||||||
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import request from '@/utils/request.ts'
|
import request from '@/utils/request.ts'
|
||||||
|
|
||||||
export const getProviderList = async () => {
|
// opts.silent: 让本次请求失败时不弹全局红 toast(调用方自行 catch 处理,
|
||||||
return await request.get('/get_all_providers')
|
// 比如 onboarding 撞名重试这种预期内失败)
|
||||||
|
interface CallOpts { silent?: boolean }
|
||||||
|
const cfg = (o?: CallOpts) => (o?.silent ? { suppressToast: true } : undefined)
|
||||||
|
|
||||||
|
export const getProviderList = async (opts?: CallOpts) => {
|
||||||
|
return await request.get('/get_all_providers', cfg(opts))
|
||||||
}
|
}
|
||||||
export const getProviderById = async (id: string) => {
|
export const getProviderById = async (id: string) => {
|
||||||
return await request.get(`/get_provider_by_id/${id}`)
|
return await request.get(`/get_provider_by_id/${id}`)
|
||||||
}
|
}
|
||||||
export const updateProviderById = async (data: any) => {
|
export const updateProviderById = async (data: any, opts?: CallOpts) => {
|
||||||
return await request.post('/update_provider', data)
|
return await request.post('/update_provider', data, cfg(opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addProvider = async (data: any) => {
|
export const addProvider = async (data: any, opts?: CallOpts) => {
|
||||||
return await request.post('/add_provider', data)
|
return await request.post('/add_provider', data, cfg(opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const testConnection = async (data: any) => {
|
export const testConnection = async (data: any, opts?: CallOpts) => {
|
||||||
return await request.post('/connect_test', data)
|
return await request.post('/connect_test', data, cfg(opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchModels = async (providerId: string) => {
|
export const fetchModels = async (providerId: string) => {
|
||||||
@@ -26,8 +31,11 @@ export const fetchEnableModelById = async (id: string) => {
|
|||||||
return await request.get('/model_enable/' + id)
|
return await request.get('/model_enable/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addModel(data: { provider_id: string; model_name: string }) {
|
export async function addModel(
|
||||||
return request.post('/models', data)
|
data: { provider_id: string; model_name: string },
|
||||||
|
opts?: CallOpts,
|
||||||
|
) {
|
||||||
|
return request.post('/models', data, cfg(opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchEnableModels = async () => {
|
export const fetchEnableModels = async () => {
|
||||||
|
|||||||
19
BillNote_frontend/src/services/proxy.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export interface ProxyConfig {
|
||||||
|
enabled: boolean
|
||||||
|
url: string
|
||||||
|
/** 后端实际生效的代理(可能来自配置,也可能来自 HTTP_PROXY 环境变量兜底) */
|
||||||
|
effective: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProxyConfig = async (): Promise<ProxyConfig> => {
|
||||||
|
return await request.get('/proxy_config')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateProxyConfig = async (data: {
|
||||||
|
enabled: boolean
|
||||||
|
url?: string
|
||||||
|
}): Promise<ProxyConfig> => {
|
||||||
|
return await request.post('/proxy_config', data)
|
||||||
|
}
|
||||||
@@ -1,9 +1,29 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
export const systemCheck = async () => {
|
export interface SysHealth {
|
||||||
|
backend: 'ok' | 'error'
|
||||||
|
ffmpeg: 'ok' | 'missing'
|
||||||
|
db: 'ok' | 'error'
|
||||||
|
whisper_model: {
|
||||||
|
/** 当前选中的模型 size,例如 'tiny' / 'base' / 'large-v3' */
|
||||||
|
size: string | null
|
||||||
|
/** 转写器类型 */
|
||||||
|
type: string | null
|
||||||
|
/** 是否已完整下载到本地(仅本地引擎有意义) */
|
||||||
|
downloaded: boolean
|
||||||
|
/** 是否实际检查过 —— 在线引擎跳过检查时为 false */
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详细健康状态:用于设置页 / 启动诊断。后端始终返回 200,按字段判断各项。 */
|
||||||
|
export const getSysHealth = async (): Promise<SysHealth> => {
|
||||||
return await request.get('/sys_health')
|
return await request.get('/sys_health')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保留旧 systemCheck 函数名(App.tsx 启动时仍调用),返回值同 getSysHealth。 */
|
||||||
|
export const systemCheck = getSysHealth
|
||||||
|
|
||||||
export interface DeployStatus {
|
export interface DeployStatus {
|
||||||
backend: {
|
backend: {
|
||||||
status: string
|
status: string
|
||||||
@@ -11,12 +31,16 @@ export interface DeployStatus {
|
|||||||
}
|
}
|
||||||
cuda: {
|
cuda: {
|
||||||
available: boolean
|
available: boolean
|
||||||
|
/** 新增:torch 是否安装。轻量部署没装 torch 时为 false,避免误判为 CUDA 故障 */
|
||||||
|
torch_installed?: boolean
|
||||||
version: string | null
|
version: string | null
|
||||||
gpu_name: string | null
|
gpu_name: string | null
|
||||||
}
|
}
|
||||||
whisper: {
|
whisper: {
|
||||||
model_size: string
|
model_size: string
|
||||||
transcriber_type: string
|
transcriber_type: string
|
||||||
|
/** 新增:模型是否已完整下载(fast-whisper 看 model.bin / mlx 看 config.json) */
|
||||||
|
downloaded: boolean
|
||||||
}
|
}
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
available: boolean
|
available: boolean
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export interface TranscriberConfig {
|
|||||||
whisper_model_size: string
|
whisper_model_size: string
|
||||||
available_types: { value: string; label: string }[]
|
available_types: { value: string; label: string }[]
|
||||||
whisper_model_sizes: string[]
|
whisper_model_sizes: string[]
|
||||||
|
/** 内置模型映射:size → HF repo_id */
|
||||||
|
whisper_builtin_models?: Record<string, string>
|
||||||
|
/** 用户自定义模型映射:名称 → HF repo_id 或本地路径 */
|
||||||
|
whisper_custom_models?: Record<string, string>
|
||||||
mlx_whisper_available: boolean
|
mlx_whisper_available: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,3 +45,23 @@ export const downloadModel = async (data: {
|
|||||||
}) => {
|
}) => {
|
||||||
return await request.post('/transcriber_download', data)
|
return await request.post('/transcriber_download', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WhisperModelsResponse {
|
||||||
|
builtin: Record<string, string>
|
||||||
|
custom: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出内置 + 自定义 whisper 模型映射 */
|
||||||
|
export const listWhisperModels = async (): Promise<WhisperModelsResponse> => {
|
||||||
|
return await request.get('/whisper_models')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增自定义模型映射(名称 → HF repo_id 或本地路径) */
|
||||||
|
export const addWhisperModel = async (data: { name: string; target: string }) => {
|
||||||
|
return await request.post('/whisper_models', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除自定义模型映射(不会删除已下载的模型文件) */
|
||||||
|
export const deleteWhisperModel = async (name: string) => {
|
||||||
|
return await request.delete(`/whisper_models/${encodeURIComponent(name)}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,10 +169,25 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
if (!task) return
|
if (!task) return
|
||||||
|
|
||||||
const newFormData = payload || task.formData
|
const newFormData = payload || task.formData
|
||||||
await generateNote({
|
try {
|
||||||
...newFormData,
|
await generateNote({
|
||||||
task_id: id,
|
...newFormData,
|
||||||
})
|
task_id: id,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
// 就绪门禁:转写模型未下载好。不要把任务标成 PENDING(会一直转),
|
||||||
|
// 给提示让用户先去下载。
|
||||||
|
if (e?.data?.reason === 'transcriber_model_not_ready') {
|
||||||
|
toast.error(
|
||||||
|
e?.data?.downloading
|
||||||
|
? '转写模型正在下载中,请稍候再重试'
|
||||||
|
: '转写模型尚未下载,请先去「设置 → 音频转写配置」页下载',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('重试任务失败:', e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
tasks: state.tasks.map(t =>
|
tasks: state.tasks.map(t =>
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export interface IResponse<T = any> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 允许调用方在 axios 配置里带 suppressToast: true,让拦截器对【预期内的失败】
|
||||||
|
// 不弹全局红 toast(例如 onboarding 撞名重试、轮询健康检查)。业务代码自己 catch 处理。
|
||||||
|
declare module 'axios' {
|
||||||
|
export interface AxiosRequestConfig {
|
||||||
|
suppressToast?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 模拟一个消息提示函数 (实际项目中会使用UI库的组件,如 Ant Design 的 message 或 Element UI 的 ElMessage)
|
// 模拟一个消息提示函数 (实际项目中会使用UI库的组件,如 Ant Design 的 message 或 Element UI 的 ElMessage)
|
||||||
// This function simulates a message display (in real projects, you'd use a UI library's component)
|
// This function simulates a message display (in real projects, you'd use a UI library's component)
|
||||||
|
|
||||||
@@ -28,25 +36,24 @@ request.interceptors.response.use(
|
|||||||
// showMessage('success', res.msg || '操作成功'); // 如果需要显示成功消息
|
// showMessage('success', res.msg || '操作成功'); // 如果需要显示成功消息
|
||||||
return res.data; // 返回data部分,简化后续业务代码
|
return res.data; // 返回data部分,简化后续业务代码
|
||||||
} else {
|
} else {
|
||||||
// 业务错误,统一显示后端返回的错误消息
|
// 业务错误,统一显示后端返回的错误消息(除非调用方显式 suppressToast)
|
||||||
// Business error, uniformly display the error message returned from the backend
|
if (!response.config?.suppressToast) {
|
||||||
toast.error(res.msg || '操作失败,请稍后再试');
|
toast.error(res.msg || '操作失败,请稍后再试');
|
||||||
|
}
|
||||||
return Promise.reject(res); // 拒绝Promise,让业务代码可以捕获并处理
|
return Promise.reject(res); // 拒绝Promise,让业务代码可以捕获并处理
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
const suppress = error?.config?.suppressToast === true
|
||||||
// 网络/服务器错误
|
// 网络/服务器错误
|
||||||
const res = error?.response?.data as IResponse | undefined;
|
const res = error?.response?.data as IResponse | undefined;
|
||||||
if (res) {
|
if (res) {
|
||||||
// 如果后端有返回错误信息,则显示后端信息
|
// 如果后端有返回错误信息,则显示后端信息
|
||||||
// If the backend returns an error message, display it
|
if (!suppress) toast.error(res.msg || '服务器错误,请稍后再试');
|
||||||
|
|
||||||
toast.error(res.msg || '服务器错误,请稍后再试');
|
|
||||||
return Promise.reject(res);
|
return Promise.reject(res);
|
||||||
} else {
|
} else {
|
||||||
// 没有响应数据(如网络中断),显示通用网络错误
|
// 没有响应数据(如网络中断),显示通用网络错误
|
||||||
// No response data (e.g., network disconnected), display generic network error
|
if (!suppress) toast.error('请求失败,请检查网络连接或稍后再试')
|
||||||
toast.error( '请求失败,请检查网络连接或稍后再试')
|
|
||||||
return Promise.reject({
|
return Promise.reject({
|
||||||
code: -1,
|
code: -1,
|
||||||
msg: '请求失败,请检查网络连接',
|
msg: '请求失败,请检查网络连接',
|
||||||
|
|||||||
2
BillNote_frontend/src/vite-env.d.ts
vendored
@@ -1 +1,3 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
function readAppVersion() {
|
||||||
|
const fallbackVersion = '0.0.0'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tauriConfigPath = path.resolve(__dirname, 'src-tauri/tauri.conf.json')
|
||||||
|
const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf-8')) as { version?: string }
|
||||||
|
return tauriConfig.version || fallbackVersion
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return fallbackVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
|
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
|
||||||
@@ -14,9 +28,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
|
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
|
||||||
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
|
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
|
||||||
|
const appVersion = env.VITE_APP_VERSION || process.env.VITE_APP_VERSION || readAppVersion()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
base: './',
|
base: './',
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
|
},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
99
CHANGELOG.md
@@ -2,6 +2,105 @@
|
|||||||
|
|
||||||
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||||
|
|
||||||
|
## [2.4.0] - 2026-06-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **可配置 Whisper 模型**:转写设置支持自定义 Whisper 模型与名称映射,可指定自定义 HuggingFace repo 或本地路径(新增 `backend/app/transcriber/whisper_models.py` + 测试),转写设置页可选择 / 配置模型。
|
||||||
|
- **关注公众号获取交流群**:关于页群二维码改为公众号二维码,关注公众号后回复「交流群」即可获取最新群二维码,避免群码过期失效。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **浏览器扩展**:修复标题显示异常,优化脑图(Markmap)导出。
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- **GPU/CUDA 部署**:README 补全 GPU/CUDA 部署说明。
|
||||||
|
|
||||||
|
## [2.3.4] - 2026-05-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **一对一搭建服务二维码**:新增「BiliNote AI 笔记系统一对一搭建服务」二维码(README + 关于页),扫码加微信、备注「搭建服务」即可咨询。
|
||||||
|
|
||||||
|
## [2.3.3] - 2026-05-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **预构建 Docker 镜像数据持久化**:文档的 `docker run` 只挂了 `data/`(媒体缓存),而 SQLite 数据库(LLM 供应商配置 + 笔记历史)和笔记文件不在该卷下,导致删除 / 升级容器时丢失配置与历史。现将数据库重定向到 `/app/backend/data/bili_note.db`、笔记到 `data/note_results`(随 data 卷持久化);README 更新为挂载 `data` / `config` / `static` / `models` 四个数据卷,并提示**勿**挂整个 `/app/backend`(命名卷会固化镜像内代码,导致 `docker pull` 升级不生效)。`docker-compose` 路径本就正确(`./backend:/app` 整目录绑挂),未受影响。
|
||||||
|
|
||||||
|
## [2.3.2] - 2026-05-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **后端启动崩溃(Docker)**:`python:3.11-slim` 基础镜像升级到 Debian 13 / glibc 2.41 后,`ctranslate2` 4.5.0 预编译库带「可执行栈」标记被 glibc 拒绝加载(`cannot enable executable stack ... Invalid argument`)。由于 `from faster_whisper import WhisperModel` 在顶层 import,import 失败直接拖垮整个后端启动 → 容器反复重启。升级 `ctranslate2` 4.5.0→4.6.0(wheel 加入 `noexecstack` 链接标志,从二进制层根治)
|
||||||
|
- **whisper 模型误报「离线模式找不到模型」**:下载(modelscope 自定义目录)与加载(faster-whisper HF cache)布局不一致导致命不中缓存。统一为下载 / 加载 / 完整性检测 / 损坏自愈都走 HF cache 布局,并向后兼容老 modelscope 目录
|
||||||
|
- **桌面端构建产物版本恒为 2.0.0**:Release 工作流在 `pnpm tauri build` 前从 git tag 注入版本号到 `tauri.conf.json`,使产物版本与 Release 版本对齐
|
||||||
|
|
||||||
|
## [2.3.1] - 2026-05-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **更新微信交流群二维码**:旧二维码即将失效,替换 README 中 5 个交流群(群 1-5)的入群二维码。
|
||||||
|
|
||||||
|
## [2.3.0] - 2026-05-14
|
||||||
|
|
||||||
|
主线:一波部署与运行时韧性专项——Docker / 桌面端 / 在线引擎三端的"装不上、起不来、跑一半挂"问题集中清理,并新增全局代理与转写模型就绪门禁。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **全局代理**:新增 `ProxyConfigManager`(`config/proxy.json` 持久化 + `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` 环境变量兜底)。一处配置同时作用于 LLM API、转写 API(Groq 等)、yt-dlp 视频下载、youtube-transcript-api 字幕拉取。前端「设置 → 下载配置」页新增代理卡片,会显示当前实际生效值(含 env 兜底来源提示)
|
||||||
|
- **转写模型就绪门禁**:`/generate_note` 在排队前检查本地转写引擎(fast-whisper / mlx-whisper)的模型是否已下载完整,未就绪直接拦截并返回 `reason=transcriber_model_not_ready`,不再让任务静默卡在首次大文件下载;前端引导用户去「音频转写配置」页下载
|
||||||
|
- **桌面端后端健康监控韧性**:Tauri 侧 spawn sidecar 后以 HTTP 探针轮询 `/api/sys_check` 判就绪并 emit `backend-ready`;`RunEvent::Exit` 钩子在 app 退出前 kill sidecar,杜绝孤儿进程占用 8483 端口;启动失败对话框展示原因 + 最近 stderr + 一键重启 / 复制日志
|
||||||
|
- `/sys_health` 重构为结构化健康响应 `{backend, ffmpeg, db, whisper_model}`;部署监控页显示 Whisper 模型本地下载状态
|
||||||
|
- 所有 Dockerfile 新增 `BASE_REGISTRY` build-arg,国内拉不到 docker.io 时可换 daocloud 等镜像源
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **whisper 模型损坏自愈**:`model.bin` 截断 / 损坏导致 `Unable to open file 'model.bin'` 死循环——加载失败时删除损坏目录、重新下载、重试一次;mlx-whisper 同样按 `config.json` 判定完整性
|
||||||
|
- **空 API Key 天书报错**:空 key 会让 httpx 拼出非法 header `Bearer ` 并抛 `LocalProtocolError: Illegal header value b'Bearer '`。新增 `build_openai_client` 在入口校验,给出「xxx 的 API Key 未配置」的清晰提示
|
||||||
|
- **新模型 temperature 不兼容**:OpenAI o1 / o3 / gpt-5 系列拒绝自定义 `temperature`,命中后就地去掉该参数重试,不消耗重试预算
|
||||||
|
- **桌面端「后端加载中」死循环**:`useCheckBackend` 重写——60s 总超时取代 `while(true)` 无限轮询,订阅 Tauri `backend-ready` / `backend-terminated` / `backend-startup-timeout` 事件;裸 `fetch` 探测避免启动期 toast 叠堆
|
||||||
|
- **CORS 漏配桌面端 origin**:补全 `tauri://localhost` / `https://tauri.localhost`,修桌面端 fetch 拿到 200 却被浏览器 CORS 拒绝读响应(表现为"连不上后端"但后端日志全 200)
|
||||||
|
- `/api/api/sys_health` 双 `/api` 前缀导致健康检查 404
|
||||||
|
- `docker-compose` 的 `restart: on-failure:3` 改为 `unless-stopped`,避免短暂崩溃后容器被永久打死;GPU compose 补齐 `healthcheck` / `restart` / `mem_limit`
|
||||||
|
- `Dockerfile.complete` 的 supervisord 用 `%(ENV_*)s` 透传环境变量给 backend 子进程(此前只白名单 2 个,`docker run -e` 配的变量后端看不到)
|
||||||
|
- `.env.example`:修正 `VITE_API_BASE_URL` 端口(8000→8483)、`WHISPER_MODEL_SIZE`(medium→tiny,首次启动不被 ~1.5GB 下载卡住)
|
||||||
|
- Onboarding:第 1 步后端连通检测改为自动重试 + Tauri 事件触发 + 手动重检按钮;第 2 步撞预置供应商名时改为更新已存在供应商而非报错
|
||||||
|
- 模型供应商列表卡片整行可点击切换(此前仅 icon 区域响应)
|
||||||
|
- `connect_test` 改用真实 chat completion 探测而非 `/v1/models`(后者在 key 无 inference 权限 / 供应商不实现该端点时会误判)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- `backend/main.py` lifespan 拆为 `[startup 1/5]…[startup 5/5]` 分段日志,启动期异常可一眼定位死在哪一步
|
||||||
|
- `request.ts` 新增 `suppressToast` 配置位,预期内的失败(如 onboarding 撞名重试)不弹全局红 toast
|
||||||
|
- `CLAUDE.md` 勘误:移除不存在的 `app/messaging/` / `app/i18n/` / `worker_registry.py` 描述,修正 `events/` 路径,补 `pytest` / 前端 `typecheck` 命令
|
||||||
|
|
||||||
|
## [2.2.3] - 2026-05-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 前端 vite build 在 Docker / Tauri CI 中失败:`Rollup failed to resolve import '@tauri-apps/api/event'`。v2.2.0 加的 P1/P2 桌面端组件用了 `await import('@tauri-apps/api/event')` 与 `'@tauri-apps/api/core'`,但 `@tauri-apps/api` 只是 `@tauri-apps/plugin-shell` 的间接依赖,没在 `BillNote_frontend/package.json` 直接声明,Rollup 在 production build 时静态分析报"无法解析"
|
||||||
|
- `BillNote_frontend/package.json`:把 `@tauri-apps/api` 加为直接依赖(`^2.10.1`,与 lockfile 中已有的 transitive 版本一致)
|
||||||
|
- 本地 `DOCKER_BUILD=1 pnpm run build` 复现 + 验证修复
|
||||||
|
|
||||||
|
## [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
|
## [2.2.0] - 2026-05-09
|
||||||
|
|
||||||
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
|
主线:浏览器插件功能与 web 端 NoteForm 完整对齐;桌面客户端 UX 与错误恢复一波重炼。
|
||||||
|
|||||||
16
CLAUDE.md
@@ -13,6 +13,8 @@ BiliNote is an AI video note generation tool. It extracts content from video lin
|
|||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python main.py # Starts on 0.0.0.0:8483
|
python main.py # Starts on 0.0.0.0:8483
|
||||||
|
pytest # Run tests in backend/tests/
|
||||||
|
pytest tests/test_request_chunker.py::test_name # Run a single test
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (React 19 + Vite + TypeScript)
|
### Frontend (React 19 + Vite + TypeScript)
|
||||||
@@ -43,6 +45,8 @@ pnpm install
|
|||||||
pnpm dev # watch mode → ./extension/
|
pnpm dev # watch mode → ./extension/
|
||||||
pnpm build # production build → ./extension/
|
pnpm build # production build → ./extension/
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
pnpm test # Vitest unit tests
|
||||||
|
pnpm test:e2e # Playwright e2e
|
||||||
```
|
```
|
||||||
Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension/`. Talks to the same backend at `http://localhost:8483` (configurable in the options page). CORS in `backend/main.py` already accepts `chrome-extension://` and `moz-extension://` via regex.
|
Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension/`. Talks to the same backend at `http://localhost:8483` (configurable in the options page). CORS in `backend/main.py` already accepts `chrome-extension://` and `moz-extension://` via regex.
|
||||||
|
|
||||||
@@ -56,15 +60,15 @@ Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension
|
|||||||
- `chat_service.py` + `chat_tools.py` + `vector_store.py` — RAG-based AI Q&A with Function Calling, indexing transcripts and video metadata
|
- `chat_service.py` + `chat_tools.py` + `vector_store.py` — RAG-based AI Q&A with Function Calling, indexing transcripts and video metadata
|
||||||
- `cookie_manager.py` — per-platform cookie storage; injected into yt-dlp by downloaders (e.g. Bilibili)
|
- `cookie_manager.py` — per-platform cookie storage; injected into yt-dlp by downloaders (e.g. Bilibili)
|
||||||
- `transcriber_config_manager.py` — persisted transcriber settings
|
- `transcriber_config_manager.py` — persisted transcriber settings
|
||||||
- `worker_registry.py` — **optional** Nacos registration + heartbeat for distributed worker mode (no-op when `NACOS_SERVER_ADDR` unset)
|
|
||||||
- `app/messaging/` — **optional** RabbitMQ producer/consumer publishing task progress/results to `bilinote.task.feedback` exchange. Silently degrades when `RABBITMQ_URL` is unset; always import-safe.
|
|
||||||
- `app/downloaders/` — Platform adapters (bilibili, youtube, douyin, kuaishou, local) with shared `base.py` interface
|
- `app/downloaders/` — Platform adapters (bilibili, youtube, douyin, kuaishou, local) with shared `base.py` interface
|
||||||
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`. YouTube path prefers existing subtitles and skips audio download when available.
|
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`. YouTube path prefers existing subtitles and skips audio download when available.
|
||||||
- `app/gpt/` — LLM integration with factory pattern (`gpt_factory.py`), prompt templates (`prompt.py`, `prompt_builder.py`), and `request_chunker.py` for long transcripts
|
- `app/gpt/` — LLM integration with factory pattern (`gpt_factory.py`), prompt templates (`prompt.py`, `prompt_builder.py`), and `request_chunker.py` for long transcripts
|
||||||
- `app/db/` — SQLite + SQLAlchemy: DAO pattern (`provider_dao.py`, `model_dao.py`, `video_task_dao.py`), models in `models/`
|
- `app/db/` — SQLite + SQLAlchemy: DAO pattern (`provider_dao.py`, `model_dao.py`, `video_task_dao.py`), models in `models/`
|
||||||
- `app/utils/` — `response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX), `ppt_generator.py`, `minio_client.py`
|
- `app/utils/` — `response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX), `ppt_generator.py`, `minio_client.py`
|
||||||
- `app/i18n/` — backend localization
|
- `app/validators/video_url_validator.py` — URL → platform detection (mirrored client-side in the extension)
|
||||||
- `events/` (root level) — Blinker signal system for post-processing (e.g., temp file cleanup after transcription)
|
- `app/exceptions/` — `BizException` + handlers wired in `main.py` via `register_exception_handlers`
|
||||||
|
- `backend/events/` — Blinker signal system for post-processing (e.g., temp file cleanup after transcription); registered in `lifespan` startup
|
||||||
|
- `backend/ffmpeg_helper.py` — `ensure_ffmpeg_or_raise` is called at startup; respects `FFMPEG_BIN_PATH`
|
||||||
|
|
||||||
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
|
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
|
||||||
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
|
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
|
||||||
@@ -94,8 +98,8 @@ Load unpacked at `chrome://extensions/` → select `BillNote_extension/extension
|
|||||||
- **Environment**: Root `.env` (copy from `.env.example`). LLM API keys are configured through the UI, not env vars.
|
- **Environment**: Root `.env` (copy from `.env.example`). LLM API keys are configured through the UI, not env vars.
|
||||||
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
|
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
|
||||||
- **FFmpeg**: Required system dependency for video/audio processing
|
- **FFmpeg**: Required system dependency for video/audio processing
|
||||||
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir)
|
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir; falls back to current dir when `DOCKER_BUILD` is set)
|
||||||
- **Distributed mode (optional)**: Setting `NACOS_SERVER_ADDR` enables Nacos worker registration; setting `RABBITMQ_URL` enables MQ feedback. Both are no-ops when unset — single-node deployment works without either. Other knobs: `WORKER_ID`, `WORKER_SELF_URL`, `WORKER_MAX_CONCURRENT`, `TASK_MAX_WORKERS`.
|
- **CORS**: `backend/main.py` uses a regex (`CORS_ORIGIN_REGEX`) that allows localhost, `tauri.localhost`, and `chrome-extension://` / `moz-extension://` origins — required for the desktop app and the browser extension.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
# BASE_REGISTRY 默认 docker.io;国内拉不到可换镜像源:
|
||||||
|
# docker build --build-arg BASE_REGISTRY=docker.m.daocloud.io -f Dockerfile.complete .
|
||||||
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
|
||||||
# === 阶段1:构建 Backend ===
|
# === 阶段1:构建 Backend ===
|
||||||
FROM python:3.11-slim AS backend-builder
|
FROM ${BASE_REGISTRY}/library/python:3.11-slim AS backend-builder
|
||||||
|
|
||||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
@@ -28,9 +32,18 @@ COPY ./backend /tmp/backend
|
|||||||
# === 阶段2:构建 Frontend ===
|
# === 阶段2:构建 Frontend ===
|
||||||
# Node 18-alpine 跑不动 Tailwind v4 / Vite 6(前者要求 Node 20+,后者推荐 Node 20+),
|
# Node 18-alpine 跑不动 Tailwind v4 / Vite 6(前者要求 Node 20+,后者推荐 Node 20+),
|
||||||
# 升到 node:20-alpine。alpine 走 musl,pnpm 会按 lockfile 拉 *-linux-x64-musl native binary。
|
# 升到 node:20-alpine。alpine 走 musl,pnpm 会按 lockfile 拉 *-linux-x64-musl native binary。
|
||||||
FROM node:20-alpine AS frontend-builder
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
FROM ${BASE_REGISTRY}/library/node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
# 可由发布 workflow 从 git tag 注入,用于前端 About 页展示版本;未传时由 Vite 回退读取 tauri.conf.json。
|
||||||
|
ARG VITE_APP_VERSION=
|
||||||
|
ENV VITE_APP_VERSION=${VITE_APP_VERSION}
|
||||||
|
|
||||||
|
# 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
|
WORKDIR /tmp/frontend
|
||||||
|
|
||||||
@@ -46,7 +59,8 @@ ENV DOCKER_BUILD=1
|
|||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# === 阶段3:完整应用镜像 ===
|
# === 阶段3:完整应用镜像 ===
|
||||||
FROM python:3.11-slim
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
FROM ${BASE_REGISTRY}/library/python:3.11-slim
|
||||||
|
|
||||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||||
|
|
||||||
@@ -81,13 +95,20 @@ RUN rm -rf /etc/nginx/conf.d/default.conf
|
|||||||
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
|
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# 创建 supervisor 配置
|
# 创建 supervisor 配置
|
||||||
RUN mkdir -p /var/log/supervisor
|
# 关键点:supervisord 默认 *不* 把自己的环境变量传给子进程。
|
||||||
|
# 在 [supervisord] 块用 environment= 设兜底默认值;在 [program:backend] 用
|
||||||
|
# %(ENV_*)s 显式引用,等价于「把 host 通过 docker run -e 或 env_file 传进来的
|
||||||
|
# 变量再透传给 python main.py」。漏掉这一步就是用户「改 .env 没反应」的根因。
|
||||||
|
# /app/backend/data 用于持久化数据库与笔记(见下方 DATABASE_URL / NOTE_OUTPUT_DIR),
|
||||||
|
# 预建好目录,避免不挂卷启动时 sqlite 因父目录不存在而创建库失败。
|
||||||
|
RUN mkdir -p /var/log/supervisor /app/backend/data
|
||||||
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
|
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
|
||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon=true
|
nodaemon=true
|
||||||
user=root
|
user=root
|
||||||
logfile=/var/log/supervisor/supervisord.log
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
pidfile=/var/run/supervisord.pid
|
pidfile=/var/run/supervisord.pid
|
||||||
|
environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0",TRANSCRIBER_TYPE="fast-whisper",WHISPER_MODEL_SIZE="tiny",FFMPEG_BIN_PATH="",HF_ENDPOINT="https://hf-mirror.com",STATIC="/static",OUT_DIR="./static/screenshots",DATA_DIR="data",NOTE_OUTPUT_DIR="data/note_results",DATABASE_URL="sqlite:////app/backend/data/bili_note.db",IMAGE_BASE_URL="/static/screenshots",ENV="production",GROQ_TRANSCRIBER_MODEL="whisper-large-v3-turbo"
|
||||||
|
|
||||||
[program:nginx]
|
[program:nginx]
|
||||||
command=nginx -g "daemon off;"
|
command=nginx -g "daemon off;"
|
||||||
@@ -103,7 +124,7 @@ stdout_logfile=/var/log/supervisor/backend.log
|
|||||||
stderr_logfile=/var/log/supervisor/backend.log
|
stderr_logfile=/var/log/supervisor/backend.log
|
||||||
autorestart=true
|
autorestart=true
|
||||||
priority=20
|
priority=20
|
||||||
environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0"
|
environment=BACKEND_PORT="%(ENV_BACKEND_PORT)s",BACKEND_HOST="%(ENV_BACKEND_HOST)s",TRANSCRIBER_TYPE="%(ENV_TRANSCRIBER_TYPE)s",WHISPER_MODEL_SIZE="%(ENV_WHISPER_MODEL_SIZE)s",FFMPEG_BIN_PATH="%(ENV_FFMPEG_BIN_PATH)s",HF_ENDPOINT="%(ENV_HF_ENDPOINT)s",STATIC="%(ENV_STATIC)s",OUT_DIR="%(ENV_OUT_DIR)s",DATA_DIR="%(ENV_DATA_DIR)s",NOTE_OUTPUT_DIR="%(ENV_NOTE_OUTPUT_DIR)s",DATABASE_URL="%(ENV_DATABASE_URL)s",IMAGE_BASE_URL="%(ENV_IMAGE_BASE_URL)s",ENV="%(ENV_ENV)s",GROQ_TRANSCRIBER_MODEL="%(ENV_GROQ_TRANSCRIBER_MODEL)s"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# 修改 nginx 配置以使用本地 backend
|
# 修改 nginx 配置以使用本地 backend
|
||||||
@@ -111,5 +132,9 @@ RUN sed -i 's/proxy_pass http:\/\/backend:8483/proxy_pass http:\/\/127.0.0.1:848
|
|||||||
sed -i 's/proxy_pass http:\/\/frontend:80/proxy_pass http:\/\/127.0.0.1:8080/g' /etc/nginx/conf.d/default.conf
|
sed -i 's/proxy_pass http:\/\/frontend:80/proxy_pass http:\/\/127.0.0.1:8080/g' /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# 启动 supervisor
|
# 启动 supervisor
|
||||||
|
# 推荐启动方式(覆盖默认 env):
|
||||||
|
# docker run -d --name bilinote --env-file .env -p 8080:80 bilinote-aio
|
||||||
|
# 单个变量覆盖:
|
||||||
|
# docker run -d -e TRANSCRIBER_TYPE=groq -e WHISPER_MODEL_SIZE=base ...
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
200
README.md
@@ -3,7 +3,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
|
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
|
||||||
</p>
|
</p>
|
||||||
<h1 align="center" > BiliNote v2.2.0</h1>
|
<h1 align="center" > BiliNote v2.4.0</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
|
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
|
||||||
@@ -18,23 +18,51 @@
|
|||||||
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
|
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.bilinote.app/"><b>🚀 BiliNote Pro · 在线版</b></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<b>不想折腾部署?</b>访问 <a href="https://www.bilinote.app/"><b>www.bilinote.app</b></a> 即开即用 —— 免安装、免配置环境、免下模型,注册即可把视频转成笔记。
|
||||||
|
<br/>
|
||||||
|
本地部署遇到的依赖、代理、模型下载这些坑,云端版统统不用管。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.bilinote.app/">
|
||||||
|
<img src="https://img.shields.io/badge/%E7%AB%8B%E5%8D%B3%E4%BD%93%E9%AA%8C-BiliNote%20Pro-ff5c5c?style=for-the-badge" alt="立即体验 BiliNote Pro" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## ✨ 项目简介
|
## ✨ 项目简介
|
||||||
|
|
||||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。
|
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。
|
||||||
|
|
||||||
|
> 💡 **想直接用、不想本地部署?** —— [BiliNote Pro 在线版 www.bilinote.app](https://www.bilinote.app/) 已上线,云端托管、开箱即用,省去依赖安装 / 代理配置 / 模型下载的全部麻烦。
|
||||||
|
|
||||||
|
## 🌐 在线使用(推荐)
|
||||||
|
|
||||||
|
直接访问 **[www.bilinote.app](https://www.bilinote.app/)** 即可使用 BiliNote Pro 在线版,无需本地部署。
|
||||||
|
|
||||||
## 📝 使用文档
|
## 📝 使用文档
|
||||||
详细文档可以查看[这里](https://docs.bilinote.app/)
|
详细文档可以查看[这里](https://docs.bilinote.app/)
|
||||||
|
|
||||||
## 体验地址
|
|
||||||
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
|
|
||||||
|
|
||||||
## 📦 桌面版下载
|
## 📦 桌面版下载
|
||||||
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
|
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
|
||||||
|
|
||||||
> Windows 用户请注意:一定要在没有中文路径的环境下运行。
|
> Windows 用户请注意:一定要在没有中文路径的环境下运行。
|
||||||
|
|
||||||
|
## 💎 BiliNote AI笔记系统一对一搭建服务
|
||||||
|
|
||||||
|
提供 **BiliNote AI笔记系统一对一搭建服务**:专人一对一远程协助,从环境部署、模型配置到上手使用全程陪跑,帮你快速跑通整套系统。扫码添加微信,备注「搭建服务」即可咨询:
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="./doc/remote-install-wechat.png" alt="BiliNote AI笔记系统一对一搭建服务" width="220" /><br/>BiliNote AI笔记系统一对一搭建服务</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## 🔧 功能特性
|
## 🔧 功能特性
|
||||||
|
|
||||||
- 支持多平台:Bilibili、YouTube、本地视频、抖音、快手
|
- 支持多平台:Bilibili、YouTube、本地视频、抖音、快手
|
||||||
@@ -53,6 +81,26 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y
|
|||||||
- 笔记顶部视频封面 Banner 展示
|
- 笔记顶部视频封面 Banner 展示
|
||||||
- 工作区和生成历史面板支持折叠/展开
|
- 工作区和生成历史面板支持折叠/展开
|
||||||
|
|
||||||
|
### v2.3.0 新增
|
||||||
|
|
||||||
|
- 全局代理:一处配置同时作用于 AI 模型接口、转写接口(Groq 等)、YouTube 下载(设置 → 下载配置页),支持 `HTTP_PROXY` 环境变量兜底
|
||||||
|
- 转写模型就绪门禁:本地引擎模型没下载好时拦截视频任务,引导先去下载,不再静默卡在首次下载
|
||||||
|
- 桌面端后端健康监控韧性:退出自动清理 sidecar、启动失败展示原因 + 日志、不再无限「加载中」
|
||||||
|
- whisper 模型损坏自愈:`model.bin` 截断时自动删除重下;空 API Key / 新模型 temperature 不兼容给出清晰提示
|
||||||
|
- Docker 部署韧性:`BASE_REGISTRY` 可换国内镜像源、restart 策略修正、`.env.example` 端口与默认模型修正、新增部署 FAQ
|
||||||
|
|
||||||
|
### v2.2.3 修订
|
||||||
|
|
||||||
|
- 修:vite build 在 CI 中报 'Rollup failed to resolve import @tauri-apps/api/event'(缺直接依赖声明)
|
||||||
|
|
||||||
|
### 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 新增
|
### v2.2.0 新增
|
||||||
|
|
||||||
- **浏览器插件**笔记选项与 web 端完整对齐:style 9 个预设下拉、format 4 个 checkbox、extras 文本框、多模态视频理解开关
|
- **浏览器插件**笔记选项与 web 端完整对齐:style 9 个预设下拉、format 4 个 checkbox、extras 文本框、多模态视频理解开关
|
||||||
@@ -125,22 +173,90 @@ docker pull ghcr.io/jefferyhcool/bilinote:latest
|
|||||||
|
|
||||||
docker run -d -p 80:80 \
|
docker run -d -p 80:80 \
|
||||||
-v bilinote-data:/app/backend/data \
|
-v bilinote-data:/app/backend/data \
|
||||||
|
-v bilinote-config:/app/backend/config \
|
||||||
|
-v bilinote-static:/app/backend/static \
|
||||||
|
-v bilinote-models:/app/backend/models \
|
||||||
--name bilinote \
|
--name bilinote \
|
||||||
ghcr.io/jefferyhcool/bilinote:latest
|
ghcr.io/jefferyhcool/bilinote:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
上面四个卷分别持久化:`data`(SQLite 数据库 + 生成的笔记)、`config`(LLM 供应商配置 / Cookie / 转写设置)、`static`(笔记引用的视频截图)、`models`(Whisper 模型缓存,可选,避免每次重新下载)。这样 `docker pull` 升级新镜像、删旧容器重建后,配置和历史都不会丢。
|
||||||
|
|
||||||
|
> ⚠️ **不要**用 `-v 卷名:/app/backend` 挂整个后端目录——命名卷会用首次启动时的镜像内容固化,之后 `docker pull` 升级也会被旧代码盖住,导致「升级不生效」。只挂上面这些数据子目录即可。
|
||||||
|
|
||||||
访问:`http://localhost`
|
访问:`http://localhost`
|
||||||
|
|
||||||
也可以使用 docker-compose 本地构建:
|
也可以使用 docker-compose 本地构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 标准部署
|
cp .env.example .env # 第一次部署务必先创建 .env,否则 BACKEND_PORT/APP_PORT 等变量为空会启动失败
|
||||||
docker-compose up -d
|
docker-compose up --build -d
|
||||||
|
|
||||||
# GPU 加速部署(需要 NVIDIA GPU)
|
# GPU 加速部署(需要 NVIDIA GPU + NVIDIA Container Toolkit)
|
||||||
docker-compose -f docker-compose.gpu.yml up -d
|
docker-compose -f docker-compose.gpu.yml up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Docker 部署常见问题(FAQ)
|
||||||
|
|
||||||
|
社区反馈最集中的几个坑,遇到先按下面排查:
|
||||||
|
|
||||||
|
**0. 国内拉不到 docker.io(build 阶段报 `dial tcp ... i/o timeout`)**
|
||||||
|
|
||||||
|
`docker-compose build` 拉 `python:3.11-slim` / `node:20-alpine` / `nginx:1.25-alpine` 时连 `auth.docker.io` 超时。三种解法,按推荐顺序:
|
||||||
|
|
||||||
|
- **方法 A:直接用预构建镜像(最省事)**——不要本地 build,跳到上面的 `docker pull ghcr.io/jefferyhcool/bilinote:latest` 路径,ghcr.io 在国内通常比 docker.io 顺。
|
||||||
|
- **方法 B:配置 Docker daemon 镜像加速器**——编辑 `~/.docker/daemon.json`(Linux 在 `/etc/docker/daemon.json`),加:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry-mirrors": ["https://docker.m.daocloud.io"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
然后重启 Docker Desktop / `sudo systemctl restart docker`。这是一劳永逸的做法。
|
||||||
|
- **方法 C:临时切换 base image 镜像源**——本项目所有 Dockerfile 都暴露了 `BASE_REGISTRY` build-arg:
|
||||||
|
```bash
|
||||||
|
BASE_REGISTRY=docker.m.daocloud.io docker-compose build
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
或永久写到 `.env`:`echo 'BASE_REGISTRY=docker.m.daocloud.io' >> .env`。
|
||||||
|
|
||||||
|
注意:Chinese 公共 docker 镜像源时常被关停,2025-2026 之间可用的列表会变;如果 `docker.m.daocloud.io` 不通,搜一下"Docker 镜像加速 可用"找最新可用源即可。
|
||||||
|
|
||||||
|
**1. 容器一直 restart / unhealthy**
|
||||||
|
|
||||||
|
先看后端日志:
|
||||||
|
```bash
|
||||||
|
docker logs -f bilinote-backend
|
||||||
|
```
|
||||||
|
后端启动会按顺序打印 `[startup 1/5] ... [startup 5/5] 启动完成`。若日志卡在某一步或出现 `[startup FAILED]`,就是那一步的问题,常见:
|
||||||
|
- **卡在 `[startup 3/5]`**:转写器配置读不到。检查 `.env` 里 `TRANSCRIBER_TYPE` 是否写错,`mlx-whisper` 只能在 Apple Silicon 用,Linux/Docker 请用 `fast-whisper` 或 `groq`。
|
||||||
|
- **首次跑视频时容器被 kill**:whisper 模型下载触发 OOM。先把 `.env` 里 `WHISPER_MODEL_SIZE` 改成 `tiny`,跑通后再去前端「音频转写配置」里逐档升。
|
||||||
|
|
||||||
|
**2. 改了 `.env` 没生效**
|
||||||
|
|
||||||
|
区分两类变量:
|
||||||
|
- `VITE_*` 是**构建时**变量(前端 bundle 里硬编码),改完必须 `docker-compose build frontend && docker-compose up -d`。只 `restart` 不会重新打包。
|
||||||
|
- 其他后端变量(`TRANSCRIBER_TYPE`、`WHISPER_MODEL_SIZE`、`FFMPEG_BIN_PATH` 等)是**运行时**变量,改完 `docker-compose up -d` 即可。
|
||||||
|
|
||||||
|
注意:**LLM API key 不要写 `.env`**,从前端「模型供应商」页面录入,会保存到 SQLite 数据库并持久化。
|
||||||
|
|
||||||
|
**3. 数据存在哪?删容器会丢吗?**
|
||||||
|
|
||||||
|
`docker-compose` 用的是 `./backend:/app` 绑挂,下面这些文件都在宿主机的 `./backend/` 目录里、删容器不会丢:
|
||||||
|
- `./backend/bili_note.db` —— SQLite 库(含 LLM 供应商配置、笔记历史)
|
||||||
|
- `./backend/config/transcriber.json` —— 转写器运行时配置
|
||||||
|
- `./backend/static/screenshots/` —— 视频截图
|
||||||
|
- `./backend/uploads/` —— 上传的本地视频
|
||||||
|
|
||||||
|
要彻底重置就 `docker-compose down && rm backend/bili_note.db backend/config/transcriber.json`。
|
||||||
|
|
||||||
|
**4. 前端打开是空白页 / 报 502**
|
||||||
|
|
||||||
|
通常是 nginx 起来了但 backend 还没 healthy。`docker ps` 看 backend 容器 STATUS 是不是 `(healthy)`;若长期 `(unhealthy)`,按问题 1 排查后端日志。
|
||||||
|
|
||||||
|
**5. 不要用 `restart: on-failure:N`**
|
||||||
|
|
||||||
|
如果你 fork 后改过 compose 文件、把 restart 策略改成了 `on-failure:3`:任何 3 次连续崩溃都会让容器永远不再启动,之后改 `.env` 也没用。本项目自带的 compose 已经统一用 `unless-stopped`。
|
||||||
|
|
||||||
### 方式二:源码部署
|
### 方式二:源码部署
|
||||||
|
|
||||||
#### 1. 克隆仓库
|
#### 1. 克隆仓库
|
||||||
@@ -187,10 +303,42 @@ sudo apt install ffmpeg
|
|||||||
>
|
>
|
||||||
> Docker 部署已内置 FFmpeg,无需额外安装。
|
> Docker 部署已内置 FFmpeg,无需额外安装。
|
||||||
|
|
||||||
### 🚀 CUDA 加速(可选)
|
### 🚀 CUDA / GPU 加速(可选)
|
||||||
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
|
|
||||||
|
|
||||||
具体 `fast-whisper` 配置方法,请参考:[fast-whisper 项目地址](http://github.com/SYSTRAN/faster-whisper#requirements)
|
本地 **Faster Whisper** 转写可用 NVIDIA GPU 加速(在线引擎 Groq / 必剪 / 快手 与 GPU 无关)。仓库已自带 GPU 镜像与编排,**无需改代码、无需手动配置 device**——后端会自动检测 CUDA,可用就走 GPU,否则回退 CPU。
|
||||||
|
|
||||||
|
**1. 宿主机前提**
|
||||||
|
|
||||||
|
- NVIDIA 显卡 + 较新驱动(CUDA ≥ 12.4),宿主机 `nvidia-smi` 能正常输出;
|
||||||
|
- 安装 [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)(最易漏的一步,没它 Docker 进不去 GPU)。装完验证:
|
||||||
|
```bash
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
能列出显卡即 OK。
|
||||||
|
|
||||||
|
**2. 切换到 GPU 编排**(在源码目录里)
|
||||||
|
|
||||||
|
CPU 与 GPU 两套 compose 用了相同的容器名,先停掉当前栈再起 GPU 栈:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down # 停掉当前(CPU)栈
|
||||||
|
docker-compose -f docker-compose.gpu.yml up --build -d # 用 GPU 栈重建
|
||||||
|
```
|
||||||
|
|
||||||
|
- GPU 栈用 `backend/Dockerfile.gpu`(CUDA 12.4.1 + cuDNN 基础镜像,并额外装 torch 用于 CUDA 检测),compose 已声明 `deploy...devices: nvidia` 自动透传 GPU。
|
||||||
|
- **数据不丢**:两套 compose 都把 `./backend` 整目录绑挂进容器,数据库 / 配置 / 已下载模型都保留。
|
||||||
|
- 首次构建较大较慢(CUDA 基础镜像数 GB + torch),耐心等。
|
||||||
|
|
||||||
|
**3. 启用并确认**
|
||||||
|
|
||||||
|
- 「设置 → 音频转写配置」转写引擎选 **Faster Whisper(本地)**,GPU 下可放心选大模型(如 `large-v3`)。
|
||||||
|
- 确认真的走了 GPU:`docker logs bilinote-backend | grep -i cuda` 看到 `CUDA 可用,使用 GPU`;或转写时宿主机 `nvidia-smi` 能看到 python 进程占显存。
|
||||||
|
|
||||||
|
**国内镜像**:GPU compose 支持 `BASE_REGISTRY` / `APT_MIRROR` / `PIP_INDEX` 这几个 build-arg(注意 `BASE_REGISTRY` 选的源必须支持 `nvidia/cuda` 命名空间,否则拉不到 CUDA 基础镜像)。
|
||||||
|
|
||||||
|
**起来了但没走 GPU?** 依次排查:① 宿主机 `nvidia-smi` 是否正常 → ② NVIDIA Container Toolkit 是否装好(上面 `--gpus all` 测试是否通过)→ ③ `docker logs bilinote-backend` 是否有 CUDA / cuDNN 报错(驱动 CUDA 版本需 ≥ 12.4)。
|
||||||
|
|
||||||
|
`fast-whisper` 本身的 GPU 依赖说明可参考:[faster-whisper 项目](https://github.com/SYSTRAN/faster-whisper#requirements)
|
||||||
|
|
||||||
### 🐳 使用 Docker 一键部署
|
### 🐳 使用 Docker 一键部署
|
||||||
|
|
||||||
@@ -203,10 +351,17 @@ docker pull ghcr.io/jefferyhcool/bilinote:latest
|
|||||||
# 运行容器
|
# 运行容器
|
||||||
docker run -d -p 80:80 \
|
docker run -d -p 80:80 \
|
||||||
-v bilinote-data:/app/backend/data \
|
-v bilinote-data:/app/backend/data \
|
||||||
|
-v bilinote-config:/app/backend/config \
|
||||||
|
-v bilinote-static:/app/backend/static \
|
||||||
|
-v bilinote-models:/app/backend/models \
|
||||||
--name bilinote \
|
--name bilinote \
|
||||||
ghcr.io/jefferyhcool/bilinote:latest
|
ghcr.io/jefferyhcool/bilinote:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
上面四个卷分别持久化:`data`(SQLite 数据库 + 生成的笔记)、`config`(LLM 供应商配置 / Cookie / 转写设置)、`static`(笔记引用的视频截图)、`models`(Whisper 模型缓存,可选,避免每次重新下载)。这样 `docker pull` 升级新镜像、删旧容器重建后,配置和历史都不会丢。
|
||||||
|
|
||||||
|
> ⚠️ **不要**用 `-v 卷名:/app/backend` 挂整个后端目录——命名卷会用首次启动时的镜像内容固化,之后 `docker pull` 升级也会被旧代码盖住,导致「升级不生效」。只挂上面这些数据子目录即可。
|
||||||
|
|
||||||
访问:`http://localhost`
|
访问:`http://localhost`
|
||||||
|
|
||||||
也可以使用 docker-compose 本地构建:
|
也可以使用 docker-compose 本地构建:
|
||||||
@@ -215,8 +370,8 @@ docker run -d -p 80:80 \
|
|||||||
# 标准部署
|
# 标准部署
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# GPU 加速部署(需要 NVIDIA GPU)
|
# GPU 加速部署(需要 NVIDIA GPU + NVIDIA Container Toolkit,详见上方「CUDA / GPU 加速」)
|
||||||
docker-compose -f docker-compose.gpu.yml up -d
|
docker-compose -f docker-compose.gpu.yml up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧠 TODO
|
## 🧠 TODO
|
||||||
@@ -231,11 +386,20 @@ docker-compose -f docker-compose.gpu.yml up -d
|
|||||||
|
|
||||||
### Contact and Join-联系和加入社区
|
### Contact and Join-联系和加入社区
|
||||||
|
|
||||||
扫码加入 BiliNote 交流微信群(如二维码失效,请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈):
|
扫码加入 BiliNote 交流微信群(共 5 个群,任选一个即可;二维码会定期更新,如已失效请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈):
|
||||||
|
|
||||||
<p align="center">
|
<table align="center">
|
||||||
<img src="./doc/wechat.png" alt="BiliNote 交流微信群" width="240" />
|
<tr>
|
||||||
</p>
|
<td align="center"><img src="./doc/wechat-group-1.png" alt="BiliNote 交流群 1" width="200" /><br/>交流群 1</td>
|
||||||
|
<td align="center"><img src="./doc/wechat-group-2.png" alt="BiliNote 交流群 2" width="200" /><br/>交流群 2</td>
|
||||||
|
<td align="center"><img src="./doc/wechat-group-3.png" alt="BiliNote 交流群 3" width="200" /><br/>交流群 3</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="./doc/wechat-group-4.png" alt="BiliNote 交流群 4" width="200" /><br/>交流群 4</td>
|
||||||
|
<td align="center"><img src="./doc/wechat-group-5.png" alt="BiliNote 交流群 5" width="200" /><br/>交流群 5</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
FROM python:3.11-slim
|
# BASE_REGISTRY 默认走 docker.io;国内拉不到 docker.io 时可换 daocloud / 阿里云 / 自建镜像源:
|
||||||
|
# docker-compose build --build-arg BASE_REGISTRY=docker.m.daocloud.io
|
||||||
|
# 或写到 docker-compose.yml 的 build.args / 环境变量里
|
||||||
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
FROM ${BASE_REGISTRY}/library/python:3.11-slim
|
||||||
|
|
||||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
# BASE_REGISTRY 默认走 docker.io;国内可换 daocloud / 阿里云镜像(注意所选镜像需支持 nvidia/cuda 命名空间)
|
||||||
|
ARG BASE_REGISTRY=docker.io
|
||||||
|
FROM ${BASE_REGISTRY}/nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
||||||
|
|
||||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|||||||
@@ -9,12 +9,22 @@ from app.downloaders.base import Downloader, DownloadQuality
|
|||||||
from app.downloaders.youtube_subtitle import YouTubeSubtitleFetcher
|
from app.downloaders.youtube_subtitle import YouTubeSubtitleFetcher
|
||||||
from app.models.notes_model import AudioDownloadResult
|
from app.models.notes_model import AudioDownloadResult
|
||||||
from app.models.transcriber_model import TranscriptResult
|
from app.models.transcriber_model import TranscriptResult
|
||||||
|
from app.services.proxy_config_manager import ProxyConfigManager
|
||||||
from app.utils.path_helper import get_data_dir
|
from app.utils.path_helper import get_data_dir
|
||||||
from app.utils.url_parser import extract_video_id
|
from app.utils.url_parser import extract_video_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_proxy(ydl_opts: dict) -> dict:
|
||||||
|
"""YouTube 在国内需要代理。配置了全局代理就塞进 yt-dlp opts。"""
|
||||||
|
proxy = ProxyConfigManager().get_proxy_url()
|
||||||
|
if proxy:
|
||||||
|
ydl_opts['proxy'] = proxy
|
||||||
|
logger.info(f"yt-dlp 走代理: {proxy}")
|
||||||
|
return ydl_opts
|
||||||
|
|
||||||
|
|
||||||
class YoutubeDownloader(Downloader, ABC):
|
class YoutubeDownloader(Downloader, ABC):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
||||||
@@ -46,6 +56,7 @@ class YoutubeDownloader(Downloader, ABC):
|
|||||||
if skip_download:
|
if skip_download:
|
||||||
ydl_opts['skip_download'] = True
|
ydl_opts['skip_download'] = True
|
||||||
|
|
||||||
|
_apply_proxy(ydl_opts)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(video_url, download=not skip_download)
|
info = ydl.extract_info(video_url, download=not skip_download)
|
||||||
video_id = info.get("id")
|
video_id = info.get("id")
|
||||||
@@ -91,6 +102,7 @@ class YoutubeDownloader(Downloader, ABC):
|
|||||||
'merge_output_format': 'mp4', # 确保合并成 mp4
|
'merge_output_format': 'mp4', # 确保合并成 mp4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_apply_proxy(ydl_opts)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(video_url, download=True)
|
info = ydl.extract_info(video_url, download=True)
|
||||||
video_id = info.get("id")
|
video_id = info.get("id")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Optional, List
|
|||||||
from youtube_transcript_api import YouTubeTranscriptApi
|
from youtube_transcript_api import YouTubeTranscriptApi
|
||||||
|
|
||||||
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
||||||
|
from app.services.proxy_config_manager import ProxyConfigManager
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -17,7 +18,21 @@ class YouTubeSubtitleFetcher:
|
|||||||
"""通过 youtube-transcript-api 获取 YouTube 字幕。"""
|
"""通过 youtube-transcript-api 获取 YouTube 字幕。"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._api = YouTubeTranscriptApi()
|
# 配了全局代理就给 youtube-transcript-api 套一个带 proxies 的 requests.Session,
|
||||||
|
# 否则国内拉字幕同样会超时。代理未配置时退回默认无代理客户端。
|
||||||
|
proxy = ProxyConfigManager().get_proxy_url()
|
||||||
|
if proxy:
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
session = requests.Session()
|
||||||
|
session.proxies = {"http": proxy, "https": proxy}
|
||||||
|
self._api = YouTubeTranscriptApi(http_client=session)
|
||||||
|
logger.info(f"YouTube 字幕走代理: {proxy}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"为 youtube-transcript-api 注入代理失败,回退无代理: {e}")
|
||||||
|
self._api = YouTubeTranscriptApi()
|
||||||
|
else:
|
||||||
|
self._api = YouTubeTranscriptApi()
|
||||||
|
|
||||||
def fetch_subtitles(
|
def fetch_subtitles(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
from app.gpt.base import GPT
|
from app.gpt.base import GPT
|
||||||
from openai import OpenAI
|
from app.utils.openai_client import build_openai_client
|
||||||
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT
|
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT
|
||||||
from app.gpt.utils import fix_markdown
|
from app.gpt.utils import fix_markdown
|
||||||
from app.models.gpt_model import GPTSource
|
from app.models.gpt_model import GPTSource
|
||||||
@@ -15,7 +15,7 @@ class DeepSeekGPT(GPT):
|
|||||||
self.base_url = getenv("DEEP_SEEK_API_BASE_URL")
|
self.base_url = getenv("DEEP_SEEK_API_BASE_URL")
|
||||||
self.model=getenv('DEEP_SEEK_MODEL')
|
self.model=getenv('DEEP_SEEK_MODEL')
|
||||||
print(self.model)
|
print(self.model)
|
||||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
self.client = build_openai_client(self.api_key, self.base_url, key_label="DeepSeek 的 API Key")
|
||||||
self.screenshot = False
|
self.screenshot = False
|
||||||
|
|
||||||
def _format_time(self, seconds: float) -> str:
|
def _format_time(self, seconds: float) -> str:
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.openai_client import build_openai_client
|
||||||
|
|
||||||
logging= get_logger(__name__)
|
logging= get_logger(__name__)
|
||||||
class OpenAICompatibleProvider:
|
class OpenAICompatibleProvider:
|
||||||
def __init__(self, api_key: str, base_url: str, model: Union[str, None]=None):
|
def __init__(self, api_key: str, base_url: str, model: Union[str, None]=None):
|
||||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
# build_openai_client:注入全局代理 + 校验 api_key 非空
|
||||||
|
self.client = build_openai_client(api_key, base_url, key_label="模型供应商的 API Key")
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -15,17 +15,27 @@ class OpenAICompatibleProvider:
|
|||||||
return self.client
|
return self.client
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def test_connection(api_key: str, base_url: str) -> bool:
|
def test_connection(api_key: str, base_url: str, model: str) -> bool:
|
||||||
|
"""发一条最小化 chat completion 验证 key / base_url / model 三方都通。
|
||||||
|
|
||||||
|
为什么不用 client.models.list():
|
||||||
|
- 部分代理 / 自建供应商不实现 /v1/models(如某些 OpenAI 兼容网关)
|
||||||
|
- 部分供应商 key 在没有 inference 权限时 /v1/models 仍返回 200
|
||||||
|
最终用户跑的就是 chat.completions.create,所以直接测它最忠实。
|
||||||
|
max_tokens=1 + temperature=0 让请求开销 < 0.0001 美元、延迟 < 2s。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
client = OpenAI(api_key=api_key, base_url=base_url)
|
client = build_openai_client(
|
||||||
model = client.models.list()
|
api_key, base_url, key_label="模型供应商的 API Key", timeout=15.0,
|
||||||
# for segment in model:
|
)
|
||||||
# print(segment)
|
client.chat.completions.create(
|
||||||
# print(model)
|
model=model,
|
||||||
logging.info("连通性测试成功")
|
messages=[{"role": "user", "content": "ping"}],
|
||||||
|
max_tokens=1,
|
||||||
|
temperature=0,
|
||||||
|
)
|
||||||
|
logging.info(f"连通性测试成功(model={model})")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.info(f"连通性测试失败:{e}")
|
logging.warning(f"连通性测试失败(model={model}):{e}")
|
||||||
|
|
||||||
# print(f"Error connecting to OpenAI API: {e}")
|
|
||||||
return False
|
return False
|
||||||
@@ -185,15 +185,40 @@ class UniversalGPT(GPT):
|
|||||||
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
||||||
return status in {408, 409, 429, 500, 502, 503, 504, 524}
|
return status in {408, 409, 429, 500, 502, 503, 504, 524}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_temperature_unsupported_error(exc: Exception) -> bool:
|
||||||
|
"""OpenAI o1/o3/gpt-5 系列等新模型不接受自定义 temperature,
|
||||||
|
只允许默认值 1,传 0.7 会报 `'temperature' does not support 0.7 ...`。"""
|
||||||
|
raw = str(exc).lower()
|
||||||
|
return "temperature" in raw and (
|
||||||
|
"does not support" in raw
|
||||||
|
or "unsupported_value" in raw
|
||||||
|
or "only the default" in raw
|
||||||
|
)
|
||||||
|
|
||||||
|
def _do_create(self, messages: list):
|
||||||
|
"""单次调用。如果模型拒绝自定义 temperature,就地去掉该参数再试一次
|
||||||
|
(不消耗外层的重试次数预算),仍失败则把异常抛给外层重试逻辑。"""
|
||||||
|
try:
|
||||||
|
return self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=self.temperature,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if self._is_temperature_unsupported_error(exc):
|
||||||
|
print(f"[universal_gpt] 模型 {self.model} 不支持自定义 temperature,改用默认值重试")
|
||||||
|
return self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
def _chat_completion_create(self, messages: list):
|
def _chat_completion_create(self, messages: list):
|
||||||
last_exc = None
|
last_exc = None
|
||||||
for attempt in range(self._max_retry_attempts):
|
for attempt in range(self._max_retry_attempts):
|
||||||
try:
|
try:
|
||||||
return self.client.chat.completions.create(
|
return self._do_create(messages)
|
||||||
model=self.model,
|
|
||||||
messages=messages,
|
|
||||||
temperature=self.temperature
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
if attempt == self._max_retry_attempts - 1 or not self._is_retryable_error(exc):
|
if attempt == self._max_retry_attempts - 1 or not self._is_retryable_error(exc):
|
||||||
|
|||||||
@@ -61,16 +61,53 @@ WHISPER_MODEL_SIZES = ["tiny", "base", "small", "medium", "large-v3", "large-v3-
|
|||||||
@router.get("/transcriber_config")
|
@router.get("/transcriber_config")
|
||||||
def get_transcriber_config():
|
def get_transcriber_config():
|
||||||
from app.transcriber.transcriber_provider import MLX_WHISPER_AVAILABLE
|
from app.transcriber.transcriber_provider import MLX_WHISPER_AVAILABLE
|
||||||
|
from app.transcriber.whisper_models import get_registry, BUILTIN_WHISPER_MODELS
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
config = transcriber_config_manager.get_config()
|
config = transcriber_config_manager.get_config()
|
||||||
return R.success(data={
|
return R.success(data={
|
||||||
**config,
|
**config,
|
||||||
"available_types": AVAILABLE_TRANSCRIBER_TYPES,
|
"available_types": AVAILABLE_TRANSCRIBER_TYPES,
|
||||||
"whisper_model_sizes": WHISPER_MODEL_SIZES,
|
# 内置可见档位 + 用户自定义模型,供前端下拉
|
||||||
|
"whisper_model_sizes": registry.visible_model_names(),
|
||||||
|
"whisper_builtin_models": BUILTIN_WHISPER_MODELS,
|
||||||
|
"whisper_custom_models": registry.get_custom_models(),
|
||||||
"mlx_whisper_available": MLX_WHISPER_AVAILABLE,
|
"mlx_whisper_available": MLX_WHISPER_AVAILABLE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class WhisperCustomModelRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
target: str # HF repo_id(如 Systran/faster-whisper-large-v3)或本地模型目录路径
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/whisper_models")
|
||||||
|
def list_whisper_models():
|
||||||
|
"""列出内置 + 用户自定义的 whisper 模型映射。"""
|
||||||
|
from app.transcriber.whisper_models import get_registry, BUILTIN_WHISPER_MODELS
|
||||||
|
reg = get_registry()
|
||||||
|
return R.success(data={"builtin": BUILTIN_WHISPER_MODELS, "custom": reg.get_custom_models()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/whisper_models")
|
||||||
|
def add_whisper_model(data: WhisperCustomModelRequest):
|
||||||
|
"""新增自定义 whisper 模型映射(名称 → HF repo_id 或本地路径)。"""
|
||||||
|
from app.transcriber.whisper_models import get_registry
|
||||||
|
try:
|
||||||
|
custom = get_registry().add_custom_model(data.name, data.target)
|
||||||
|
except ValueError as e:
|
||||||
|
return R.error(msg=str(e))
|
||||||
|
return R.success(data={"custom": custom}, msg="已添加自定义模型")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/whisper_models/{name}")
|
||||||
|
def delete_whisper_model(name: str):
|
||||||
|
"""删除自定义 whisper 模型映射(不会删除已下载的模型文件)。"""
|
||||||
|
from app.transcriber.whisper_models import get_registry
|
||||||
|
custom = get_registry().remove_custom_model(name)
|
||||||
|
return R.success(data={"custom": custom}, msg="已删除自定义模型")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/transcriber_config")
|
@router.post("/transcriber_config")
|
||||||
def update_transcriber_config(data: TranscriberConfigRequest):
|
def update_transcriber_config(data: TranscriberConfigRequest):
|
||||||
config = transcriber_config_manager.update_config(
|
config = transcriber_config_manager.update_config(
|
||||||
@@ -80,6 +117,36 @@ def update_transcriber_config(data: TranscriberConfigRequest):
|
|||||||
return R.success(data=config)
|
return R.success(data=config)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 全局代理配置(作用于 LLM API + 转写 API + yt-dlp 下载)----
|
||||||
|
|
||||||
|
class ProxyConfigRequest(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/proxy_config")
|
||||||
|
def get_proxy_config():
|
||||||
|
from app.services.proxy_config_manager import ProxyConfigManager
|
||||||
|
mgr = ProxyConfigManager()
|
||||||
|
cfg = mgr.get_config()
|
||||||
|
# effective 给前端展示「当前实际生效的代理」——可能来自配置,也可能来自 env 兜底
|
||||||
|
return R.success(data={
|
||||||
|
**cfg,
|
||||||
|
"effective": mgr.get_proxy_url() or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/proxy_config")
|
||||||
|
def update_proxy_config(data: ProxyConfigRequest):
|
||||||
|
from app.services.proxy_config_manager import ProxyConfigManager
|
||||||
|
mgr = ProxyConfigManager()
|
||||||
|
cfg = mgr.update_config(enabled=data.enabled, url=data.url)
|
||||||
|
return R.success(data={
|
||||||
|
**cfg,
|
||||||
|
"effective": mgr.get_proxy_url() or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ---- Whisper 模型下载状态 & 下载触发 ----
|
# ---- Whisper 模型下载状态 & 下载触发 ----
|
||||||
|
|
||||||
# 用于跟踪正在进行的下载任务
|
# 用于跟踪正在进行的下载任务
|
||||||
@@ -87,17 +154,63 @@ _downloading: dict[str, str] = {} # model_size -> status ("downloading" | "done
|
|||||||
|
|
||||||
|
|
||||||
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
|
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
|
||||||
"""检查指定 whisper 模型是否已下载到本地。"""
|
"""检查指定 whisper 模型是否已下载完整到本地。
|
||||||
model_dir = get_model_dir(subdir)
|
|
||||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
先把模型名 resolve 成可加载标识,再按类型判定:
|
||||||
return Path(model_path).exists()
|
- 本地路径模型 → 直接看该目录下有没有 model.bin
|
||||||
|
- HF repo_id → 看 HF cache 布局
|
||||||
|
<model_dir>/models--{org}--{name}/snapshots/<hash>/model.bin
|
||||||
|
(历史 modelscope 布局 <model_dir>/whisper-{size}/model.bin 也兼容识别)
|
||||||
|
"""
|
||||||
|
from app.transcriber.whisper_models import (
|
||||||
|
resolve_whisper_model,
|
||||||
|
is_local_target,
|
||||||
|
hf_cache_dirname,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
target = resolve_whisper_model(model_size)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if is_local_target(target):
|
||||||
|
return (Path(target) / "model.bin").exists()
|
||||||
|
|
||||||
|
model_dir = Path(get_model_dir(subdir))
|
||||||
|
# HF cache 布局(适配任意 org/repo,不再写死 Systran)
|
||||||
|
hf_repo_dir = model_dir / hf_cache_dirname(target) / "snapshots"
|
||||||
|
if hf_repo_dir.exists():
|
||||||
|
for snapshot in hf_repo_dir.iterdir():
|
||||||
|
if (snapshot / "model.bin").exists():
|
||||||
|
return True
|
||||||
|
# 历史 modelscope 布局(向后兼容老用户)
|
||||||
|
legacy = model_dir / f"whisper-{model_size}" / "model.bin"
|
||||||
|
return legacy.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_mlx_whisper_model_exists(model_size: str) -> bool:
|
||||||
|
"""检查 mlx-whisper 模型是否已下载完整到本地。
|
||||||
|
|
||||||
|
与 fast-whisper 的目录布局不同:mlx 模型按 HuggingFace repo_id
|
||||||
|
(如 mlx-community/whisper-tiny-mlx)落盘,且没有 model.bin,
|
||||||
|
用 config.json 作为「下载完成」的判据,和 mlx_whisper_transcriber.py 保持一致。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.transcriber.mlx_whisper_transcriber import MLX_MODEL_MAP
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
repo_id = MLX_MODEL_MAP.get(model_size)
|
||||||
|
if not repo_id:
|
||||||
|
return False
|
||||||
|
model_dir = get_model_dir("mlx-whisper")
|
||||||
|
model_path = os.path.join(model_dir, repo_id)
|
||||||
|
return (Path(model_path) / "config.json").exists()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transcriber_models_status")
|
@router.get("/transcriber_models_status")
|
||||||
def get_transcriber_models_status():
|
def get_transcriber_models_status():
|
||||||
"""返回所有 whisper 模型的下载状态。"""
|
"""返回所有 whisper 模型的下载状态(含用户自定义模型)。"""
|
||||||
|
from app.transcriber.whisper_models import get_registry
|
||||||
statuses = []
|
statuses = []
|
||||||
for size in WHISPER_MODEL_SIZES:
|
for size in get_registry().visible_model_names():
|
||||||
downloaded = _check_whisper_model_exists(size, "whisper")
|
downloaded = _check_whisper_model_exists(size, "whisper")
|
||||||
download_status = _downloading.get(size)
|
download_status = _downloading.get(size)
|
||||||
statuses.append({
|
statuses.append({
|
||||||
@@ -113,11 +226,9 @@ def get_transcriber_models_status():
|
|||||||
from app.transcriber.mlx_whisper_transcriber import MLX_MODEL_MAP
|
from app.transcriber.mlx_whisper_transcriber import MLX_MODEL_MAP
|
||||||
for size in WHISPER_MODEL_SIZES:
|
for size in WHISPER_MODEL_SIZES:
|
||||||
mlx_key = f"mlx-{size}"
|
mlx_key = f"mlx-{size}"
|
||||||
model_dir = get_model_dir("mlx-whisper")
|
|
||||||
repo_id = MLX_MODEL_MAP.get(size)
|
repo_id = MLX_MODEL_MAP.get(size)
|
||||||
# 模型在本地按 repo_id(如 mlx-community/whisper-small-mlx)落盘
|
# 用 config.json 判定,和 _check_mlx_whisper_model_exists / 加载逻辑保持一致
|
||||||
model_path = os.path.join(model_dir, repo_id) if repo_id else None
|
downloaded = _check_mlx_whisper_model_exists(size)
|
||||||
downloaded = bool(model_path and Path(model_path).exists())
|
|
||||||
mlx_statuses.append({
|
mlx_statuses.append({
|
||||||
"model_size": size,
|
"model_size": size,
|
||||||
"downloaded": downloaded,
|
"downloaded": downloaded,
|
||||||
@@ -138,23 +249,48 @@ class ModelDownloadRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _do_download_whisper(model_size: str):
|
def _do_download_whisper(model_size: str):
|
||||||
"""后台下载 faster-whisper 模型。"""
|
"""后台下载 faster-whisper 模型(支持内置 size / 自定义 repo_id / 本地路径)。
|
||||||
from app.transcriber.whisper import MODEL_MAP
|
|
||||||
from modelscope import snapshot_download
|
模型名先 resolve:
|
||||||
|
- 本地路径模型:无需下载,目录里有 model.bin 即 done,否则 failed;
|
||||||
|
- HF repo_id:snapshot_download 到 HF cache 布局(cache_dir=model_dir),
|
||||||
|
与加载逻辑 WhisperModel(download_root=model_dir) 完全对齐。
|
||||||
|
"""
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
from app.transcriber.whisper_models import resolve_whisper_model, is_local_target
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_downloading[model_size] = "downloading"
|
_downloading[model_size] = "downloading"
|
||||||
model_dir = get_model_dir("whisper")
|
model_dir = get_model_dir("whisper")
|
||||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
|
||||||
if Path(model_path).exists():
|
# 已经下好就不重复下
|
||||||
|
if _check_whisper_model_exists(model_size, "whisper"):
|
||||||
_downloading[model_size] = "done"
|
_downloading[model_size] = "done"
|
||||||
return
|
return
|
||||||
repo_id = MODEL_MAP.get(model_size)
|
|
||||||
if not repo_id:
|
target = resolve_whisper_model(model_size)
|
||||||
_downloading[model_size] = "failed"
|
if is_local_target(target):
|
||||||
|
# 本地模型不下载,只校验 model.bin 是否就位
|
||||||
|
ok = (Path(target) / "model.bin").exists()
|
||||||
|
_downloading[model_size] = "done" if ok else "failed"
|
||||||
|
if not ok:
|
||||||
|
logger.warning(f"本地模型 {model_size} 路径 {target} 下没有 model.bin,无法使用")
|
||||||
return
|
return
|
||||||
logger.info(f"开始下载 whisper 模型: {model_size}")
|
|
||||||
snapshot_download(repo_id, local_dir=model_path)
|
logger.info(f"开始下载 whisper 模型: {model_size} ← {target}")
|
||||||
|
# 跟 faster-whisper utils.py 用同样的 allow_patterns,避免多下无关文件;
|
||||||
|
# 不传 local_dir 让它走 HF 默认 cache 布局(与加载逻辑对齐)
|
||||||
|
snapshot_download(
|
||||||
|
target,
|
||||||
|
cache_dir=model_dir,
|
||||||
|
allow_patterns=[
|
||||||
|
"config.json",
|
||||||
|
"preprocessor_config.json",
|
||||||
|
"model.bin",
|
||||||
|
"tokenizer.json",
|
||||||
|
"vocabulary.*",
|
||||||
|
],
|
||||||
|
)
|
||||||
logger.info(f"whisper 模型下载完成: {model_size}")
|
logger.info(f"whisper 模型下载完成: {model_size}")
|
||||||
_downloading[model_size] = "done"
|
_downloading[model_size] = "done"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -179,7 +315,8 @@ def _do_download_mlx_whisper(model_size: str):
|
|||||||
|
|
||||||
model_dir = get_model_dir("mlx-whisper")
|
model_dir = get_model_dir("mlx-whisper")
|
||||||
model_path = os.path.join(model_dir, repo_id)
|
model_path = os.path.join(model_dir, repo_id)
|
||||||
if Path(model_path).exists():
|
# 用 config.json 判定而非目录存在:半成品目录不能算「已下载」
|
||||||
|
if (Path(model_path) / "config.json").exists():
|
||||||
_downloading[key] = "done"
|
_downloading[key] = "done"
|
||||||
return
|
return
|
||||||
logger.info(f"开始下载 mlx-whisper 模型: {model_size} ← {repo_id}")
|
logger.info(f"开始下载 mlx-whisper 模型: {model_size} ← {repo_id}")
|
||||||
@@ -193,11 +330,11 @@ def _do_download_mlx_whisper(model_size: str):
|
|||||||
|
|
||||||
@router.post("/transcriber_download")
|
@router.post("/transcriber_download")
|
||||||
def download_transcriber_model(data: ModelDownloadRequest, background_tasks: BackgroundTasks):
|
def download_transcriber_model(data: ModelDownloadRequest, background_tasks: BackgroundTasks):
|
||||||
"""触发后台下载指定的 whisper 模型。"""
|
"""触发后台下载指定的 whisper 模型(fast-whisper 支持内置档位 + 自定义模型)。"""
|
||||||
if data.model_size not in WHISPER_MODEL_SIZES:
|
|
||||||
return R.error(msg=f"不支持的模型大小: {data.model_size}")
|
|
||||||
|
|
||||||
if data.transcriber_type == "mlx-whisper":
|
if data.transcriber_type == "mlx-whisper":
|
||||||
|
# mlx 只认内置档位(mlx-community 的固定映射)
|
||||||
|
if data.model_size not in WHISPER_MODEL_SIZES:
|
||||||
|
return R.error(msg=f"MLX 不支持的模型大小: {data.model_size}")
|
||||||
if platform.system() != "Darwin":
|
if platform.system() != "Darwin":
|
||||||
return R.error(msg="MLX Whisper 仅支持 macOS")
|
return R.error(msg="MLX Whisper 仅支持 macOS")
|
||||||
key = f"mlx-{data.model_size}"
|
key = f"mlx-{data.model_size}"
|
||||||
@@ -205,6 +342,10 @@ def download_transcriber_model(data: ModelDownloadRequest, background_tasks: Bac
|
|||||||
return R.success(msg="模型正在下载中")
|
return R.success(msg="模型正在下载中")
|
||||||
background_tasks.add_task(_do_download_mlx_whisper, data.model_size)
|
background_tasks.add_task(_do_download_mlx_whisper, data.model_size)
|
||||||
else:
|
else:
|
||||||
|
# fast-whisper:内置档位 / 自定义 repo_id / 本地路径都允许
|
||||||
|
from app.transcriber.whisper_models import get_registry
|
||||||
|
if not get_registry().is_known(data.model_size):
|
||||||
|
return R.error(msg=f"不支持的模型: {data.model_size}(请先在自定义模型中登记)")
|
||||||
if _downloading.get(data.model_size) == "downloading":
|
if _downloading.get(data.model_size) == "downloading":
|
||||||
return R.success(msg="模型正在下载中")
|
return R.success(msg="模型正在下载中")
|
||||||
background_tasks.add_task(_do_download_whisper, data.model_size)
|
background_tasks.add_task(_do_download_whisper, data.model_size)
|
||||||
@@ -214,46 +355,119 @@ def download_transcriber_model(data: ModelDownloadRequest, background_tasks: Bac
|
|||||||
|
|
||||||
@router.get("/sys_health")
|
@router.get("/sys_health")
|
||||||
async def sys_health():
|
async def sys_health():
|
||||||
|
"""结构化健康状态——任何子项异常都不应让整个 endpoint 5xx。
|
||||||
|
|
||||||
|
每个字段:'ok' | 'missing' | 'error'。
|
||||||
|
前端 useCheckBackend 用 /sys_check 做存活判定(不依赖外部依赖),
|
||||||
|
/sys_health 用来在设置页区分「后端没起」vs「后端起了但 ffmpeg 缺」vs「DB 写不进去」等更细的状态。
|
||||||
|
"""
|
||||||
|
ffmpeg_status = "ok"
|
||||||
try:
|
try:
|
||||||
ensure_ffmpeg_or_raise()
|
ensure_ffmpeg_or_raise()
|
||||||
return R.success()
|
except Exception:
|
||||||
except EnvironmentError:
|
ffmpeg_status = "missing"
|
||||||
return R.error(msg="系统未安装 ffmpeg 请先进行安装")
|
|
||||||
|
db_status = "ok"
|
||||||
|
try:
|
||||||
|
from app.db.engine import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
except Exception:
|
||||||
|
db_status = "error"
|
||||||
|
|
||||||
|
# 当前转写器配置 + 模型是否已下载(用 model.bin 落盘判定,与 transcriber 加载逻辑一致)
|
||||||
|
whisper_info: dict = {"size": None, "type": None, "downloaded": False, "checked": False}
|
||||||
|
try:
|
||||||
|
cfg = transcriber_config_manager.get_config()
|
||||||
|
size = cfg["whisper_model_size"]
|
||||||
|
ttype = cfg["transcriber_type"]
|
||||||
|
whisper_info["size"] = size
|
||||||
|
whisper_info["type"] = ttype
|
||||||
|
# 只有本地引擎才有「下载」概念;groq / bcut / kuaishou 在线引擎跳过
|
||||||
|
if ttype == "fast-whisper":
|
||||||
|
whisper_info["downloaded"] = _check_whisper_model_exists(size, "whisper")
|
||||||
|
whisper_info["checked"] = True
|
||||||
|
elif ttype == "mlx-whisper":
|
||||||
|
whisper_info["downloaded"] = _check_mlx_whisper_model_exists(size)
|
||||||
|
whisper_info["checked"] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return R.success(data={
|
||||||
|
"backend": "ok",
|
||||||
|
"ffmpeg": ffmpeg_status,
|
||||||
|
"db": db_status,
|
||||||
|
"whisper_model": whisper_info,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sys_check")
|
@router.get("/sys_check")
|
||||||
async def sys_check():
|
async def sys_check():
|
||||||
|
"""轻量存活判定:后端进程能响应这个 endpoint 就算「起来了」,不查外部依赖。
|
||||||
|
|
||||||
|
给桌面端 useCheckBackend / Tauri ready-probe 用。
|
||||||
|
"""
|
||||||
return R.success()
|
return R.success()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/deploy_status")
|
@router.get("/deploy_status")
|
||||||
async def deploy_status():
|
async def deploy_status():
|
||||||
"""返回部署监控所需的所有状态信息"""
|
"""返回部署监控所需的所有状态信息。
|
||||||
import torch
|
|
||||||
|
所有子项都用 try 包起来——监控页本身不应该被任何一个子项打死。
|
||||||
|
特别是 torch:它只在 fast-whisper 路径用得到,用 Groq / 必剪 / 快手在线
|
||||||
|
引擎的轻量部署完全可以不装,那种情况这个 endpoint 不应该 500。
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# CUDA 状态
|
# CUDA 状态
|
||||||
cuda_available = torch.cuda.is_available()
|
try:
|
||||||
cuda_info = {
|
import torch
|
||||||
"available": cuda_available,
|
cuda_available = torch.cuda.is_available()
|
||||||
"version": torch.version.cuda if cuda_available else None,
|
cuda_info = {
|
||||||
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
|
"available": cuda_available,
|
||||||
}
|
"torch_installed": True,
|
||||||
|
"version": torch.version.cuda if cuda_available else None,
|
||||||
# Whisper 模型状态(从配置文件读取,与前端设置同步)
|
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
|
||||||
transcriber_cfg = transcriber_config_manager.get_config()
|
}
|
||||||
model_size = transcriber_cfg["whisper_model_size"]
|
except Exception:
|
||||||
transcriber_type = transcriber_cfg["transcriber_type"]
|
cuda_info = {
|
||||||
|
"available": False,
|
||||||
|
"torch_installed": False,
|
||||||
|
"version": None,
|
||||||
|
"gpu_name": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whisper 模型 / 转写器配置 + 本地下载状态
|
||||||
|
try:
|
||||||
|
transcriber_cfg = transcriber_config_manager.get_config()
|
||||||
|
size = transcriber_cfg["whisper_model_size"]
|
||||||
|
ttype = transcriber_cfg["transcriber_type"]
|
||||||
|
if ttype == "fast-whisper":
|
||||||
|
downloaded = _check_whisper_model_exists(size, "whisper")
|
||||||
|
elif ttype == "mlx-whisper":
|
||||||
|
downloaded = _check_mlx_whisper_model_exists(size)
|
||||||
|
else:
|
||||||
|
downloaded = False # 在线引擎无下载概念
|
||||||
|
whisper_info = {
|
||||||
|
"model_size": size,
|
||||||
|
"transcriber_type": ttype,
|
||||||
|
"downloaded": downloaded,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
whisper_info = {"model_size": None, "transcriber_type": None, "downloaded": False}
|
||||||
|
|
||||||
# FFmpeg 状态
|
# FFmpeg 状态
|
||||||
try:
|
try:
|
||||||
ensure_ffmpeg_or_raise()
|
ensure_ffmpeg_or_raise()
|
||||||
ffmpeg_ok = True
|
ffmpeg_ok = True
|
||||||
except:
|
except Exception:
|
||||||
ffmpeg_ok = False
|
ffmpeg_ok = False
|
||||||
|
|
||||||
return R.success(data={
|
return R.success(data={
|
||||||
"backend": {"status": "running", "port": int(os.getenv("BACKEND_PORT", 8483))},
|
"backend": {"status": "running", "port": int(os.getenv("BACKEND_PORT", 8483))},
|
||||||
"cuda": cuda_info,
|
"cuda": cuda_info,
|
||||||
"whisper": {"model_size": model_size, "transcriber_type": transcriber_type},
|
"whisper": whisper_info,
|
||||||
"ffmpeg": {"available": ffmpeg_ok},
|
"ffmpeg": {"available": ffmpeg_ok},
|
||||||
})
|
})
|
||||||
@@ -180,6 +180,24 @@ async def upload(file: UploadFile = File(...)):
|
|||||||
@router.post("/generate_note")
|
@router.post("/generate_note")
|
||||||
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
|
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
|
||||||
try:
|
try:
|
||||||
|
# 就绪门禁:本地转写引擎(fast-whisper / mlx-whisper)必须等模型下载完才能跑视频,
|
||||||
|
# 否则任务会卡在首次下载(慢 / OOM / 截断),用户只看到一个静默失败的任务。
|
||||||
|
# 客户端已抓好字幕(prefetched_transcript)则不需要转写,跳过检查。
|
||||||
|
if not data.prefetched_transcript:
|
||||||
|
from app.services.transcriber_config_manager import TranscriberConfigManager
|
||||||
|
readiness = TranscriberConfigManager().is_model_ready()
|
||||||
|
if not readiness["ready"]:
|
||||||
|
logger.warning(f"拒绝 generate_note:{readiness['reason']}")
|
||||||
|
return R.error(
|
||||||
|
msg=readiness["reason"],
|
||||||
|
code=300102,
|
||||||
|
data={
|
||||||
|
"reason": "transcriber_model_not_ready",
|
||||||
|
"transcriber_type": readiness["transcriber_type"],
|
||||||
|
"model_size": readiness["model_size"],
|
||||||
|
"downloading": readiness["downloading"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
video_id = extract_video_id(data.video_url, data.platform)
|
video_id = extract_video_id(data.video_url, data.platform)
|
||||||
# if not video_id:
|
# if not video_id:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class ProviderRequest(BaseModel):
|
|||||||
|
|
||||||
class TestRequest(BaseModel):
|
class TestRequest(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
# 可选:指定用哪个 model 跑连通性测试;不传则用该 provider 在 DB 里的第一个模型
|
||||||
|
model: Optional[str] = None
|
||||||
class ProviderUpdateRequest(BaseModel):
|
class ProviderUpdateRequest(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -91,5 +93,5 @@ def update_provider(data: ProviderUpdateRequest):
|
|||||||
|
|
||||||
@router.post('/connect_test')
|
@router.post('/connect_test')
|
||||||
def gpt_connect_test(data: TestRequest):
|
def gpt_connect_test(data: TestRequest):
|
||||||
ModelService().connect_test(data.id)
|
ModelService().connect_test(data.id, model=data.model)
|
||||||
return R.success(msg='连接成功')
|
return R.success(msg='连接成功')
|
||||||
|
|||||||
@@ -100,23 +100,46 @@ class ModelService:
|
|||||||
logger.error(f"[{provider_id}] 获取模型失败: {e}")
|
logger.error(f"[{provider_id}] 获取模型失败: {e}")
|
||||||
return []
|
return []
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def connect_test(id: str) -> bool:
|
def connect_test(id: str, model: str | None = None) -> bool:
|
||||||
|
"""连通性测试:发一条最小化 chat completion。
|
||||||
|
|
||||||
|
model 优先级:
|
||||||
|
1. 调用方显式传入(前端可在「模型选择」UI 里挑一个再测)
|
||||||
|
2. DB 中该 provider 已保存的第一个模型
|
||||||
|
3. 都没有 → 抛错让用户先加一个模型
|
||||||
|
"""
|
||||||
provider = ProviderService.get_provider_by_id(id)
|
provider = ProviderService.get_provider_by_id(id)
|
||||||
|
if not provider:
|
||||||
if provider:
|
raise ProviderError(
|
||||||
if not provider.get('api_key'):
|
code=ProviderErrorEnum.NOT_FOUND.code,
|
||||||
raise ProviderError(code=ProviderErrorEnum.NOT_FOUND.code, message=ProviderErrorEnum.NOT_FOUND.message)
|
message=ProviderErrorEnum.NOT_FOUND.message,
|
||||||
result = OpenAICompatibleProvider.test_connection(
|
)
|
||||||
api_key=provider.get('api_key'),
|
if not provider.get('api_key'):
|
||||||
base_url=provider.get('base_url')
|
raise ProviderError(
|
||||||
|
code=ProviderErrorEnum.NOT_FOUND.code,
|
||||||
|
message=ProviderErrorEnum.NOT_FOUND.message,
|
||||||
)
|
)
|
||||||
if result:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
raise ProviderError(code=ProviderErrorEnum.WRONG_PARAMETER.code,message=ProviderErrorEnum.WRONG_PARAMETER.message)
|
|
||||||
|
|
||||||
raise ProviderError(code=ProviderErrorEnum.NOT_FOUND.code, message=ProviderErrorEnum.NOT_FOUND.message)
|
if not model:
|
||||||
|
saved_models = ModelService.get_enabled_models_by_provider(provider["id"])
|
||||||
|
if not saved_models:
|
||||||
|
raise ProviderError(
|
||||||
|
code=ProviderErrorEnum.WRONG_PARAMETER.code,
|
||||||
|
message="请先为该供应商添加至少一个模型再测试连通性",
|
||||||
|
)
|
||||||
|
model = saved_models[0]["model_name"]
|
||||||
|
|
||||||
|
ok = OpenAICompatibleProvider.test_connection(
|
||||||
|
api_key=provider.get('api_key'),
|
||||||
|
base_url=provider.get('base_url'),
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
return True
|
||||||
|
raise ProviderError(
|
||||||
|
code=ProviderErrorEnum.WRONG_PARAMETER.code,
|
||||||
|
message=ProviderErrorEnum.WRONG_PARAMETER.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ class ProviderService:
|
|||||||
try:
|
try:
|
||||||
# 过滤掉空值
|
# 过滤掉空值
|
||||||
filtered_data = {k: v for k, v in data.items() if v is not None and k != 'id'}
|
filtered_data = {k: v for k, v in data.items() if v is not None and k != 'id'}
|
||||||
|
# 防御掩码污染:前端展示时 api_key 被 mask_key() 处理过(如 a92f****...2d3a),
|
||||||
|
# 如果用户未重新输入直接保存,带星号的值不应覆盖原 key。
|
||||||
|
if 'api_key' in filtered_data and '*' in str(filtered_data.get('api_key', '')):
|
||||||
|
filtered_data.pop('api_key')
|
||||||
print('更新模型供应商',filtered_data)
|
print('更新模型供应商',filtered_data)
|
||||||
update_provider(id, **filtered_data)
|
update_provider(id, **filtered_data)
|
||||||
# 获取更新后的供应商信息
|
# 获取更新后的供应商信息
|
||||||
|
|||||||
60
backend/app/services/proxy_config_manager.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyConfigManager:
|
||||||
|
"""全局代理配置,存 JSON 文件,支持前端动态修改。
|
||||||
|
|
||||||
|
作用范围:LLM API + 转写 API(Groq 等)+ yt-dlp 视频下载。
|
||||||
|
优先级:配置文件里 enabled=true 的 url > 环境变量 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY。
|
||||||
|
这样桌面端/web 用户在设置页填,docker/服务器部署用环境变量兜底。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, filepath: str = "config/proxy.json"):
|
||||||
|
self.path = Path(filepath)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _read(self) -> Dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with self.path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _write(self, data: Dict[str, Any]):
|
||||||
|
with self.path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def get_config(self) -> Dict[str, Any]:
|
||||||
|
data = self._read()
|
||||||
|
return {
|
||||||
|
"enabled": bool(data.get("enabled", False)),
|
||||||
|
"url": data.get("url", "") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_config(self, enabled: bool, url: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
data = self._read()
|
||||||
|
data["enabled"] = bool(enabled)
|
||||||
|
if url is not None:
|
||||||
|
data["url"] = url.strip()
|
||||||
|
self._write(data)
|
||||||
|
return self.get_config()
|
||||||
|
|
||||||
|
def get_proxy_url(self) -> Optional[str]:
|
||||||
|
"""返回当前生效的代理 URL;没有则 None。
|
||||||
|
|
||||||
|
- 配置文件 enabled=true 且 url 非空 → 用配置的 url
|
||||||
|
- 否则回退到环境变量(标准的 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY,大小写都认)
|
||||||
|
"""
|
||||||
|
cfg = self.get_config()
|
||||||
|
if cfg["enabled"] and cfg["url"]:
|
||||||
|
return cfg["url"]
|
||||||
|
for key in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"):
|
||||||
|
val = os.environ.get(key)
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
@@ -61,3 +61,54 @@ class TranscriberConfigManager:
|
|||||||
|
|
||||||
def get_whisper_model_size(self) -> str:
|
def get_whisper_model_size(self) -> str:
|
||||||
return self.get_config()["whisper_model_size"]
|
return self.get_config()["whisper_model_size"]
|
||||||
|
|
||||||
|
def is_model_ready(self) -> Dict[str, Any]:
|
||||||
|
"""当前转写器是否就绪可用。
|
||||||
|
|
||||||
|
返回 {ready, transcriber_type, model_size, downloading, reason}:
|
||||||
|
- 在线引擎 (groq/bcut/kuaishou):永远 ready(不需要本地模型)
|
||||||
|
- fast-whisper:检查 whisper-{size}/model.bin 落盘
|
||||||
|
- mlx-whisper:检查 {repo_id}/config.json 落盘
|
||||||
|
给 /generate_note 入口做「开始视频前先确认模型下载好」的门禁用。
|
||||||
|
"""
|
||||||
|
cfg = self.get_config()
|
||||||
|
ttype = cfg["transcriber_type"]
|
||||||
|
size = cfg["whisper_model_size"]
|
||||||
|
result = {
|
||||||
|
"ready": True,
|
||||||
|
"transcriber_type": ttype,
|
||||||
|
"model_size": size,
|
||||||
|
"downloading": False,
|
||||||
|
"reason": "",
|
||||||
|
}
|
||||||
|
if ttype not in ("fast-whisper", "mlx-whisper"):
|
||||||
|
return result # 在线引擎无需本地模型
|
||||||
|
|
||||||
|
# 延迟 import 避免与 routers.config 的循环依赖;只取纯函数,不触发路由副作用
|
||||||
|
try:
|
||||||
|
from app.routers.config import (
|
||||||
|
_check_whisper_model_exists,
|
||||||
|
_check_mlx_whisper_model_exists,
|
||||||
|
_downloading,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 拿不到检查函数时保守放行,不要把用户卡死
|
||||||
|
result["reason"] = f"无法检查模型状态: {e}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if ttype == "fast-whisper":
|
||||||
|
downloaded = _check_whisper_model_exists(size, "whisper")
|
||||||
|
downloading = _downloading.get(size) == "downloading"
|
||||||
|
else: # mlx-whisper
|
||||||
|
downloaded = _check_mlx_whisper_model_exists(size)
|
||||||
|
downloading = _downloading.get(f"mlx-{size}") == "downloading"
|
||||||
|
|
||||||
|
result["downloading"] = downloading
|
||||||
|
if downloaded:
|
||||||
|
return result
|
||||||
|
result["ready"] = False
|
||||||
|
result["reason"] = (
|
||||||
|
f"转写模型 {ttype} / {size} 尚未下载就绪"
|
||||||
|
+ (",正在下载中,请稍候" if downloading else ",请先在「设置 → 音频转写配置」页下载")
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from app.decorators.timeit import timeit
|
|||||||
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
||||||
from app.services.provider import ProviderService
|
from app.services.provider import ProviderService
|
||||||
from app.transcriber.base import Transcriber
|
from app.transcriber.base import Transcriber
|
||||||
from openai import OpenAI
|
from app.utils.openai_client import build_openai_client
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
import tempfile
|
import tempfile
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -30,12 +30,14 @@ class GroqTranscriber(Transcriber, ABC):
|
|||||||
print(f"压缩完成,临时路径:{file_path}")
|
print(f"压缩完成,临时路径:{file_path}")
|
||||||
provider = ProviderService.get_provider_by_id('groq')
|
provider = ProviderService.get_provider_by_id('groq')
|
||||||
|
|
||||||
|
|
||||||
if not provider:
|
if not provider:
|
||||||
raise Exception("Groq 供应商未配置,请配置以后使用。")
|
raise Exception("Groq 供应商未配置,请配置以后使用。")
|
||||||
client = OpenAI(
|
# build_openai_client 会校验 api_key 非空(空 key 会抛天书般的
|
||||||
|
# `Illegal header value b'Bearer '`),并自动注入全局代理
|
||||||
|
client = build_openai_client(
|
||||||
api_key=provider.get('api_key'),
|
api_key=provider.get('api_key'),
|
||||||
base_url=provider.get('base_url')
|
base_url=provider.get('base_url'),
|
||||||
|
key_label="Groq 转写引擎的 API Key",
|
||||||
)
|
)
|
||||||
filename = file_path
|
filename = file_path
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,16 @@ class MLXWhisperTranscriber(Transcriber):
|
|||||||
# 设置模型路径
|
# 设置模型路径
|
||||||
model_dir = get_model_dir("mlx-whisper")
|
model_dir = get_model_dir("mlx-whisper")
|
||||||
self.model_path = os.path.join(model_dir, self.model_name)
|
self.model_path = os.path.join(model_dir, self.model_name)
|
||||||
# 检查并下载模型
|
# 用 config.json 而非目录存在作为「下载完成」的判据,
|
||||||
if not Path(self.model_path).exists():
|
# 同 fast-whisper 的 model.bin:避免半成品目录把后续下载吞掉
|
||||||
logger.info(f"模型 {self.model_name} 不存在,开始下载...")
|
config_file = Path(self.model_path) / "config.json"
|
||||||
|
if not config_file.exists():
|
||||||
|
if Path(self.model_path).exists():
|
||||||
|
logger.warning(
|
||||||
|
f"MLX 模型目录 {self.model_path} 存在但 config.json 缺失(上次下载未完成),重新下载"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"模型 {self.model_name} 不存在,开始下载...")
|
||||||
snapshot_download(
|
snapshot_download(
|
||||||
self.model_name,
|
self.model_name,
|
||||||
local_dir=self.model_path,
|
local_dir=self.model_path,
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ from faster_whisper import WhisperModel
|
|||||||
from app.decorators.timeit import timeit
|
from app.decorators.timeit import timeit
|
||||||
from app.models.transcriber_model import TranscriptSegment, TranscriptResult
|
from app.models.transcriber_model import TranscriptSegment, TranscriptResult
|
||||||
from app.transcriber.base import Transcriber
|
from app.transcriber.base import Transcriber
|
||||||
|
from app.transcriber.whisper_models import (
|
||||||
|
resolve_whisper_model,
|
||||||
|
is_local_target,
|
||||||
|
hf_cache_dirname,
|
||||||
|
)
|
||||||
from app.utils.env_checker import is_cuda_available, is_torch_installed
|
from app.utils.env_checker import is_cuda_available, is_torch_installed
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.path_helper import get_model_dir
|
from app.utils.path_helper import get_model_dir
|
||||||
@@ -10,8 +15,7 @@ from app.utils.path_helper import get_model_dir
|
|||||||
from events import transcription_finished
|
from events import transcription_finished
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
from tqdm import tqdm
|
import shutil
|
||||||
from modelscope import snapshot_download
|
|
||||||
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
@@ -19,19 +23,16 @@ from modelscope import snapshot_download
|
|||||||
'''
|
'''
|
||||||
logger=get_logger(__name__)
|
logger=get_logger(__name__)
|
||||||
|
|
||||||
MODEL_MAP={
|
# 历史遗留:之前用 modelscope 下载到自定义目录然后把路径传给 WhisperModel。
|
||||||
"tiny": "pengzhendong/faster-whisper-tiny",
|
# 但 faster-whisper 1.1.1 的 download_model(utils.py:76)逻辑是:
|
||||||
'base':'pengzhendong/faster-whisper-base',
|
# 只要 size_or_id 里含 "/" 就当 HF repo_id 处理,没有「本地目录直接返回」分支。
|
||||||
'small':'pengzhendong/faster-whisper-small',
|
# 我们传 /app/models/whisper/whisper-tiny 进去 → 被当成不存在的 HF repo →
|
||||||
'medium':'pengzhendong/faster-whisper-medium',
|
# 在线请求失败 → fallback local_files_only=True → HF cache 找不到(因为是
|
||||||
'large-v1':'pengzhendong/faster-whisper-large-v1',
|
# modelscope 目录布局不是 HF)→ LocalEntryNotFoundError,误导说"离线模式"。
|
||||||
'large-v2':'pengzhendong/faster-whisper-large-v2',
|
# 解法:彻底让 faster-whisper 自己处理下载——传 size name,配 download_root
|
||||||
'large-v3':'pengzhendong/faster-whisper-large-v3',
|
# 作为 HF cache 根目录,HF_ENDPOINT 已经在 Dockerfile 里指到 hf-mirror.com,
|
||||||
'large-v3-turbo':'pengzhendong/faster-whisper-large-v3-turbo',
|
# 国内能用。删掉 modelscope 那一套,避免布局不匹配。
|
||||||
}
|
|
||||||
|
|
||||||
class WhisperTranscriber(Transcriber):
|
class WhisperTranscriber(Transcriber):
|
||||||
# TODO:修改为可配置
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
model_size: str = "base",
|
model_size: str = "base",
|
||||||
@@ -47,25 +48,53 @@ class WhisperTranscriber(Transcriber):
|
|||||||
print('没有 cuda 使用 cpu进行计算')
|
print('没有 cuda 使用 cpu进行计算')
|
||||||
|
|
||||||
self.compute_type = compute_type or ("float16" if self.device == "cuda" else "int8")
|
self.compute_type = compute_type or ("float16" if self.device == "cuda" else "int8")
|
||||||
|
self.model_size = model_size
|
||||||
|
|
||||||
model_dir = get_model_dir("whisper")
|
model_dir = get_model_dir("whisper")
|
||||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
try:
|
||||||
if not Path(model_path).exists():
|
self.model = self._build_model(model_size, model_dir)
|
||||||
logger.info(f"模型 whisper-{model_size} 不存在,开始下载...")
|
except Exception as e:
|
||||||
repo_id = MODEL_MAP[model_size]
|
# 自愈:损坏 / 截断 / 半成品 cache → 删掉对应 HF cache 重下一次
|
||||||
model_path = snapshot_download(
|
logger.warning(f"加载 whisper-{model_size} 失败:{e};清理 cache 后重新下载")
|
||||||
repo_id,
|
self._purge_cache(model_dir, model_size)
|
||||||
|
self.model = self._build_model(model_size, model_dir)
|
||||||
|
|
||||||
local_dir=model_path,
|
def _build_model(self, model_size: str, model_dir: str) -> WhisperModel:
|
||||||
)
|
# resolve 把模型名映射成可加载标识:内置 size→Systran repo_id、自定义映射、
|
||||||
logger.info("模型下载完成")
|
# 直通的 repo_id 或本地路径。faster-whisper 对本地目录走 os.path.isdir 分支,
|
||||||
|
# 对 repo_id 走 download_model(cache_dir=download_root),两者都吃 model_size_or_path。
|
||||||
self.model = WhisperModel(
|
target = resolve_whisper_model(model_size)
|
||||||
model_size_or_path=model_path,
|
return WhisperModel(
|
||||||
|
model_size_or_path=target,
|
||||||
device=self.device,
|
device=self.device,
|
||||||
compute_type=self.compute_type,
|
compute_type=self.compute_type,
|
||||||
download_root=model_dir
|
download_root=model_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _purge_cache(model_dir: str, model_size: str) -> None:
|
||||||
|
"""加载失败时清掉对应 HF cache 的 snapshot 目录,强制下次重下。
|
||||||
|
|
||||||
|
关键:本地路径模型**绝不删**——那是用户自己的文件,删了就是数据丢失;
|
||||||
|
只清 HF cache 布局 <model_dir>/models--{org}--{name}/(含历史 modelscope 目录)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
target = resolve_whisper_model(model_size)
|
||||||
|
except Exception:
|
||||||
|
target = model_size
|
||||||
|
if is_local_target(target):
|
||||||
|
logger.warning(
|
||||||
|
f"模型 {model_size} 指向本地路径 {target},加载失败不清理用户文件,请检查该目录是否完整"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
candidates = [
|
||||||
|
Path(model_dir) / hf_cache_dirname(target), # HF cache: models--org--name
|
||||||
|
Path(model_dir) / f"whisper-{model_size}", # 历史 modelscope 目录,顺手清掉
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if path.exists():
|
||||||
|
logger.info(f"清理损坏 cache: {path}")
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_torch_installed() -> bool:
|
def is_torch_installed() -> bool:
|
||||||
try:
|
try:
|
||||||
|
|||||||
156
backend/app/transcriber/whisper_models.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""fast-whisper 模型名 → 可加载标识(HF repo_id 或本地路径)的映射注册表。
|
||||||
|
|
||||||
|
背景:faster-whisper 加载时 `WhisperModel(model_size_or_path=...)` 接受三种入参——
|
||||||
|
内置 size 名、HF repo_id(含 "/")、或本地模型目录(`os.path.isdir` 命中则直接用)。
|
||||||
|
此前后端把「size → Systran/faster-whisper-{size}」这层约定**隐式**散落在加载/下载/
|
||||||
|
检测三处,用户想用命名不符合该约定的模型(比如社区微调版、或自己下到本地的模型)就接不上。
|
||||||
|
|
||||||
|
本模块把映射**显式化 + 可配置**(对齐 mlx_whisper_transcriber.MLX_MODEL_MAP 的模式):
|
||||||
|
- 内置:size → Systran/faster-whisper-{size}
|
||||||
|
- 自定义:用户在 config/whisper_models.json 登记 {名称: "<repo_id 或本地路径>"}
|
||||||
|
(JSON 持久化;Docker 下随 config 卷持久化)
|
||||||
|
|
||||||
|
解析优先级(resolve):自定义 > 内置 > 直通(含 "/" 当 repo_id;已存在目录当本地路径)。
|
||||||
|
加载 / 下载 / 完整性检测三处统一调用 resolve,路径不再各写各的。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# 内置模型:size → faster-whisper 兼容的 HF repo_id(CTranslate2 转换版,Systran 官方维护)。
|
||||||
|
BUILTIN_WHISPER_MODELS: Dict[str, str] = {
|
||||||
|
"tiny": "Systran/faster-whisper-tiny",
|
||||||
|
"base": "Systran/faster-whisper-base",
|
||||||
|
"small": "Systran/faster-whisper-small",
|
||||||
|
"medium": "Systran/faster-whisper-medium",
|
||||||
|
"large-v1": "Systran/faster-whisper-large-v1",
|
||||||
|
"large-v2": "Systran/faster-whisper-large-v2",
|
||||||
|
"large-v3": "Systran/faster-whisper-large-v3",
|
||||||
|
"large-v3-turbo": "Systran/faster-whisper-large-v3-turbo",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 前端下拉默认展示的内置档位(保持与历史 WHISPER_MODEL_SIZES 一致,不把 8 个全列出来)
|
||||||
|
DEFAULT_VISIBLE_BUILTINS: List[str] = ["tiny", "base", "small", "medium", "large-v3", "large-v3-turbo"]
|
||||||
|
|
||||||
|
|
||||||
|
def is_local_target(target: str) -> bool:
|
||||||
|
"""判断解析出的 target 是本地路径而非 HF repo_id。
|
||||||
|
|
||||||
|
HF repo_id 形如 'Org/Name'(恰一个斜杠、无前导斜杠、非已存在目录)。
|
||||||
|
本地路径:绝对路径 / 以 . 或 ~ 开头 / 已存在的目录。
|
||||||
|
"""
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
|
if os.path.isabs(target) or target.startswith(".") or target.startswith("~"):
|
||||||
|
return True
|
||||||
|
return os.path.isdir(target)
|
||||||
|
|
||||||
|
|
||||||
|
def hf_cache_dirname(repo_id: str) -> str:
|
||||||
|
"""huggingface_hub snapshot 的本地缓存目录名:Org/Name → models--Org--Name。"""
|
||||||
|
return "models--" + repo_id.replace("/", "--")
|
||||||
|
|
||||||
|
|
||||||
|
class WhisperModelRegistry:
|
||||||
|
"""内置 + 用户自定义的 whisper 模型映射,自定义部分持久化到 JSON。"""
|
||||||
|
|
||||||
|
def __init__(self, filepath: str = "config/whisper_models.json"):
|
||||||
|
self.path = Path(filepath)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ---- 持久化 ----
|
||||||
|
def _read_custom(self) -> Dict[str, str]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with self.path.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f) or {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"读取自定义 whisper 模型配置失败,按空处理: {e}")
|
||||||
|
return {}
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for name, val in data.items():
|
||||||
|
if isinstance(val, str) and val.strip():
|
||||||
|
out[name] = val.strip()
|
||||||
|
elif isinstance(val, dict) and isinstance(val.get("target"), str):
|
||||||
|
out[name] = val["target"].strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _write_custom(self, data: Dict[str, str]) -> None:
|
||||||
|
with self.path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# ---- 查询 ----
|
||||||
|
def get_custom_models(self) -> Dict[str, str]:
|
||||||
|
return self._read_custom()
|
||||||
|
|
||||||
|
def visible_model_names(self) -> List[str]:
|
||||||
|
"""给前端下拉 / 下载状态用:默认可见内置档位 + 全部自定义名称。"""
|
||||||
|
names = list(DEFAULT_VISIBLE_BUILTINS)
|
||||||
|
for name in self._read_custom():
|
||||||
|
if name not in names:
|
||||||
|
names.append(name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
def is_known(self, name: str) -> bool:
|
||||||
|
try:
|
||||||
|
self.resolve(name)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def resolve(self, name: str) -> str:
|
||||||
|
"""模型名 → 可加载标识(HF repo_id 或本地路径)。
|
||||||
|
|
||||||
|
优先级:自定义映射 > 内置映射 > 直通(含 "/" 的 repo_id / 已存在的本地目录)。
|
||||||
|
无法识别时抛 ValueError。
|
||||||
|
"""
|
||||||
|
name = (name or "").strip()
|
||||||
|
custom = self._read_custom()
|
||||||
|
if name in custom:
|
||||||
|
return custom[name]
|
||||||
|
if name in BUILTIN_WHISPER_MODELS:
|
||||||
|
return BUILTIN_WHISPER_MODELS[name]
|
||||||
|
# 直通:用户直接把 repo_id(含 "/")或本地已存在目录当 model_size 传进来
|
||||||
|
if "/" in name or os.path.isdir(name):
|
||||||
|
return name
|
||||||
|
raise ValueError(
|
||||||
|
f"未知 whisper 模型 '{name}'。内置可选: {', '.join(BUILTIN_WHISPER_MODELS)};"
|
||||||
|
"或在「音频转写配置」添加自定义模型(HF repo_id 或本地路径)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 增删 ----
|
||||||
|
def add_custom_model(self, name: str, target: str) -> Dict[str, str]:
|
||||||
|
name = (name or "").strip()
|
||||||
|
target = (target or "").strip()
|
||||||
|
if not name or not target:
|
||||||
|
raise ValueError("模型名称与目标(HF repo_id 或本地路径)都不能为空")
|
||||||
|
if name in BUILTIN_WHISPER_MODELS:
|
||||||
|
raise ValueError(f"'{name}' 与内置模型重名,请换一个名称")
|
||||||
|
data = self._read_custom()
|
||||||
|
data[name] = target
|
||||||
|
self._write_custom(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def remove_custom_model(self, name: str) -> Dict[str, str]:
|
||||||
|
data = self._read_custom()
|
||||||
|
data.pop((name or "").strip(), None)
|
||||||
|
self._write_custom(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# 模块级单例
|
||||||
|
_registry = WhisperModelRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> WhisperModelRegistry:
|
||||||
|
return _registry
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_whisper_model(name: str) -> str:
|
||||||
|
return _registry.resolve(name)
|
||||||
45
backend/app/utils/openai_client.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""统一构造 OpenAI 兼容客户端:注入全局代理 + 校验 api_key。
|
||||||
|
|
||||||
|
为什么要这一层:
|
||||||
|
- 代理:openai SDK 默认只认进程级 HTTP_PROXY 环境变量,桌面端用户在 UI 里
|
||||||
|
填的代理需要显式塞进 httpx.Client 才生效。
|
||||||
|
- api_key 校验:空 key 会让 httpx 拼出非法 header `Bearer `,抛出
|
||||||
|
`httpx.LocalProtocolError: Illegal header value b'Bearer '` 这种天书报错。
|
||||||
|
在入口挡掉,给用户「xxx 的 API Key 未配置」这种能看懂的提示。
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from app.services.proxy_config_manager import ProxyConfigManager
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_openai_client(
|
||||||
|
api_key: Optional[str],
|
||||||
|
base_url: Optional[str],
|
||||||
|
*,
|
||||||
|
key_label: str = "API Key",
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
) -> OpenAI:
|
||||||
|
"""构造 OpenAI 客户端。api_key 为空直接抛清晰错误;代理已配置则注入。
|
||||||
|
|
||||||
|
key_label 用于错误提示,例如 "Groq 的 API Key" / "OpenAI 供应商的 API Key"。
|
||||||
|
"""
|
||||||
|
if not api_key or not str(api_key).strip():
|
||||||
|
raise ValueError(f"{key_label} 未配置,请先在「设置」里填写后再使用")
|
||||||
|
|
||||||
|
kwargs = {"api_key": str(api_key).strip(), "base_url": base_url}
|
||||||
|
if timeout is not None:
|
||||||
|
kwargs["timeout"] = timeout
|
||||||
|
|
||||||
|
proxy_url = ProxyConfigManager().get_proxy_url()
|
||||||
|
if proxy_url:
|
||||||
|
# 延迟 import httpx:仅在确实要走代理时才需要
|
||||||
|
import httpx
|
||||||
|
kwargs["http_client"] = httpx.Client(proxy=proxy_url, timeout=timeout or 600.0)
|
||||||
|
logger.info(f"OpenAI 客户端走代理: {proxy_url}")
|
||||||
|
|
||||||
|
return OpenAI(**kwargs)
|
||||||
@@ -1,11 +1,41 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
def _load_dotenv_from_multiple_paths():
|
||||||
|
"""尝试多个位置加载 .env,适配源码运行和 PyInstaller 打包场景。
|
||||||
|
|
||||||
|
PyInstaller 打包后当前工作目录是 EXE 所在目录,而源码运行时 .env
|
||||||
|
通常在项目根目录或 backend/ 同级。遍历常见候选路径确保能命中。
|
||||||
|
"""
|
||||||
|
candidates = []
|
||||||
|
# 1. 当前工作目录(EXE 所在目录)
|
||||||
|
candidates.append(os.path.join(os.getcwd(), '.env'))
|
||||||
|
# 2. 本脚本所在目录(backend/)
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
candidates.append(os.path.join(script_dir, '.env'))
|
||||||
|
# 3. 项目根目录(backend/../.env)
|
||||||
|
candidates.append(os.path.join(script_dir, '..', '.env'))
|
||||||
|
# 4. PyInstaller 打包后的 _internal/ 子目录
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
exe_dir = os.path.dirname(sys.executable)
|
||||||
|
candidates.append(os.path.join(exe_dir, '_internal', '.env'))
|
||||||
|
|
||||||
|
for path in candidates:
|
||||||
|
normalized = os.path.normpath(path)
|
||||||
|
if os.path.isfile(normalized):
|
||||||
|
load_dotenv(normalized)
|
||||||
|
return
|
||||||
|
# 都没找到,fallback 到默认行为(从 CWD 找)
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
_load_dotenv_from_multiple_paths()
|
||||||
def check_ffmpeg_exists() -> bool:
|
def check_ffmpeg_exists() -> bool:
|
||||||
"""
|
"""
|
||||||
检查 ffmpeg 是否可用。优先使用 FFMPEG_BIN_PATH 环境变量指定的路径。
|
检查 ffmpeg 是否可用。优先使用 FFMPEG_BIN_PATH 环境变量指定的路径。
|
||||||
|
|||||||
@@ -39,24 +39,50 @@ if not os.path.exists(out_dir):
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
register_handler()
|
# 启动序列拆成 5 步、每步独立日志 + 异常时打明确的 [startup N/5 FAILED] 标记。
|
||||||
init_db()
|
# 目的:用户 docker logs 一眼能看出后端死在哪一步,避免「容器一直重启但看不出原因」。
|
||||||
# 转写器不再在启动时强制初始化,而是在首次生成笔记时按需创建
|
try:
|
||||||
# 如果配置了不可用的类型(如 mlx-whisper 未安装),会在使用时报错而非静默回退
|
logger.info("[startup 1/5] register_handler() — 注册事件处理器")
|
||||||
_cfg = TranscriberConfigManager().get_config()
|
register_handler()
|
||||||
logger.info(f"当前转写器配置: type={_cfg['transcriber_type']}, model_size={_cfg['whisper_model_size']}")
|
|
||||||
seed_default_providers()
|
logger.info("[startup 2/5] init_db() — 初始化 SQLite 数据库")
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
logger.info("[startup 3/5] TranscriberConfigManager — 读取转写器配置")
|
||||||
|
# 转写器不再在启动时强制初始化,而是在首次生成笔记时按需创建。
|
||||||
|
# 如果配置了不可用的类型(如 mlx-whisper 未安装),会在使用时报错而非静默回退。
|
||||||
|
_cfg = TranscriberConfigManager().get_config()
|
||||||
|
logger.info(
|
||||||
|
f" 当前转写器: type={_cfg['transcriber_type']}, "
|
||||||
|
f"model_size={_cfg['whisper_model_size']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("[startup 4/5] seed_default_providers() — 初始化默认 LLM 供应商")
|
||||||
|
seed_default_providers()
|
||||||
|
|
||||||
|
logger.info("[startup 5/5] 启动完成,等待请求")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[startup FAILED] 后端启动期异常,详见堆栈;容器会退出并由 restart 策略决定是否重试")
|
||||||
|
raise
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
app = create_app(lifespan=lifespan)
|
app = create_app(lifespan=lifespan)
|
||||||
|
|
||||||
# 允许的源:本地 web 端 + Tauri 桌面端 + 浏览器扩展(chrome/edge/firefox)
|
# 允许的源:本地 web 端 + Tauri 桌面端 + 浏览器扩展(chrome/edge/firefox)
|
||||||
# 用 regex 是因为 chrome-extension://<id> 的 id 在每次开发版加载时不固定
|
# 用 regex 是因为 chrome-extension://<id> 的 id 在每次开发版加载时不固定
|
||||||
|
# Tauri 2 不同平台 webview origin 不一样,必须全列:
|
||||||
|
# - macOS: tauri://localhost (自定义协议)
|
||||||
|
# - Windows: https://tauri.localhost (Edge WebView2)
|
||||||
|
# - Linux: http://tauri.localhost (WebKitGTK)
|
||||||
|
# 漏掉哪个都会导致桌面端 fetch 返回 200 但 browser 因为 CORS 拒绝读响应,
|
||||||
|
# 表现为前端「连不上后端」但后端日志一片 200 OK。
|
||||||
CORS_ORIGIN_REGEX = (
|
CORS_ORIGIN_REGEX = (
|
||||||
r"^chrome-extension://[a-z]+$"
|
r"^chrome-extension://[a-z]+$"
|
||||||
r"|^moz-extension://.+$"
|
r"|^moz-extension://.+$"
|
||||||
r"|^http://(localhost|127\.0\.0\.1)(:\d+)?$"
|
r"|^http://(localhost|127\.0\.0\.1)(:\d+)?$"
|
||||||
r"|^http://tauri\.localhost$"
|
r"|^tauri://localhost$"
|
||||||
|
r"|^https?://tauri\.localhost$"
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ click-repl==0.3.0
|
|||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
coloredlogs==15.0.1
|
coloredlogs==15.0.1
|
||||||
cssselect2==0.8.0
|
cssselect2==0.8.0
|
||||||
ctranslate2==4.5.0
|
ctranslate2==4.6.0
|
||||||
distro==1.9.0
|
distro==1.9.0
|
||||||
dnspython==2.7.0
|
dnspython==2.7.0
|
||||||
email_validator==2.2.0
|
email_validator==2.2.0
|
||||||
|
|||||||
132
backend/tests/test_whisper_models.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Unit tests for app.transcriber.whisper_models(whisper 模型名→标识 的映射注册表)。
|
||||||
|
|
||||||
|
直接按文件路径加载被测模块,并桩掉 app.utils.logger,避免触发 app/__init__.py
|
||||||
|
(会 import faster_whisper / ctranslate2 等重依赖),使本测试无需安装转写依赖即可运行。
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
MODULE_PATH = ROOT / "app" / "transcriber" / "whisper_models.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module():
|
||||||
|
if "app" not in sys.modules:
|
||||||
|
app_pkg = types.ModuleType("app")
|
||||||
|
app_pkg.__path__ = [] # 标记为 package
|
||||||
|
sys.modules["app"] = app_pkg
|
||||||
|
if "app.utils" not in sys.modules:
|
||||||
|
utils_pkg = types.ModuleType("app.utils")
|
||||||
|
utils_pkg.__path__ = []
|
||||||
|
sys.modules["app.utils"] = utils_pkg
|
||||||
|
if "app.utils.logger" not in sys.modules:
|
||||||
|
logger_mod = types.ModuleType("app.utils.logger")
|
||||||
|
logger_mod.get_logger = lambda name=None: logging.getLogger(name or "test")
|
||||||
|
sys.modules["app.utils.logger"] = logger_mod
|
||||||
|
spec = importlib.util.spec_from_file_location("whisper_models_under_test", MODULE_PATH)
|
||||||
|
assert spec and spec.loader
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
wm = _load_module()
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolve(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.cfg = os.path.join(self.tmp.name, "whisper_models.json")
|
||||||
|
self.reg = wm.WhisperModelRegistry(filepath=self.cfg)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_builtin_resolves_to_systran(self):
|
||||||
|
self.assertEqual(self.reg.resolve("tiny"), "Systran/faster-whisper-tiny")
|
||||||
|
self.assertEqual(self.reg.resolve("large-v3-turbo"), "Systran/faster-whisper-large-v3-turbo")
|
||||||
|
|
||||||
|
def test_passthrough_repo_id(self):
|
||||||
|
# 用户直接把 HF repo_id 当 model_size 传进来(含 "/")
|
||||||
|
self.assertEqual(self.reg.resolve("SomeOrg/my-whisper-ct2"), "SomeOrg/my-whisper-ct2")
|
||||||
|
|
||||||
|
def test_unknown_raises(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.reg.resolve("definitely-not-a-model")
|
||||||
|
|
||||||
|
def test_custom_overrides_and_persists(self):
|
||||||
|
self.reg.add_custom_model("myhf", "someorg/whisper-ct2")
|
||||||
|
self.assertEqual(self.reg.resolve("myhf"), "someorg/whisper-ct2")
|
||||||
|
# 新实例读同一文件 → 确认持久化(Docker 下随 config 卷保留)
|
||||||
|
reg2 = wm.WhisperModelRegistry(filepath=self.cfg)
|
||||||
|
self.assertEqual(reg2.resolve("myhf"), "someorg/whisper-ct2")
|
||||||
|
|
||||||
|
def test_custom_can_override_builtin_key_resolution(self):
|
||||||
|
# 自定义优先级高于内置:把 "tiny" 强行指到别的 repo(resolve 层允许;add 层禁止重名)
|
||||||
|
self.reg._write_custom({"tiny": "Other/tiny-ct2"})
|
||||||
|
self.assertEqual(self.reg.resolve("tiny"), "Other/tiny-ct2")
|
||||||
|
|
||||||
|
def test_local_path_resolution_and_detection(self):
|
||||||
|
model_dir = os.path.join(self.tmp.name, "mymodel")
|
||||||
|
os.makedirs(model_dir)
|
||||||
|
self.reg.add_custom_model("local1", model_dir)
|
||||||
|
self.assertEqual(self.reg.resolve("local1"), model_dir)
|
||||||
|
self.assertTrue(wm.is_local_target(self.reg.resolve("local1")))
|
||||||
|
|
||||||
|
def test_bare_existing_dir_passthrough(self):
|
||||||
|
# 没登记、但直接传一个已存在目录 → 直通为本地路径
|
||||||
|
model_dir = os.path.join(self.tmp.name, "bare")
|
||||||
|
os.makedirs(model_dir)
|
||||||
|
self.assertEqual(self.reg.resolve(model_dir), model_dir)
|
||||||
|
|
||||||
|
def test_add_rejects_builtin_collision_and_empty(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.reg.add_custom_model("tiny", "x/y") # 与内置重名
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.reg.add_custom_model("", "x/y")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.reg.add_custom_model("ok", "")
|
||||||
|
|
||||||
|
def test_remove(self):
|
||||||
|
self.reg.add_custom_model("tmpm", "a/b")
|
||||||
|
self.assertIn("tmpm", self.reg.get_custom_models())
|
||||||
|
self.reg.remove_custom_model("tmpm")
|
||||||
|
self.assertNotIn("tmpm", self.reg.get_custom_models())
|
||||||
|
|
||||||
|
def test_visible_includes_builtin_and_custom(self):
|
||||||
|
self.reg.add_custom_model("zzz", "a/b")
|
||||||
|
names = self.reg.visible_model_names()
|
||||||
|
self.assertIn("tiny", names)
|
||||||
|
self.assertIn("large-v3", names)
|
||||||
|
self.assertIn("zzz", names)
|
||||||
|
|
||||||
|
def test_is_known(self):
|
||||||
|
self.assertTrue(self.reg.is_known("base"))
|
||||||
|
self.assertTrue(self.reg.is_known("Org/Name"))
|
||||||
|
self.assertFalse(self.reg.is_known("nope-not-real"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpers(unittest.TestCase):
|
||||||
|
def test_hf_cache_dirname(self):
|
||||||
|
self.assertEqual(
|
||||||
|
wm.hf_cache_dirname("Systran/faster-whisper-tiny"),
|
||||||
|
"models--Systran--faster-whisper-tiny",
|
||||||
|
)
|
||||||
|
self.assertEqual(wm.hf_cache_dirname("Org/Name"), "models--Org--Name")
|
||||||
|
|
||||||
|
def test_is_local_target(self):
|
||||||
|
self.assertTrue(wm.is_local_target("/abs/path"))
|
||||||
|
self.assertTrue(wm.is_local_target("./rel"))
|
||||||
|
self.assertTrue(wm.is_local_target("~/home/model"))
|
||||||
|
self.assertFalse(wm.is_local_target("Org/Name")) # repo_id 不是本地路径
|
||||||
|
self.assertFalse(wm.is_local_target(""))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
BIN
doc/remote-install-wechat.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
doc/wechat-group-1.png
Normal file
|
After Width: | Height: | Size: 560 KiB |
BIN
doc/wechat-group-2.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
doc/wechat-group-3.png
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
doc/wechat-group-4.png
Normal file
|
After Width: | Height: | Size: 565 KiB |
BIN
doc/wechat-group-5.png
Normal file
|
After Width: | Height: | Size: 491 KiB |
BIN
doc/wechat-gzh.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -6,6 +6,8 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile.gpu
|
dockerfile: backend/Dockerfile.gpu
|
||||||
args:
|
args:
|
||||||
|
# 国内拉不到 docker.io 时设置 BASE_REGISTRY;注意所选镜像需要支持 nvidia/cuda 命名空间
|
||||||
|
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
|
||||||
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
||||||
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
||||||
env_file:
|
env_file:
|
||||||
@@ -14,9 +16,20 @@ services:
|
|||||||
- BACKEND_PORT=${BACKEND_PORT}
|
- BACKEND_PORT=${BACKEND_PORT}
|
||||||
- BACKEND_HOST=${BACKEND_HOST}
|
- BACKEND_HOST=${BACKEND_HOST}
|
||||||
volumes:
|
volumes:
|
||||||
|
# 同 docker-compose.yml:./backend 绑到 /app,DB / 转写器配置 / 截图 / 上传都持久化
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
expose:
|
expose:
|
||||||
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
|
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
|
||||||
|
# 用 unless-stopped 避免短暂崩溃把容器永久打死后再也读不到 .env 修改
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_PORT}/api/sys_health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s # GPU 镜像首次加载 CUDA 比 CPU 慢,给久一点
|
||||||
|
# GPU 部署默认跑较大模型,把内存限制提到 8g 避免 host OOM
|
||||||
|
mem_limit: 8g
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
reservations:
|
reservations:
|
||||||
@@ -30,10 +43,14 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: BillNote_frontend/Dockerfile
|
dockerfile: BillNote_frontend/Dockerfile
|
||||||
|
args:
|
||||||
|
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
expose:
|
expose:
|
||||||
- "80" # 不暴露给宿主机,只供 nginx 访问
|
- "80" # 不暴露给宿主机,只供 nginx 访问
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 512m
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
container_name: bilinote-nginx
|
container_name: bilinote-nginx
|
||||||
@@ -43,5 +60,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
- frontend
|
condition: service_healthy
|
||||||
|
frontend:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 256m
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
args:
|
args:
|
||||||
|
# 国内拉不到 docker.io 时设置 BASE_REGISTRY=docker.m.daocloud.io(或其他可用镜像)
|
||||||
|
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
|
||||||
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
||||||
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
||||||
env_file:
|
env_file:
|
||||||
@@ -14,16 +16,25 @@ services:
|
|||||||
- BACKEND_PORT=${BACKEND_PORT}
|
- BACKEND_PORT=${BACKEND_PORT}
|
||||||
- BACKEND_HOST=${BACKEND_HOST}
|
- BACKEND_HOST=${BACKEND_HOST}
|
||||||
volumes:
|
volumes:
|
||||||
|
# 把整个 backend/ 目录绑到 /app,意味着这些都持久化到宿主机、删容器不丢:
|
||||||
|
# ./backend/bili_note.db — SQLite 数据库(含 LLM 供应商配置、笔记历史)
|
||||||
|
# ./backend/config/transcriber.json — 转写器运行时配置
|
||||||
|
# ./backend/static/screenshots/ — 视频截图
|
||||||
|
# ./backend/uploads/ — 上传的本地视频
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
expose:
|
expose:
|
||||||
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
|
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
|
||||||
restart: on-failure:3
|
# 用 unless-stopped 而非 on-failure:N,避免任何短暂崩溃把容器永久打死后
|
||||||
|
# 再也接收不到用户修过的 .env。手动 docker-compose stop 仍可正常停下。
|
||||||
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_PORT}/api/sys_health"]
|
test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_PORT}/api/sys_health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
|
# WHISPER_MODEL_SIZE 选 medium 及以上请把这里调到 8g+,
|
||||||
|
# 否则首次模型加载时容易被 host OOM-killer 干掉。
|
||||||
mem_limit: 4g
|
mem_limit: 4g
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
@@ -31,11 +42,13 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: BillNote_frontend/Dockerfile
|
dockerfile: BillNote_frontend/Dockerfile
|
||||||
|
args:
|
||||||
|
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
expose:
|
expose:
|
||||||
- "80" # 不暴露给宿主机,只供 nginx 访问
|
- "80" # 不暴露给宿主机,只供 nginx 访问
|
||||||
restart: on-failure:3
|
restart: unless-stopped
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -50,5 +63,5 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
frontend:
|
frontend:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
restart: on-failure:3
|
restart: unless-stopped
|
||||||
mem_limit: 256m
|
mem_limit: 256m
|
||||||
|
|||||||