Merge branch 'feat/browser-extension' into develop

This commit is contained in:
huangjianwu
2026-05-07 13:06:44 +08:00
83 changed files with 13986 additions and 29 deletions

17
BillNote_extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
.DS_Store
.idea/
.vite-ssg-dist
.vite-ssg-temp
*.crx
*.local
*.log
*.pem
*.xpi
*.zip
dist
dist-ssr
extension/manifest.json
node_modules
src/auto-imports.d.ts
src/components.d.ts
.eslintcache

7
BillNote_extension/.gitpod.Dockerfile vendored Normal file
View File

@@ -0,0 +1,7 @@
FROM gitpod/workspace-full-vnc
USER root
# Install dependencies
RUN apt-get update \
&& apt-get install -y firefox

View File

@@ -0,0 +1,23 @@
image:
file: .gitpod.Dockerfile
tasks:
- init: pnpm install && pnpm run build
name: dev
command: |
gp sync-done ready
pnpm run dev
- name: pnpm start:chromium
command: |
gp sync-await ready
gp ports await 6080
gp preview $(gp url 6080)
sleep 5
pnpm start:chromium
openMode: split-right
ports:
- port: 5900
onOpen: ignore
- port: 6080
onOpen: ignore

View File

@@ -0,0 +1,2 @@
shamefully-hoist=true
auto-install-peers=true

View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"vue.volar",
"antfu.iconify",
"antfu.unocss",
"dbaeumer.vscode-eslint",
"csstools.postcss"
]
}

View File

@@ -0,0 +1,12 @@
{
"cSpell.words": ["Vitesse"],
"typescript.tsdk": "node_modules/typescript/lib",
"vite.autoStart": false,
"eslint.experimental.useFlatConfig": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"files.associations": {
"*.css": "postcss"
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Anthony Fu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,53 @@
# BiliNote 浏览器插件
把 BiliNote 的"视频链接 → Markdown 笔记"能力下沉到浏览器插件。当前为 P1 MVP仅工具栏 popup
## 当前状态P1 MVP
- ✅ 工具栏图标 popup自动读当前 tab URL识别支持平台触发笔记生成
- ✅ 设置页:后端地址、供应商/模型、画质、截图/跳转/风格默认值
- ✅ 任务进度可视化、Markdown 渲染、复制 / 下载 .md
- ✅ chrome.storage.local 持久化设置和最近 30 个任务
- ⏳ P2视频页悬浮按钮 + 右键菜单 + 浏览器 cookie 直通
- ⏳ P3side panel + 思维导图markmap
- ⏳ P4RAG 问答
## 开发
依赖node 20+ / pnpm 9+
```bash
cd BillNote_extension
pnpm install
pnpm dev # watch 模式,产物输出到 ./extension/
```
加载到 Chrome
1. `chrome://extensions/` → 打开右上"开发者模式"
2. 点"加载已解压的扩展程序",选 `BillNote_extension/extension/` 目录
3. 启动后端:`cd backend && python main.py`(默认 8483
4. 浏览器开任意支持的视频页B 站 / YouTube / 抖音 / 快手),点工具栏 BiliNote 图标
5. 首次使用先打开"设置",填后端地址 → 选供应商 + 模型
## 后端要求
后端 `backend/main.py` 的 CORS 白名单已通过 regex 兼容 `chrome-extension://``moz-extension://` 与本地 web。无需新增任何 backend endpoint。
## 构建发布
```bash
pnpm build # 产物 → ./extension/
pnpm pack:zip # 打包 → ./extension.zip (上传 Chrome Web Store
pnpm pack:crx # 打包 → ./extension.crx
pnpm pack:xpi # 打包 → ./extension.xpi Firefox
```
## 与桌面端的关系
桌面 web 端(`BillNote_frontend/`)继续负责:供应商/模型管理、转写器配置、笔记历史。
插件**不**复刻这些管理界面,仅消费已配置好的供应商。
## 致谢
骨架基于 [vitesse-webext](https://github.com/antfu-collective/vitesse-webext)Antfu

View File

@@ -0,0 +1,20 @@
import { expect, isDevArtifact, name, test } from './fixtures'
test('example test', async ({ page }, testInfo) => {
testInfo.skip(!isDevArtifact(), 'contentScript is in closed ShadowRoot mode')
await page.goto('https://example.com')
await page.locator(`#${name} button`).click()
await expect(page.locator(`#${name} h1`)).toHaveText('Vitesse WebExt')
})
test('popup page', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/dist/popup/index.html`)
await expect(page.locator('button')).toHaveText('Open Options')
})
test('options page', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/dist/options/index.html`)
await expect(page.locator('img')).toHaveAttribute('alt', 'extension icon')
})

View File

@@ -0,0 +1,48 @@
import path from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import fs from 'fs-extra'
import { type BrowserContext, test as base, chromium } from '@playwright/test'
import type { Manifest } from 'webextension-polyfill'
export { name } from '../package.json'
export const extensionPath = path.join(__dirname, '../extension')
export const test = base.extend<{
context: BrowserContext
extensionId: string
}>({
context: async ({ headless }, use) => {
// workaround for the Vite server has started but contentScript is not yet.
await sleep(1000)
const context = await chromium.launchPersistentContext('', {
headless,
args: [
...(headless ? ['--headless=new'] : []),
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
})
await use(context)
await context.close()
},
extensionId: async ({ context }, use) => {
// for manifest v3:
let [background] = context.serviceWorkers()
if (!background)
background = await context.waitForEvent('serviceworker')
const extensionId = background.url().split('/')[2]
await use(extensionId)
},
})
export const expect = test.expect
export function isDevArtifact() {
const manifest: Manifest.WebExtensionManifest = fs.readJsonSync(path.resolve(extensionPath, 'manifest.json'))
return Boolean(
typeof manifest.content_security_policy === 'object'
&& manifest.content_security_policy.extension_pages?.includes('localhost'),
)
}

View File

@@ -0,0 +1,5 @@
import antfu from '@antfu/eslint-config'
export default antfu(
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,12 @@
<svg width="415" height="412" viewBox="0 0 415 412" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 28C0 12.536 12.536 0 28 0H387C402.464 0 415 12.536 415 28V384C415 399.464 402.464 412 387 412H28C12.536 412 0 399.464 0 384V28Z" fill="#3C77FB"/>
<rect x="60" y="64" width="296" height="283" rx="37" fill="white"/>
<path d="M268.422 175.657C276.308 180.298 276.308 191.702 268.422 196.343L186.335 244.641C178.336 249.348 168.25 243.58 168.25 234.298V137.702C168.25 128.42 178.336 122.652 186.335 127.359L268.422 175.657Z" fill="#3C77FB"/>
<path d="M17 282C17 270.954 25.9543 262 37 262H83C94.0457 262 103 270.954 103 282V282C103 293.046 94.0457 302 83 302H37C25.9543 302 17 293.046 17 282V282Z" fill="#3C77FB"/>
<path d="M38 281.5C38 274.044 44.0442 268 51.5 268H82.5C89.9558 268 96 274.044 96 281.5V281.5C96 288.956 89.9558 295 82.5 295H51.5C44.0442 295 38 288.956 38 281.5V281.5Z" fill="white"/>
<path d="M17 206C17 194.954 25.9543 186 37 186H83C94.0457 186 103 194.954 103 206V206C103 217.046 94.0457 226 83 226H37C25.9543 226 17 217.046 17 206V206Z" fill="#3C77FB"/>
<path d="M38 205.5C38 198.044 44.0442 192 51.5 192H82.5C89.9558 192 96 198.044 96 205.5V205.5C96 212.956 89.9558 219 82.5 219H51.5C44.0442 219 38 212.956 38 205.5V205.5Z" fill="white"/>
<path d="M17 130C17 118.954 25.9543 110 37 110H83C94.0457 110 103 118.954 103 130V130C103 141.046 94.0457 150 83 150H37C25.9543 150 17 141.046 17 130V130Z" fill="#3C77FB"/>
<path d="M38 129.5C38 122.044 44.0442 116 51.5 116H82.5C89.9558 116 96 122.044 96 129.5V129.5C96 136.956 89.9558 143 82.5 143H51.5C44.0442 143 38 136.956 38 129.5V129.5Z" fill="white"/>
<path d="M145 290C145 285.582 148.582 282 153 282H284C288.418 282 292 285.582 292 290V299C292 303.418 288.418 307 284 307H153C148.582 307 145 303.418 145 299V290Z" fill="#3C77FB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

10
BillNote_extension/modules.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare module 'vue' {
interface ComponentCustomProperties {
$app: {
context: string
}
}
}
// https://stackoverflow.com/a/64189046/479957
export {}

View File

@@ -0,0 +1,77 @@
{
"name": "bilinote-extension",
"displayName": "BiliNote",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@9.7.1",
"description": "在浏览器里把视频链接一键变成 Markdown 笔记Bilibili / YouTube / Douyin / Kuaishou",
"scripts": {
"dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*",
"dev-firefox": "npm run clear && cross-env NODE_ENV=development EXTENSION=firefox run-p dev:*",
"dev:prepare": "esno scripts/prepare.ts",
"dev:background": "npm run build:background -- --mode development",
"dev:web": "vite",
"dev:js": "npm run build:js -- --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js",
"build:prepare": "esno scripts/prepare.ts",
"build:background": "vite build --config vite.config.background.mts",
"build:web": "vite build",
"build:js": "vite build --config vite.config.content.mts",
"pack": "cross-env NODE_ENV=production run-p pack:*",
"pack:zip": "rimraf extension.zip && jszip-cli add extension/* -o ./extension.zip",
"pack:crx": "crx pack extension -o ./extension.crx",
"pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename extension.xpi --overwrite-dest",
"start:chromium": "web-ext run --source-dir ./extension --target=chromium",
"start:firefox": "web-ext run --source-dir ./extension --target=firefox-desktop",
"clear": "rimraf --glob extension/dist extension/manifest.json extension.*",
"lint": "eslint --cache .",
"test": "vitest test",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@antfu/eslint-config": "^2.27.0",
"@ffflorian/jszip-cli": "^3.8.5",
"@iconify/json": "^2.2.239",
"@playwright/test": "^1.46.1",
"@types/fs-extra": "^11.0.4",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.5.0",
"@types/webextension-polyfill": "^0.12.0",
"@typescript-eslint/eslint-plugin": "^8.2.0",
"@unocss/reset": "^0.62.2",
"@vitejs/plugin-vue": "^5.1.2",
"@vue/compiler-sfc": "^3.4.38",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^11.0.1",
"chokidar": "^3.6.0",
"cross-env": "^7.0.3",
"crx": "^5.0.1",
"eslint": "^9.9.0",
"esno": "^4.7.0",
"fs-extra": "^11.2.0",
"jsdom": "^24.1.1",
"kolorist": "^1.8.0",
"lint-staged": "^15.2.9",
"npm-run-all": "^4.1.5",
"rimraf": "^6.0.1",
"simple-git-hooks": "^2.11.1",
"typescript": "^5.5.4",
"unocss": "^0.62.2",
"unplugin-auto-import": "^0.18.2",
"unplugin-icons": "^0.19.2",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.2",
"vitest": "^2.0.5",
"vue": "^3.4.38",
"vue-demi": "^0.14.10",
"web-ext": "^8.2.0",
"webext-bridge": "^6.0.1",
"webextension-polyfill": "^0.12.0"
},
"dependencies": {
"markdown-it": "^14.1.0",
"markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12"
}
}

View File

@@ -0,0 +1,15 @@
/**
* @see {@link https://playwright.dev/docs/chrome-extensions Chrome extensions | Playwright}
*/
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
retries: 2,
webServer: {
command: 'npm run dev',
// start e2e test after the Vite server is fully prepared
url: 'http://localhost:3303/popup/main.ts',
reuseExistingServer: true,
},
})

9983
BillNote_extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import fs from 'fs-extra'
import { getManifest } from '../src/manifest'
import { log, r } from './utils'
export async function writeManifest() {
await fs.writeJSON(r('extension/manifest.json'), await getManifest(), { spaces: 2 })
log('PRE', 'write manifest.json')
}
writeManifest()

View File

@@ -0,0 +1,40 @@
// generate stub index.html files for dev entry
import { execSync } from 'node:child_process'
import fs from 'fs-extra'
import chokidar from 'chokidar'
import { isDev, log, port, r } from './utils'
/**
* Stub index.html to use Vite in development
*/
async function stubIndexHtml() {
const views = ['options', 'popup', 'sidepanel']
for (const view of views) {
await fs.ensureDir(r(`extension/dist/${view}`))
let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8')
data = data
.replace('"./main.ts"', `"http://localhost:${port}/${view}/main.ts"`)
.replace('<div id="app"></div>', '<div id="app">Vite server did not start</div>')
await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8')
log('PRE', `stub ${view}`)
}
}
function writeManifest() {
execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' })
}
writeManifest()
if (isDev) {
stubIndexHtml()
chokidar.watch(r('src/**/*.html'))
.on('change', () => {
stubIndexHtml()
})
chokidar.watch([r('src/manifest.ts'), r('package.json')])
.on('change', () => {
writeManifest()
})
}

View File

@@ -0,0 +1,12 @@
import { resolve } from 'node:path'
import process from 'node:process'
import { bgCyan, black } from 'kolorist'
export const port = Number(process.env.PORT || '') || 3303
export const r = (...args: string[]) => resolve(__dirname, '..', ...args)
export const isDev = process.env.NODE_ENV !== 'production'
export const isFirefox = process.env.EXTENSION === 'firefox'
export function log(name: string, message: string) {
console.log(black(bgCyan(` ${name} `)), message)
}

10
BillNote_extension/shim.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import type { ProtocolWithReturn } from 'webext-bridge'
declare module 'webext-bridge' {
export interface ProtocolMap {
// define message protocol types
// see https://github.com/antfu/webext-bridge#type-safe-protocols
'tab-prev': { title: string | undefined }
'get-current-tab': ProtocolWithReturn<{ tabId: number }, { title?: string }>
}
}

View File

@@ -0,0 +1,12 @@
<svg width="415" height="412" viewBox="0 0 415 412" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 28C0 12.536 12.536 0 28 0H387C402.464 0 415 12.536 415 28V384C415 399.464 402.464 412 387 412H28C12.536 412 0 399.464 0 384V28Z" fill="#3C77FB"/>
<rect x="60" y="64" width="296" height="283" rx="37" fill="white"/>
<path d="M268.422 175.657C276.308 180.298 276.308 191.702 268.422 196.343L186.335 244.641C178.336 249.348 168.25 243.58 168.25 234.298V137.702C168.25 128.42 178.336 122.652 186.335 127.359L268.422 175.657Z" fill="#3C77FB"/>
<path d="M17 282C17 270.954 25.9543 262 37 262H83C94.0457 262 103 270.954 103 282V282C103 293.046 94.0457 302 83 302H37C25.9543 302 17 293.046 17 282V282Z" fill="#3C77FB"/>
<path d="M38 281.5C38 274.044 44.0442 268 51.5 268H82.5C89.9558 268 96 274.044 96 281.5V281.5C96 288.956 89.9558 295 82.5 295H51.5C44.0442 295 38 288.956 38 281.5V281.5Z" fill="white"/>
<path d="M17 206C17 194.954 25.9543 186 37 186H83C94.0457 186 103 194.954 103 206V206C103 217.046 94.0457 226 83 226H37C25.9543 226 17 217.046 17 206V206Z" fill="#3C77FB"/>
<path d="M38 205.5C38 198.044 44.0442 192 51.5 192H82.5C89.9558 192 96 198.044 96 205.5V205.5C96 212.956 89.9558 219 82.5 219H51.5C44.0442 219 38 212.956 38 205.5V205.5Z" fill="white"/>
<path d="M17 130C17 118.954 25.9543 110 37 110H83C94.0457 110 103 118.954 103 130V130C103 141.046 94.0457 150 83 150H37C25.9543 150 17 141.046 17 130V130Z" fill="#3C77FB"/>
<path d="M38 129.5C38 122.044 44.0442 116 51.5 116H82.5C89.9558 116 96 122.044 96 129.5V129.5C96 136.956 89.9558 143 82.5 143H51.5C44.0442 143 38 136.956 38 129.5V129.5Z" fill="white"/>
<path d="M145 290C145 285.582 148.582 282 153 282H284C288.418 282 292 285.582 292 290V299C292 303.418 288.418 307 284 307H153C148.582 307 145 303.418 145 299V290Z" fill="#3C77FB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,18 @@
import { isFirefox, isForbiddenUrl } from '~/env'
// Firefox fetch files from cache instead of reloading changes from disk,
// hmr will not work as Chromium based browser
browser.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => {
// Filter out non main window events.
if (frameId !== 0)
return
if (isForbiddenUrl(url))
return
// inject the latest scripts
browser.tabs.executeScript(tabId, {
file: `${isFirefox ? '' : '.'}/dist/contentScripts/index.global.js`,
runAt: 'document_end',
}).catch(error => console.error(error))
})

