mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-10 17:43:40 +08:00
feat(extension): options 改为多 tab,搬入 web 端的全部设置项
把原来一长条的 options 拆成五个 tab,覆盖 web 端 SettingPage 的全部能力。今后新功能优先在插件里做,web 端逐步退役。 - 通用:后端地址 + 默认供应商/模型 + 默认生成选项(原 Options.vue 内容) - 模型供应商:完整 CRUD —— 列表 / 启用切换 / 编辑 / 测试连接 / 添加 / 模型增删 - 音频转写配置:转写器引擎切换(fast-whisper / mlx-whisper / Groq / 必剪 / 快手)+ Whisper 模型大小切换 + 模型本地下载状态 + 触发下载 · 直接修复 'MLX Whisper 不可用' 报错——非 Mac 用户现在能切到 fast-whisper / Groq - 下载配置:每平台 cookie 显示 / 浏览器一键同步 / 手动粘贴保存 - 部署监控:后端、FFmpeg、CUDA、Whisper 模型 当前状态 logic/api.ts 补齐:provider CRUD / model CRUD / connect_test / transcriber_config / transcriber_models_status / transcriber_download / get_downloader_cookie / deploy_status / sys_health。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,16 @@
|
||||
import type { GenerateRequest, Model, Provider, TaskStatusResponse } from './types'
|
||||
import type {
|
||||
DeployStatus,
|
||||
GenerateRequest,
|
||||
Model,
|
||||
Provider,
|
||||
ProviderCreatePayload,
|
||||
ProviderUpdatePayload,
|
||||
TaskStatusResponse,
|
||||
TranscriberConfig,
|
||||
TranscriberModelsStatus,
|
||||
TranscriberType,
|
||||
WhisperModelSize,
|
||||
} from './types'
|
||||
import { settings } from './storage'
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
@@ -44,6 +56,97 @@ export async function setDownloaderCookie(platform: string, cookie: string): Pro
|
||||
})
|
||||
}
|
||||
|
||||
export async function getDownloaderCookie(platform: string): Promise<string | null> {
|
||||
// 后端:未配置时返回 {code:0, msg:'未找到Cookies', data:null};配置时 data: {platform, cookie}
|
||||
const data = await request<{ platform: string, cookie: string } | null>(
|
||||
`/api/get_downloader_cookie/${platform}`,
|
||||
)
|
||||
return data?.cookie ?? null
|
||||
}
|
||||
|
||||
// ---- Provider CRUD ----
|
||||
export async function addProvider(payload: ProviderCreatePayload): Promise<string | null> {
|
||||
return request<string | null>('/api/add_provider', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ logo: 'custom', ...payload }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateProvider(payload: ProviderUpdatePayload): Promise<{ id: string, enabled: number }> {
|
||||
return request<{ id: string, enabled: number }>('/api/update_provider', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProviderById(id: string): Promise<Provider> {
|
||||
return request<Provider>(`/api/get_provider_by_id/${id}`)
|
||||
}
|
||||
|
||||
export async function connectTest(id: string): Promise<void> {
|
||||
await request('/api/connect_test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Model CRUD ----
|
||||
export async function listAllModels(providerId: string): Promise<Model[]> {
|
||||
return request<Model[]>(`/api/model_list/${providerId}`)
|
||||
}
|
||||
|
||||
export async function addModel(providerId: string, modelName: string): Promise<void> {
|
||||
await request('/api/models', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider_id: providerId, model_name: modelName }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteModel(modelId: number | string): Promise<void> {
|
||||
await request(`/api/models/delete/${modelId}`)
|
||||
}
|
||||
|
||||
// ---- Transcriber ----
|
||||
export async function getTranscriberConfig(): Promise<TranscriberConfig> {
|
||||
return request<TranscriberConfig>('/api/transcriber_config')
|
||||
}
|
||||
|
||||
export async function setTranscriberConfig(transcriberType: TranscriberType, whisperModelSize?: WhisperModelSize): Promise<TranscriberConfig> {
|
||||
return request<TranscriberConfig>('/api/transcriber_config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
transcriber_type: transcriberType,
|
||||
whisper_model_size: whisperModelSize ?? null,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTranscriberModelsStatus(): Promise<TranscriberModelsStatus> {
|
||||
return request<TranscriberModelsStatus>('/api/transcriber_models_status')
|
||||
}
|
||||
|
||||
export async function downloadTranscriberModel(modelSize: WhisperModelSize, transcriberType: TranscriberType = 'fast-whisper'): Promise<void> {
|
||||
await request('/api/transcriber_download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model_size: modelSize, transcriber_type: transcriberType }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Monitor ----
|
||||
export async function getDeployStatus(): Promise<DeployStatus> {
|
||||
return request<DeployStatus>('/api/deploy_status')
|
||||
}
|
||||
|
||||
export async function getSysHealth(): Promise<{ ok: boolean, msg?: string }> {
|
||||
try {
|
||||
await request('/api/sys_health')
|
||||
return { ok: true }
|
||||
}
|
||||
catch (e) {
|
||||
return { ok: false, msg: (e as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateNote(payload: GenerateRequest): Promise<{ task_id: string }> {
|
||||
return request<{ task_id: string }>('/api/generate_note', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -80,3 +80,56 @@ export interface Settings {
|
||||
link: boolean
|
||||
style: string
|
||||
}
|
||||
|
||||
export interface ProviderUpdatePayload {
|
||||
id: string
|
||||
name?: string
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
type?: string
|
||||
enabled?: number
|
||||
}
|
||||
|
||||
export interface ProviderCreatePayload {
|
||||
name: string
|
||||
api_key: string
|
||||
base_url: string
|
||||
type: string
|
||||
logo?: string
|
||||
}
|
||||
|
||||
export type TranscriberType = 'fast-whisper' | 'bcut' | 'kuaishou' | 'groq' | 'mlx-whisper'
|
||||
export type WhisperModelSize = 'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo'
|
||||
|
||||
export interface TranscriberOption {
|
||||
value: TranscriberType
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface TranscriberConfig {
|
||||
transcriber_type: TranscriberType
|
||||
whisper_model_size: WhisperModelSize | null
|
||||
available_types: TranscriberOption[]
|
||||
whisper_model_sizes: WhisperModelSize[]
|
||||
mlx_whisper_available: boolean
|
||||
}
|
||||
|
||||
export interface WhisperModelStatus {
|
||||
model_size: WhisperModelSize
|
||||
downloaded: boolean
|
||||
downloading: boolean
|
||||
}
|
||||
|
||||
export interface TranscriberModelsStatus {
|
||||
whisper: WhisperModelStatus[]
|
||||
mlx_whisper: WhisperModelStatus[]
|
||||
mlx_available: boolean
|
||||
}
|
||||
|
||||
export interface DeployStatus {
|
||||
backend: { status: string, port: number }
|
||||
cuda: { available: boolean, version: string | null, gpu_name: string | null }
|
||||
whisper: { model_size: string, transcriber_type: string }
|
||||
ffmpeg: { available: boolean }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,202 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { getModelsByProvider, getProviders, ping } from '~/logic/api'
|
||||
import { settings, settingsReady } from '~/logic/storage'
|
||||
import { SUPPORTED_COOKIE_PLATFORMS, syncCookieToBackend } from '~/logic/cookies'
|
||||
import { PLATFORM_LABELS } from '~/logic/platform'
|
||||
import type { Model, Platform, Provider } from '~/logic/types'
|
||||
import { computed, ref } from 'vue'
|
||||
import GeneralPage from './pages/General.vue'
|
||||
import ProvidersPage from './pages/Providers.vue'
|
||||
import TranscriberPage from './pages/Transcriber.vue'
|
||||
import DownloaderPage from './pages/Downloader.vue'
|
||||
import MonitorPage from './pages/Monitor.vue'
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const models = ref<Model[]>([])
|
||||
const loading = ref(false)
|
||||
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
|
||||
const TABS = [
|
||||
{ id: 'general', label: '通用', icon: '⚙️', component: GeneralPage },
|
||||
{ id: 'providers', label: '模型供应商', icon: '🧠', component: ProvidersPage },
|
||||
{ id: 'transcriber', label: '音频转写配置', icon: '🎙️', component: TranscriberPage },
|
||||
{ id: 'downloader', label: '下载配置', icon: '🍪', component: DownloaderPage },
|
||||
{ id: 'monitor', label: '部署监控', icon: '📊', component: MonitorPage },
|
||||
] as const
|
||||
|
||||
async function refreshProviders() {
|
||||
loading.value = true
|
||||
status.value = { kind: 'idle', text: '' }
|
||||
try {
|
||||
providers.value = (await getProviders()).filter(p => p.enabled === 1)
|
||||
if (settings.value.providerId)
|
||||
await refreshModels(settings.value.providerId)
|
||||
status.value = { kind: 'ok', text: `已加载 ${providers.value.length} 个供应商` }
|
||||
}
|
||||
catch (e) {
|
||||
status.value = { kind: 'err', text: `加载失败:${(e as Error).message}` }
|
||||
providers.value = []
|
||||
models.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModels(providerId: string) {
|
||||
if (!providerId) {
|
||||
models.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
models.value = await getModelsByProvider(providerId)
|
||||
}
|
||||
catch {
|
||||
models.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
status.value = { kind: 'idle', text: '正在测试…' }
|
||||
const ok = await ping()
|
||||
status.value = ok
|
||||
? { kind: 'ok', text: '后端连通 ✓' }
|
||||
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS 配置' }
|
||||
}
|
||||
|
||||
const cookieStatus = ref<Record<string, { kind: 'ok' | 'err' | 'idle', text: string }>>({})
|
||||
const cookieBusy = ref<Record<string, boolean>>({})
|
||||
|
||||
async function syncCookie(platform: Exclude<Platform, 'local'>) {
|
||||
cookieBusy.value[platform] = true
|
||||
cookieStatus.value[platform] = { kind: 'idle', text: '同步中…' }
|
||||
const res = await syncCookieToBackend(platform)
|
||||
cookieStatus.value[platform] = res.ok
|
||||
? { kind: 'ok', text: `已同步 ${res.count} 条 cookie ✓` }
|
||||
: { kind: 'err', text: res.error || '同步失败' }
|
||||
cookieBusy.value[platform] = false
|
||||
}
|
||||
|
||||
watch(() => settings.value?.providerId, (id) => {
|
||||
if (id)
|
||||
refreshModels(id)
|
||||
// 切换供应商时清空已选模型,避免错配
|
||||
if (id !== providers.value.find(p => p.id === id)?.id)
|
||||
settings.value.modelName = ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsReady
|
||||
if (settings.value.backendUrl)
|
||||
await refreshProviders()
|
||||
})
|
||||
const activeTab = ref<typeof TABS[number]['id']>('general')
|
||||
const ActiveComponent = computed(() => TABS.find(t => t.id === activeTab.value)?.component ?? GeneralPage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="max-w-2xl mx-auto p-6 text-gray-800 dark:text-gray-100">
|
||||
<header class="flex items-center gap-2 mb-6">
|
||||
<h1 class="text-xl font-bold">BiliNote 浏览器插件 · 设置</h1>
|
||||
</header>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||
<h2 class="font-semibold">后端地址</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="settings.backendUrl"
|
||||
class="flex-1 border rounded px-2 py-1"
|
||||
placeholder="http://localhost:8483"
|
||||
<div class="flex h-screen bg-gray-50 text-gray-800">
|
||||
<aside class="w-56 shrink-0 border-r bg-white flex flex-col">
|
||||
<div class="px-4 py-4 border-b">
|
||||
<div class="text-lg font-bold">BiliNote</div>
|
||||
<div class="text-xs text-gray-500">浏览器插件设置</div>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-auto py-2">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
class="w-full text-left px-4 py-2 text-sm flex items-center gap-2 hover:bg-gray-100"
|
||||
:class="activeTab === tab.id ? 'bg-blue-50 text-blue-700 font-medium border-l-2 border-blue-500' : 'text-gray-700'"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<button class="btn-secondary" @click="testConnection">测试连通</button>
|
||||
<button class="btn-secondary" :disabled="loading" @click="refreshProviders">
|
||||
{{ loading ? '加载中…' : '刷新' }}
|
||||
<span>{{ tab.icon }}</span>
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="px-4 py-2 text-xs text-gray-400 border-t">
|
||||
v0.1.0
|
||||
</div>
|
||||
<div
|
||||
v-if="status.text"
|
||||
class="text-xs"
|
||||
:class="{
|
||||
'text-green-700': status.kind === 'ok',
|
||||
'text-red-600': status.kind === 'err',
|
||||
'text-gray-500': status.kind === 'idle',
|
||||
}"
|
||||
>
|
||||
{{ status.text }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
默认 http://localhost:8483 — 需要在该地址先跑起 BiliNote 后端 (cd backend && python main.py)
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||
<h2 class="font-semibold">默认供应商与模型</h2>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">供应商</span>
|
||||
<select v-model="settings.providerId" class="border rounded px-2 py-1">
|
||||
<option value="">— 选择供应商 —</option>
|
||||
<option v-for="p in providers" :key="p.id" :value="p.id">
|
||||
{{ p.name }} <span v-if="p.type === 'built-in'">(内置)</span>
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">模型</span>
|
||||
<select v-model="settings.modelName" class="border rounded px-2 py-1" :disabled="!settings.providerId">
|
||||
<option value="">— 选择模型 —</option>
|
||||
<option v-for="m in models" :key="m.id" :value="m.model_name">{{ m.model_name }}</option>
|
||||
</select>
|
||||
<span v-if="settings.providerId && models.length === 0" class="text-xs text-amber-700">
|
||||
该供应商下还没有可用模型;请到桌面 web 端的「模型设置」里添加
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||
<h2 class="font-semibold">默认生成选项</h2>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">画质</span>
|
||||
<select v-model="settings.quality" class="border rounded px-2 py-1">
|
||||
<option value="fast">快速 (32k)</option>
|
||||
<option value="medium">中等 (64k)</option>
|
||||
<option value="slow">高质 (128k)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<input v-model="settings.style" class="border rounded px-2 py-1" placeholder="留空使用默认">
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||
<h2 class="font-semibold">浏览器 Cookie 同步</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
从当前浏览器读取你已登录站点的 cookie,并写入后端 (POST /api/update_downloader_cookie)。
|
||||
Bilibili / Douyin / Kuaishou 这类需要登录态的下载尤其需要这一步。
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="p in SUPPORTED_COOKIE_PLATFORMS"
|
||||
:key="p"
|
||||
class="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span class="w-20">{{ PLATFORM_LABELS[p] }}</span>
|
||||
<button class="btn-secondary" :disabled="cookieBusy[p]" @click="syncCookie(p)">
|
||||
{{ cookieBusy[p] ? '同步中…' : '同步 Cookie' }}
|
||||
</button>
|
||||
<span
|
||||
class="flex-1 text-xs"
|
||||
:class="{
|
||||
'text-green-700': cookieStatus[p]?.kind === 'ok',
|
||||
'text-red-600': cookieStatus[p]?.kind === 'err',
|
||||
'text-gray-500': cookieStatus[p]?.kind === 'idle',
|
||||
}"
|
||||
>
|
||||
{{ cookieStatus[p]?.text || '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
所有设置自动保存。不在桌面端管理供应商/模型?请在 BiliNote web 端 (http://localhost:3015) 完成。
|
||||
</p>
|
||||
</main>
|
||||
<main class="flex-1 overflow-auto">
|
||||
<component :is="ActiveComponent" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.btn-primary { @apply bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm; }
|
||||
.btn-secondary { @apply bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200 text-sm disabled:opacity-50; }
|
||||
.btn-danger { @apply bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600 text-sm disabled:opacity-50; }
|
||||
.tag { @apply text-xs px-1.5 py-0.5 rounded; }
|
||||
.input { @apply border rounded px-2 py-1 text-sm; }
|
||||
.section-card { @apply bg-white border rounded p-4 mb-4 flex flex-col gap-3; }
|
||||
</style>
|
||||
|
||||
127
BillNote_extension/src/options/pages/Downloader.vue
Normal file
127
BillNote_extension/src/options/pages/Downloader.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { getDownloaderCookie, setDownloaderCookie } from '~/logic/api'
|
||||
import { SUPPORTED_COOKIE_PLATFORMS, syncCookieToBackend } from '~/logic/cookies'
|
||||
import { PLATFORM_LABELS } from '~/logic/platform'
|
||||
import type { Platform } from '~/logic/types'
|
||||
|
||||
interface Row {
|
||||
cookie: string
|
||||
busy: boolean
|
||||
status: { kind: 'ok' | 'err' | 'idle', text: string }
|
||||
}
|
||||
|
||||
const rows = reactive<Record<string, Row>>({})
|
||||
const refreshing = ref(false)
|
||||
|
||||
function ensureRow(p: string) {
|
||||
if (!rows[p])
|
||||
rows[p] = { cookie: '', busy: false, status: { kind: 'idle', text: '' } }
|
||||
return rows[p]
|
||||
}
|
||||
|
||||
async function refreshOne(p: Exclude<Platform, 'local'>) {
|
||||
const r = ensureRow(p)
|
||||
try {
|
||||
r.cookie = (await getDownloaderCookie(p)) ?? ''
|
||||
}
|
||||
catch (e) {
|
||||
r.status = { kind: 'err', text: `读取失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
refreshing.value = true
|
||||
await Promise.all(SUPPORTED_COOKIE_PLATFORMS.map(refreshOne))
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
async function syncFromBrowser(p: Exclude<Platform, 'local'>) {
|
||||
const r = ensureRow(p)
|
||||
r.busy = true
|
||||
r.status = { kind: 'idle', text: '从浏览器读取并同步…' }
|
||||
const res = await syncCookieToBackend(p)
|
||||
r.status = res.ok
|
||||
? { kind: 'ok', text: `已同步 ${res.count} 条 cookie ✓` }
|
||||
: { kind: 'err', text: res.error || '同步失败' }
|
||||
if (res.ok)
|
||||
await refreshOne(p)
|
||||
r.busy = false
|
||||
}
|
||||
|
||||
async function saveManual(p: Exclude<Platform, 'local'>) {
|
||||
const r = ensureRow(p)
|
||||
r.busy = true
|
||||
r.status = { kind: 'idle', text: '保存中…' }
|
||||
try {
|
||||
await setDownloaderCookie(p, r.cookie || '')
|
||||
r.status = { kind: 'ok', text: '已保存 ✓' }
|
||||
}
|
||||
catch (e) {
|
||||
r.status = { kind: 'err', text: `保存失败:${(e as Error).message}` }
|
||||
}
|
||||
finally {
|
||||
r.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
SUPPORTED_COOKIE_PLATFORMS.forEach(ensureRow)
|
||||
refreshAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-3xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">下载配置</h1>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
每平台的 cookie 写入后端 (config/downloader.json);下载时由对应 downloader 读取注入 yt-dlp。
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-secondary" :disabled="refreshing" @click="refreshAll">
|
||||
{{ refreshing ? '刷新中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-for="p in SUPPORTED_COOKIE_PLATFORMS"
|
||||
:key="p"
|
||||
class="section-card"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">{{ PLATFORM_LABELS[p] }}</h2>
|
||||
<span
|
||||
v-if="rows[p]?.cookie"
|
||||
class="tag bg-green-100 text-green-700"
|
||||
>已配置</span>
|
||||
<span v-else class="tag bg-gray-100 text-gray-500">未配置</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="rows[p].cookie"
|
||||
class="input font-mono text-xs h-20 resize-y"
|
||||
placeholder="name=value; name=value; ..."
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn-primary" :disabled="rows[p]?.busy" @click="syncFromBrowser(p)">
|
||||
{{ rows[p]?.busy ? '处理中…' : '从浏览器同步' }}
|
||||
</button>
|
||||
<button class="btn-secondary" :disabled="rows[p]?.busy" @click="saveManual(p)">
|
||||
手动保存
|
||||
</button>
|
||||
<span
|
||||
v-if="rows[p]?.status?.text"
|
||||
class="text-xs"
|
||||
:class="{
|
||||
'text-green-700': rows[p].status.kind === 'ok',
|
||||
'text-red-600': rows[p].status.kind === 'err',
|
||||
'text-gray-500': rows[p].status.kind === 'idle',
|
||||
}"
|
||||
>{{ rows[p].status.text }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
142
BillNote_extension/src/options/pages/General.vue
Normal file
142
BillNote_extension/src/options/pages/General.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getProviders, ping } from '~/logic/api'
|
||||
import { settings, settingsReady } from '~/logic/storage'
|
||||
import { getModelsByProvider } from '~/logic/api'
|
||||
import type { Model, Provider } from '~/logic/types'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const models = ref<Model[]>([])
|
||||
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
status.value = { kind: 'idle', text: '' }
|
||||
try {
|
||||
providers.value = (await getProviders()).filter(p => p.enabled === 1)
|
||||
if (settings.value.providerId)
|
||||
await refreshModels(settings.value.providerId)
|
||||
status.value = { kind: 'ok', text: `已加载 ${providers.value.length} 个供应商` }
|
||||
}
|
||||
catch (e) {
|
||||
status.value = { kind: 'err', text: `加载失败:${(e as Error).message}` }
|
||||
providers.value = []
|
||||
models.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModels(providerId: string) {
|
||||
if (!providerId) {
|
||||
models.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
models.value = await getModelsByProvider(providerId)
|
||||
}
|
||||
catch {
|
||||
models.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
status.value = { kind: 'idle', text: '正在测试…' }
|
||||
const ok = await ping()
|
||||
status.value = ok
|
||||
? { kind: 'ok', text: '后端连通 ✓' }
|
||||
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS' }
|
||||
}
|
||||
|
||||
watch(() => settings.value?.providerId, (id) => {
|
||||
if (id)
|
||||
refreshModels(id)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsReady
|
||||
if (settings.value.backendUrl)
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl">
|
||||
<h1 class="text-xl font-bold mb-4">通用</h1>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">后端地址</h2>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="settings.backendUrl" class="input flex-1" placeholder="http://localhost:8483">
|
||||
<button class="btn-secondary" @click="testConnection">测试连通</button>
|
||||
<button class="btn-secondary" :disabled="loading" @click="refresh">
|
||||
{{ loading ? '加载中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="status.text"
|
||||
class="text-xs"
|
||||
:class="{
|
||||
'text-green-700': status.kind === 'ok',
|
||||
'text-red-600': status.kind === 'err',
|
||||
'text-gray-500': status.kind === 'idle',
|
||||
}"
|
||||
>
|
||||
{{ status.text }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
默认 http://localhost:8483 — 需要在该地址先跑起 BiliNote 后端
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">默认供应商与模型</h2>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">供应商</span>
|
||||
<select v-model="settings.providerId" class="input">
|
||||
<option value="">— 选择供应商 —</option>
|
||||
<option v-for="p in providers" :key="p.id" :value="p.id">
|
||||
{{ p.name }} <span v-if="p.type === 'built-in'">(内置)</span>
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">模型</span>
|
||||
<select v-model="settings.modelName" class="input" :disabled="!settings.providerId">
|
||||
<option value="">— 选择模型 —</option>
|
||||
<option v-for="m in models" :key="m.id" :value="m.model_name">{{ m.model_name }}</option>
|
||||
</select>
|
||||
<span v-if="settings.providerId && models.length === 0" class="text-xs text-amber-700">
|
||||
该供应商还没添加可用模型,去「模型供应商」页编辑
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">默认生成选项</h2>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">画质</span>
|
||||
<select v-model="settings.quality" class="input">
|
||||
<option value="fast">快速 (32k)</option>
|
||||
<option value="medium">中等 (64k)</option>
|
||||
<option value="slow">高质 (128k)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<input v-model="settings.style" class="input" placeholder="留空使用默认">
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
85
BillNote_extension/src/options/pages/Monitor.vue
Normal file
85
BillNote_extension/src/options/pages/Monitor.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getDeployStatus, getSysHealth } from '~/logic/api'
|
||||
import type { DeployStatus } from '~/logic/types'
|
||||
|
||||
const status = ref<DeployStatus | null>(null)
|
||||
const health = ref<{ ok: boolean, msg?: string } | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [s, h] = await Promise.all([getDeployStatus(), getSysHealth()])
|
||||
status.value = s
|
||||
health.value = h
|
||||
}
|
||||
catch (e) {
|
||||
error.value = (e as Error).message
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold">部署监控</h1>
|
||||
<button class="btn-secondary" :disabled="loading" @click="refresh">
|
||||
{{ loading ? '检查中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-red-600 text-sm mb-4">{{ error }}</div>
|
||||
|
||||
<template v-if="status">
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">后端</h2>
|
||||
<div class="text-sm">
|
||||
<span class="tag bg-green-100 text-green-700">{{ status.backend.status }}</span>
|
||||
<span class="ml-2 text-gray-600">端口 {{ status.backend.port }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">FFmpeg</h2>
|
||||
<div class="text-sm flex items-center gap-3">
|
||||
<span
|
||||
class="tag"
|
||||
:class="status.ffmpeg.available ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>{{ status.ffmpeg.available ? '可用' : '不可用' }}</span>
|
||||
<span v-if="health && !health.ok" class="text-red-600 text-xs">{{ health.msg }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">CUDA / GPU</h2>
|
||||
<div class="text-sm">
|
||||
<span
|
||||
class="tag"
|
||||
:class="status.cuda.available ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
|
||||
>{{ status.cuda.available ? '可用' : '不可用' }}</span>
|
||||
<div v-if="status.cuda.available" class="mt-1 text-gray-600 text-xs">
|
||||
CUDA {{ status.cuda.version }} · {{ status.cuda.gpu_name }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">Whisper</h2>
|
||||
<div class="text-sm text-gray-600">
|
||||
引擎:<span class="text-gray-800">{{ status.whisper.transcriber_type }}</span>
|
||||
<span v-if="status.whisper.model_size" class="ml-3">
|
||||
模型:<span class="text-gray-800">{{ status.whisper.model_size }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
239
BillNote_extension/src/options/pages/Providers.vue
Normal file
239
BillNote_extension/src/options/pages/Providers.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
addModel,
|
||||
addProvider,
|
||||
connectTest,
|
||||
deleteModel,
|
||||
getProviderById,
|
||||
getProviders,
|
||||
listAllModels,
|
||||
updateProvider,
|
||||
} from '~/logic/api'
|
||||
import type { Model, Provider, ProviderUpdatePayload } from '~/logic/types'
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const selectedId = ref<string>('')
|
||||
const editing = ref<Partial<Provider> & { api_key?: string, base_url?: string }>({})
|
||||
const models = ref<Model[]>([])
|
||||
const newModelName = ref('')
|
||||
const isCreating = ref(false)
|
||||
const message = ref<{ kind: 'ok' | 'err' | 'idle', text: string }>({ kind: 'idle', text: '' })
|
||||
|
||||
const isBuiltIn = computed(() => editing.value?.type === 'built-in')
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
providers.value = await getProviders()
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `加载供应商失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id: string) {
|
||||
isCreating.value = false
|
||||
selectedId.value = id
|
||||
message.value = { kind: 'idle', text: '' }
|
||||
try {
|
||||
const p = await getProviderById(id)
|
||||
editing.value = { ...p }
|
||||
models.value = await listAllModels(id)
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `读取供应商失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
isCreating.value = true
|
||||
selectedId.value = ''
|
||||
editing.value = {
|
||||
name: '',
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
type: 'custom',
|
||||
enabled: 1,
|
||||
}
|
||||
models.value = []
|
||||
}
|
||||
|
||||
async function save() {
|
||||
message.value = { kind: 'idle', text: '保存中…' }
|
||||
try {
|
||||
if (isCreating.value) {
|
||||
const id = await addProvider({
|
||||
name: editing.value.name || '',
|
||||
api_key: editing.value.api_key || '',
|
||||
base_url: editing.value.base_url || '',
|
||||
type: 'custom',
|
||||
})
|
||||
await refresh()
|
||||
message.value = { kind: 'ok', text: '已创建' }
|
||||
if (id)
|
||||
await select(id as unknown as string)
|
||||
}
|
||||
else if (selectedId.value) {
|
||||
const payload: ProviderUpdatePayload = {
|
||||
id: selectedId.value,
|
||||
name: editing.value.name,
|
||||
api_key: editing.value.api_key,
|
||||
base_url: editing.value.base_url,
|
||||
enabled: editing.value.enabled,
|
||||
}
|
||||
await updateProvider(payload)
|
||||
await refresh()
|
||||
message.value = { kind: 'ok', text: '已保存' }
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `保存失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(p: Provider) {
|
||||
try {
|
||||
await updateProvider({ id: p.id, enabled: p.enabled === 1 ? 0 : 1 })
|
||||
await refresh()
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `切换启用失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
if (!selectedId.value)
|
||||
return
|
||||
message.value = { kind: 'idle', text: '测试中…' }
|
||||
try {
|
||||
await connectTest(selectedId.value)
|
||||
message.value = { kind: 'ok', text: '连接成功 ✓' }
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `连接失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function addNewModel() {
|
||||
if (!selectedId.value || !newModelName.value.trim())
|
||||
return
|
||||
try {
|
||||
await addModel(selectedId.value, newModelName.value.trim())
|
||||
newModelName.value = ''
|
||||
models.value = await listAllModels(selectedId.value)
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `添加模型失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function removeModel(modelId: number | string) {
|
||||
if (!confirm('确认删除该模型?'))
|
||||
return
|
||||
try {
|
||||
await deleteModel(modelId)
|
||||
if (selectedId.value)
|
||||
models.value = await listAllModels(selectedId.value)
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `删除模型失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 flex gap-6">
|
||||
<aside class="w-64 shrink-0 flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold">模型供应商</h1>
|
||||
<button class="btn-secondary" @click="startCreate">新增</button>
|
||||
</div>
|
||||
<div class="bg-white border rounded">
|
||||
<div
|
||||
v-for="p in providers"
|
||||
:key="p.id"
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-gray-50"
|
||||
:class="{ 'bg-blue-50': p.id === selectedId }"
|
||||
@click="select(p.id)"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="truncate">{{ p.name }}</div>
|
||||
<span
|
||||
class="tag"
|
||||
:class="p.type === 'built-in' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'"
|
||||
>{{ p.type === 'built-in' ? '内置' : '自定义' }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs"
|
||||
:class="p.enabled === 1 ? 'text-green-600' : 'text-gray-400'"
|
||||
:title="p.enabled === 1 ? '已启用,点击禁用' : '已禁用,点击启用'"
|
||||
@click.stop="toggleEnabled(p)"
|
||||
>
|
||||
{{ p.enabled === 1 ? '✓ 启用' : '○ 禁用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 max-w-2xl">
|
||||
<div v-if="!selectedId && !isCreating" class="text-gray-400 text-sm pt-12 text-center">
|
||||
左侧选一个供应商查看 / 编辑,或点「新增」添加新供应商
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ isCreating ? '新增供应商' : '编辑供应商' }}
|
||||
</h2>
|
||||
|
||||
<section class="section-card">
|
||||
<label class="flex items-center gap-3 text-sm">
|
||||
<span class="w-20 text-right text-gray-600">名称</span>
|
||||
<input v-model="editing.name" class="input flex-1" :disabled="isBuiltIn">
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm">
|
||||
<span class="w-20 text-right text-gray-600">API Key</span>
|
||||
<input v-model="editing.api_key" class="input flex-1" type="password">
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm">
|
||||
<span class="w-20 text-right text-gray-600">API 地址</span>
|
||||
<input v-model="editing.base_url" class="input flex-1">
|
||||
</label>
|
||||
<label v-if="!isCreating" class="flex items-center gap-3 text-sm">
|
||||
<span class="w-20 text-right text-gray-600">类型</span>
|
||||
<input :value="editing.type" class="input flex-1" disabled>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<button class="btn-primary" @click="save">{{ isCreating ? '创建' : '保存' }}</button>
|
||||
<button v-if="!isCreating" class="btn-secondary" @click="test">测试连接</button>
|
||||
<span
|
||||
v-if="message.text"
|
||||
class="text-xs"
|
||||
:class="{
|
||||
'text-green-700': message.kind === 'ok',
|
||||
'text-red-600': message.kind === 'err',
|
||||
'text-gray-500': message.kind === 'idle',
|
||||
}"
|
||||
>{{ message.text }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!isCreating" class="section-card">
|
||||
<h3 class="font-semibold">模型列表</h3>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newModelName" class="input flex-1" placeholder="例如 gpt-4o-mini">
|
||||
<button class="btn-secondary" @click="addNewModel">添加模型</button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li v-for="m in models" :key="m.id" class="flex justify-between items-center px-2 py-1 rounded hover:bg-gray-50">
|
||||
<span class="text-sm">{{ m.model_name }}</span>
|
||||
<button class="text-xs text-red-500 hover:text-red-700" @click="removeModel(m.id)">删除</button>
|
||||
</li>
|
||||
<li v-if="models.length === 0" class="text-xs text-gray-400">该供应商下还没有模型</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
162
BillNote_extension/src/options/pages/Transcriber.vue
Normal file
162
BillNote_extension/src/options/pages/Transcriber.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
downloadTranscriberModel,
|
||||
getTranscriberConfig,
|
||||
getTranscriberModelsStatus,
|
||||
setTranscriberConfig,
|
||||
} from '~/logic/api'
|
||||
import type {
|
||||
TranscriberConfig,
|
||||
TranscriberModelsStatus,
|
||||
TranscriberType,
|
||||
WhisperModelSize,
|
||||
WhisperModelStatus,
|
||||
} from '~/logic/types'
|
||||
|
||||
const config = ref<TranscriberConfig | null>(null)
|
||||
const status = ref<TranscriberModelsStatus | null>(null)
|
||||
|
||||
const selType = ref<TranscriberType>('fast-whisper')
|
||||
const selSize = ref<WhisperModelSize>('medium')
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const message = ref<{ kind: 'ok' | 'err' | 'idle', text: string }>({ kind: 'idle', text: '' })
|
||||
|
||||
const isWhisperLike = computed(() => selType.value === 'fast-whisper' || selType.value === 'mlx-whisper')
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
message.value = { kind: 'idle', text: '' }
|
||||
try {
|
||||
const [cfg, st] = await Promise.all([getTranscriberConfig(), getTranscriberModelsStatus()])
|
||||
config.value = cfg
|
||||
status.value = st
|
||||
selType.value = cfg.transcriber_type
|
||||
if (cfg.whisper_model_size)
|
||||
selSize.value = cfg.whisper_model_size
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `读取失败:${(e as Error).message}` }
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = { kind: 'idle', text: '保存中…' }
|
||||
try {
|
||||
const cfg = await setTranscriberConfig(selType.value, isWhisperLike.value ? selSize.value : undefined)
|
||||
config.value = cfg
|
||||
message.value = { kind: 'ok', text: '已保存。下一次生成笔记会用新配置。' }
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `保存失败:${(e as Error).message}` }
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerDownload(size: WhisperModelSize) {
|
||||
try {
|
||||
await downloadTranscriberModel(size, selType.value === 'mlx-whisper' ? 'mlx-whisper' : 'fast-whisper')
|
||||
message.value = { kind: 'ok', text: `已开始下载 ${size}` }
|
||||
await refresh()
|
||||
}
|
||||
catch (e) {
|
||||
message.value = { kind: 'err', text: `触发下载失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
const currentSizeStatus = computed<WhisperModelStatus[]>(() => {
|
||||
if (!status.value)
|
||||
return []
|
||||
return selType.value === 'mlx-whisper' ? status.value.mlx_whisper : status.value.whisper
|
||||
})
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-3xl">
|
||||
<h1 class="text-xl font-bold mb-1">音频转写配置</h1>
|
||||
<p class="text-xs text-gray-500 mb-4">
|
||||
选择把视频音频转成文字的引擎。在线引擎(Groq / 必剪 / 快手)走第三方 API,本地 Whisper 需要先下载模型。
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="text-sm text-gray-500">加载中…</div>
|
||||
|
||||
<template v-else-if="config">
|
||||
<section class="section-card">
|
||||
<h2 class="font-semibold">引擎</h2>
|
||||
<select v-model="selType" class="input">
|
||||
<option v-for="opt in config.available_types" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="selType === 'mlx-whisper' && !config.mlx_whisper_available" class="text-xs text-red-600">
|
||||
⚠ 当前后端没有装 mlx_whisper 包(仅 macOS 可用)。如果不是 Mac,请改用 fast-whisper / Groq / 必剪 / 快手。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-if="isWhisperLike" class="section-card">
|
||||
<h2 class="font-semibold">Whisper 模型大小</h2>
|
||||
<select v-model="selSize" class="input">
|
||||
<option v-for="s in config.whisper_model_sizes" :key="s" :value="s">
|
||||
{{ s }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<h3 class="text-sm font-medium mt-2">下载状态</h3>
|
||||
<table class="text-sm w-full">
|
||||
<thead>
|
||||
<tr class="text-left text-gray-500">
|
||||
<th class="py-1 font-normal">模型</th>
|
||||
<th class="py-1 font-normal">本地</th>
|
||||
<th class="py-1 font-normal">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in currentSizeStatus" :key="row.model_size" class="border-t">
|
||||
<td class="py-1">{{ row.model_size }}</td>
|
||||
<td class="py-1">
|
||||
<span v-if="row.downloaded" class="tag bg-green-100 text-green-700">已下载</span>
|
||||
<span v-else-if="row.downloading" class="tag bg-yellow-100 text-yellow-700">下载中…</span>
|
||||
<span v-else class="tag bg-gray-100 text-gray-500">未下载</span>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<button
|
||||
v-if="!row.downloaded && !row.downloading"
|
||||
class="btn-secondary"
|
||||
@click="triggerDownload(row.model_size)"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="flex items-center gap-3">
|
||||
<button class="btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中…' : '保存配置' }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="refresh">刷新</button>
|
||||
<span
|
||||
v-if="message.text"
|
||||
class="text-xs"
|
||||
:class="{
|
||||
'text-green-700': message.kind === 'ok',
|
||||
'text-red-600': message.kind === 'err',
|
||||
'text-gray-500': message.kind === 'idle',
|
||||
}"
|
||||
>{{ message.text }}</span>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user