Compare commits
54 Commits
v2.2.3
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd8b4dc44e | ||
|
|
e5a7cf7151 | ||
|
|
f5bfb43619 | ||
|
|
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 | ||
|
|
e9d4740cc7 | ||
|
|
ec33ae35ed | ||
|
|
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 # 后端端口
|
||||
FRONTEND_PORT=3015
|
||||
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
|
||||
APP_PORT= 3015 # docker 部署时用
|
||||
# 前端访问后端用 (开发环境使用)
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
APP_PORT=3015 # docker 部署时对外暴露端口(浏览器访问的端口)
|
||||
|
||||
# 前端访问后端用(开发环境直连;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_FRONTEND_PORT=3015
|
||||
|
||||
# 生产环境配置
|
||||
ENV=production
|
||||
STATIC=/static
|
||||
@@ -14,11 +30,16 @@ OUT_DIR=./static/screenshots
|
||||
NOTE_OUTPUT_DIR=note_results
|
||||
IMAGE_BASE_URL=/static/screenshots
|
||||
DATA_DIR=data
|
||||
# FFMPEG 配置
|
||||
|
||||
# FFMPEG 配置(Docker 镜像已内置 ffmpeg,留空即可;自建/桌面端可填绝对路径)
|
||||
FFMPEG_BIN_PATH=
|
||||
|
||||
# transcriber 相关配置
|
||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
|
||||
WHISPER_MODEL_SIZE=medium
|
||||
# 转写器配置
|
||||
# TRANSCRIBER_TYPE 可选:fast-whisper / bcut / kuaishou / mlx-whisper(仅 Apple Silicon) / groq
|
||||
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
|
||||
|
||||
15
.github/workflows/docker-build.yml
vendored
@@ -42,6 +42,16 @@ jobs:
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Resolve app version
|
||||
id: app-version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "version=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -50,6 +60,8 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VITE_APP_VERSION=${{ steps.app-version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -66,6 +78,9 @@ jobs:
|
||||
echo "Run the container:"
|
||||
echo " docker run -d -p 80:80 \\"
|
||||
echo " -v bilinote-data:/app/backend/data \\"
|
||||
echo " -v bilinote-config:/app/backend/config \\"
|
||||
echo " -v bilinote-static:/app/backend/static \\"
|
||||
echo " -v bilinote-models:/app/backend/models \\"
|
||||
echo " --name bilinote \\"
|
||||
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||
echo ""
|
||||
|
||||
13
.github/workflows/main.yml
vendored
@@ -79,6 +79,19 @@ jobs:
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
# 从 tag 注入版本号到 tauri.conf.json:Tauri 取该文件的静态 version 作为
|
||||
# 产物版本,不同步的话构建产物会恒为 conf 里写死的值(此前的 2.0.0)。
|
||||
# github.ref_name 形如 v2.3.2,去掉前缀 v。workflow_dispatch(无 tag)时跳过,保留静态值。
|
||||
- name: Sync version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
working-directory: BillNote_frontend
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "Injecting version $VERSION into tauri.conf.json"
|
||||
node -e "const f='src-tauri/tauri.conf.json'; const fs=require('fs'); const j=JSON.parse(fs.readFileSync(f,'utf8')); j.version=process.argv[1]; fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n');" "$VERSION"
|
||||
node -e "console.log('tauri.conf.json version =', require('./src-tauri/tauri.conf.json').version)"
|
||||
|
||||
# 打包 Tauri 应用
|
||||
- name: Build Tauri App
|
||||
working-directory: BillNote_frontend
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Settings, TaskRecord } from '~/logic/types'
|
||||
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
|
||||
import { detectPlatform } from '~/logic/platform'
|
||||
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||
import { normalizeVideoTitle } from '~/logic/task-display'
|
||||
|
||||
// only on dev mode
|
||||
if (import.meta.hot) {
|
||||
@@ -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 displayTitle = normalizeVideoTitle(title)
|
||||
if (!platform)
|
||||
return { ok: false, error: '当前链接不是支持的视频平台' }
|
||||
|
||||
@@ -107,6 +109,7 @@ async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, e
|
||||
message: '已提交',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
title: displayTitle,
|
||||
})
|
||||
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 }) => {
|
||||
const result = await startTask(data.url)
|
||||
onMessage<{ url: string; title?: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
|
||||
const result = await startTask(data.url, data.title)
|
||||
// 成功就把侧边栏拉起来给用户看进度
|
||||
if (result.ok)
|
||||
await openSidePanelInTab(sender?.tabId)
|
||||
@@ -168,7 +171,7 @@ browser.contextMenus?.onClicked.addListener(async (info, tab) => {
|
||||
const url = info.linkUrl || tab?.url
|
||||
if (!url)
|
||||
return
|
||||
const result = await startTask(url)
|
||||
const result = await startTask(url, tab?.title)
|
||||
if (result.ok)
|
||||
await openSidePanelInTab(tab?.id)
|
||||
else
|
||||
|
||||
@@ -1,32 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Transformer } from 'markmap-lib'
|
||||
import { Markmap } from 'markmap-view'
|
||||
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
|
||||
|
||||
const props = defineProps<{ markdown: string }>()
|
||||
|
||||
const wrapRef = ref<HTMLDivElement | null>(null)
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
let mm: Markmap | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
const transformer = new Transformer()
|
||||
const MIN_EXPORT_FONT_PX = 256
|
||||
const MIN_EXPORT_WIDTH = 12800
|
||||
const MAX_EXPORT_SCALE = 24
|
||||
const MAX_CANVAS_SIDE = 32767
|
||||
|
||||
function render() {
|
||||
if (!svgRef.value)
|
||||
return
|
||||
const md = absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))
|
||||
const { root } = transformer.transform(md)
|
||||
if (!mm)
|
||||
mm = Markmap.create(svgRef.value, undefined, root)
|
||||
else
|
||||
mm.setData(root).then(() => mm?.fit())
|
||||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob)
|
||||
resolve(blob)
|
||||
else
|
||||
reject(new Error('导出思维导图图片失败'))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full bg-white rounded border overflow-hidden">
|
||||
<svg ref="svgRef" class="w-full h-full" />
|
||||
<div ref="wrapRef" class="w-full h-full min-h-[360px] bg-white rounded border overflow-hidden">
|
||||
<svg ref="svgRef" class="w-full h-full min-h-[360px]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +19,7 @@ async function trigger() {
|
||||
const res = await sendMessage('bilinote-start', {
|
||||
url: window.location.href,
|
||||
platform,
|
||||
title: document.title,
|
||||
}, 'background')
|
||||
const ok = res && (res as any).ok
|
||||
toast.value = ok
|
||||
|
||||
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
|
||||
updatedAt: number
|
||||
result?: NoteResult
|
||||
// 从浏览器 tab.title 抓取,任务完成前用来替代 videoUrl 显示
|
||||
title?: string
|
||||
}
|
||||
|
||||
// 与 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 { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
|
||||
import { NOTE_FORMATS, NOTE_STYLES, type NoteFormat, type TaskRecord } from '~/logic/types'
|
||||
import { getTaskDisplayTitle, normalizeVideoTitle } from '~/logic/task-display'
|
||||
|
||||
const tabUrl = ref<string>('')
|
||||
const tabTitle = ref<string>('')
|
||||
@@ -43,6 +44,7 @@ async function poll(taskId: string) {
|
||||
createdAt: activeTask.value?.createdAt ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
result: res.result ?? activeTask.value?.result,
|
||||
title: activeTask.value?.title || normalizeVideoTitle(tabTitle.value),
|
||||
})
|
||||
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||
pollTimer = setTimeout(() => poll(taskId), 3000)
|
||||
@@ -94,6 +96,7 @@ async function start() {
|
||||
message: '已提交',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
title: normalizeVideoTitle(tabTitle.value),
|
||||
})
|
||||
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 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) {
|
||||
if (!ts)
|
||||
@@ -177,8 +180,8 @@ onUnmounted(() => {
|
||||
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
|
||||
</header>
|
||||
|
||||
<div class="text-xs text-gray-500 truncate" :title="tabUrl">
|
||||
{{ tabUrl || '当前没有打开的标签页' }}
|
||||
<div class="text-xs text-gray-500 truncate" :title="normalizeVideoTitle(tabTitle) || tabUrl">
|
||||
{{ normalizeVideoTitle(tabTitle) || tabUrl || '当前没有打开的标签页' }}
|
||||
</div>
|
||||
|
||||
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
|
||||
@@ -331,8 +334,8 @@ onUnmounted(() => {
|
||||
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
|
||||
@click="selectTask(t.taskId)"
|
||||
>
|
||||
<span class="truncate flex-1" :title="t.videoUrl">
|
||||
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
|
||||
<span class="truncate flex-1" :title="getTaskDisplayTitle(t)">
|
||||
{{ getTaskDisplayTitle(t) }}
|
||||
</span>
|
||||
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
|
||||
</li>
|
||||
|
||||
@@ -3,14 +3,17 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { getTaskStatus, resolveImageUrl } from '~/logic/api'
|
||||
import { tasks, tasksReady, settingsReady, upsertTask } from '~/logic/storage'
|
||||
import type { TaskRecord } from '~/logic/types'
|
||||
import { getTaskDisplayTitle } from '~/logic/task-display'
|
||||
|
||||
type ViewMode = 'markdown' | 'mindmap' | 'chat'
|
||||
|
||||
const activeTaskId = ref<string>('')
|
||||
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
const viewMode = ref<ViewMode>('markdown')
|
||||
const showHistory = ref(false)
|
||||
const mindMapRef = ref<{ toPngBlob: () => Promise<Blob> } | null>(null)
|
||||
|
||||
const isDone = computed(() => activeTask.value?.status === 'SUCCESS')
|
||||
const isFailed = computed(() => activeTask.value?.status === 'FAILED')
|
||||
@@ -41,6 +44,7 @@ async function poll(taskId: string) {
|
||||
message: res.message,
|
||||
result: res.result ?? cur.result,
|
||||
updatedAt: Date.now(),
|
||||
title: cur.title || getTaskDisplayTitle(cur),
|
||||
})
|
||||
}
|
||||
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||
@@ -74,11 +78,19 @@ async function copyMarkdown() {
|
||||
await navigator.clipboard.writeText(md)
|
||||
}
|
||||
|
||||
function safeFilename(name: string): string {
|
||||
return (name || 'bilinote')
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120) || 'bilinote'
|
||||
}
|
||||
|
||||
function downloadMarkdown() {
|
||||
const md = activeTask.value?.result?.markdown
|
||||
if (!md)
|
||||
return
|
||||
const title = (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 url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
@@ -88,8 +100,44 @@ function downloadMarkdown() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const activeTitle = computed(() =>
|
||||
(activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || activeTask.value?.videoUrl || '')
|
||||
async function copyMindMapImage() {
|
||||
try {
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
const blob = await mindMapRef.value?.toPngBlob()
|
||||
if (!blob)
|
||||
return
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob }),
|
||||
])
|
||||
successMsg.value = '思维导图图片已复制'
|
||||
setTimeout(() => { successMsg.value = '' }, 2000)
|
||||
}
|
||||
catch (e) {
|
||||
errorMsg.value = (e as Error).message || '复制思维导图图片失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadMindMapImage() {
|
||||
try {
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
const blob = await mindMapRef.value?.toPngBlob()
|
||||
if (!blob)
|
||||
return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${safeFilename(getTaskDisplayTitle(activeTask.value))}.png`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch (e) {
|
||||
errorMsg.value = (e as Error).message || '下载思维导图图片失败'
|
||||
}
|
||||
}
|
||||
|
||||
const activeTitle = computed(() => getTaskDisplayTitle(activeTask.value))
|
||||
|
||||
const activeCover = computed(() =>
|
||||
(activeTask.value?.result?.audio_meta as { cover_url?: string } | undefined)?.cover_url)
|
||||
@@ -140,8 +188,8 @@ onUnmounted(() => {
|
||||
:class="{ 'bg-white border': t.taskId === activeTaskId }"
|
||||
@click="selectTask(t.taskId)"
|
||||
>
|
||||
<span class="truncate flex-1" :title="t.videoUrl">
|
||||
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
|
||||
<span class="truncate flex-1" :title="getTaskDisplayTitle(t)">
|
||||
{{ getTaskDisplayTitle(t) }}
|
||||
</span>
|
||||
<span class="text-gray-400 shrink-0">{{ STAGE_LABELS[t.status] || t.status }}</span>
|
||||
</li>
|
||||
@@ -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">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
<div v-if="successMsg" class="text-xs text-green-700 px-3 py-1 break-words bg-green-50 shrink-0">
|
||||
{{ successMsg }}
|
||||
</div>
|
||||
|
||||
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
|
||||
还没有任务。在视频页点悬浮按钮、在 popup 提交,或右键菜单选「用 BiliNote 总结」。
|
||||
@@ -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"
|
||||
:href="activeTask.videoUrl"
|
||||
target="_blank"
|
||||
:title="activeTask.videoUrl"
|
||||
:title="activeTitle || activeTask.videoUrl"
|
||||
>{{ activeTitle }}</a>
|
||||
<span
|
||||
v-if="isDone"
|
||||
@@ -224,6 +275,18 @@ onUnmounted(() => {
|
||||
title="下载 .md"
|
||||
@click="downloadMarkdown"
|
||||
>下载</button>
|
||||
<button
|
||||
v-if="viewMode === 'mindmap'"
|
||||
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
|
||||
title="复制思维导图图片"
|
||||
@click="copyMindMapImage"
|
||||
>复制</button>
|
||||
<button
|
||||
v-if="viewMode === 'mindmap'"
|
||||
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
|
||||
title="下载思维导图 PNG"
|
||||
@click="downloadMindMapImage"
|
||||
>下载</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区:占满剩余空间 -->
|
||||
@@ -236,6 +299,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<MindMap
|
||||
v-else-if="isDone && activeTask.result?.markdown && viewMode === 'mindmap'"
|
||||
ref="mindMapRef"
|
||||
:markdown="activeTask.result.markdown"
|
||||
class="h-full"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# === 前端构建阶段 ===
|
||||
# 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
|
||||
|
||||
# 可由发布 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
|
||||
@@ -16,7 +23,9 @@ COPY ./BillNote_frontend/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
# --- 阶段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
|
||||
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
"@radix-ui/react-tabs": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@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",
|
||||
"antd": "^5.24.8",
|
||||
"axios": "^1.8.4",
|
||||
@@ -57,6 +57,7 @@
|
||||
"react-router-dom": "^7.5.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"rehype-slug": "5.1.0",
|
||||
"remark-gfm": "3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"sonner": "^2.0.3",
|
||||
|
||||
190
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)
|
||||
'@hookform/resolvers':
|
||||
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':
|
||||
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)
|
||||
@@ -52,16 +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)
|
||||
'@tailwindcss/vite':
|
||||
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.10.1
|
||||
version: 2.10.1
|
||||
specifier: ^2.11.0
|
||||
version: 2.11.0
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: ~2.2.2
|
||||
version: 2.2.2
|
||||
specifier: ~2.3.5
|
||||
version: 2.3.5
|
||||
'@uiw/react-markdown-preview':
|
||||
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:
|
||||
specifier: ^5.24.8
|
||||
version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -76,13 +76,13 @@ importers:
|
||||
version: 2.1.1
|
||||
fuse.js:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
version: 7.3.0
|
||||
github-markdown-css:
|
||||
specifier: ^5.8.1
|
||||
version: 5.9.0
|
||||
idb-keyval:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
version: 6.2.4
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
@@ -149,6 +149,9 @@ importers:
|
||||
rehype-katex:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
rehype-slug:
|
||||
specifier: 5.1.0
|
||||
version: 5.1.0
|
||||
remark-gfm:
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1
|
||||
@@ -169,7 +172,7 @@ importers:
|
||||
version: 1.4.0
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
version: 11.1.1
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.25.76
|
||||
@@ -197,7 +200,7 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-react':
|
||||
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:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.27(postcss@8.5.8)
|
||||
@@ -227,7 +230,7 @@ importers:
|
||||
version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.7.3)
|
||||
vite:
|
||||
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:
|
||||
|
||||
@@ -732,8 +735,8 @@ packages:
|
||||
react: ^16 || ^17 || ^18 || ^19
|
||||
react-dom: ^16 || ^17 || ^18 || ^19
|
||||
|
||||
'@hookform/resolvers@5.2.2':
|
||||
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
|
||||
'@hookform/resolvers@5.4.0':
|
||||
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||
peerDependencies:
|
||||
react-hook-form: ^7.55.0
|
||||
|
||||
@@ -1590,8 +1593,8 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@tauri-apps/api@2.10.1':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
'@tauri-apps/api@2.11.0':
|
||||
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
||||
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
|
||||
@@ -1669,8 +1672,8 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-shell@2.2.2':
|
||||
resolution: {integrity: sha512-fg9XKWfzRQsN8p+Zrk82WeHvXFvGVnG0/mTlujQdLWNnO5cM6WD9qCrHbFytScVS+WhmRAkuypQPcxeKKl3VBg==}
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
@@ -1786,6 +1789,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
@@ -1910,8 +1916,8 @@ packages:
|
||||
'@uiw/copy-to-clipboard@1.0.20':
|
||||
resolution: {integrity: sha512-IFQhS62CLNon1YgYJTEzXR2N3WVXg7V1FaBRDLMlzU6JY5X6Hr3OPAcw4WNoKcz2XcFD6XCgwEjlsmj+JA0mWA==}
|
||||
|
||||
'@uiw/react-markdown-preview@5.1.5':
|
||||
resolution: {integrity: sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg==}
|
||||
'@uiw/react-markdown-preview@5.2.1':
|
||||
resolution: {integrity: sha512-JjvcHveT6glhlJYJx1XGBZij6wkw+VwREV6Z6m/GpsjPPdLjF1x8nlPBSB/ATyUF4lD7C8ttMkCqVH9N9XMgEA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
@@ -1919,6 +1925,9 @@ packages:
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@ungap/structured-clone@1.3.1':
|
||||
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||
|
||||
'@upsetjs/venn.js@2.0.0':
|
||||
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
|
||||
|
||||
@@ -2689,8 +2698,8 @@ packages:
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
fuse.js@7.1.0:
|
||||
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
|
||||
fuse.js@7.3.0:
|
||||
resolution: {integrity: sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
@@ -2794,9 +2803,15 @@ packages:
|
||||
hast-util-from-parse5@8.0.3:
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
|
||||
|
||||
@@ -2833,6 +2848,9 @@ packages:
|
||||
hast-util-to-parse5@8.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
|
||||
|
||||
@@ -2883,8 +2901,8 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb-keyval@6.2.2:
|
||||
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||
idb-keyval@6.2.4:
|
||||
resolution: {integrity: sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
@@ -4228,12 +4246,6 @@ packages:
|
||||
'@types/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:
|
||||
resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==}
|
||||
peerDependencies:
|
||||
@@ -4372,8 +4384,8 @@ packages:
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
rehype-attr@3.0.3:
|
||||
resolution: {integrity: sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw==}
|
||||
rehype-attr@4.0.0:
|
||||
resolution: {integrity: sha512-tANn9EmhG4mEZlNdDDRKuS0OXPDvc6P6OjJ1yApzOjIdCvKNLiuU2HdMSLTpiVi3D/FyLK6B+ZZ8PYtRxiGg7Q==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
rehype-autolink-headings@7.1.0:
|
||||
@@ -4408,6 +4420,9 @@ packages:
|
||||
resolution: {integrity: sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
rehype-slug@5.1.0:
|
||||
resolution: {integrity: sha512-Gf91dJoXneiorNEnn+Phx97CO7oRMrpi+6r155tTxzGuLtm+QrI4cTwCa9e1rtePdL4i9tSO58PeSS6HWfgsiw==}
|
||||
|
||||
rehype-slug@6.0.0:
|
||||
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
|
||||
|
||||
@@ -4822,12 +4837,12 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
uuid@11.1.1:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@13.0.0:
|
||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
||||
uuid@13.0.2:
|
||||
resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==}
|
||||
hasBin: true
|
||||
|
||||
uvu@0.5.6:
|
||||
@@ -4856,8 +4871,8 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@6.4.1:
|
||||
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
|
||||
vite@6.4.2:
|
||||
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -5534,7 +5549,7 @@ snapshots:
|
||||
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:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.72.0(react@19.2.4)
|
||||
@@ -5725,7 +5740,7 @@ snapshots:
|
||||
unified: 11.0.5
|
||||
url-join: 5.0.0
|
||||
use-merge-value: 1.2.0(react@19.2.4)
|
||||
uuid: 13.0.0
|
||||
uuid: 13.0.2
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@types/mdast'
|
||||
@@ -6456,14 +6471,14 @@ snapshots:
|
||||
postcss: 8.5.8
|
||||
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:
|
||||
'@tailwindcss/node': 4.2.2
|
||||
'@tailwindcss/oxide': 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':
|
||||
optional: true
|
||||
@@ -6512,9 +6527,9 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-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:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
@@ -6664,6 +6679,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/hast@2.3.10':
|
||||
@@ -6811,14 +6828,14 @@ snapshots:
|
||||
|
||||
'@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:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@uiw/copy-to-clipboard': 1.0.20
|
||||
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)
|
||||
rehype-attr: 3.0.3
|
||||
react-markdown: 10.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||
rehype-attr: 4.0.0
|
||||
rehype-autolink-headings: 7.1.0
|
||||
rehype-ignore: 2.0.3
|
||||
rehype-prism-plus: 2.0.0
|
||||
@@ -6834,6 +6851,8 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
|
||||
'@upsetjs/venn.js@2.0.0':
|
||||
optionalDependencies:
|
||||
d3-selection: 3.0.0
|
||||
@@ -6846,7 +6865,7 @@ snapshots:
|
||||
'@use-gesture/core': 10.3.1
|
||||
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:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||
@@ -6854,7 +6873,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||
'@types/babel__core': 7.20.5
|
||||
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:
|
||||
- supports-color
|
||||
|
||||
@@ -7630,7 +7649,7 @@ snapshots:
|
||||
|
||||
estree-util-attach-comments@3.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
estree-util-build-jsx@3.0.1:
|
||||
dependencies:
|
||||
@@ -7742,7 +7761,7 @@ snapshots:
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
fuse.js@7.1.0: {}
|
||||
fuse.js@7.3.0: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
@@ -7871,10 +7890,16 @@ snapshots:
|
||||
vfile-location: 5.0.3
|
||||
web-namespaces: 2.0.1
|
||||
|
||||
hast-util-has-property@2.0.1: {}
|
||||
|
||||
hast-util-has-property@3.0.0:
|
||||
dependencies:
|
||||
'@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:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -7934,7 +7959,7 @@ snapshots:
|
||||
|
||||
hast-util-to-estree@3.1.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
comma-separated-tokens: 2.0.3
|
||||
@@ -7969,7 +7994,7 @@ snapshots:
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
comma-separated-tokens: 2.0.3
|
||||
@@ -7997,6 +8022,10 @@ snapshots:
|
||||
web-namespaces: 2.0.1
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-string@2.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 2.3.10
|
||||
|
||||
hast-util-to-string@3.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -8070,7 +8099,7 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb-keyval@6.2.2: {}
|
||||
idb-keyval@6.2.4: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
@@ -8690,7 +8719,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
'@ungap/structured-clone': 1.3.0
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
devlop: 1.1.0
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
trim-lines: 3.0.1
|
||||
@@ -8760,7 +8789,7 @@ snapshots:
|
||||
roughjs: 4.6.6
|
||||
stylis: 4.3.6
|
||||
ts-dedent: 2.2.0
|
||||
uuid: 11.1.0
|
||||
uuid: 11.1.1
|
||||
|
||||
micromark-core-commonmark@1.1.0:
|
||||
dependencies:
|
||||
@@ -8957,7 +8986,7 @@ snapshots:
|
||||
|
||||
micromark-extension-mdx-expression@3.0.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
devlop: 1.1.0
|
||||
micromark-factory-mdx-expression: 2.0.3
|
||||
micromark-factory-space: 2.0.1
|
||||
@@ -8968,7 +8997,7 @@ snapshots:
|
||||
|
||||
micromark-extension-mdx-jsx@3.0.2:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
devlop: 1.1.0
|
||||
estree-util-is-identifier-name: 3.0.0
|
||||
micromark-factory-mdx-expression: 2.0.3
|
||||
@@ -8985,7 +9014,7 @@ snapshots:
|
||||
|
||||
micromark-extension-mdxjs-esm@3.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
micromark-util-character: 2.1.1
|
||||
@@ -9034,7 +9063,7 @@ snapshots:
|
||||
|
||||
micromark-factory-mdx-expression@2.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
devlop: 1.1.0
|
||||
micromark-factory-space: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
@@ -9150,7 +9179,7 @@ snapshots:
|
||||
|
||||
micromark-util-events-to-acorn@2.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
'@types/unist': 3.0.3
|
||||
devlop: 1.1.0
|
||||
estree-util-visit: 2.0.0
|
||||
@@ -9936,23 +9965,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -10078,7 +10090,7 @@ snapshots:
|
||||
|
||||
recma-parse@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
esast-util-from-js: 2.0.1
|
||||
unified: 11.0.5
|
||||
vfile: 6.0.3
|
||||
@@ -10120,7 +10132,7 @@ snapshots:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
rehype-attr@3.0.3:
|
||||
rehype-attr@4.0.0:
|
||||
dependencies:
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.0.0
|
||||
@@ -10201,6 +10213,16 @@ snapshots:
|
||||
unified: 11.0.5
|
||||
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:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -10696,9 +10718,9 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
@@ -10741,7 +10763,7 @@ snapshots:
|
||||
'@types/unist': 3.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:
|
||||
esbuild: 0.25.12
|
||||
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"]
|
||||
|
||||
[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]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.5.0", features = ["devtools"] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
[package.metadata.tauri.bundle.macOS]
|
||||
|
||||
@@ -3,10 +3,19 @@ use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use std::env;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
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 时杀旧进程
|
||||
struct SidecarHandle(Mutex<Option<CommandChild>>);
|
||||
|
||||
@@ -50,6 +59,10 @@ pub fn run() {
|
||||
})?;
|
||||
app.manage(SidecarHandle(Mutex::new(Some(child))));
|
||||
|
||||
// 启动 ready probe:异步轮询本地 BACKEND_PORT 是否在监听,
|
||||
// 解决前端 useCheckBackend 在 PyInstaller 解压期瞎猜后端起没起的问题。
|
||||
spawn_backend_ready_probe(app.handle().clone());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -60,8 +73,33 @@ pub fn run() {
|
||||
get_install_path_diagnostics,
|
||||
restart_backend_sidecar
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.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>,
|
||||
app: tauri::AppHandle,
|
||||
) -> 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 失败也继续,可能进程已经退了)
|
||||
{
|
||||
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") {
|
||||
let _ = window.emit("backend-restarted", ());
|
||||
}
|
||||
// 4. 重启后同样起一次 ready probe,让前端能及时退出失败态
|
||||
spawn_backend_ready_probe(app);
|
||||
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/* 经常炸;
|
||||
// 父目录不可写时模型 / 配置 / 日志也无法落盘
|
||||
#[derive(Serialize, Clone)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "BiliNote",
|
||||
"version": "2.0.0",
|
||||
"version": "2.4.0",
|
||||
"identifier": "com.jefferyhuang.bilinote",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import './App.css'
|
||||
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 { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||
import { systemCheck } from '@/services/system.ts'
|
||||
@@ -33,7 +33,7 @@ const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
|
||||
function App() {
|
||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||
const { loading, initialized } = useCheckBackend()
|
||||
const { loading, initialized, failed, lastError, retry } = useCheckBackend()
|
||||
|
||||
// 在后端初始化完成后执行系统检查
|
||||
useEffect(() => {
|
||||
@@ -42,22 +42,31 @@ function App() {
|
||||
}
|
||||
}, [initialized])
|
||||
|
||||
// 如果后端还未初始化,显示初始化对话框
|
||||
// 如果后端还未初始化,显示初始化对话框(loading 或 failed 都展示,由 dialog 内部决定渲染哪一态)
|
||||
if (!initialized) {
|
||||
return (
|
||||
<>
|
||||
<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 (
|
||||
<>
|
||||
<StartupBanner />
|
||||
<BackendHealthIndicator />
|
||||
<BrowserRouter>
|
||||
<Router>
|
||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
||||
<Routes>
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
@@ -81,7 +90,7 @@ function App() {
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</Router>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import BackendLogPanel from './BackendLogPanel'
|
||||
type Health = 'green' | 'yellow' | 'red' | 'unknown'
|
||||
|
||||
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 {
|
||||
// 与 services/request.ts 用的一致
|
||||
// 与 utils/request.ts 的 baseURL 计算保持一致:env 没设走 '/api' 兜底。
|
||||
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 = () => {
|
||||
|
||||
@@ -35,6 +35,9 @@ export function useBackendEvents(): BackendEvents {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
// 用 ref 持有最新 logs 数组,append 时不被闭包陷阱卡到旧值
|
||||
const logsRef = useRef<LogEntry[]>([])
|
||||
// 主动重启期:Rust 在 kill 老 sidecar 前会 emit 'backend-restarting'。
|
||||
// 期间到达的 'backend-terminated' 是我们自己造成的,不要污染状态。
|
||||
const ignoreNextTerminatedRef = useRef(false)
|
||||
|
||||
function append(entry: LogEntry) {
|
||||
const next = logsRef.current.concat(entry)
|
||||
@@ -58,7 +61,23 @@ export function useBackendEvents(): BackendEvents {
|
||||
const offErr = await listen<string>('backend-error', event => {
|
||||
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 => {
|
||||
// 主动重启窗口内的 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')
|
||||
setExitCode(event.payload ?? null)
|
||||
append({
|
||||
@@ -73,7 +92,7 @@ export function useBackendEvents(): BackendEvents {
|
||||
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
|
||||
})
|
||||
|
||||
unlisteners = [offMsg, offErr, offTerm, offRestart]
|
||||
unlisteners = [offMsg, offErr, offRestarting, offTerm, offRestart]
|
||||
})()
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,13 +1,141 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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 {
|
||||
/** 加载中:显示转圈对话框 */
|
||||
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 (
|
||||
<Dialog open={open}>
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="text-center">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-center gap-2">
|
||||
@@ -15,9 +143,12 @@ interface Props {
|
||||
后端正在初始化中…
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-muted-foreground mt-2">请稍候,系统正在启动后端服务,出现报错属于正常现象</p>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
请稍候,系统正在启动后端服务。首次启动可能需要 10-30 秒解压依赖。
|
||||
</p>
|
||||
</DialogContent>
|
||||
</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
|
||||
className={
|
||||
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' : '')
|
||||
}
|
||||
// 整行可点跳转到对应供应商编辑页(之前 onClick 只挂在 icon+名字那一小块 div 上,
|
||||
// 名字和开关之间的空白区域点不动)
|
||||
onClick={() => navigate(`/settings/model/${id}`)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center text-lg"
|
||||
onClick={() => navigate(`/settings/model/${id}`)}
|
||||
>
|
||||
<div className="flex items-center text-lg">
|
||||
<div className="flex h-9 w-9 items-center">
|
||||
<AILogo name={Icon} />
|
||||
</div>
|
||||
<div className="font-semibold">{providerName}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Switch 自己的点击不应该冒泡触发整行跳转 */}
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
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 的日志面板。
|
||||
unlisteners = [offWarning, offTerminated]
|
||||
unlisteners = [offWarning, offTerminated, offRestarted, offReady]
|
||||
})()
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,52 +1,156 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import request from '@/utils/request'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
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 [loading, setLoading] = useState(false)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
|
||||
useEffect(() => {
|
||||
let retries = 0
|
||||
// 直接用 fetch 而非 utils/request 的共享 axios:那个 axios 装了全局 toast 拦截器,
|
||||
// 启动期每次 /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 () => {
|
||||
try {
|
||||
await request.get('/sys_check')
|
||||
setInitialized(true)
|
||||
setLoading(false)
|
||||
} catch {
|
||||
if (retries === 0) {
|
||||
// 第一次失败时开始显示加载状态
|
||||
setLoading(true)
|
||||
}
|
||||
async function probeSysCheck(): Promise<boolean> {
|
||||
const url = `${getBackendBase()}/sys_check`
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(url, { signal: ctrl.signal })
|
||||
if (!res.ok) return false
|
||||
const json = await res.json().catch(() => null)
|
||||
return json?.code === 0
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries++
|
||||
setTimeout(check, RETRY_INTERVAL)
|
||||
} else {
|
||||
// 达到重试上限,继续轮询直到后端就绪
|
||||
waitUntilBackendReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
interface Status {
|
||||
loading: boolean
|
||||
initialized: boolean
|
||||
failed: boolean
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
const waitUntilBackendReady = async () => {
|
||||
while (true) {
|
||||
try {
|
||||
await request.get('/sys_health')
|
||||
setInitialized(true)
|
||||
setLoading(false)
|
||||
break
|
||||
} catch {
|
||||
await new Promise(res => setTimeout(res, RETRY_INTERVAL))
|
||||
}
|
||||
}
|
||||
}
|
||||
interface BackendCheck extends Status {
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
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 remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'github-markdown-css/github-markdown-light.css'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
@@ -47,7 +48,7 @@ const steps = [
|
||||
]
|
||||
|
||||
const remarkPlugins = [gfm, remarkMath]
|
||||
const rehypePlugins = [rehypeKatex]
|
||||
const rehypePlugins = [rehypeKatex, rehypeSlug]
|
||||
|
||||
/**
|
||||
* 构建 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 (
|
||||
<a
|
||||
href={href}
|
||||
|
||||
@@ -5,6 +5,171 @@ import { Toolbar } from 'markmap-toolbar'
|
||||
import 'markmap-toolbar/dist/style.css'
|
||||
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 {
|
||||
/** 要渲染的 Markdown 文本 */
|
||||
value: string
|
||||
@@ -34,6 +199,13 @@ export default function MarkmapEditor({
|
||||
|
||||
// 用于跟踪是否处于全屏状态
|
||||
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(() => {
|
||||
@@ -64,7 +236,7 @@ export default function MarkmapEditor({
|
||||
// 导出HTML思维导图
|
||||
const exportHtml = () => {
|
||||
try {
|
||||
const { root } = transformer.transform(value)
|
||||
const { root } = transformMindmap(value)
|
||||
const data = JSON.stringify(root)
|
||||
|
||||
// 创建HTML内容
|
||||
@@ -202,7 +374,7 @@ export default function MarkmapEditor({
|
||||
// 导出XMind格式思维导图
|
||||
const exportXMind = async () => {
|
||||
try {
|
||||
const { root } = transformer.transform(value);
|
||||
const { root } = transformMindmap(value);
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||
@@ -311,100 +483,44 @@ export default function MarkmapEditor({
|
||||
try {
|
||||
if (!svgRef.current || !mmRef.current) return;
|
||||
|
||||
const svgEl = svgRef.current;
|
||||
const mm = mmRef.current;
|
||||
|
||||
// 先调用fit()确保显示完整的思维导图内容
|
||||
await mm.fit();
|
||||
// 等待渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 获取SVG实际尺寸
|
||||
const svgWidth = svgEl.width.baseVal.value || svgEl.clientWidth || 800;
|
||||
const svgHeight = svgEl.height.baseVal.value || svgEl.clientHeight || 600;
|
||||
|
||||
// 设置足够大的缩放比例以确保高清输出
|
||||
const scale = 3;
|
||||
|
||||
// 克隆SVG以避免修改原始SVG
|
||||
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
|
||||
// 设置SVG的背景为白色
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = 'svg { background-color: white; }';
|
||||
clonedSvg.insertBefore(style, clonedSvg.firstChild);
|
||||
|
||||
// 确保SVG有正确的命名空间
|
||||
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
clonedSvg.setAttribute('width', svgWidth.toString());
|
||||
clonedSvg.setAttribute('height', svgHeight.toString());
|
||||
|
||||
// 将SVG转换为Data URI (避免使用Blob URL来解决跨域问题)
|
||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||||
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
|
||||
|
||||
// 创建Canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = svgWidth * scale;
|
||||
canvas.height = svgHeight * scale;
|
||||
|
||||
// 获取上下文并设置白色背景
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('无法获取Canvas上下文');
|
||||
}
|
||||
|
||||
// 设置白色背景
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 创建Image对象
|
||||
const img = new Image();
|
||||
|
||||
// 当图片加载完成后,在Canvas上绘制并导出
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 应用缩放
|
||||
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
|
||||
// 绘制SVG
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 重置变换
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
|
||||
// 将Canvas转换为PNG Blob
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
console.error('无法创建Blob对象');
|
||||
}
|
||||
}, 'image/png');
|
||||
} catch (err) {
|
||||
console.error('Canvas处理失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置图片加载错误处理
|
||||
img.onerror = (error) => {
|
||||
console.error('导出PNG失败(图片加载错误):', error);
|
||||
};
|
||||
|
||||
// 开始加载SVG图像 (使用Data URI而不是Blob URL)
|
||||
img.src = dataUri;
|
||||
|
||||
setPngAction('exporting');
|
||||
setPngMessage('正在生成高清 PNG…');
|
||||
const blob = await exportSvgToPngBlob(svgRef.current);
|
||||
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);
|
||||
showPngMessage('PNG 已开始下载');
|
||||
} catch (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(() => {
|
||||
const mm = mmRef.current
|
||||
if (!mm) return
|
||||
const { root } = transformer.transform(value)
|
||||
const { root } = transformMindmap(value)
|
||||
mm.setData(root).then(() => mm.fit())
|
||||
}, [value])
|
||||
|
||||
@@ -459,8 +575,17 @@ export default function MarkmapEditor({
|
||||
onClick={exportPng}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
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
|
||||
onClick={exportHtml}
|
||||
@@ -483,6 +608,11 @@ export default function MarkmapEditor({
|
||||
</button>
|
||||
)}
|
||||
</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 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 { fetchModels } from '@/services/model.ts'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
/* -------------------- 校验 Schema -------------------- */
|
||||
const formSchema = z
|
||||
@@ -229,8 +230,25 @@ const NoteForm = () => {
|
||||
}
|
||||
|
||||
// message.success('已提交任务')
|
||||
const data = await generateNote(payload)
|
||||
addPendingTask(data.task_id, values.platform, payload)
|
||||
try {
|
||||
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>) => {
|
||||
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 { addProvider, addModel, getProviderList, testConnection } from '@/services/model'
|
||||
import { addProvider, addModel, testConnection, getProviderList, updateProviderById } from '@/services/model'
|
||||
import { getTranscriberConfig, updateTranscriberConfig } from '@/services/transcriber'
|
||||
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',路由守卫不再拦。
|
||||
//
|
||||
// 1. 后端连通性自检
|
||||
@@ -52,24 +79,52 @@ const Onboarding = () => {
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
if (step !== 1) return
|
||||
let cancelled = false
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||
let offReady: (() => void) | null = null
|
||||
let offRestarted: (() => void) | null = null
|
||||
|
||||
;(async () => {
|
||||
setPinging(true)
|
||||
try {
|
||||
await getProviderList()
|
||||
if (!cancelled) setBackendOk(true)
|
||||
const ok = await doPing()
|
||||
if (cancelled) return
|
||||
if (!ok) {
|
||||
// 后端可能正在解压/启动,2s 后再试一次
|
||||
timerId = setTimeout(() => { if (!cancelled) doPing() }, 2000)
|
||||
}
|
||||
catch {
|
||||
if (!cancelled) setBackendOk(false)
|
||||
}
|
||||
finally {
|
||||
if (!cancelled) setPinging(false)
|
||||
|
||||
// 桌面端订阅 Tauri 事件:后端真正就绪 / 重启完成时立刻再检查一次
|
||||
if (isTauri) {
|
||||
try {
|
||||
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() {
|
||||
setError('')
|
||||
@@ -79,31 +134,61 @@ const Onboarding = () => {
|
||||
if (!modelName.trim()) { setError('请填模型名'); return }
|
||||
setSavingProvider(true)
|
||||
try {
|
||||
// 复用桌面 web 端的 add_provider;type 必须是 'custom'(backend 强制)
|
||||
const res: any = await addProvider({
|
||||
name: providerName.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
base_url: baseUrl.trim(),
|
||||
type: 'custom',
|
||||
logo: 'custom',
|
||||
})
|
||||
const newId = (res?.data ?? res) as string | undefined
|
||||
if (!newId) throw new Error('后端未返回 provider id')
|
||||
setProviderId(newId)
|
||||
const name = providerName.trim()
|
||||
let pid: string | undefined
|
||||
|
||||
// 加一个默认 model
|
||||
await addModel({ provider_id: newId, model_name: modelName.trim() })
|
||||
// 后端 seed_default_providers() 会预置 OpenAI / DeepSeek / Qwen 等同名供应商,
|
||||
// 直接 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 })
|
||||
}
|
||||
|
||||
// 测试连通
|
||||
try { await testConnection({ id: newId }) }
|
||||
setProviderId(pid!)
|
||||
|
||||
// 加一个默认 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) {
|
||||
// 测试失败不阻断流程,让用户自己决定继续
|
||||
console.warn('测试连接失败:', e?.message ?? e)
|
||||
console.warn('测试连接失败:', errText(e))
|
||||
}
|
||||
next()
|
||||
}
|
||||
catch (e: any) {
|
||||
setError(`保存失败:${e?.message ?? e}`)
|
||||
setError(`保存失败:${errText(e)}`)
|
||||
}
|
||||
finally {
|
||||
setSavingProvider(false)
|
||||
@@ -123,7 +208,7 @@ const Onboarding = () => {
|
||||
next()
|
||||
}
|
||||
catch (e: any) {
|
||||
setError(`保存失败:${e?.message ?? e}`)
|
||||
setError(`保存失败:${errText(e)}`)
|
||||
}
|
||||
finally {
|
||||
setSavingTranscriber(false)
|
||||
@@ -171,6 +256,15 @@ const Onboarding = () => {
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Provider from '@/components/Form/modelForm/Provider.tsx'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Options from '@/components/Form/DownloaderForm/Options.tsx'
|
||||
import ProxyConfig from '@/components/Form/DownloaderForm/ProxyConfig.tsx'
|
||||
const Downloader = () => {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<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" />
|
||||
Whisper 模型
|
||||
</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>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
@@ -192,6 +196,14 @@ export default function Monitor() {
|
||||
<span className="text-muted-foreground">转写引擎:</span>
|
||||
<span className="font-mono">{status.whisper.transcriber_type}</span>
|
||||
</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>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
@@ -5,9 +5,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Github, Star, ExternalLink, Download } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import logo from '@/assets/icon.svg'
|
||||
import wechatQr from '@/assets/wechat.png'
|
||||
|
||||
// 二维码统一走 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() {
|
||||
const appVersion = __APP_VERSION__
|
||||
const images = [
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504102850.png',
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103028.png',
|
||||
@@ -27,7 +32,7 @@ export default function AboutPage() {
|
||||
height={50}
|
||||
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>
|
||||
<p className="text-muted-foreground mb-6 text-xl italic">
|
||||
AI 视频笔记生成工具 让 AI 为你的视频做笔记
|
||||
@@ -197,12 +202,28 @@ export default function AboutPage() {
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">联系和加入社区</h2>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<div className="flex flex-col items-center justify-center gap-10 md:flex-row md:items-start">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
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 {
|
||||
getTranscriberConfig,
|
||||
updateTranscriberConfig,
|
||||
getModelsStatus,
|
||||
downloadModel,
|
||||
addWhisperModel,
|
||||
deleteWhisperModel,
|
||||
TranscriberConfig,
|
||||
ModelStatus,
|
||||
} from '@/services/transcriber'
|
||||
@@ -33,6 +36,19 @@ export default function Transcriber() {
|
||||
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
|
||||
const [mlxModelStatuses, setMlxModelStatuses] = useState<ModelStatus[]>([])
|
||||
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 () => {
|
||||
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) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@@ -272,6 +323,97 @@ export default function Transcriber() {
|
||||
</CardContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import request from '@/utils/request.ts'
|
||||
|
||||
export const getProviderList = async () => {
|
||||
return await request.get('/get_all_providers')
|
||||
// opts.silent: 让本次请求失败时不弹全局红 toast(调用方自行 catch 处理,
|
||||
// 比如 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) => {
|
||||
return await request.get(`/get_provider_by_id/${id}`)
|
||||
}
|
||||
export const updateProviderById = async (data: any) => {
|
||||
return await request.post('/update_provider', data)
|
||||
export const updateProviderById = async (data: any, opts?: CallOpts) => {
|
||||
return await request.post('/update_provider', data, cfg(opts))
|
||||
}
|
||||
|
||||
export const addProvider = async (data: any) => {
|
||||
return await request.post('/add_provider', data)
|
||||
export const addProvider = async (data: any, opts?: CallOpts) => {
|
||||
return await request.post('/add_provider', data, cfg(opts))
|
||||
}
|
||||
|
||||
export const testConnection = async (data: any) => {
|
||||
return await request.post('/connect_test', data)
|
||||
export const testConnection = async (data: any, opts?: CallOpts) => {
|
||||
return await request.post('/connect_test', data, cfg(opts))
|
||||
}
|
||||
|
||||
export const fetchModels = async (providerId: string) => {
|
||||
@@ -26,8 +31,11 @@ export const fetchEnableModelById = async (id: string) => {
|
||||
return await request.get('/model_enable/' + id)
|
||||
}
|
||||
|
||||
export async function addModel(data: { provider_id: string; model_name: string }) {
|
||||
return request.post('/models', data)
|
||||
export async function addModel(
|
||||
data: { provider_id: string; model_name: string },
|
||||
opts?: CallOpts,
|
||||
) {
|
||||
return request.post('/models', data, cfg(opts))
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
/** 保留旧 systemCheck 函数名(App.tsx 启动时仍调用),返回值同 getSysHealth。 */
|
||||
export const systemCheck = getSysHealth
|
||||
|
||||
export interface DeployStatus {
|
||||
backend: {
|
||||
status: string
|
||||
@@ -11,12 +31,16 @@ export interface DeployStatus {
|
||||
}
|
||||
cuda: {
|
||||
available: boolean
|
||||
/** 新增:torch 是否安装。轻量部署没装 torch 时为 false,避免误判为 CUDA 故障 */
|
||||
torch_installed?: boolean
|
||||
version: string | null
|
||||
gpu_name: string | null
|
||||
}
|
||||
whisper: {
|
||||
model_size: string
|
||||
transcriber_type: string
|
||||
/** 新增:模型是否已完整下载(fast-whisper 看 model.bin / mlx 看 config.json) */
|
||||
downloaded: boolean
|
||||
}
|
||||
ffmpeg: {
|
||||
available: boolean
|
||||
|
||||
@@ -5,6 +5,10 @@ export interface TranscriberConfig {
|
||||
whisper_model_size: string
|
||||
available_types: { value: string; label: 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
|
||||
}
|
||||
|
||||
@@ -41,3 +45,23 @@ export const downloadModel = async (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
|
||||
|
||||
const newFormData = payload || task.formData
|
||||
await generateNote({
|
||||
...newFormData,
|
||||
task_id: id,
|
||||
})
|
||||
try {
|
||||
await generateNote({
|
||||
...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 => ({
|
||||
tasks: state.tasks.map(t =>
|
||||
|
||||
@@ -8,6 +8,14 @@ export interface IResponse<T = any> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 允许调用方在 axios 配置里带 suppressToast: true,让拦截器对【预期内的失败】
|
||||
// 不弹全局红 toast(例如 onboarding 撞名重试、轮询健康检查)。业务代码自己 catch 处理。
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
suppressToast?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟一个消息提示函数 (实际项目中会使用UI库的组件,如 Ant Design 的 message 或 Element UI 的 ElMessage)
|
||||
// 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 || '操作成功'); // 如果需要显示成功消息
|
||||
return res.data; // 返回data部分,简化后续业务代码
|
||||
} else {
|
||||
// 业务错误,统一显示后端返回的错误消息
|
||||
// Business error, uniformly display the error message returned from the backend
|
||||
toast.error(res.msg || '操作失败,请稍后再试');
|
||||
// 业务错误,统一显示后端返回的错误消息(除非调用方显式 suppressToast)
|
||||
if (!response.config?.suppressToast) {
|
||||
toast.error(res.msg || '操作失败,请稍后再试');
|
||||
}
|
||||
return Promise.reject(res); // 拒绝Promise,让业务代码可以捕获并处理
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
const suppress = error?.config?.suppressToast === true
|
||||
// 网络/服务器错误
|
||||
const res = error?.response?.data as IResponse | undefined;
|
||||
if (res) {
|
||||
// 如果后端有返回错误信息,则显示后端信息
|
||||
// If the backend returns an error message, display it
|
||||
|
||||
toast.error(res.msg || '服务器错误,请稍后再试');
|
||||
if (!suppress) toast.error(res.msg || '服务器错误,请稍后再试');
|
||||
return Promise.reject(res);
|
||||
} else {
|
||||
// 没有响应数据(如网络中断),显示通用网络错误
|
||||
// No response data (e.g., network disconnected), display generic network error
|
||||
toast.error( '请求失败,请检查网络连接或稍后再试')
|
||||
if (!suppress) toast.error('请求失败,请检查网络连接或稍后再试')
|
||||
return Promise.reject({
|
||||
code: -1,
|
||||
msg: '请求失败,请检查网络连接',
|
||||
|
||||
2
BillNote_frontend/src/vite-env.d.ts
vendored
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
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/
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
|
||||
@@ -14,9 +28,13 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
|
||||
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
|
||||
const appVersion = env.VITE_APP_VERSION || process.env.VITE_APP_VERSION || readAppVersion()
|
||||
|
||||
return {
|
||||
base: './',
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
74
CHANGELOG.md
@@ -2,6 +2,80 @@
|
||||
|
||||
本项目所有重要变更记录于此。格式参考 [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
|
||||
|
||||
16
CLAUDE.md
@@ -13,6 +13,8 @@ BiliNote is an AI video note generation tool. It extracts content from video lin
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
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)
|
||||
@@ -43,6 +45,8 @@ pnpm install
|
||||
pnpm dev # watch mode → ./extension/
|
||||
pnpm build # production build → ./extension/
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
- `cookie_manager.py` — per-platform cookie storage; injected into yt-dlp by downloaders (e.g. Bilibili)
|
||||
- `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/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/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/i18n/` — backend localization
|
||||
- `events/` (root level) — Blinker signal system for post-processing (e.g., temp file cleanup after transcription)
|
||||
- `app/validators/video_url_validator.py` — URL → platform detection (mirrored client-side in the extension)
|
||||
- `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:
|
||||
- `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.
|
||||
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
|
||||
- **FFmpeg**: Required system dependency for video/audio processing
|
||||
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir)
|
||||
- **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`.
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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 ===
|
||||
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 PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
@@ -28,7 +32,12 @@ COPY ./backend /tmp/backend
|
||||
# === 阶段2:构建 Frontend ===
|
||||
# Node 18-alpine 跑不动 Tailwind v4 / Vite 6(前者要求 Node 20+,后者推荐 Node 20+),
|
||||
# 升到 node:20-alpine。alpine 走 musl,pnpm 会按 lockfile 拉 *-linux-x64-musl native binary。
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
ARG BASE_REGISTRY=docker.io
|
||||
FROM ${BASE_REGISTRY}/library/node:20-alpine AS frontend-builder
|
||||
|
||||
# 可由发布 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 生成
|
||||
@@ -50,7 +59,8 @@ ENV DOCKER_BUILD=1
|
||||
RUN pnpm run build
|
||||
|
||||
# === 阶段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
|
||||
|
||||
@@ -85,13 +95,20 @@ RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 创建 supervisor 配置
|
||||
RUN mkdir -p /var/log/supervisor
|
||||
# 关键点: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
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
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]
|
||||
command=nginx -g "daemon off;"
|
||||
@@ -107,7 +124,7 @@ stdout_logfile=/var/log/supervisor/backend.log
|
||||
stderr_logfile=/var/log/supervisor/backend.log
|
||||
autorestart=true
|
||||
priority=20
|
||||
environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0"
|
||||
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
|
||||
|
||||
# 修改 nginx 配置以使用本地 backend
|
||||
@@ -115,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
|
||||
|
||||
# 启动 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
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
|
||||
181
README.md
@@ -3,7 +3,7 @@
|
||||
<p align="center">
|
||||
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
|
||||
</p>
|
||||
<h1 align="center" > BiliNote v2.2.3</h1>
|
||||
<h1 align="center" > BiliNote v2.4.0</h1>
|
||||
</div>
|
||||
|
||||
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
|
||||
@@ -18,23 +18,51 @@
|
||||
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
|
||||
</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 Pro 在线版 www.bilinote.app](https://www.bilinote.app/) 已上线,云端托管、开箱即用,省去依赖安装 / 代理配置 / 模型下载的全部麻烦。
|
||||
|
||||
## 🌐 在线使用(推荐)
|
||||
|
||||
直接访问 **[www.bilinote.app](https://www.bilinote.app/)** 即可使用 BiliNote Pro 在线版,无需本地部署。
|
||||
|
||||
## 📝 使用文档
|
||||
详细文档可以查看[这里](https://docs.bilinote.app/)
|
||||
|
||||
## 体验地址
|
||||
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
|
||||
|
||||
## 📦 桌面版下载
|
||||
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
|
||||
|
||||
> 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、本地视频、抖音、快手
|
||||
@@ -53,6 +81,14 @@ BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、Y
|
||||
- 笔记顶部视频封面 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'(缺直接依赖声明)
|
||||
@@ -137,22 +173,90 @@ docker pull ghcr.io/jefferyhcool/bilinote:latest
|
||||
|
||||
docker run -d -p 80:80 \
|
||||
-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 \
|
||||
ghcr.io/jefferyhcool/bilinote:latest
|
||||
```
|
||||
|
||||
上面四个卷分别持久化:`data`(SQLite 数据库 + 生成的笔记)、`config`(LLM 供应商配置 / Cookie / 转写设置)、`static`(笔记引用的视频截图)、`models`(Whisper 模型缓存,可选,避免每次重新下载)。这样 `docker pull` 升级新镜像、删旧容器重建后,配置和历史都不会丢。
|
||||
|
||||
> ⚠️ **不要**用 `-v 卷名:/app/backend` 挂整个后端目录——命名卷会用首次启动时的镜像内容固化,之后 `docker pull` 升级也会被旧代码盖住,导致「升级不生效」。只挂上面这些数据子目录即可。
|
||||
|
||||
访问:`http://localhost`
|
||||
|
||||
也可以使用 docker-compose 本地构建:
|
||||
|
||||
```bash
|
||||
# 标准部署
|
||||
docker-compose up -d
|
||||
cp .env.example .env # 第一次部署务必先创建 .env,否则 BACKEND_PORT/APP_PORT 等变量为空会启动失败
|
||||
docker-compose up --build -d
|
||||
|
||||
# GPU 加速部署(需要 NVIDIA GPU)
|
||||
docker-compose -f docker-compose.gpu.yml up -d
|
||||
# GPU 加速部署(需要 NVIDIA GPU + NVIDIA Container Toolkit)
|
||||
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. 克隆仓库
|
||||
@@ -199,10 +303,42 @@ sudo apt install ffmpeg
|
||||
>
|
||||
> Docker 部署已内置 FFmpeg,无需额外安装。
|
||||
|
||||
### 🚀 CUDA 加速(可选)
|
||||
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
|
||||
### 🚀 CUDA / GPU 加速(可选)
|
||||
|
||||
具体 `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 一键部署
|
||||
|
||||
@@ -215,10 +351,17 @@ docker pull ghcr.io/jefferyhcool/bilinote:latest
|
||||
# 运行容器
|
||||
docker run -d -p 80:80 \
|
||||
-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 \
|
||||
ghcr.io/jefferyhcool/bilinote:latest
|
||||
```
|
||||
|
||||
上面四个卷分别持久化:`data`(SQLite 数据库 + 生成的笔记)、`config`(LLM 供应商配置 / Cookie / 转写设置)、`static`(笔记引用的视频截图)、`models`(Whisper 模型缓存,可选,避免每次重新下载)。这样 `docker pull` 升级新镜像、删旧容器重建后,配置和历史都不会丢。
|
||||
|
||||
> ⚠️ **不要**用 `-v 卷名:/app/backend` 挂整个后端目录——命名卷会用首次启动时的镜像内容固化,之后 `docker pull` 升级也会被旧代码盖住,导致「升级不生效」。只挂上面这些数据子目录即可。
|
||||
|
||||
访问:`http://localhost`
|
||||
|
||||
也可以使用 docker-compose 本地构建:
|
||||
@@ -227,8 +370,8 @@ docker run -d -p 80:80 \
|
||||
# 标准部署
|
||||
docker-compose up -d
|
||||
|
||||
# GPU 加速部署(需要 NVIDIA GPU)
|
||||
docker-compose -f docker-compose.gpu.yml up -d
|
||||
# GPU 加速部署(需要 NVIDIA GPU + NVIDIA Container Toolkit,详见上方「CUDA / GPU 加速」)
|
||||
docker-compose -f docker-compose.gpu.yml up --build -d
|
||||
```
|
||||
|
||||
## 🧠 TODO
|
||||
@@ -243,11 +386,13 @@ docker-compose -f docker-compose.gpu.yml up -d
|
||||
|
||||
### Contact and Join-联系和加入社区
|
||||
|
||||
扫码加入 BiliNote 交流微信群(如二维码失效,请到 [Issues](https://github.com/JefferyHcool/BiliNote/issues) 反馈):
|
||||
扫描下方公众号二维码,关注后回复 **「交流群」** 即可获取最新的微信交流群二维码(群码会自动更新,避免过期失效):
|
||||
|
||||
<p align="center">
|
||||
<img src="./doc/wechat.png" alt="BiliNote 交流微信群" width="240" />
|
||||
</p>
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center"><img src="./doc/wechat-gzh.png" alt="BiliNote 公众号" width="200" /><br/>BiliNote 公众号</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 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 PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import base64
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import tempfile
|
||||
from abc import ABC
|
||||
from typing import Union, Optional, List
|
||||
@@ -18,6 +21,54 @@ from app.services.cookie_manager import CookieConfigManager
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _patch_bilibili_extractor():
|
||||
"""
|
||||
Monkey-patch yt-dlp's BilibiliBaseIE._download_playinfo to inject the
|
||||
dm_img_* / web_location risk-control parameters that Bilibili's
|
||||
wbi/playurl gateway started requiring (returns HTTP 412 without them).
|
||||
|
||||
The parameter format (string.printable source + [:-2] base64 truncation)
|
||||
mirrors yt-dlp's own implementation for the same fields in the channel
|
||||
search endpoint (yt_dlp/extractor/bilibili.py, BiliBiliSpaceIE).
|
||||
"""
|
||||
try:
|
||||
from yt_dlp.extractor.bilibili import BilibiliBaseIE
|
||||
|
||||
# Guard: skip if already patched (e.g. module reloaded in tests)
|
||||
if getattr(BilibiliBaseIE._download_playinfo, '_bili_dm_patched', False):
|
||||
return
|
||||
|
||||
_original_download_playinfo = BilibiliBaseIE._download_playinfo
|
||||
|
||||
def _patched_download_playinfo(self, bvid, cid, headers=None, query=None):
|
||||
# Inject dummy risk-control fingerprints expected by Bilibili's gateway.
|
||||
# The [:-2] truncation and string.printable source intentionally match
|
||||
# yt-dlp's own pattern used for the x/space/wbi/arc/search endpoint.
|
||||
extra = {
|
||||
'web_location': 1550101,
|
||||
'dm_img_list': '[]',
|
||||
'dm_img_str': base64.b64encode(
|
||||
''.join(random.choices(string.printable, k=random.randint(16, 64))).encode()
|
||||
)[:-2].decode(),
|
||||
'dm_cover_img_str': base64.b64encode(
|
||||
''.join(random.choices(string.printable, k=random.randint(32, 128))).encode()
|
||||
)[:-2].decode(),
|
||||
'dm_img_inter': '{"ds":[],"wh":[6093,6631,31],"of":[430,760,380]}',
|
||||
}
|
||||
# Caller-supplied query params take precedence over the dummy values
|
||||
merged_query = {**extra, **(query or {})}
|
||||
return _original_download_playinfo(self, bvid, cid, headers=headers, query=merged_query)
|
||||
|
||||
_patched_download_playinfo._bili_dm_patched = True
|
||||
BilibiliBaseIE._download_playinfo = _patched_download_playinfo
|
||||
logger.info("Applied Bilibili wbi/playurl dm_img patch to yt-dlp BilibiliBaseIE")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply Bilibili dm_img patch: %s", e)
|
||||
|
||||
|
||||
_patch_bilibili_extractor()
|
||||
|
||||
|
||||
class BilibiliDownloader(Downloader, ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
@@ -9,12 +9,22 @@ from app.downloaders.base import Downloader, DownloadQuality
|
||||
from app.downloaders.youtube_subtitle import YouTubeSubtitleFetcher
|
||||
from app.models.notes_model import AudioDownloadResult
|
||||
from app.models.transcriber_model import TranscriptResult
|
||||
from app.services.proxy_config_manager import ProxyConfigManager
|
||||
from app.utils.path_helper import get_data_dir
|
||||
from app.utils.url_parser import extract_video_id
|
||||
|
||||
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):
|
||||
def __init__(self):
|
||||
|
||||
@@ -46,6 +56,7 @@ class YoutubeDownloader(Downloader, ABC):
|
||||
if skip_download:
|
||||
ydl_opts['skip_download'] = True
|
||||
|
||||
_apply_proxy(ydl_opts)
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=not skip_download)
|
||||
video_id = info.get("id")
|
||||
@@ -91,6 +102,7 @@ class YoutubeDownloader(Downloader, ABC):
|
||||
'merge_output_format': 'mp4', # 确保合并成 mp4
|
||||
}
|
||||
|
||||
_apply_proxy(ydl_opts)
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
video_id = info.get("id")
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Optional, List
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
|
||||
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
|
||||
from app.services.proxy_config_manager import ProxyConfigManager
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -17,7 +18,21 @@ class YouTubeSubtitleFetcher:
|
||||
"""通过 youtube-transcript-api 获取 YouTube 字幕。"""
|
||||
|
||||
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(
|
||||
self,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import List
|
||||
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.utils import fix_markdown
|
||||
from app.models.gpt_model import GPTSource
|
||||
@@ -15,7 +15,7 @@ class DeepSeekGPT(GPT):
|
||||
self.base_url = getenv("DEEP_SEEK_API_BASE_URL")
|
||||
self.model=getenv('DEEP_SEEK_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
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.openai_client import build_openai_client
|
||||
|
||||
logging= get_logger(__name__)
|
||||
class OpenAICompatibleProvider:
|
||||
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
|
||||
|
||||
@property
|
||||
@@ -15,17 +15,27 @@ class OpenAICompatibleProvider:
|
||||
return self.client
|
||||
|
||||
@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:
|
||||
client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
model = client.models.list()
|
||||
# for segment in model:
|
||||
# print(segment)
|
||||
# print(model)
|
||||
logging.info("连通性测试成功")
|
||||
client = build_openai_client(
|
||||
api_key, base_url, key_label="模型供应商的 API Key", timeout=15.0,
|
||||
)
|
||||
client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
max_tokens=1,
|
||||
temperature=0,
|
||||
)
|
||||
logging.info(f"连通性测试成功(model={model})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.info(f"连通性测试失败:{e}")
|
||||
|
||||
# print(f"Error connecting to OpenAI API: {e}")
|
||||
logging.warning(f"连通性测试失败(model={model}):{e}")
|
||||
return False
|
||||
@@ -185,15 +185,40 @@ class UniversalGPT(GPT):
|
||||
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
||||
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):
|
||||
last_exc = None
|
||||
for attempt in range(self._max_retry_attempts):
|
||||
try:
|
||||
return self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=self.temperature
|
||||
)
|
||||
return self._do_create(messages)
|
||||
except Exception as exc:
|
||||
last_exc = 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")
|
||||
def get_transcriber_config():
|
||||
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()
|
||||
return R.success(data={
|
||||
**config,
|
||||
"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,
|
||||
})
|
||||
|
||||
|
||||
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")
|
||||
def update_transcriber_config(data: TranscriberConfigRequest):
|
||||
config = transcriber_config_manager.update_config(
|
||||
@@ -80,6 +117,36 @@ def update_transcriber_config(data: TranscriberConfigRequest):
|
||||
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 模型下载状态 & 下载触发 ----
|
||||
|
||||
# 用于跟踪正在进行的下载任务
|
||||
@@ -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:
|
||||
"""检查指定 whisper 模型是否已下载到本地。"""
|
||||
model_dir = get_model_dir(subdir)
|
||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
||||
return Path(model_path).exists()
|
||||
"""检查指定 whisper 模型是否已下载完整到本地。
|
||||
|
||||
先把模型名 resolve 成可加载标识,再按类型判定:
|
||||
- 本地路径模型 → 直接看该目录下有没有 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")
|
||||
def get_transcriber_models_status():
|
||||
"""返回所有 whisper 模型的下载状态。"""
|
||||
"""返回所有 whisper 模型的下载状态(含用户自定义模型)。"""
|
||||
from app.transcriber.whisper_models import get_registry
|
||||
statuses = []
|
||||
for size in WHISPER_MODEL_SIZES:
|
||||
for size in get_registry().visible_model_names():
|
||||
downloaded = _check_whisper_model_exists(size, "whisper")
|
||||
download_status = _downloading.get(size)
|
||||
statuses.append({
|
||||
@@ -113,11 +226,9 @@ def get_transcriber_models_status():
|
||||
from app.transcriber.mlx_whisper_transcriber import MLX_MODEL_MAP
|
||||
for size in WHISPER_MODEL_SIZES:
|
||||
mlx_key = f"mlx-{size}"
|
||||
model_dir = get_model_dir("mlx-whisper")
|
||||
repo_id = MLX_MODEL_MAP.get(size)
|
||||
# 模型在本地按 repo_id(如 mlx-community/whisper-small-mlx)落盘
|
||||
model_path = os.path.join(model_dir, repo_id) if repo_id else None
|
||||
downloaded = bool(model_path and Path(model_path).exists())
|
||||
# 用 config.json 判定,和 _check_mlx_whisper_model_exists / 加载逻辑保持一致
|
||||
downloaded = _check_mlx_whisper_model_exists(size)
|
||||
mlx_statuses.append({
|
||||
"model_size": size,
|
||||
"downloaded": downloaded,
|
||||
@@ -138,23 +249,48 @@ class ModelDownloadRequest(BaseModel):
|
||||
|
||||
|
||||
def _do_download_whisper(model_size: str):
|
||||
"""后台下载 faster-whisper 模型。"""
|
||||
from app.transcriber.whisper import MODEL_MAP
|
||||
from modelscope import snapshot_download
|
||||
"""后台下载 faster-whisper 模型(支持内置 size / 自定义 repo_id / 本地路径)。
|
||||
|
||||
模型名先 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:
|
||||
_downloading[model_size] = "downloading"
|
||||
model_dir = get_model_dir("whisper")
|
||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
||||
if Path(model_path).exists():
|
||||
|
||||
# 已经下好就不重复下
|
||||
if _check_whisper_model_exists(model_size, "whisper"):
|
||||
_downloading[model_size] = "done"
|
||||
return
|
||||
repo_id = MODEL_MAP.get(model_size)
|
||||
if not repo_id:
|
||||
_downloading[model_size] = "failed"
|
||||
|
||||
target = resolve_whisper_model(model_size)
|
||||
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
|
||||
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}")
|
||||
_downloading[model_size] = "done"
|
||||
except Exception as e:
|
||||
@@ -179,7 +315,8 @@ def _do_download_mlx_whisper(model_size: str):
|
||||
|
||||
model_dir = get_model_dir("mlx-whisper")
|
||||
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"
|
||||
return
|
||||
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")
|
||||
def download_transcriber_model(data: ModelDownloadRequest, background_tasks: BackgroundTasks):
|
||||
"""触发后台下载指定的 whisper 模型。"""
|
||||
if data.model_size not in WHISPER_MODEL_SIZES:
|
||||
return R.error(msg=f"不支持的模型大小: {data.model_size}")
|
||||
|
||||
"""触发后台下载指定的 whisper 模型(fast-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":
|
||||
return R.error(msg="MLX Whisper 仅支持 macOS")
|
||||
key = f"mlx-{data.model_size}"
|
||||
@@ -205,6 +342,10 @@ def download_transcriber_model(data: ModelDownloadRequest, background_tasks: Bac
|
||||
return R.success(msg="模型正在下载中")
|
||||
background_tasks.add_task(_do_download_mlx_whisper, data.model_size)
|
||||
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":
|
||||
return R.success(msg="模型正在下载中")
|
||||
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")
|
||||
async def sys_health():
|
||||
"""结构化健康状态——任何子项异常都不应让整个 endpoint 5xx。
|
||||
|
||||
每个字段:'ok' | 'missing' | 'error'。
|
||||
前端 useCheckBackend 用 /sys_check 做存活判定(不依赖外部依赖),
|
||||
/sys_health 用来在设置页区分「后端没起」vs「后端起了但 ffmpeg 缺」vs「DB 写不进去」等更细的状态。
|
||||
"""
|
||||
ffmpeg_status = "ok"
|
||||
try:
|
||||
ensure_ffmpeg_or_raise()
|
||||
return R.success()
|
||||
except EnvironmentError:
|
||||
return R.error(msg="系统未安装 ffmpeg 请先进行安装")
|
||||
except Exception:
|
||||
ffmpeg_status = "missing"
|
||||
|
||||
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")
|
||||
async def sys_check():
|
||||
"""轻量存活判定:后端进程能响应这个 endpoint 就算「起来了」,不查外部依赖。
|
||||
|
||||
给桌面端 useCheckBackend / Tauri ready-probe 用。
|
||||
"""
|
||||
return R.success()
|
||||
|
||||
|
||||
@router.get("/deploy_status")
|
||||
async def deploy_status():
|
||||
"""返回部署监控所需的所有状态信息"""
|
||||
import torch
|
||||
"""返回部署监控所需的所有状态信息。
|
||||
|
||||
所有子项都用 try 包起来——监控页本身不应该被任何一个子项打死。
|
||||
特别是 torch:它只在 fast-whisper 路径用得到,用 Groq / 必剪 / 快手在线
|
||||
引擎的轻量部署完全可以不装,那种情况这个 endpoint 不应该 500。
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
# CUDA 状态
|
||||
cuda_available = torch.cuda.is_available()
|
||||
cuda_info = {
|
||||
"available": cuda_available,
|
||||
"version": torch.version.cuda if cuda_available else None,
|
||||
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
|
||||
}
|
||||
|
||||
# Whisper 模型状态(从配置文件读取,与前端设置同步)
|
||||
transcriber_cfg = transcriber_config_manager.get_config()
|
||||
model_size = transcriber_cfg["whisper_model_size"]
|
||||
transcriber_type = transcriber_cfg["transcriber_type"]
|
||||
|
||||
try:
|
||||
import torch
|
||||
cuda_available = torch.cuda.is_available()
|
||||
cuda_info = {
|
||||
"available": cuda_available,
|
||||
"torch_installed": True,
|
||||
"version": torch.version.cuda if cuda_available else None,
|
||||
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
|
||||
}
|
||||
except Exception:
|
||||
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 状态
|
||||
try:
|
||||
ensure_ffmpeg_or_raise()
|
||||
ffmpeg_ok = True
|
||||
except:
|
||||
except Exception:
|
||||
ffmpeg_ok = False
|
||||
|
||||
|
||||
return R.success(data={
|
||||
"backend": {"status": "running", "port": int(os.getenv("BACKEND_PORT", 8483))},
|
||||
"cuda": cuda_info,
|
||||
"whisper": {"model_size": model_size, "transcriber_type": transcriber_type},
|
||||
"whisper": whisper_info,
|
||||
"ffmpeg": {"available": ffmpeg_ok},
|
||||
})
|
||||
@@ -180,6 +180,24 @@ async def upload(file: UploadFile = File(...)):
|
||||
@router.post("/generate_note")
|
||||
def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
|
||||
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)
|
||||
# if not video_id:
|
||||
|
||||
@@ -20,6 +20,8 @@ class ProviderRequest(BaseModel):
|
||||
|
||||
class TestRequest(BaseModel):
|
||||
id: str
|
||||
# 可选:指定用哪个 model 跑连通性测试;不传则用该 provider 在 DB 里的第一个模型
|
||||
model: Optional[str] = None
|
||||
class ProviderUpdateRequest(BaseModel):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
@@ -91,5 +93,5 @@ def update_provider(data: ProviderUpdateRequest):
|
||||
|
||||
@router.post('/connect_test')
|
||||
def gpt_connect_test(data: TestRequest):
|
||||
ModelService().connect_test(data.id)
|
||||
ModelService().connect_test(data.id, model=data.model)
|
||||
return R.success(msg='连接成功')
|
||||
|
||||
@@ -100,23 +100,46 @@ class ModelService:
|
||||
logger.error(f"[{provider_id}] 获取模型失败: {e}")
|
||||
return []
|
||||
@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)
|
||||
|
||||
if provider:
|
||||
if not provider.get('api_key'):
|
||||
raise ProviderError(code=ProviderErrorEnum.NOT_FOUND.code, message=ProviderErrorEnum.NOT_FOUND.message)
|
||||
result = OpenAICompatibleProvider.test_connection(
|
||||
api_key=provider.get('api_key'),
|
||||
base_url=provider.get('base_url')
|
||||
if not provider:
|
||||
raise ProviderError(
|
||||
code=ProviderErrorEnum.NOT_FOUND.code,
|
||||
message=ProviderErrorEnum.NOT_FOUND.message,
|
||||
)
|
||||
if not provider.get('api_key'):
|
||||
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:
|
||||
# 过滤掉空值
|
||||
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)
|
||||
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:
|
||||
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.services.provider import ProviderService
|
||||
from app.transcriber.base import Transcriber
|
||||
from openai import OpenAI
|
||||
from app.utils.openai_client import build_openai_client
|
||||
import ffmpeg
|
||||
import tempfile
|
||||
from dotenv import load_dotenv
|
||||
@@ -30,12 +30,14 @@ class GroqTranscriber(Transcriber, ABC):
|
||||
print(f"压缩完成,临时路径:{file_path}")
|
||||
provider = ProviderService.get_provider_by_id('groq')
|
||||
|
||||
|
||||
if not provider:
|
||||
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'),
|
||||
base_url=provider.get('base_url')
|
||||
base_url=provider.get('base_url'),
|
||||
key_label="Groq 转写引擎的 API Key",
|
||||
)
|
||||
filename = file_path
|
||||
|
||||
|
||||
@@ -58,9 +58,16 @@ class MLXWhisperTranscriber(Transcriber):
|
||||
# 设置模型路径
|
||||
model_dir = get_model_dir("mlx-whisper")
|
||||
self.model_path = os.path.join(model_dir, self.model_name)
|
||||
# 检查并下载模型
|
||||
if not Path(self.model_path).exists():
|
||||
logger.info(f"模型 {self.model_name} 不存在,开始下载...")
|
||||
# 用 config.json 而非目录存在作为「下载完成」的判据,
|
||||
# 同 fast-whisper 的 model.bin:避免半成品目录把后续下载吞掉
|
||||
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(
|
||||
self.model_name,
|
||||
local_dir=self.model_path,
|
||||
|
||||
@@ -3,6 +3,11 @@ from faster_whisper import WhisperModel
|
||||
from app.decorators.timeit import timeit
|
||||
from app.models.transcriber_model import TranscriptSegment, TranscriptResult
|
||||
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.logger import get_logger
|
||||
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 pathlib import Path
|
||||
import os
|
||||
from tqdm import tqdm
|
||||
from modelscope import snapshot_download
|
||||
import shutil
|
||||
|
||||
|
||||
'''
|
||||
@@ -19,19 +23,16 @@ from modelscope import snapshot_download
|
||||
'''
|
||||
logger=get_logger(__name__)
|
||||
|
||||
MODEL_MAP={
|
||||
"tiny": "pengzhendong/faster-whisper-tiny",
|
||||
'base':'pengzhendong/faster-whisper-base',
|
||||
'small':'pengzhendong/faster-whisper-small',
|
||||
'medium':'pengzhendong/faster-whisper-medium',
|
||||
'large-v1':'pengzhendong/faster-whisper-large-v1',
|
||||
'large-v2':'pengzhendong/faster-whisper-large-v2',
|
||||
'large-v3':'pengzhendong/faster-whisper-large-v3',
|
||||
'large-v3-turbo':'pengzhendong/faster-whisper-large-v3-turbo',
|
||||
}
|
||||
|
||||
# 历史遗留:之前用 modelscope 下载到自定义目录然后把路径传给 WhisperModel。
|
||||
# 但 faster-whisper 1.1.1 的 download_model(utils.py:76)逻辑是:
|
||||
# 只要 size_or_id 里含 "/" 就当 HF repo_id 处理,没有「本地目录直接返回」分支。
|
||||
# 我们传 /app/models/whisper/whisper-tiny 进去 → 被当成不存在的 HF repo →
|
||||
# 在线请求失败 → fallback local_files_only=True → HF cache 找不到(因为是
|
||||
# modelscope 目录布局不是 HF)→ LocalEntryNotFoundError,误导说"离线模式"。
|
||||
# 解法:彻底让 faster-whisper 自己处理下载——传 size name,配 download_root
|
||||
# 作为 HF cache 根目录,HF_ENDPOINT 已经在 Dockerfile 里指到 hf-mirror.com,
|
||||
# 国内能用。删掉 modelscope 那一套,避免布局不匹配。
|
||||
class WhisperTranscriber(Transcriber):
|
||||
# TODO:修改为可配置
|
||||
def __init__(
|
||||
self,
|
||||
model_size: str = "base",
|
||||
@@ -47,25 +48,53 @@ class WhisperTranscriber(Transcriber):
|
||||
print('没有 cuda 使用 cpu进行计算')
|
||||
|
||||
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_path = os.path.join(model_dir, f"whisper-{model_size}")
|
||||
if not Path(model_path).exists():
|
||||
logger.info(f"模型 whisper-{model_size} 不存在,开始下载...")
|
||||
repo_id = MODEL_MAP[model_size]
|
||||
model_path = snapshot_download(
|
||||
repo_id,
|
||||
try:
|
||||
self.model = self._build_model(model_size, model_dir)
|
||||
except Exception as e:
|
||||
# 自愈:损坏 / 截断 / 半成品 cache → 删掉对应 HF cache 重下一次
|
||||
logger.warning(f"加载 whisper-{model_size} 失败:{e};清理 cache 后重新下载")
|
||||
self._purge_cache(model_dir, model_size)
|
||||
self.model = self._build_model(model_size, model_dir)
|
||||
|
||||
local_dir=model_path,
|
||||
)
|
||||
logger.info("模型下载完成")
|
||||
|
||||
self.model = WhisperModel(
|
||||
model_size_or_path=model_path,
|
||||
def _build_model(self, model_size: str, model_dir: str) -> WhisperModel:
|
||||
# resolve 把模型名映射成可加载标识:内置 size→Systran repo_id、自定义映射、
|
||||
# 直通的 repo_id 或本地路径。faster-whisper 对本地目录走 os.path.isdir 分支,
|
||||
# 对 repo_id 走 download_model(cache_dir=download_root),两者都吃 model_size_or_path。
|
||||
target = resolve_whisper_model(model_size)
|
||||
return WhisperModel(
|
||||
model_size_or_path=target,
|
||||
device=self.device,
|
||||
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
|
||||
def is_torch_installed() -> bool:
|
||||
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 subprocess
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
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:
|
||||
"""
|
||||
检查 ffmpeg 是否可用。优先使用 FFMPEG_BIN_PATH 环境变量指定的路径。
|
||||
|
||||
@@ -39,24 +39,50 @@ if not os.path.exists(out_dir):
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
register_handler()
|
||||
init_db()
|
||||
# 转写器不再在启动时强制初始化,而是在首次生成笔记时按需创建
|
||||
# 如果配置了不可用的类型(如 mlx-whisper 未安装),会在使用时报错而非静默回退
|
||||
_cfg = TranscriberConfigManager().get_config()
|
||||
logger.info(f"当前转写器配置: type={_cfg['transcriber_type']}, model_size={_cfg['whisper_model_size']}")
|
||||
seed_default_providers()
|
||||
# 启动序列拆成 5 步、每步独立日志 + 异常时打明确的 [startup N/5 FAILED] 标记。
|
||||
# 目的:用户 docker logs 一眼能看出后端死在哪一步,避免「容器一直重启但看不出原因」。
|
||||
try:
|
||||
logger.info("[startup 1/5] register_handler() — 注册事件处理器")
|
||||
register_handler()
|
||||
|
||||
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
|
||||
|
||||
app = create_app(lifespan=lifespan)
|
||||
|
||||
# 允许的源:本地 web 端 + Tauri 桌面端 + 浏览器扩展(chrome/edge/firefox)
|
||||
# 用 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 = (
|
||||
r"^chrome-extension://[a-z]+$"
|
||||
r"|^moz-extension://.+$"
|
||||
r"|^http://(localhost|127\.0\.0\.1)(:\d+)?$"
|
||||
r"|^http://tauri\.localhost$"
|
||||
r"|^tauri://localhost$"
|
||||
r"|^https?://tauri\.localhost$"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
|
||||
@@ -24,7 +24,7 @@ click-repl==0.3.0
|
||||
colorama==0.4.6
|
||||
coloredlogs==15.0.1
|
||||
cssselect2==0.8.0
|
||||
ctranslate2==4.5.0
|
||||
ctranslate2==4.6.0
|
||||
distro==1.9.0
|
||||
dnspython==2.7.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: .
|
||||
dockerfile: backend/Dockerfile.gpu
|
||||
args:
|
||||
# 国内拉不到 docker.io 时设置 BASE_REGISTRY;注意所选镜像需要支持 nvidia/cuda 命名空间
|
||||
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
|
||||
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
||||
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
||||
env_file:
|
||||
@@ -14,9 +16,20 @@ services:
|
||||
- BACKEND_PORT=${BACKEND_PORT}
|
||||
- BACKEND_HOST=${BACKEND_HOST}
|
||||
volumes:
|
||||
# 同 docker-compose.yml:./backend 绑到 /app,DB / 转写器配置 / 截图 / 上传都持久化
|
||||
- ./backend:/app
|
||||
expose:
|
||||
- "${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:
|
||||
resources:
|
||||
reservations:
|
||||
@@ -30,10 +43,14 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: BillNote_frontend/Dockerfile
|
||||
args:
|
||||
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "80" # 不暴露给宿主机,只供 nginx 访问
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
|
||||
nginx:
|
||||
container_name: bilinote-nginx
|
||||
@@ -43,5 +60,9 @@ services:
|
||||
volumes:
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
mem_limit: 256m
|
||||
|
||||
@@ -6,6 +6,8 @@ services:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
args:
|
||||
# 国内拉不到 docker.io 时设置 BASE_REGISTRY=docker.m.daocloud.io(或其他可用镜像)
|
||||
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
|
||||
APT_MIRROR: ${APT_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
||||
PIP_INDEX: ${PIP_INDEX:-https://pypi.tuna.tsinghua.edu.cn/simple}
|
||||
env_file:
|
||||
@@ -14,16 +16,25 @@ services:
|
||||
- BACKEND_PORT=${BACKEND_PORT}
|
||||
- BACKEND_HOST=${BACKEND_HOST}
|
||||
volumes:
|
||||
# 把整个 backend/ 目录绑到 /app,意味着这些都持久化到宿主机、删容器不丢:
|
||||
# ./backend/bili_note.db — SQLite 数据库(含 LLM 供应商配置、笔记历史)
|
||||
# ./backend/config/transcriber.json — 转写器运行时配置
|
||||
# ./backend/static/screenshots/ — 视频截图
|
||||
# ./backend/uploads/ — 上传的本地视频
|
||||
- ./backend:/app
|
||||
expose:
|
||||
- "${BACKEND_PORT}" # 不再对外暴露,用于 nginx 内部通信
|
||||
restart: on-failure:3
|
||||
# 用 unless-stopped 而非 on-failure:N,避免任何短暂崩溃把容器永久打死后
|
||||
# 再也接收不到用户修过的 .env。手动 docker-compose stop 仍可正常停下。
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:${BACKEND_PORT}/api/sys_health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
# WHISPER_MODEL_SIZE 选 medium 及以上请把这里调到 8g+,
|
||||
# 否则首次模型加载时容易被 host OOM-killer 干掉。
|
||||
mem_limit: 4g
|
||||
|
||||
frontend:
|
||||
@@ -31,11 +42,13 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: BillNote_frontend/Dockerfile
|
||||
args:
|
||||
BASE_REGISTRY: ${BASE_REGISTRY:-docker.io}
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "80" # 不暴露给宿主机,只供 nginx 访问
|
||||
restart: on-failure:3
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
|
||||
nginx:
|
||||
@@ -50,5 +63,5 @@ services:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
restart: on-failure:3
|
||||
restart: unless-stopped
|
||||
mem_limit: 256m
|
||||
|
||||