View File

@@ -0,0 +1,184 @@
import { onMessage } from 'webext-bridge/background'
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'
// only on dev mode
if (import.meta.hot) {
// @ts-expect-error for background HMR
import('/@vite/client')
// load latest content script
import('./contentScriptHMR')
}
// ---------- 直接操作 chrome.storageservice worker 里别用 Vue 反应式)----------
async function readSettings(): Promise<Settings> {
const obj = await browser.storage.local.get(SETTINGS_KEY)
const raw = obj[SETTINGS_KEY] as string | undefined
if (!raw)
return { ...DEFAULT_SETTINGS }
try {
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) }
}
catch {
return { ...DEFAULT_SETTINGS }
}
}
async function readTasks(): Promise<TaskRecord[]> {
const obj = await browser.storage.local.get(TASKS_KEY)
const raw = obj[TASKS_KEY] as string | undefined
if (!raw)
return []
try {
return JSON.parse(raw) as TaskRecord[]
}
catch {
return []
}
}
async function writeTasks(tasks: TaskRecord[]) {
await browser.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks.slice(0, MAX_TASKS)) })
}
async function upsertTask(record: TaskRecord) {
const tasks = await readTasks()
const idx = tasks.findIndex(t => t.taskId === record.taskId)
if (idx >= 0)
tasks.splice(idx, 1, { ...tasks[idx], ...record })
else
tasks.unshift(record)
await writeTasks(tasks)
}
// ---------- 启动任务 ----------
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
const platform = detectPlatform(url)
if (!platform)
return { ok: false, error: '当前链接不是支持的视频平台' }
const settings = await readSettings()
if (!settings.providerId || !settings.modelName)
return { ok: false, error: '请先在设置页选择供应商与模型' }
const backend = settings.backendUrl.replace(/\/$/, '')
// B 站:先在浏览器里抓字幕(带本地登录态 cookie随提交带过去
const prefetched = platform === 'bilibili' ? await fetchBilibiliSubtitle(url) : null
try {
const res = await fetch(`${backend}/api/generate_note`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_url: url,
platform,
quality: settings.quality,
provider_id: settings.providerId,
model_name: settings.modelName,
screenshot: settings.screenshot,
link: settings.link,
style: settings.style || undefined,
format: [
...(settings.screenshot ? ['screenshot'] : []),
...(settings.link ? ['link'] : []),
],
prefetched_transcript: prefetched ?? undefined,
}),
})
if (!res.ok)
return { ok: false, error: `HTTP ${res.status}` }
const body = await res.json() as { code: number, msg: string, data: { task_id: string } }
if (body.code !== 0)
return { ok: false, error: body.msg }
await upsertTask({
taskId: body.data.task_id,
videoUrl: url,
platform,
status: 'PENDING',
message: '已提交',
createdAt: Date.now(),
updatedAt: Date.now(),
})
return { ok: true, taskId: body.data.task_id }
}
catch (e) {
return { ok: false, error: (e as Error).message }
}
}
async function openSidePanelInTab(tabId?: number) {
try {
// @ts-expect-error chrome.sidePanel 类型在 webextension-polyfill 中尚未补全
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open && tabId !== undefined)
// @ts-expect-error see above
await chrome.sidePanel.open({ tabId })
}
catch (err) {
console.warn('打开侧边栏失败:', err)
}
}
// ---------- 消息桥 ----------
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
const result = await startTask(data.url)
// 成功就把侧边栏拉起来给用户看进度
if (result.ok)
await openSidePanelInTab(sender?.tabId)
return result
})
// ---------- 安装时事件 ----------
browser.runtime.onInstalled.addListener(() => {
console.log('BiliNote extension installed')
// 右键菜单:在视频页或视频链接上"用 BiliNote 总结"
try {
browser.contextMenus.create({
id: 'bilinote-summarize-page',
title: '用 BiliNote 总结此视频',
contexts: ['page', 'link', 'video'],
documentUrlPatterns: [
'*://*.bilibili.com/*',
'*://*.youtube.com/*',
'*://youtu.be/*',
'*://*.douyin.com/*',
'*://*.kuaishou.com/*',
],
})
}
catch (e) {
console.warn('注册右键菜单失败:', e)
}
})
browser.contextMenus?.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== 'bilinote-summarize-page')
return
const url = info.linkUrl || tab?.url
if (!url)
return
const result = await startTask(url)
if (result.ok)
await openSidePanelInTab(tab?.id)
else
console.warn('右键启动失败:', result.error)
})
// content script 占位握手 —— 未来可扩展为查询当前任务等
onMessage('get-current-tab', async () => {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
return { title: tab?.title, url: tab?.url }
}
catch {
return { title: undefined, url: undefined }
}
})

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { askChat, getChatStatus, indexChatTask, type ChatMessage } from '~/logic/api'
import { settings } from '~/logic/storage'
const props = defineProps<{ taskId: string }>()
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const messages = ref<ChatMessage[]>([])
const draft = ref('')
const sending = ref(false)
const indexState = ref<'idle' | 'indexing' | 'indexed' | 'failed' | 'unknown'>('unknown')
const error = ref('')
const scrollEl = ref<HTMLElement | null>(null)
let pollTimer: ReturnType<typeof setTimeout> | null = null
const ready = computed(() => indexState.value === 'indexed')
const canSend = computed(() => ready.value && draft.value.trim().length > 0 && !sending.value && !!settings.value.providerId && !!settings.value.modelName)
async function pollIndex() {
try {
const res = await getChatStatus(props.taskId)
indexState.value = res.status
if (res.status === 'indexing')
pollTimer = setTimeout(pollIndex, 2000)
}
catch (e) {
error.value = (e as Error).message
indexState.value = 'failed'
}
}
async function ensureIndexed() {
error.value = ''
indexState.value = 'unknown'
try {
const status = await getChatStatus(props.taskId)
indexState.value = status.status
if (status.indexed)
return
indexState.value = 'indexing'
await indexChatTask(props.taskId)
pollIndex()
}
catch (e) {
error.value = (e as Error).message
indexState.value = 'failed'
}
}
async function send() {
if (!canSend.value)
return
const question = draft.value.trim()
draft.value = ''
messages.value.push({ role: 'user', content: question })
await scrollDown()
sending.value = true
try {
const res = await askChat({
task_id: props.taskId,
question,
history: messages.value.slice(0, -1),
provider_id: settings.value.providerId,
model_name: settings.value.modelName,
}) as { answer?: string, content?: string, message?: string } | string
const reply = typeof res === 'string'
? res
: (res.answer ?? res.content ?? res.message ?? JSON.stringify(res))
messages.value.push({ role: 'assistant', content: reply })
await scrollDown()
}
catch (e) {
messages.value.push({ role: 'assistant', content: `❌ 调用失败:${(e as Error).message}` })
}
finally {
sending.value = false
}
}
async function scrollDown() {
await nextTick()
if (scrollEl.value)
scrollEl.value.scrollTop = scrollEl.value.scrollHeight
}
watch(() => props.taskId, () => {
messages.value = []
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
ensureIndexed()
}, { immediate: false })
onMounted(ensureIndexed)
onUnmounted(() => {
if (pollTimer)
clearTimeout(pollTimer)
})
</script>
<template>
<div class="flex flex-col h-full bg-white">
<header class="px-2 py-1 text-xs border-b flex items-center gap-2">
<span v-if="indexState === 'indexed'" class="tag bg-green-100 text-green-700">已索引</span>
<span v-else-if="indexState === 'indexing'" class="tag bg-yellow-100 text-yellow-700">索引中</span>
<span v-else-if="indexState === 'failed'" class="tag bg-red-100 text-red-700">索引失败</span>
<span v-else class="tag bg-gray-100 text-gray-500">检查中</span>
<button class="ml-auto text-xs text-gray-500 hover:text-gray-800" @click="ensureIndexed">
重新索引
</button>
</header>
<div v-if="error" class="text-xs text-red-600 px-2 py-1">{{ error }}</div>
<div ref="scrollEl" class="flex-1 overflow-auto px-2 py-2 flex flex-col gap-2">
<div v-if="messages.length === 0 && ready" class="text-xs text-gray-400 italic">
基于这条笔记的全文 + 视频元信息提问例如这个视频的核心论点是什么
</div>
<div
v-for="(m, i) in messages"
:key="i"
class="text-sm"
>
<div
class="inline-block max-w-[90%] px-3 py-2 rounded"
:class="m.role === 'user'
? 'bg-blue-600 text-white ml-auto block'
: 'bg-gray-100 text-gray-800'"
>
<div v-if="m.role === 'assistant'" v-html="md.render(m.content)" class="prose prose-sm max-w-none" />
<div v-else class="whitespace-pre-wrap break-words">{{ m.content }}</div>
</div>
</div>
<div v-if="sending" class="text-xs text-gray-500 italic">思考中</div>
</div>
<footer class="border-t p-2 flex gap-2">
<textarea
v-model="draft"
class="input flex-1 resize-none"
rows="2"
:placeholder="ready ? '问点什么…Cmd/Ctrl + Enter 发送)' : '索引完成后才能问答'"
:disabled="!ready"
@keydown.enter.exact.meta.prevent="send"
@keydown.enter.exact.ctrl.prevent="send"
/>
<button class="btn-primary" :disabled="!canSend" @click="send">
{{ sending ? '' : '发送' }}
</button>
</footer>
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<a class="icon-btn mx-2 text-2xl" rel="noreferrer" href="https://github.com/antfu/vitesse-webext" target="_blank" title="GitHub">
<pixelarticons-power />
</a>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'
const props = defineProps<{ markdown: string, title?: string, hideActions?: boolean }>()
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const html = computed(() => md.render(absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))))
async function copy() {
await navigator.clipboard.writeText(props.markdown)
}
function download() {
const blob = new Blob([props.markdown], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.title || 'bilinote'}.md`
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<div v-if="!hideActions" class="flex gap-2 justify-end shrink-0">
<button class="btn-secondary" @click="copy">复制 Markdown</button>
<button class="btn-secondary" @click="download">下载 .md</button>
</div>
<div class="prose prose-sm max-w-none px-3 py-2 flex-1 min-h-0 overflow-auto" v-html="html" />
</div>
</template>
<style>
.prose img { max-width: 100%; }
.prose h1, .prose h2, .prose h3 { font-weight: 600; margin-top: 0.8em; margin-bottom: 0.4em; }
.prose p { margin-bottom: 0.5em; line-height: 1.55; }
.prose ul, .prose ol { padding-left: 1.4em; margin-bottom: 0.5em; }
.prose code { background: #eee; padding: 0 4px; border-radius: 3px; font-size: 0.9em; }
.prose a { color: #2563eb; text-decoration: underline; }
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted, 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 svgRef = ref<SVGSVGElement | null>(null)
let mm: Markmap | null = null
const transformer = new Transformer()
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())
}
onMounted(render)
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>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Platform } from '~/logic/types'
import { PLATFORM_LABELS } from '~/logic/platform'
const props = defineProps<{ platform: Platform | null }>()
const colorMap: Record<Platform, string> = {
bilibili: 'bg-pink-100 text-pink-700',
youtube: 'bg-red-100 text-red-700',
douyin: 'bg-zinc-200 text-zinc-800',
kuaishou: 'bg-orange-100 text-orange-700',
local: 'bg-gray-100 text-gray-600',
}
const cls = computed(() => (props.platform ? colorMap[props.platform] : 'bg-gray-100 text-gray-500'))
const label = computed(() => (props.platform ? PLATFORM_LABELS[props.platform] : '未识别'))
</script>
<template>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :class="cls">
{{ label }}
</span>
</template>

View File

@@ -0,0 +1,11 @@
## Components
Components in this dir will be auto-registered and on-demand, powered by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components).
Components can be shared in all views.
### Icons
You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/).
It will only bundle the icons you use. Check out [unplugin-icons](https://github.com/unplugin/unplugin-icons) for more details.

View File

@@ -0,0 +1,5 @@
<template>
<p class="mt-2 opacity-50">
This is the {{ $app.context }} page
</p>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TaskStatus } from '~/logic/types'
const props = defineProps<{ status: TaskStatus, message?: string }>()
const STAGE_ORDER: TaskStatus[] = ['PENDING', 'PARSING', 'DOWNLOADING', 'TRANSCRIBING', 'SUMMARIZING', 'FORMATTING', 'SAVING', 'SUCCESS']
const STAGE_LABELS: Record<TaskStatus, string> = {
PENDING: '排队中',
PARSING: '解析中',
DOWNLOADING: '下载中',
TRANSCRIBING: '转写中',
SUMMARIZING: '总结中',
FORMATTING: '格式化',
SAVING: '保存中',
SUCCESS: '完成',
FAILED: '失败',
}
const currentIdx = computed(() => STAGE_ORDER.indexOf(props.status))
const isFailed = computed(() => props.status === 'FAILED')
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 text-sm">
<span :class="isFailed ? 'text-red-600' : 'text-blue-600'" class="font-medium">
{{ STAGE_LABELS[status] }}
</span>
<span v-if="message" class="text-gray-500 text-xs truncate">{{ message }}</span>
</div>
<div v-if="!isFailed" class="flex gap-1">
<div
v-for="(s, i) in STAGE_ORDER"
:key="s"
class="h-1 flex-1 rounded-full"
:class="i <= currentIdx ? 'bg-blue-500' : 'bg-gray-200'"
/>
</div>
<div v-else class="h-1 rounded-full bg-red-500" />
</div>
</template>

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import Logo from '../Logo.vue'
describe('logo component', () => {
it('should render', () => {
const wrapper = mount(Logo)
expect(wrapper.html()).toBeTruthy()
})
})

View File

@@ -0,0 +1,166 @@
import { StorageSerializers } from '@vueuse/core'
import { pausableWatch, toValue, tryOnScopeDispose } from '@vueuse/shared'
import { ref, shallowRef } from 'vue-demi'
import { storage } from 'webextension-polyfill'
import type {
StorageLikeAsync,
UseStorageAsyncOptions,
} from '@vueuse/core'
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/shared'
import type { Ref } from 'vue-demi'
import type { Storage } from 'webextension-polyfill'
export type WebExtensionStorageOptions<T> = UseStorageAsyncOptions<T>
// https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorage/guess.ts
export function guessSerializerType(rawInit: unknown) {
return rawInit == null
? 'any'
: rawInit instanceof Set
? 'set'
: rawInit instanceof Map
? 'map'
: rawInit instanceof Date
? 'date'
: typeof rawInit === 'boolean'
? 'boolean'
: typeof rawInit === 'string'
? 'string'
: typeof rawInit === 'object'
? 'object'
: Number.isNaN(rawInit)
? 'any'
: 'number'
}
const storageInterface: StorageLikeAsync = {
removeItem(key: string) {
return storage.local.remove(key)
},
setItem(key: string, value: string) {
return storage.local.set({ [key]: value })
},
async getItem(key: string) {
const storedData = await storage.local.get(key)
return storedData[key] as string
},
}
/**
* https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorageAsync/index.ts
*
* @param key
* @param initialValue
* @param options
*/
export function useWebExtensionStorage<T>(
key: string,
initialValue: MaybeRefOrGetter<T>,
options: WebExtensionStorageOptions<T> = {},
): { data: RemovableRef<T>, dataReady: Promise<T> } {
const {
flush = 'pre',
deep = true,
listenToStorageChanges = true,
writeDefaults = true,
mergeDefaults = false,
shallow,
eventFilter,
onError = (e) => {
console.error(e)
},
} = options
const rawInit: T = toValue(initialValue)
const type = guessSerializerType(rawInit)
const data = (shallow ? shallowRef : ref)(initialValue) as Ref<T>
const serializer = options.serializer ?? StorageSerializers[type]
async function read(event?: { key: string, newValue: string | null }) {
if (event && event.key !== key)
return
try {
const rawValue = event ? event.newValue : await storageInterface.getItem(key)
if (rawValue == null) {
data.value = rawInit
if (writeDefaults && rawInit !== null)
await storageInterface.setItem(key, await serializer.write(rawInit))
}
else if (mergeDefaults) {
const value = await serializer.read(rawValue) as T
if (typeof mergeDefaults === 'function')
data.value = mergeDefaults(value, rawInit)
else if (type === 'object' && !Array.isArray(value))
data.value = { ...(rawInit as Record<keyof unknown, unknown>), ...(value as Record<keyof unknown, unknown>) } as T
else data.value = value
}
else {
data.value = await serializer.read(rawValue) as T
}
}
catch (error) {
onError(error)
}
}
const dataReadyPromise = new Promise<T>((resolve, reject) => {
read().then(() => resolve(data.value)).catch(reject)
})
async function write() {
try {
await (
data.value == null
? storageInterface.removeItem(key)
: storageInterface.setItem(key, await serializer.write(data.value))
)
}
catch (error) {
onError(error)
}
}
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
data,
write,
{
flush,
deep,
eventFilter,
},
)
if (listenToStorageChanges) {
const listener = async (changes: Record<string, Storage.StorageChange>) => {
try {
pauseWatch()
for (const [key, change] of Object.entries(changes)) {
await read({
key,
newValue: change.newValue as string | null,
})
}
}
finally {
resumeWatch()
}
}
storage.onChanged.addListener(listener)
tryOnScopeDispose(() => {
storage.onChanged.removeListener(listener)
})
}
return {
data: data as RemovableRef<T>,
dataReady: dataReadyPromise,
}
}

View File

@@ -0,0 +1,24 @@
import { createApp } from 'vue'
import App from './views/App.vue'
import { setupApp } from '~/logic/common-setup'
import { detectPlatform } from '~/logic/platform'
// 只在支持的视频平台上挂悬浮按钮,避免污染其他网站
(() => {
if (!detectPlatform(window.location.href))
return
const container = document.createElement('div')
container.id = __NAME__
const root = document.createElement('div')
const styleEl = document.createElement('link')
const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container
styleEl.setAttribute('rel', 'stylesheet')
styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css'))
shadowDOM.appendChild(styleEl)
shadowDOM.appendChild(root)
document.body.appendChild(container)
const app = createApp(App)
setupApp(app)
app.mount(root)
})()

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import 'uno.css'
import { computed, ref } from 'vue'
import { sendMessage } from 'webext-bridge/content-script'
import { detectPlatform, PLATFORM_LABELS } from '~/logic/platform'
const platform = detectPlatform(window.location.href)
const busy = ref(false)
const toast = ref<{ kind: 'ok' | 'err', text: string } | null>(null)
const label = computed(() => platform ? `用 BiliNote 总结这个${PLATFORM_LABELS[platform]}视频` : '')
async function trigger() {
if (!platform || busy.value)
return
busy.value = true
toast.value = null
try {
const res = await sendMessage('bilinote-start', {
url: window.location.href,
platform,
}, 'background')
const ok = res && (res as any).ok
toast.value = ok
? { kind: 'ok', text: '已开始生成笔记,可在侧边栏 / popup 查看进度' }
: { kind: 'err', text: (res as any)?.error || '提交失败,请打开设置检查后端与供应商' }
}
catch (e) {
toast.value = { kind: 'err', text: (e as Error).message }
}
finally {
busy.value = false
setTimeout(() => { toast.value = null }, 4000)
}
}
</script>
<template>
<div v-if="platform" class="bilinote-fab fixed bottom-24 right-6 z-[2147483647] flex flex-col items-end gap-2 font-sans select-none">
<div
v-if="toast"
class="text-xs px-3 py-2 rounded shadow max-w-[260px]"
:class="toast.kind === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'"
>
{{ toast.text }}
</div>
<button
class="flex items-center gap-2 px-3 py-2 rounded-full shadow-lg cursor-pointer border-none text-white text-sm font-medium bg-pink-600 hover:bg-pink-700 disabled:bg-pink-300"
:disabled="busy"
:title="label"
@click="trigger"
>
<span class="text-base">📝</span>
<span>{{ busy ? '提交中…' : 'BiliNote' }}</span>
</button>
</div>
</template>

View File

@@ -0,0 +1,14 @@
const forbiddenProtocols = [
'chrome-extension://',
'chrome-search://',
'chrome://',
'devtools://',
'edge://',
'https://chrome.google.com/webstore',
]
export function isForbiddenUrl(url: string): boolean {
return forbiddenProtocols.some(protocol => url.startsWith(protocol))
}
export const isFirefox = navigator.userAgent.includes('Firefox')

8
BillNote_extension/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare const __DEV__: boolean
/** Extension name, defined in packageJson.name */
declare const __NAME__: string
declare module '*.vue' {
const component: any
export default component
}

View File

@@ -0,0 +1,235 @@
import type {
DeployStatus,
GenerateRequest,
Model,
Provider,
ProviderCreatePayload,
ProviderUpdatePayload,
TaskStatusResponse,
TranscriberConfig,
TranscriberModelsStatus,
TranscriberType,
WhisperModelSize,
} from './types'
import { settings } from './storage'
interface ApiEnvelope<T> {
code: number
msg: string
data: T
}
function backendUrl(): string {
return (settings.value?.backendUrl || 'http://localhost:8483').replace(/\/$/, '')
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${backendUrl()}${path}`, {
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
...init,
})
if (!res.ok)
throw new Error(`HTTP ${res.status}: ${await res.text()}`)
const body = (await res.json()) as ApiEnvelope<T> | T
// 后端 ResponseWrapper 包了 {code, msg, data};非 0 视为业务错
if (body && typeof body === 'object' && 'code' in body) {
const env = body as ApiEnvelope<T>
if (env.code !== 0)
throw new Error(env.msg || '后端返回失败')
return env.data
}
return body as T
}
export async function getProviders(): Promise<Provider[]> {
return request<Provider[]>('/api/get_all_providers')
}
export async function getModelsByProvider(providerId: string): Promise<Model[]> {
return request<Model[]>(`/api/model_enable/${providerId}`)
}
export async function setDownloaderCookie(platform: string, cookie: string): Promise<void> {
await request('/api/update_downloader_cookie', {
method: 'POST',
body: JSON.stringify({ platform, cookie }),
})
}
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 }),
})
}
// ---- RAG Chat ----
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export async function indexChatTask(taskId: string): Promise<void> {
await request('/api/chat/index', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}
export async function getChatStatus(taskId: string): Promise<{ status: 'idle' | 'indexing' | 'indexed' | 'failed', indexed: boolean }> {
return request(`/api/chat/status?task_id=${encodeURIComponent(taskId)}`)
}
export async function askChat(payload: {
task_id: string
question: string
history: ChatMessage[]
provider_id: string
model_name: string
}): Promise<unknown> {
return request('/api/chat/ask', {
method: 'POST',
body: JSON.stringify(payload),
})
}
// ---- 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',
body: JSON.stringify(payload),
})
}
export async function getTaskStatus(taskId: string): Promise<TaskStatusResponse> {
// /task_status 永远 HTTP 200body 是 ResponseWrapper
// 成功:{code:0, data:{status, message, task_id, result?}}
// 任务失败:{code:500, msg:'xxx', data:null}
// 这里手动拆,把任务失败翻译成 status:'FAILED',避免 request() 抛错让 UI 收不到状态
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`)
if (!res.ok)
throw new Error(`HTTP ${res.status}`)
const body = (await res.json()) as { code: number, msg: string, data: TaskStatusResponse | null }
if (body.code === 0 && body.data)
return body.data
return { status: 'FAILED', message: body.msg || '任务失败', task_id: taskId }
}
export async function ping(): Promise<boolean> {
try {
await getProviders()
return true
}
catch {
return false
}
}
// markdown 里的 /static/screenshots/xxx 是相对路径extension 渲染时需要拼绝对地址
export function absolutizeMarkdownImages(md: string): string {
const base = backendUrl()
return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`)
}
// backend 用 note_helper 在笔记开头插一行 '> 来源链接URL'。侧边栏顶部已经有原片链接卡片,
// 渲染前把它剥掉,避免重复占位。复制/下载的 .md 保留原样以便溯源。
// 与 BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx:468 对齐
export function stripSourceLink(md: string): string {
return md.replace(/^>\s*来源链接:[^\n]*\n*/m, '')
}
// 单个图片 URL 的处理:相对路径 → 拼后端域名B 站等带防盗链的封面 → 走后端 image_proxy
export function resolveImageUrl(url: string | undefined | null): string {
if (!url)
return ''
const base = backendUrl()
if (url.startsWith('/'))
return `${base}${url}`
// B 站封面、抖音封面等会做 referer 校验;走后端代理
if (/(hdslb|byteimg|kpcdn|akamaized|ytimg)\.com/i.test(url))
return `${base}/api/image_proxy?url=${encodeURIComponent(url)}`
return url
}

