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

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,75 @@
{
"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"
}
}

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

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

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