feat(extension): 浏览器插件 P1 MVP

新建 BillNote_extension/ 工作空间(基于 vitesse-webext 骨架,Vue 3 + Vite + UnoCSS + MV3)。

P1 MVP 范围:
- popup:自动读当前 tab URL,识别 Bilibili / YouTube / 抖音 / 快手;提交 /generate_note 后轮询 /task_status;展示 markdown,复制 + 下载 .md
- options:后端地址输入与连通性测试;从 /get_all_providers + /get_models_by_provider 拉供应商/模型列表;默认画质、截图/跳转、笔记风格
- chrome.storage.local 持久化设置与最近 30 个任务,popup 重开恢复进行中任务
- markdown 里的 /static/screenshots 路径在渲染前重写为绝对地址

后端:CORS 改用 regex,新增允许 chrome-extension:// 与 moz-extension:// 源(同时保留 localhost / 127.0.0.1 / tauri.localhost)。无新增 backend endpoint。

P2-P4(content script 悬浮按钮、cookie 直通、side panel、思维导图、RAG 问答)保留 stub 文件,不在本次范围。

去掉 vitesse-webext 自带的 simple-git-hooks postinstall 配置——它会在仓库根装 pre-commit 钩子去跑 pnpm lint-staged,但仓库根没有 package.json,会破坏所有提交流。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-07 11:40:15 +08:00
parent 108ad270bf
commit b8f359e7e7
62 changed files with 11382 additions and 6 deletions

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,30 @@
import { onMessage } from 'webext-bridge/background'
// only on dev mode
if (import.meta.hot) {
// @ts-expect-error for background HMR
import('/@vite/client')
// load latest content script
import('./contentScriptHMR')
}
// 工具栏图标点击 → 打开 popup默认行为无需配置
// side panel 留给 P3 阶段:在那时把 popup 替换成"action 行为"或加单独命令
// 此处不开启 openPanelOnActionClick否则会绕过 default_popup
browser.runtime.onInstalled.addListener((): void => {
// eslint-disable-next-line no-console
console.log('BiliNote extension installed')
})
// 占位:未来 content script 通过 webext-bridge 触发任务时,由这里转发到后端
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,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 } from '~/logic/api'
const props = defineProps<{ markdown: string, title?: string }>()
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const html = computed(() => md.render(absolutizeMarkdownImages(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">
<div class="flex gap-2 justify-end">
<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 border rounded p-3 bg-gray-50 max-h-[400px] 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,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,30 @@
/* eslint-disable no-console */
import { onMessage } from 'webext-bridge/content-script'
import { createApp } from 'vue'
import App from './views/App.vue'
import { setupApp } from '~/logic/common-setup'
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
(() => {
console.info('[vitesse-webext] Hello world from content script')
// communication example: send previous tab title from background page
onMessage('tab-prev', ({ data }) => {
console.log(`[vitesse-webext] Navigate from page "${data.title}"`)
})
// mount component to context window
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,9 @@
<script setup lang="ts">
// P2 计划:在视频页注入悬浮按钮 → 一键调起 BiliNote 任务。
// MVP 阶段无注入;保留组件外壳以便编译与未来扩展。
import 'uno.css'
</script>
<template>
<!-- intentionally empty in P1 MVP -->
</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,69 @@
import type { GenerateRequest, Model, Provider, TaskStatusResponse } 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/get_models_by_provider/${providerId}`)
}
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/{id} 返回的是裸对象(非 ResponseWrapper 包装),见 routers/note.py
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`)
if (!res.ok)
throw new Error(`HTTP ${res.status}`)
return (await res.json()) as TaskStatusResponse
}
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})`)
}

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 @@
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,44 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
import type { Settings, TaskRecord } 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: '',
}
// 全局共享设置popup / options / sidepanel / background 都读这一份)
export const { data: settings, dataReady: settingsReady } = useWebExtensionStorage<Settings>(
'bilinote-settings',
DEFAULT_SETTINGS,
{ mergeDefaults: true },
)
// 历史任务列表,最近的在前
export const { data: tasks, dataReady: tasksReady } = useWebExtensionStorage<TaskRecord[]>(
'bilinote-tasks',
[],
)
export const MAX_TASKS = 30
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,82 @@
// 与 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
}
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
}

View File

@@ -0,0 +1,91 @@
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',
],
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,157 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { getModelsByProvider, getProviders, ping } from '~/logic/api'
import { settings, settingsReady } from '~/logic/storage'
import type { Model, Provider } from '~/logic/types'
const providers = ref<Provider[]>([])
const models = ref<Model[]>([])
const loading = ref(false)
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
async function refreshProviders() {
loading.value = true
status.value = { kind: 'idle', text: '' }
try {
providers.value = (await getProviders()).filter(p => p.enabled === 1)
if (settings.value.providerId)
await refreshModels(settings.value.providerId)
status.value = { kind: 'ok', text: `已加载 ${providers.value.length} 个供应商` }
}
catch (e) {
status.value = { kind: 'err', text: `加载失败:${(e as Error).message}` }
providers.value = []
models.value = []
}
finally {
loading.value = false
}
}
async function refreshModels(providerId: string) {
if (!providerId) {
models.value = []
return
}
try {
models.value = await getModelsByProvider(providerId)
}
catch {
models.value = []
}
}
async function testConnection() {
status.value = { kind: 'idle', text: '正在测试…' }
const ok = await ping()
status.value = ok
? { kind: 'ok', text: '后端连通 ✓' }
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS 配置' }
}
watch(() => settings.value?.providerId, (id) => {
if (id)
refreshModels(id)
// 切换供应商时清空已选模型,避免错配
if (id !== providers.value.find(p => p.id === id)?.id)
settings.value.modelName = ''
})
onMounted(async () => {
await settingsReady
if (settings.value.backendUrl)
await refreshProviders()
})
</script>
<template>
<main class="max-w-2xl mx-auto p-6 text-gray-800 dark:text-gray-100">
<header class="flex items-center gap-2 mb-6">
<h1 class="text-xl font-bold">BiliNote 浏览器插件 · 设置</h1>
</header>
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
<h2 class="font-semibold">后端地址</h2>
<div class="flex gap-2">
<input
v-model="settings.backendUrl"
class="flex-1 border rounded px-2 py-1"
placeholder="http://localhost:8483"
>
<button class="btn-secondary" @click="testConnection">测试连通</button>
<button class="btn-secondary" :disabled="loading" @click="refreshProviders">
{{ 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 后端 (cd backend && python main.py)
</p>
</section>
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
<h2 class="font-semibold">默认供应商与模型</h2>
<label class="flex flex-col gap-1 text-sm">
<span class="text-gray-600">供应商</span>
<select v-model="settings.providerId" class="border rounded px-2 py-1">
<option value=""> 选择供应商 </option>
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} <span v-if="p.type === 'built-in'">(内置)</span>
</option>
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-gray-600">模型</span>
<select v-model="settings.modelName" class="border rounded px-2 py-1" :disabled="!settings.providerId">
<option value=""> 选择模型 </option>
<option v-for="m in models" :key="m.id" :value="m.model_name">{{ m.model_name }}</option>
</select>
<span v-if="settings.providerId && models.length === 0" class="text-xs text-amber-700">
该供应商下还没有可用模型请到桌面 web 端的模型设置里添加
</span>
</label>
</section>
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
<h2 class="font-semibold">默认生成选项</h2>
<div class="grid grid-cols-2 gap-3 text-sm">
<label class="flex flex-col gap-1">
<span class="text-gray-600">画质</span>
<select v-model="settings.quality" class="border rounded px-2 py-1">
<option value="fast">快速 (32k)</option>
<option value="medium">中等 (64k)</option>
<option value="slow">高质 (128k)</option>
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-gray-600">笔记风格</span>
<input v-model="settings.style" class="border rounded px-2 py-1" placeholder="留空使用默认">
</label>
<label class="flex items-center gap-2">
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
</label>
<label class="flex items-center gap-2">
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
</label>
</div>
</section>
<p class="text-xs text-gray-500">
所有设置自动保存不在桌面端管理供应商/模型请在 BiliNote web (http://localhost:3015) 完成。
</p>
</main>
</template>
<style>
.btn-secondary { @apply bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200 text-sm disabled:opacity-50; }
</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,212 @@
<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 } from '~/logic/api'
import type { TaskRecord } from '~/logic/types'
const tabUrl = ref<string>('')
const tabTitle = ref<string>('')
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 ?? ''
}
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 {
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'] : []),
],
})
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)
}
catch (e) {
errorMsg.value = (e as Error).message
}
finally {
submitting.value = false
}
}
function openOptions() {
browser.runtime.openOptionsPage()
}
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)
}
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">
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
<MarkdownView
v-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown"
:markdown="activeTask.result.markdown"
:title="activeTask.result.audio_meta?.title || tabTitle"
/>
</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?.title || t.videoUrl }}</span>
<span class="text-gray-500">{{ 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; }
</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,18 @@
<script setup lang="ts">
function openOptionsPage() {
browser.runtime.openOptionsPage()
}
</script>
<template>
<main class="w-full h-full p-4 flex flex-col gap-3 text-sm text-gray-700">
<h1 class="text-base font-semibold">BiliNote 侧边栏</h1>
<p class="text-gray-500">
P3 计划在这里展示任务进度思维导图与 RAG 问答
当前 MVP 仅启用工具栏 popup
</p>
<button class="bg-gray-100 hover:bg-gray-200 px-3 py-1 rounded w-fit" @click="openOptionsPage">
打开设置
</button>
</main>
</template>

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)
})
})