View File

@@ -0,0 +1,125 @@
// 在浏览器里直接调 B 站 player API 抓字幕。
// 因为 manifest host_permissions: '*://*/*' 覆盖 api.bilibili.comservice worker 里的
// fetch 会自动带 .bilibili.com 域下的用户 cookie并且绕过 CORS——AI 字幕需要登录态,
// 这等于用用户当前浏览器的登录身份代替了 backend 那边的 SESSDATA 配置。
//
// 与 backend/app/downloaders/bilibili_subtitle.py 的 BilibiliSubtitleFetcher 行为对齐。
const UA
= 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
export interface PrefetchedTranscript {
language: string
full_text: string
segments: Array<{ start: number, end: number, text: string }>
source: 'bilibili_extension'
}
interface SubtitleEntry {
lan?: string
ai_type?: number
subtitle_url?: string
}
function extractBvid(url: string): string | null {
const m = url.match(/BV([0-9A-Za-z]+)/)
return m ? `BV${m[1]}` : null
}
async function jsonGet<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url, {
credentials: 'include',
headers: { 'User-Agent': UA, 'Referer': 'https://www.bilibili.com' },
})
if (!res.ok)
return null
return await res.json() as T
}
catch (e) {
console.warn('[bilinote] B 站 API 请求失败:', url, e)
return null
}
}
async function getCid(bvid: string): Promise<number | null> {
const data = await jsonGet<{ code: number, data?: { cid?: number } }>(
`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
)
if (!data || data.code !== 0)
return null
return data.data?.cid ?? null
}
async function listSubtitles(bvid: string, cid: number): Promise<SubtitleEntry[]> {
const data = await jsonGet<{
code: number
data?: { subtitle?: { subtitles?: SubtitleEntry[] } }
}>(`https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`)
if (!data || data.code !== 0)
return []
return data.data?.subtitle?.subtitles ?? []
}
function pickSubtitle(subtitles: SubtitleEntry[]): SubtitleEntry | null {
if (!subtitles.length)
return null
const isZh = (s: SubtitleEntry) => {
const lan = (s.lan || '').toLowerCase()
return lan.startsWith('zh') || lan === 'ai-zh'
}
// 优先级:人工中文 > AI 中文 > 任意非空
return (
subtitles.find(s => isZh(s) && !s.ai_type)
|| subtitles.find(s => isZh(s))
|| subtitles[0]
)
}
function normalizeUrl(url: string): string {
return url.startsWith('//') ? `https:${url}` : url
}
interface SubtitleBody {
body?: Array<{ from?: number, to?: number, content?: string }>
}
export async function fetchBilibiliSubtitle(videoUrl: string): Promise<PrefetchedTranscript | null> {
const bvid = extractBvid(videoUrl)
if (!bvid)
return null
const cid = await getCid(bvid)
if (!cid)
return null
const subtitles = await listSubtitles(bvid, cid)
const track = pickSubtitle(subtitles)
if (!track?.subtitle_url) {
console.info(`[bilinote] B 站 ${bvid} 没找到可用字幕轨(可能未登录或视频无字幕)`)
return null
}
const sub = await jsonGet<SubtitleBody>(normalizeUrl(track.subtitle_url))
const body = sub?.body || []
const segments: PrefetchedTranscript['segments'] = []
for (const item of body) {
const text = (item.content || '').trim()
if (!text)
continue
segments.push({
start: Number(item.from || 0),
end: Number(item.to || 0),
text,
})
}
if (!segments.length)
return null
return {
language: track.lan || 'zh',
full_text: segments.map(s => s.text).join(' '),
segments,
source: 'bilibili_extension',
}
}

View File

@@ -0,0 +1,15 @@
import type { App } from 'vue'
export function setupApp(app: App) {
// Inject a globally available `$app` object in template
app.config.globalProperties.$app = {
context: '',
}
// Provide access to `app` in script setup with `const app = inject('app')`
app.provide('app', app.config.globalProperties.$app)
// Here you can install additional plugins for all contexts: popup, options page and content-script.
// example: app.use(i18n)
// example excluding content-script context: if (context !== 'content-script') app.use(i18n)
}

View File

@@ -0,0 +1,18 @@
import type { Settings } from './types'
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
export const DEFAULT_SETTINGS: Settings = {
backendUrl: DEFAULT_BACKEND_URL,
providerId: '',
modelName: '',
quality: 'medium',
screenshot: false,
link: false,
style: '',
}
export const MAX_TASKS = 30
export const SETTINGS_KEY = 'bilinote-settings'
export const TASKS_KEY = 'bilinote-tasks'

View File

@@ -0,0 +1,38 @@
import { setDownloaderCookie } from './api'
import type { Platform } from './types'
// 后端期望的 cookie 字符串格式name=value; name=value; ...
// 见 backend/app/downloaders/bilibili_downloader.py 的 split("; ")
const COOKIE_DOMAINS: Record<Exclude<Platform, 'local'>, string> = {
bilibili: '.bilibili.com',
youtube: '.youtube.com',
douyin: '.douyin.com',
kuaishou: '.kuaishou.com',
}
export const SUPPORTED_COOKIE_PLATFORMS: Array<Exclude<Platform, 'local'>> = [
'bilibili',
'douyin',
'kuaishou',
'youtube',
]
export async function readBrowserCookies(platform: Exclude<Platform, 'local'>): Promise<string> {
const domain = COOKIE_DOMAINS[platform]
const list = await browser.cookies.getAll({ domain })
return list.map(c => `${c.name}=${c.value}`).join('; ')
}
export async function syncCookieToBackend(platform: Exclude<Platform, 'local'>): Promise<{ ok: boolean, count: number, error?: string }> {
try {
const cookieStr = await readBrowserCookies(platform)
if (!cookieStr)
return { ok: false, count: 0, error: '当前浏览器没有该域名的 cookie先在浏览器内登录目标站点' }
const count = cookieStr.split('; ').length
await setDownloaderCookie(platform, cookieStr)
return { ok: true, count }
}
catch (e) {
return { ok: false, count: 0, error: (e as Error).message }
}
}

View File

@@ -0,0 +1 @@
export * from './storage'

View File

@@ -0,0 +1,24 @@
import type { Platform } from './types'
// 与 backend/app/validators/video_url_validator.py 保持一致
export function detectPlatform(url: string | undefined | null): Platform | null {
if (!url)
return null
if (/bilibili\.com\/video\//.test(url))
return 'bilibili'
if (/(youtube\.com\/watch|youtu\.be\/)/.test(url))
return 'youtube'
if (url.includes('douyin'))
return 'douyin'
if (url.includes('kuaishou'))
return 'kuaishou'
return null
}
export const PLATFORM_LABELS: Record<Platform, string> = {
bilibili: '哔哩哔哩',
youtube: 'YouTube',
douyin: '抖音',
kuaishou: '快手',
local: '本地',
}

View File

@@ -0,0 +1,33 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
import type { Settings, TaskRecord } from './types'
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from './constants'
export { DEFAULT_BACKEND_URL, DEFAULT_SETTINGS, MAX_TASKS } from './constants'
// 全局共享设置popup / options / sidepanel 三个 Vue 上下文都读这一份)
// 注意background service worker 不要 import 这个文件,改用 chrome.storage 直读
export const { data: settings, dataReady: settingsReady } = useWebExtensionStorage<Settings>(
SETTINGS_KEY,
DEFAULT_SETTINGS,
{ mergeDefaults: true },
)
export const { data: tasks, dataReady: tasksReady } = useWebExtensionStorage<TaskRecord[]>(
TASKS_KEY,
[],
)
export function upsertTask(record: TaskRecord) {
const list = tasks.value ?? []
const idx = list.findIndex(t => t.taskId === record.taskId)
if (idx >= 0)
list.splice(idx, 1, { ...list[idx], ...record })
else
list.unshift(record)
tasks.value = list.slice(0, MAX_TASKS)
}
export function removeTask(taskId: string) {
const list = tasks.value ?? []
tasks.value = list.filter(t => t.taskId !== taskId)
}

View File

@@ -0,0 +1,142 @@
// 与 backend/app/routers/note.py / provider.py / model.py 对齐
export type Platform = 'bilibili' | 'youtube' | 'douyin' | 'kuaishou' | 'local'
export type Quality = 'fast' | 'medium' | 'slow'
export type TaskStatus =
| 'PENDING'
| 'PARSING'
| 'DOWNLOADING'
| 'TRANSCRIBING'
| 'SUMMARIZING'
| 'FORMATTING'
| 'SAVING'
| 'SUCCESS'
| 'FAILED'
export interface Provider {
id: string
name: string
logo: string
type: string
enabled: number
base_url?: string
api_key?: string
}
export interface Model {
id: string
model_name: string
provider_id: string
}
export interface GenerateRequest {
video_url: string
platform: Platform
quality: Quality
model_name: string
provider_id: string
screenshot?: boolean
link?: boolean
format?: string[]
style?: string
extras?: string
// 客户端在浏览器里直接抓到的字幕,跳过后端的 download_subtitles + 音频转写
prefetched_transcript?: {
language: string
full_text: string
segments: Array<{ start: number, end: number, text: string }>
source?: string
}
}
export interface NoteResult {
markdown: string
transcript?: unknown
audio_meta?: {
title?: string
duration?: number
cover_url?: string
[k: string]: unknown
}
}
export interface TaskStatusResponse {
status: TaskStatus
message: string
task_id: string
result?: NoteResult
}
export interface TaskRecord {
taskId: string
videoUrl: string
platform: Platform
status: TaskStatus
message: string
createdAt: number
updatedAt: number
result?: NoteResult
}
export interface Settings {
backendUrl: string
providerId: string
modelName: string
quality: Quality
screenshot: boolean
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 }
}

View File

@@ -0,0 +1,93 @@
import fs from 'fs-extra'
import type { Manifest } from 'webextension-polyfill'
import type PkgType from '../package.json'
import { isDev, isFirefox, port, r } from '../scripts/utils'
export async function getManifest() {
const pkg = await fs.readJSON(r('package.json')) as typeof PkgType
// update this file to update this manifest.json
// can also be conditional based on your need
const manifest: Manifest.WebExtensionManifest = {
manifest_version: 3,
name: pkg.displayName || pkg.name,
version: pkg.version,
description: pkg.description,
action: {
default_icon: 'assets/icon-512.png',
default_popup: 'dist/popup/index.html',
},
options_ui: {
page: 'dist/options/index.html',
open_in_tab: true,
},
background: isFirefox
? {
scripts: ['dist/background/index.mjs'],
type: 'module',
}
: {
service_worker: 'dist/background/index.mjs',
},
icons: {
16: 'assets/icon-512.png',
48: 'assets/icon-512.png',
128: 'assets/icon-512.png',
},
permissions: [
'tabs',
'storage',
'activeTab',
'sidePanel',
'contextMenus',
'cookies',
],
host_permissions: ['*://*/*'],
content_scripts: [
{
matches: [
'<all_urls>',
],
js: [
'dist/contentScripts/index.global.js',
],
},
],
web_accessible_resources: [
{
resources: ['dist/contentScripts/style.css'],
matches: ['<all_urls>'],
},
],
content_security_policy: {
extension_pages: isDev
// this is required on dev for Vite script to load
? `script-src \'self\' http://localhost:${port}; object-src \'self\'`
: 'script-src \'self\'; object-src \'self\'',
},
}
// add sidepanel
if (isFirefox) {
manifest.sidebar_action = {
default_panel: 'dist/sidepanel/index.html',
}
}
else {
// the sidebar_action does not work for chromium based
(manifest as any).side_panel = {
default_path: 'dist/sidepanel/index.html',
}
}
// FIXME: not work in MV3
if (isDev && false) {
// for content script, as browsers will cache them for each reload,
// we use a background script to always inject the latest version
// see src/background/contentScriptHMR.ts
delete manifest.content_scripts
manifest.permissions?.push('webNavigation')
}
return manifest
}

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
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 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
const activeTab = ref<typeof TABS[number]['id']>('general')
const ActiveComponent = computed(() => TABS.find(t => t.id === activeTab.value)?.component ?? GeneralPage)
</script>
<template>
<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"
>
<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>
</aside>
<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>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_blank">
<title>Options</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './Options.vue'
import { setupApp } from '~/logic/common-setup'
import '../styles'
const app = createApp(App)
setupApp(app)
app.mount('#app')

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,276 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { detectPlatform } from '~/logic/platform'
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
import { fetchBilibiliSubtitle } from '~/logic/bilibili-subtitle'
import type { TaskRecord } from '~/logic/types'
const tabUrl = ref<string>('')
const tabTitle = ref<string>('')
const tabId = ref<number | undefined>(undefined)
const platform = computed(() => detectPlatform(tabUrl.value))
const supported = computed(() => platform.value !== null)
const submitting = ref(false)
const errorMsg = ref('')
const activeTaskId = ref<string>('')
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function loadActiveTab() {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
tabUrl.value = tab?.url ?? ''
tabTitle.value = tab?.title ?? ''
tabId.value = tab?.id
}
catch (e) {
console.warn('无法读取当前 tab:', e)
}
}
async function poll(taskId: string) {
try {
const res = await getTaskStatus(taskId)
upsertTask({
taskId,
videoUrl: activeTask.value?.videoUrl ?? tabUrl.value,
platform: (activeTask.value?.platform ?? platform.value)!,
status: res.status,
message: res.message,
createdAt: activeTask.value?.createdAt ?? Date.now(),
updatedAt: Date.now(),
result: res.result ?? activeTask.value?.result,
})
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
pollTimer = setTimeout(() => poll(taskId), 3000)
}
catch (e) {
errorMsg.value = (e as Error).message
pollTimer = setTimeout(() => poll(taskId), 5000)
}
}
async function start() {
errorMsg.value = ''
if (!supported.value) {
errorMsg.value = '当前页面不是支持的视频链接'
return
}
if (!settings.value.providerId || !settings.value.modelName) {
errorMsg.value = '请先去设置页选择供应商和模型'
return
}
submitting.value = true
try {
// B 站:在用户浏览器里直接抓字幕(带本地登录态 cookie跳过后端的 download_subtitles 与音频转写
const prefetched = platform.value === 'bilibili' ? await fetchBilibiliSubtitle(tabUrl.value) : null
const { task_id } = await generateNote({
video_url: tabUrl.value,
platform: platform.value!,
quality: settings.value.quality,
provider_id: settings.value.providerId,
model_name: settings.value.modelName,
screenshot: settings.value.screenshot,
link: settings.value.link,
style: settings.value.style || undefined,
format: [
...(settings.value.screenshot ? ['screenshot'] : []),
...(settings.value.link ? ['link'] : []),
],
prefetched_transcript: prefetched ?? undefined,
})
activeTaskId.value = task_id
upsertTask({
taskId: task_id,
videoUrl: tabUrl.value,
platform: platform.value!,
status: 'PENDING',
message: '已提交',
createdAt: Date.now(),
updatedAt: Date.now(),
})
poll(task_id)
// 提交后顺手把侧边栏拉起来,免得用户来回切窗口
openSidePanel()
}
catch (e) {
errorMsg.value = (e as Error).message
}
finally {
submitting.value = false
}
}
function openOptions() {
browser.runtime.openOptionsPage()
}
async function openSidePanel() {
// 只能在用户操作触发的同步上下文里调,且需要明确的 tabId
try {
const target = tabId.value ?? (await browser.tabs.query({ active: true, currentWindow: true }))[0]?.id
if (target == null)
return
// @ts-expect-error sidePanel 类型在 polyfill 中不全
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open)
// @ts-expect-error see above
await chrome.sidePanel.open({ tabId: target })
}
catch (err) {
console.warn('打开侧边栏失败:', err)
}
}
function selectTask(id: string) {
activeTaskId.value = id
const t = tasks.value?.find(x => x.taskId === id)
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
poll(id)
}
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)
function fmtTime(ts?: number) {
if (!ts)
return ''
const d = new Date(ts)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
onMounted(async () => {
await Promise.all([settingsReady, tasksReady])
await loadActiveTab()
const running = tasks.value?.find(t => t.status !== 'SUCCESS' && t.status !== 'FAILED')
if (running) {
activeTaskId.value = running.taskId
poll(running.taskId)
}
})
onUnmounted(() => {
if (pollTimer)
clearTimeout(pollTimer)
})
</script>
<template>
<main class="w-[400px] p-3 text-sm text-gray-800 flex flex-col gap-3 bg-white">
<header class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-base">BiliNote</span>
<PlatformBadge :platform="platform" />
</div>
<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>
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
当前页面不是 BiliNote 支持的视频链接Bilibili / YouTube / Douyin / Kuaishou
</div>
<fieldset class="border rounded p-2 flex flex-col gap-2" :disabled="!supported || submitting">
<div class="grid grid-cols-3 gap-2 text-xs">
<label class="flex flex-col gap-1">
<span class="text-gray-600">画质</span>
<select v-model="settings.quality" class="border rounded px-1 py-0.5">
<option value="fast">快速</option>
<option value="medium">中等</option>
<option value="slow">高质</option>
</select>
</label>
<label class="flex items-center gap-1 mt-4">
<input v-model="settings.screenshot" type="checkbox"> 截图
</label>
<label class="flex items-center gap-1 mt-4">
<input v-model="settings.link" type="checkbox"> 跳转
</label>
</div>
<div class="text-xs text-gray-600">
<span v-if="settings.providerId && settings.modelName">
模型{{ settings.modelName }}
</span>
<span v-else class="text-amber-700">
未选择供应商/模型
<button class="underline" @click="openOptions">去设置</button>
</span>
</div>
<button class="btn-primary" :disabled="!supported || submitting || !settings.providerId" @click="start">
{{ submitting ? '提交中' : '生成笔记' }}
</button>
</fieldset>
<div v-if="errorMsg" class="text-xs text-red-600 break-words">
{{ errorMsg }}
</div>
<section v-if="activeTask" class="flex flex-col gap-2">
<div v-if="activeCover || activeTitle" class="flex gap-3 items-start">
<img
v-if="activeCover"
:src="resolveImageUrl(activeCover)"
class="w-20 h-12 object-cover rounded border bg-gray-100 shrink-0"
alt="cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-snug line-clamp-2 break-words" :title="activeTitle">
{{ activeTitle || '(未取到标题)' }}
</div>
<div class="text-xs text-gray-400 mt-0.5">
{{ fmtTime(activeTask.updatedAt) }}
</div>
</div>
</div>
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
<button
v-if="activeTask.status === 'SUCCESS'"
class="btn-primary"
@click="openSidePanel"
>
在侧边栏查看笔记 / 思维导图 / AI 问答
</button>
<button
v-else
class="btn-secondary"
@click="openSidePanel"
>
在侧边栏看进度
</button>
</section>
<details v-if="(tasks?.length ?? 0) > 0" class="text-xs">
<summary class="cursor-pointer text-gray-500">最近任务{{ tasks!.length }}</summary>
<ul class="mt-1 flex flex-col gap-1 max-h-32 overflow-auto">
<li
v-for="t in tasks"
:key="t.taskId"
class="flex justify-between items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-100 cursor-pointer"
: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>
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
</li>
</ul>
</details>
</main>
</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-2 py-1 rounded hover:bg-gray-200 text-xs; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_blank">
<title>Popup</title>
</head>
<body style="min-width: 100px">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './Popup.vue'
import { setupApp } from '~/logic/common-setup'
import '../styles'
const app = createApp(App)
setupApp(app)
app.mount('#app')

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
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'
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 viewMode = ref<ViewMode>('markdown')
const showHistory = ref(false)
const isDone = computed(() => activeTask.value?.status === 'SUCCESS')
const isFailed = computed(() => activeTask.value?.status === 'FAILED')
const isRunning = computed(() => !!activeTask.value && !isDone.value && !isFailed.value)
const STAGE_LABELS: Record<string, string> = {
PENDING: '排队中',
PARSING: '解析中',
DOWNLOADING: '下载中',
TRANSCRIBING: '转写中',
SUMMARIZING: '总结中',
FORMATTING: '格式化',
SAVING: '保存中',
SUCCESS: '完成',
FAILED: '失败',
}
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function poll(taskId: string) {
try {
const res = await getTaskStatus(taskId)
const cur = tasks.value?.find(t => t.taskId === taskId)
if (cur) {
upsertTask({
...cur,
status: res.status,
message: res.message,
result: res.result ?? cur.result,
updatedAt: Date.now(),
})
}
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
pollTimer = setTimeout(() => poll(taskId), 3000)
}
catch (e) {
errorMsg.value = (e as Error).message
pollTimer = setTimeout(() => poll(taskId), 5000)
}
}
function selectTask(id: string) {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
activeTaskId.value = id
showHistory.value = false
const t = tasks.value?.find(x => x.taskId === id)
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
poll(id)
}
function openOptions() {
browser.runtime.openOptionsPage()
}
async function copyMarkdown() {
const md = activeTask.value?.result?.markdown
if (md)
await navigator.clipboard.writeText(md)
}
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 blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${title}.md`
a.click()
URL.revokeObjectURL(url)
}
const activeTitle = computed(() =>
(activeTask.value?.result?.audio_meta as { title?: string } | undefined)?.title || activeTask.value?.videoUrl || '')
const activeCover = computed(() =>
(activeTask.value?.result?.audio_meta as { cover_url?: string } | undefined)?.cover_url)
onMounted(async () => {
await Promise.all([settingsReady, tasksReady])
const latest = tasks.value?.[0]
if (latest) {
activeTaskId.value = latest.taskId
if (latest.status !== 'SUCCESS' && latest.status !== 'FAILED')
poll(latest.taskId)
}
})
onUnmounted(() => {
if (pollTimer)
clearTimeout(pollTimer)
})
</script>
<template>
<main class="w-full h-full flex flex-col bg-white text-sm text-gray-800">
<!-- 顶栏极简 -->
<header class="flex items-center justify-between px-3 py-2 border-b shrink-0">
<div class="font-semibold">BiliNote</div>
<div class="flex items-center gap-1">
<button
v-if="(tasks?.length ?? 0) > 0"
class="text-xs text-gray-500 hover:text-gray-800 px-2 py-0.5 rounded hover:bg-gray-100"
:class="{ 'bg-gray-100': showHistory }"
@click="showHistory = !showHistory"
>
历史 {{ tasks?.length }}
</button>
<button class="text-xs text-gray-500 hover:text-gray-800 px-2 py-0.5 rounded hover:bg-gray-100" @click="openOptions">
设置
</button>
</div>
</header>
<!-- 历史弹层覆盖在内容上方 -->
<div v-if="showHistory" class="border-b bg-gray-50 px-2 py-2 max-h-60 overflow-auto shrink-0">
<ul class="flex flex-col gap-0.5 text-xs">
<li
v-for="t in tasks"
:key="t.taskId"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-white"
: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>
<span class="text-gray-400 shrink-0">{{ STAGE_LABELS[t.status] || t.status }}</span>
</li>
</ul>
</div>
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-1 break-words bg-red-50 shrink-0">
{{ errorMsg }}
</div>
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
还没有任务在视频页点悬浮按钮 popup 提交或右键菜单选 BiliNote 总结
</section>
<section v-else class="flex-1 flex flex-col min-h-0">
<!-- 标题区紧凑一行 -->
<div class="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<img
v-if="activeCover"
:src="resolveImageUrl(activeCover)"
class="w-12 h-7 object-cover rounded bg-gray-100 shrink-0"
alt=""
@error="($event.target as HTMLImageElement).style.display = 'none'"
>
<a
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"
>{{ activeTitle }}</a>
<span
v-if="isDone"
class="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 shrink-0"
title="完成"
></span>
<span
v-else-if="isFailed"
class="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 shrink-0"
:title="activeTask.message"
>失败</span>
<span
v-else
class="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 shrink-0 animate-pulse"
>{{ STAGE_LABELS[activeTask.status] || activeTask.status }}</span>
</div>
<!-- 进行中进度条完成tab + 操作按钮 -->
<div v-if="isRunning" class="px-3 py-2 border-b shrink-0">
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
</div>
<div
v-else-if="isDone && activeTask.result?.markdown"
class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0 text-xs"
>
<button
class="px-2 py-1 rounded"
:class="viewMode === 'markdown' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
@click="viewMode = 'markdown'"
>Markdown</button>
<button
class="px-2 py-1 rounded"
:class="viewMode === 'mindmap' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
@click="viewMode = 'mindmap'"
>思维导图</button>
<button
class="px-2 py-1 rounded"
:class="viewMode === 'chat' ? 'bg-blue-600 text-white' : 'hover:bg-gray-100 text-gray-700'"
@click="viewMode = 'chat'"
>AI 问答</button>
<div class="flex-1" />
<button
v-if="viewMode === 'markdown'"
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
title="复制 Markdown"
@click="copyMarkdown"
>复制</button>
<button
v-if="viewMode === 'markdown'"
class="text-gray-500 hover:text-gray-800 px-1.5 py-1 rounded hover:bg-gray-100"
title="下载 .md"
@click="downloadMarkdown"
>下载</button>
</div>
<!-- 内容区占满剩余空间 -->
<div class="flex-1 overflow-auto min-h-0">
<MarkdownView
v-if="isDone && activeTask.result?.markdown && viewMode === 'markdown'"
:markdown="activeTask.result.markdown"
:title="(activeTask.result.audio_meta as { title?: string } | undefined)?.title"
:hide-actions="true"
/>
<MindMap
v-else-if="isDone && activeTask.result?.markdown && viewMode === 'mindmap'"
:markdown="activeTask.result.markdown"
class="h-full"
/>
<ChatPanel
v-else-if="isDone && viewMode === 'chat'"
:task-id="activeTask.taskId"
class="h-full"
/>
<div v-else-if="isFailed" class="p-4 text-sm text-red-600">
{{ activeTask.message || '任务失败' }}
</div>
</div>
</section>
</main>
</template>
<style>
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_blank">
<title>Sidepanel</title>
</head>
<body style="min-width: 100px">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './Sidepanel.vue'
import { setupApp } from '~/logic/common-setup'
import '../styles'
const app = createApp(App)
setupApp(app)
app.mount('#app')

View File

@@ -0,0 +1,3 @@
import '@unocss/reset/tailwind.css'
import './main.css'
import 'uno.css'

View File

@@ -0,0 +1,20 @@
html,
body,
#app {
margin: 0;
padding: 0;
}
.btn {
@apply px-4 py-1 rounded inline-block
bg-teal-600 text-white cursor-pointer
hover:bg-teal-700
disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50;
}
.icon-btn {
@apply inline-block cursor-pointer select-none
opacity-75 transition duration-200 ease-in-out
hover:opacity-100 hover:text-teal-600;
font-size: 0.9em;
}

View File

@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
describe('demo', () => {
it('should work', () => {
expect(1 + 1).toBe(2)
})
})

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"incremental": false,
"target": "es2016",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"paths": {
"~/*": ["src/*"]
},
"resolveJsonModule": true,
"types": [
"vite/client"
],
"strict": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'unocss/vite'
import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss'
export default defineConfig({
presets: [
presetUno(),
presetAttributify(),
presetIcons(),
],
transformers: [
transformerDirectives(),
],
})

View File

@@ -0,0 +1,36 @@
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config.mjs'
import { isDev, r } from './scripts/utils'
import packageJson from './package.json'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
define: {
'__DEV__': isDev,
'__NAME__': JSON.stringify(packageJson.name),
// https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
},
build: {
watch: isDev
? {}
: undefined,
outDir: r('extension/dist/background'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/background/main.ts'),
name: packageJson.name,
formats: ['iife'],
},
rollupOptions: {
output: {
entryFileNames: 'index.mjs',
extend: true,
},
},
},
})

View File

@@ -0,0 +1,36 @@
import { defineConfig } from 'vite'
import { sharedConfig } from './vite.config.mjs'
import { isDev, r } from './scripts/utils'
import packageJson from './package.json'
// bundling the content script using Vite
export default defineConfig({
...sharedConfig,
define: {
'__DEV__': isDev,
'__NAME__': JSON.stringify(packageJson.name),
// https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
},
build: {
watch: isDev
? {}
: undefined,
outDir: r('extension/dist/contentScripts'),
cssCodeSplit: false,
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
lib: {
entry: r('src/contentScripts/index.ts'),
name: packageJson.name,
formats: ['iife'],
},
rollupOptions: {
output: {
entryFileNames: 'index.global.js',
extend: true,
},
},
},
})

View File

@@ -0,0 +1,115 @@
/// <reference types="vitest" />
import { dirname, relative } from 'node:path'
import type { UserConfig } from 'vite'
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import UnoCSS from 'unocss/vite'
import { isDev, port, r } from './scripts/utils'
import packageJson from './package.json'
export const sharedConfig: UserConfig = {
root: r('src'),
resolve: {
alias: {
'~/': `${r('src')}/`,
},
},
define: {
__DEV__: isDev,
__NAME__: JSON.stringify(packageJson.name),
},
plugins: [
Vue(),
AutoImport({
imports: [
'vue',
{
'webextension-polyfill': [
['=', 'browser'],
],
},
],
dts: r('src/auto-imports.d.ts'),
}),
// https://github.com/antfu/unplugin-vue-components
Components({
dirs: [r('src/components')],
// generate `components.d.ts` for ts support with Volar
dts: r('src/components.d.ts'),
resolvers: [
// auto import icons
IconsResolver({
prefix: '',
}),
],
}),
// https://github.com/antfu/unplugin-icons
Icons(),
// https://github.com/unocss/unocss
UnoCSS(),
// rewrite assets to use relative path
{
name: 'assets-rewrite',
enforce: 'post',
apply: 'build',
transformIndexHtml(html, { path }) {
return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`)
},
},
],
optimizeDeps: {
include: [
'vue',
'@vueuse/core',
'webextension-polyfill',
],
exclude: [
'vue-demi',
],
},
}
export default defineConfig(({ command }) => ({
...sharedConfig,
base: command === 'serve' ? `http://localhost:${port}/` : '/dist/',
server: {
port,
hmr: {
host: 'localhost',
},
origin: `http://localhost:${port}`,
},
build: {
watch: isDev
? {}
: undefined,
outDir: r('extension/dist'),
emptyOutDir: false,
sourcemap: isDev ? 'inline' : false,
// https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements
terserOptions: {
mangle: false,
},
rollupOptions: {
input: {
options: r('src/options/index.html'),
popup: r('src/popup/index.html'),
sidepanel: r('src/sidepanel/index.html'),
},
},
},
test: {
globals: true,
environment: 'jsdom',
},
}))

View File

@@ -8,9 +8,11 @@ interface AILogoProps {
}
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
const Icon = Icons[name as keyof typeof Icons]
const Icon = name ? Icons[name as keyof typeof Icons] : undefined
if (!Icon) {
console.error(`❌ 图标组件不存在: ${name}`)
if (name && name !== 'custom') {
console.warn(`AILogo: 未匹配到图标,使用自定义占位: ${name}`)
}
return (
<span style={{ fontSize: size }}>
<img src={CustomLogo} alt="CustomLogo" style={{ width: size, height: size }} />

View File

@@ -7,9 +7,11 @@ interface AILogoProps {
}
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
const Icon = Icons[name as keyof typeof Icons];
const Icon = name ? Icons[name as keyof typeof Icons] : undefined;
if (!Icon) {
console.error(`❌ 图标组件不存在: ${name}`);
if (name && name !== 'custom') {
console.warn(`AILogo: 未匹配到图标,使用占位: ${name}`);
}
return <span style={{ fontSize: size }}>🚫</span>;
}

View File

@@ -3,11 +3,11 @@ import { Outlet } from 'react-router-dom'
const Model = () => {
return (
<div className={'flex h-full bg-white'}>
<div className={'flex-1/5 border-r border-neutral-200 p-2'}>
<div className={'flex h-full min-h-0 bg-white'}>
<div className={'flex-1/5 min-h-0 overflow-y-auto border-r border-neutral-200 p-2'}>
<Provider></Provider>
</div>
<div className={'flex-4/5'}>
<div className={'flex-4/5 min-h-0 overflow-y-auto'}>
<Outlet />
</div>
</div>

62
CHANGELOG.md Normal file
View File

@@ -0,0 +1,62 @@
# Changelog
本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [2.1.0] - 2026-05-07
本次发布的主线是**浏览器插件**和 **B 站字幕优先链路**。配合一些后端 / 前端体验修复。
### Added — 浏览器插件 (`BillNote_extension/`)
全新 Chrome / Edge / Firefox MV3 扩展。Vue 3 + Vite + UnoCSS骨架基于 vitesse-webext。
- **入口四件套**
- 工具栏 popup识别当前 tab → 一键提交,紧凑展示标题 + 封面 + 进度
- 视频页悬浮按钮:仅在支持平台注入,点击即触发任务
- 右键菜单"用 BiliNote 总结此视频":限定 4 个支持域名
- 侧边栏side panel详情视图 + 三模式切换
- **侧边栏三视图**
- Markdown渲染笔记复制 / 下载 .md
- 思维导图:基于 markmap-lib + markmap-view 的可缩放 mind map
- AI 问答:复用后端 RAG `/chat/index``/chat/status``/chat/ask` 三件套,自动索引 + 多轮历史
- **设置页五大块**(搬入 web 端全部配置能力,今后插件即配置中心)
- 通用:后端地址、连通性测试、默认供应商 / 模型 / 画质 / 截图 / 跳转 / 风格
- 模型供应商:完整 CRUD、启用切换、连接测试、模型增删
- 音频转写配置fast-whisper / mlx-whisper / Groq / 必剪 / 快手 切换、Whisper 模型大小、本地下载状态、触发下载
- 下载配置:每平台 cookie 显示、浏览器一键同步、手动粘贴
- 部署监控:后端 / FFmpeg / CUDA / Whisper 状态总览
- **浏览器 cookie 直通**`chrome.cookies.getAll` 一键把 `.bilibili.com` 等域 cookie 同步到后端 `/api/update_downloader_cookie`
- **B 站字幕浏览器抓取**:插件直接调 player API 拿字幕,借 host_permissions 自动带本地登录态 cookie绕过 CORS随提交以 `prefetched_transcript` 字段附给后端,后端跳过 `download_subtitles` + 音频转写,直接进 GPT 总结
### Added — 后端
- `BilibiliSubtitleFetcher``app/downloaders/bilibili_subtitle.py`):直接调 B 站 player API 拿字幕,作为非插件场景下 yt-dlp 路径的更可靠替代
- `VideoRequest.prefetched_transcript` 字段:客户端预取的字幕直接落到 `<task_id>_transcript.json`NoteGenerator cache-hit 自动复用
### Added — 前端 Web
- Zustand persist 迁移到 IndexedDB#318
### Changed
- 后端 CORS从静态 origin 列表改为 regex兼容 `chrome-extension://``moz-extension://``localhost``tauri.localhost`
- mlx-whisper 仓库 ID 改用 `MLX_MODEL_MAP``whisper-{size}-mlx` 命名(`large-v3-turbo` 例外),不再 hardcode 出 404
- BilibiliDownloader 从 `CookieConfigManager` 读取 cookie 并注入 yt-dlp cookiefile#333
- CLAUDE.md 补充 v2.0.0 引入的子系统说明RAG / Chat、可选 Nacos+RabbitMQ、i18n、cookie/transcriber 管理器)以及浏览器插件 workspace
### Fixed
- AILogo自定义供应商`logo='custom'`)走兜底渲染时不再误报 `console.error`,未匹配的名称降级为 warn
- SettingPage `Model.tsx` 双栏布局加 `min-h-0 overflow-y-auto`:供应商列表过长时无法滚动
- 供应商开关切换不能实时生效(#336
- `/get_all_providers` 中 301 行历史伪内置脏数据清理 + `add_provider` 加防御(强制 `type='custom'`、同名查重、错误向上抛)
- `/api/task_status` 拆 ResponseWrapper插件侧进度条因未拆 `data` 全灰;同时把 `R.error` 翻译为 `status:'FAILED'`,避免 UI 卡在轮询循环
- ESLint / ESM `__dirname` 在 production build 中未定义(多个 docker / vite 配置修复)
- GitHub Actions 构建错误 + apt-get 安装失败 + 删除仓库内 ffmpeg 二进制
- 渲染时剥掉 backend 注入的 `> 来源链接URL` 行(与 web 端 MarkdownViewer 一致),导出文件保留原行便于溯源
- 侧边栏布局收紧:完成后不再渲染 8 段进度条;标题压成单行;视图切换 + 复制 / 下载并入一行;历史任务从底部 details 改为顶栏下拉
### Internal
- 新增分支策略:`develop` / `release/x.y.z` / `master` git-flow
- 备份 backend SQLite DB 前 / 清理脏数据后均落盘存档

View File

@@ -36,29 +36,58 @@ cd backend && ./build.sh # Build PyInstaller backend binary
cd BillNote_frontend && pnpm tauri build
```
### Browser Extension (Vue 3 + vitesse-webext, MV3)
```bash
cd BillNote_extension
pnpm install
pnpm dev # watch mode → ./extension/
pnpm build # production build → ./extension/
pnpm typecheck
```
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.
## Architecture
**Backend** (`backend/`) — FastAPI app, entry point `main.py`:
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`
- `app/services/` — Business logic: `note.py` (NoteGenerator orchestrates the full pipeline), `task_serial_executor.py` (task queue)
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`, `chat.py` (RAG Q&A on generated notes)
- `app/services/` — Business logic:
- `note.py``NoteGenerator` orchestrates the full pipeline (download → transcribe → LLM → notes)
- `task_serial_executor.py` — task queue
- `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`
- `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)
- `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)
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
- `pages/SettingPage/` — LLM provider management, system monitoring, transcriber config
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`. Persists to IndexedDB.
- `services/` — Axios API clients matching backend routes
- `hooks/useTaskPolling.ts` — Polls task status every 3 seconds
- `components/ui/` — shadcn/ui (Radix-based) components
- `i18n/``react-i18next` setup with locale JSON in `i18n/locales/`; toggled via `components/LanguageSwitcher.tsx`
- Path alias: `@``./src`
**Core Workflow**: User submits URL → task queued → download video → extract audio (FFmpeg) → transcribe (Whisper/Groq/etc) → generate notes (LLM) → frontend polls for completion → display Markdown + mind map.
**Browser Extension** (`BillNote_extension/`) — Vue 3 + Vite + UnoCSS + webextension-polyfill, MV3:
- `src/popup/Popup.vue` — main entry: detects platform from active tab URL, drives generate flow, shows progress + markdown
- `src/options/Options.vue` — settings: backend URL, default provider/model (loaded from `/get_all_providers` + `/get_models_by_provider/{id}`), quality, screenshot/link toggles, style
- `src/logic/api.ts` — backend API client (uses `settings.backendUrl`, unwraps `ResponseWrapper`, absolutizes `/static/screenshots/...` image paths)
- `src/logic/storage.ts``chrome.storage.local`-backed Pinia-like state via `useWebExtensionStorage` for settings + last 30 tasks
- `src/logic/platform.ts` — URL → platform detection mirroring `backend/app/validators/video_url_validator.py`
- `src/sidepanel/`, `src/contentScripts/` — placeholders for P2/P3 (floating button, side panel mind map, RAG chat); not wired into MVP UX
- `src/manifest.ts` — MV3 manifest, popup is default action; `host_permissions: *://*/*`
- Polling lives client-side in popup (3 s interval while open); MV3 service worker is intentionally thin in P1
## Key Configuration
- **Ports**: Backend 8483, Frontend dev 3015, Docker maps 3015→80
@@ -66,6 +95,7 @@ cd BillNote_frontend && pnpm tauri build
- **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`.
## Code Style

View File

@@ -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.0.0</h1>
<h1 align="center" > BiliNote v2.1.0</h1>
</div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
@@ -53,6 +53,16 @@ BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、Y
- 笔记顶部视频封面 Banner 展示
- 工作区和生成历史面板支持折叠/展开
### v2.1.0 新增
- 浏览器插件Chrome / Edge / Firefox MV3—— 工具栏 popup、视频页悬浮按钮、右键菜单、侧边栏Markdown / 思维导图 / AI 问答)四件套
- 插件设置页五大块:模型供应商 CRUD、音频转写配置、下载配置含浏览器 Cookie 一键同步)、部署监控
- B 站字幕优先:插件在用户浏览器里直接抓字幕(带本地登录态 cookie跳过后端音频转写
- 后端 `BilibiliSubtitleFetcher`:非插件场景下走 player API 拿字幕,作为 yt-dlp 兜底
- mlx-whisper 仓库 ID 修正(修复模型 404
- 后端 CORS 改用 regex兼容浏览器扩展源
- 详见 [CHANGELOG.md](./CHANGELOG.md)
### v2.0.0 新增
- 基于 RAG 的笔记内容 AI 问答功能,支持半屏/全屏模式

View File

@@ -8,6 +8,7 @@ from typing import Union, Optional, List
import yt_dlp
from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP
from app.downloaders.bilibili_subtitle import BilibiliSubtitleFetcher
from app.models.notes_model import AudioDownloadResult
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
from app.utils.path_helper import get_data_dir
@@ -155,6 +156,15 @@ class BilibiliDownloader(Downloader, ABC):
:param langs: 优先语言列表
:return: TranscriptResult 或 None
"""
# 1) 优先走 B 站官方 player API直拉无需下视频AI 字幕需 SESSDATA cookie
try:
result = BilibiliSubtitleFetcher().fetch_subtitles(video_url)
if result and result.segments:
return result
except Exception as e:
logger.warning(f"player API 直拉字幕异常,回退到 yt-dlp: {e}")
# 2) Fallback原 yt-dlp 路径(更脆弱,遇到签名/Cookie 问题失败概率较高)
if output_dir is None:
output_dir = get_data_dir()
if not output_dir:

View File

@@ -0,0 +1,164 @@
"""
直接调用 B 站 player API 拿字幕,绕过 yt-dlp。
流程:
1. 从 URL 提 BV id已有 utils.url_parser.extract_video_id
2. GET /x/web-interface/view?bvid=BVxxx → 拿 cid
3. GET /x/player/wbi/v2?bvid=...&cid=... → 返回 data.subtitle.subtitles[]
每条带 subtitle_urlB 站后端已经签好 auth_key 的完整地址)
4. 按优先级(人工 zh-CN > AI zh-CN > 任意 zh > 任意非空)选一条
5. fetch subtitle_url → JSON {body:[{from,to,content,...}]}
6. 解析为 TranscriptResult
AI 字幕需要登录态 cookieSESSDATA通过 CookieConfigManager 注入。
"""
from typing import List, Optional
import requests
from app.models.transcriber_model import TranscriptResult, TranscriptSegment
from app.services.cookie_manager import CookieConfigManager
from app.utils.logger import get_logger
from app.utils.url_parser import extract_video_id
logger = get_logger(__name__)
UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
class BilibiliSubtitleFetcher:
"""通过 B 站官方 API 直拉字幕。"""
def __init__(self):
self._cookie = CookieConfigManager().get("bilibili") or ""
def _headers(self) -> dict:
h = {
"User-Agent": UA,
"Referer": "https://www.bilibili.com",
}
if self._cookie:
h["Cookie"] = self._cookie
return h
def _get_cid(self, bvid: str) -> Optional[int]:
url = "https://api.bilibili.com/x/web-interface/view"
try:
resp = requests.get(url, params={"bvid": bvid}, headers=self._headers(), timeout=10)
data = resp.json()
except Exception as e:
logger.warning(f"获取 cid 失败: {e}")
return None
if data.get("code") != 0:
logger.warning(f"view API 返回错误: code={data.get('code')}, msg={data.get('message')}")
return None
cid = data.get("data", {}).get("cid")
return int(cid) if cid else None
def _list_subtitles(self, bvid: str, cid: int) -> List[dict]:
url = "https://api.bilibili.com/x/player/wbi/v2"
try:
resp = requests.get(url, params={"bvid": bvid, "cid": cid}, headers=self._headers(), timeout=10)
data = resp.json()
except Exception as e:
logger.warning(f"获取字幕列表失败: {e}")
return []
if data.get("code") != 0:
logger.warning(f"player API 返回错误: code={data.get('code')}, msg={data.get('message')}")
return []
subtitles = data.get("data", {}).get("subtitle", {}).get("subtitles", [])
return subtitles or []
def _pick(self, subtitles: List[dict]) -> Optional[dict]:
"""优先级:人工中文 > AI 中文 > 任意中文 > 任意非空。"""
if not subtitles:
return None
def is_zh(s: dict) -> bool:
lan = (s.get("lan") or "").lower()
return lan.startswith("zh") or lan == "ai-zh"
# 人工中文type 0=AI, 1=人工 ai_type=0 视为人工)
for s in subtitles:
if is_zh(s) and not s.get("ai_type"):
return s
# AI 中文
for s in subtitles:
if is_zh(s):
return s
# 任意非空
return subtitles[0]
@staticmethod
def _normalize_url(url: str) -> str:
if url.startswith("//"):
return "https:" + url
return url
def _fetch_body(self, subtitle_url: str) -> Optional[List[dict]]:
try:
resp = requests.get(self._normalize_url(subtitle_url), headers=self._headers(), timeout=15)
data = resp.json()
return data.get("body") or []
except Exception as e:
logger.warning(f"下载字幕 JSON 失败: {e}")
return None
def fetch_subtitles(self, video_url: str) -> Optional[TranscriptResult]:
bvid = extract_video_id(video_url, "bilibili")
if not bvid:
logger.info("无法从 URL 提取 BV id")
return None
cid = self._get_cid(bvid)
if not cid:
logger.info(f"{bvid} 没有取到 cid")
return None
subtitles = self._list_subtitles(bvid, cid)
if not subtitles:
logger.info(f"{bvid} (cid={cid}) 没有可用字幕轨")
return None
track = self._pick(subtitles)
if not track or not track.get("subtitle_url"):
logger.info(f"{bvid} 字幕轨存在但没有 subtitle_url可能未登录、需要 SESSDATA cookie")
return None
lan = track.get("lan") or "zh"
body = self._fetch_body(track["subtitle_url"])
if not body:
return None
segments: List[TranscriptSegment] = []
for item in body:
text = (item.get("content") or "").strip()
if not text:
continue
segments.append(TranscriptSegment(
start=float(item.get("from", 0)),
end=float(item.get("to", 0)),
text=text,
))
if not segments:
return None
full_text = " ".join(s.text for s in segments)
logger.info(f"B站直拉字幕成功: {bvid} lan={lan}{len(segments)}")
return TranscriptResult(
language=lan,
full_text=full_text,
segments=segments,
raw={
"source": "bilibili_player_api",
"bvid": bvid,
"cid": cid,
"lan": lan,
"ai_type": track.get("ai_type"),
},
)

View File

@@ -110,15 +110,19 @@ def get_transcriber_models_status():
mlx_available = platform.system() == "Darwin"
mlx_statuses = []
if mlx_available:
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")
model_path = os.path.join(model_dir, f"mlx-community/whisper-{size}")
downloaded = Path(model_path).exists()
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())
mlx_statuses.append({
"model_size": size,
"downloaded": downloaded,
"downloading": _downloading.get(mlx_key) == "downloading",
"available": repo_id is not None,
})
return R.success(data={
@@ -164,15 +168,22 @@ def _do_download_mlx_whisper(model_size: str):
try:
_downloading[key] = "downloading"
from huggingface_hub import snapshot_download as hf_download
from app.transcriber.mlx_whisper_transcriber import resolve_mlx_repo_id
try:
repo_id = resolve_mlx_repo_id(model_size)
except ValueError as e:
logger.error(str(e))
_downloading[key] = "failed"
return
model_dir = get_model_dir("mlx-whisper")
model_name = f"mlx-community/whisper-{model_size}"
model_path = os.path.join(model_dir, model_name)
model_path = os.path.join(model_dir, repo_id)
if Path(model_path).exists():
_downloading[key] = "done"
return
logger.info(f"开始下载 mlx-whisper 模型: {model_size}")
hf_download(model_name, local_dir=model_path, local_dir_use_symlinks=False)
logger.info(f"开始下载 mlx-whisper 模型: {model_size}{repo_id}")
hf_download(repo_id, local_dir=model_path, local_dir_use_symlinks=False)
logger.info(f"mlx-whisper 模型下载完成: {model_size}")
_downloading[key] = "done"
except Exception as e:

View File

@@ -50,6 +50,10 @@ class VideoRequest(BaseModel):
video_understanding: Optional[bool] = False
video_interval: Optional[int] = 0
grid_size: Optional[list] = []
# 客户端(如浏览器插件)已经在用户浏览器里抓到字幕,直接传给后端复用,
# 跳过 download_subtitles 和音频转写。形如:
# {"language": "zh", "full_text": "...", "segments": [{"start","end","text"}, ...]}
prefetched_transcript: Optional[dict] = None
@field_validator("video_url")
def validate_supported_url(cls, v):
@@ -74,6 +78,40 @@ def save_note_to_file(task_id: str, note):
json.dump(asdict(note), f, ensure_ascii=False, indent=2)
def _persist_prefetched_transcript(task_id: str, transcript: dict) -> None:
"""把客户端预取的字幕写到 NoteGenerator 期望的转写缓存文件里。
NoteGenerator.generate 会优先读 <task_id>_transcript.json命中即跳过 download_subtitles
与音频转写流程。要求字段language(可空)/full_text/segments[{start,end,text}]
"""
segments = transcript.get("segments") or []
cleaned_segments = []
for s in segments:
text = (s.get("text") or "").strip()
if not text:
continue
cleaned_segments.append({
"start": float(s.get("start", 0)),
"end": float(s.get("end", 0)),
"text": text,
})
if not cleaned_segments:
raise ValueError("prefetched_transcript 没有可用的 segments")
full_text = transcript.get("full_text") or " ".join(s["text"] for s in cleaned_segments)
payload = {
"language": transcript.get("language") or "zh",
"full_text": full_text,
"segments": cleaned_segments,
}
os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True)
target = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}_transcript.json")
with open(target, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
logger.info(f"已写入客户端预取字幕缓存: {target} ({len(cleaned_segments)} 段)")
def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality,
link: bool = False, screenshot: bool = False, model_name: str = None, provider_id: str = None,
_format: list = None, style: str = None, extras: str = None, video_understanding: bool = False,
@@ -163,6 +201,13 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks):
# 统一先写入 PENDING表示已进入队列等待串行执行
NoteGenerator()._update_status(task_id, TaskStatus.PENDING)
# 客户端已经抓好字幕的话写到转写缓存文件NoteGenerator 的 cache-hit 逻辑会直接用上
if data.prefetched_transcript:
try:
_persist_prefetched_transcript(task_id, data.prefetched_transcript)
except Exception as e:
logger.warning(f"写入预取字幕失败 (task_id={task_id}): {e}")
background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality, data.link,
data.screenshot, data.model_name, data.provider_id, data.format, data.style,
data.extras, data.video_understanding, data.video_interval, data.grid_size)

View File

@@ -71,11 +71,19 @@ class ProviderService:
@staticmethod
def add_provider( name: str, api_key: str, base_url: str, logo: str, type_: str, enabled: int = 1):
try:
# 内置供应商type='built-in')只能由 seed 流程写入API 创建一律落到 'custom'
# 否则历史上出现过批量伪内置脏数据
if type_ != 'custom':
type_ = 'custom'
existing = get_provider_by_name(name)
if existing is not None:
raise ValueError(f'供应商名称已存在: {name}')
id = uuid().lower()
logo='custom'
logo = 'custom'
return insert_provider(id, name, api_key, base_url, logo, type_, enabled)
except Exception as e:
print('创建模式失败',e)
raise
@staticmethod
def provider_to_dict(p: Provider):
return {

View File

@@ -13,6 +13,31 @@ from events import transcription_finished
logger = get_logger(__name__)
# mlx-community 上的 Whisper 仓库命名不统一:常规版本是 'whisper-{size}-mlx'
# turbo 例外没有 -mlx 后缀。直接拼 'mlx-community/whisper-{size}' 会 404。
# 已用 https://huggingface.co/api/models?author=mlx-community&search=whisper 核对过。
MLX_MODEL_MAP = {
"tiny": "mlx-community/whisper-tiny-mlx",
"base": "mlx-community/whisper-base-mlx",
"small": "mlx-community/whisper-small-mlx",
"medium": "mlx-community/whisper-medium-mlx",
"large-v1": "mlx-community/whisper-large-v1-mlx",
"large-v2": "mlx-community/whisper-large-v2-mlx",
"large-v3": "mlx-community/whisper-large-v3-mlx",
"large-v3-turbo": "mlx-community/whisper-large-v3-turbo",
}
def resolve_mlx_repo_id(model_size: str) -> str:
if model_size not in MLX_MODEL_MAP:
raise ValueError(
f"不支持的 MLX Whisper 模型大小: {model_size}"
f"可选: {', '.join(MLX_MODEL_MAP.keys())}"
)
return MLX_MODEL_MAP[model_size]
class MLXWhisperTranscriber(Transcriber):
def __init__(
self,
@@ -21,13 +46,13 @@ class MLXWhisperTranscriber(Transcriber):
# 检查平台
if platform.system() != "Darwin":
raise RuntimeError("MLX Whisper 仅支持 Apple 平台")
# 检查环境变量
if os.environ.get("TRANSCRIBER_TYPE") != "mlx-whisper":
raise RuntimeError("必须设置环境变量 TRANSCRIBER_TYPE=mlx-whisper 才能使用 MLX Whisper")
self.model_size = model_size
self.model_name = f"mlx-community/whisper-{model_size}"
self.model_name = resolve_mlx_repo_id(model_size)
self.model_path = None
# 设置模型路径

View File

@@ -49,15 +49,19 @@ async def lifespan(app: FastAPI):
yield
app = create_app(lifespan=lifespan)
origins = [
"http://localhost",
"http://127.0.0.1",
"http://tauri.localhost",
]
# 允许的源:本地 web 端 + Tauri 桌面端 + 浏览器扩展chrome/edge/firefox
# 用 regex 是因为 chrome-extension://<id> 的 id 在每次开发版加载时不固定
CORS_ORIGIN_REGEX = (
r"^chrome-extension://[a-z]+$"
r"|^moz-extension://.+$"
r"|^http://(localhost|127\.0\.0\.1)(:\d+)?$"
r"|^http://tauri\.localhost$"
)
app.add_middleware(
CORSMiddleware,
allow_origins=origins, # 加上 Tauri 的 origin
allow_origin_regex=CORS_ORIGIN_REGEX,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],