mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-17 10:47:36 +08:00
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:
17
BillNote_extension/.gitignore
vendored
Normal file
17
BillNote_extension/.gitignore
vendored
Normal 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
7
BillNote_extension/.gitpod.Dockerfile
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM gitpod/workspace-full-vnc
|
||||
|
||||
USER root
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y firefox
|
||||
23
BillNote_extension/.gitpod.yml
Normal file
23
BillNote_extension/.gitpod.yml
Normal 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
|
||||
2
BillNote_extension/.npmrc
Normal file
2
BillNote_extension/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
shamefully-hoist=true
|
||||
auto-install-peers=true
|
||||
9
BillNote_extension/.vscode/extensions.json
vendored
Normal file
9
BillNote_extension/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar",
|
||||
"antfu.iconify",
|
||||
"antfu.unocss",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"csstools.postcss"
|
||||
]
|
||||
}
|
||||
12
BillNote_extension/.vscode/settings.json
vendored
Normal file
12
BillNote_extension/.vscode/settings.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
21
BillNote_extension/LICENSE
Normal file
21
BillNote_extension/LICENSE
Normal 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.
|
||||
53
BillNote_extension/README.md
Normal file
53
BillNote_extension/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# BiliNote 浏览器插件
|
||||
|
||||
把 BiliNote 的"视频链接 → Markdown 笔记"能力下沉到浏览器插件。当前为 P1 MVP(仅工具栏 popup)。
|
||||
|
||||
## 当前状态(P1 MVP)
|
||||
|
||||
- ✅ 工具栏图标 popup:自动读当前 tab URL,识别支持平台,触发笔记生成
|
||||
- ✅ 设置页:后端地址、供应商/模型、画质、截图/跳转/风格默认值
|
||||
- ✅ 任务进度可视化、Markdown 渲染、复制 / 下载 .md
|
||||
- ✅ chrome.storage.local 持久化设置和最近 30 个任务
|
||||
- ⏳ P2:视频页悬浮按钮 + 右键菜单 + 浏览器 cookie 直通
|
||||
- ⏳ P3:side panel + 思维导图(markmap)
|
||||
- ⏳ P4:RAG 问答
|
||||
|
||||
## 开发
|
||||
|
||||
依赖: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)。
|
||||
20
BillNote_extension/e2e/basic.spec.ts
Normal file
20
BillNote_extension/e2e/basic.spec.ts
Normal 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')
|
||||
})
|
||||
48
BillNote_extension/e2e/fixtures.ts
Normal file
48
BillNote_extension/e2e/fixtures.ts
Normal 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'),
|
||||
)
|
||||
}
|
||||
5
BillNote_extension/eslint.config.mjs
Normal file
5
BillNote_extension/eslint.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu(
|
||||
|
||||
)
|
||||
BIN
BillNote_extension/extension/assets/icon-512.png
Normal file
BIN
BillNote_extension/extension/assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
12
BillNote_extension/extension/assets/icon.svg
Normal file
12
BillNote_extension/extension/assets/icon.svg
Normal 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
10
BillNote_extension/modules.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$app: {
|
||||
context: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/64189046/479957
|
||||
export {}
|
||||
75
BillNote_extension/package.json
Normal file
75
BillNote_extension/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
BillNote_extension/playwright.config.ts
Normal file
15
BillNote_extension/playwright.config.ts
Normal 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
9495
BillNote_extension/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
BillNote_extension/scripts/manifest.ts
Normal file
10
BillNote_extension/scripts/manifest.ts
Normal 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()
|
||||
40
BillNote_extension/scripts/prepare.ts
Normal file
40
BillNote_extension/scripts/prepare.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
12
BillNote_extension/scripts/utils.ts
Normal file
12
BillNote_extension/scripts/utils.ts
Normal 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
10
BillNote_extension/shim.d.ts
vendored
Normal 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 }>
|
||||
}
|
||||
}
|
||||
12
BillNote_extension/src/assets/logo.svg
Normal file
12
BillNote_extension/src/assets/logo.svg
Normal 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 |
18
BillNote_extension/src/background/contentScriptHMR.ts
Normal file
18
BillNote_extension/src/background/contentScriptHMR.ts
Normal 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))
|
||||
})
|
||||
30
BillNote_extension/src/background/main.ts
Normal file
30
BillNote_extension/src/background/main.ts
Normal 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 }
|
||||
}
|
||||
})
|
||||
|
||||
5
BillNote_extension/src/components/Logo.vue
Normal file
5
BillNote_extension/src/components/Logo.vue
Normal 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>
|
||||
44
BillNote_extension/src/components/MarkdownView.vue
Normal file
44
BillNote_extension/src/components/MarkdownView.vue
Normal 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>
|
||||
24
BillNote_extension/src/components/PlatformBadge.vue
Normal file
24
BillNote_extension/src/components/PlatformBadge.vue
Normal 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>
|
||||
11
BillNote_extension/src/components/README.md
Normal file
11
BillNote_extension/src/components/README.md
Normal 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.
|
||||
5
BillNote_extension/src/components/SharedSubtitle.vue
Normal file
5
BillNote_extension/src/components/SharedSubtitle.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p class="mt-2 opacity-50">
|
||||
This is the {{ $app.context }} page
|
||||
</p>
|
||||
</template>
|
||||
42
BillNote_extension/src/components/TaskProgress.vue
Normal file
42
BillNote_extension/src/components/TaskProgress.vue
Normal 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>
|
||||
11
BillNote_extension/src/components/__tests__/Logo.test.ts
Normal file
11
BillNote_extension/src/components/__tests__/Logo.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
166
BillNote_extension/src/composables/useWebExtensionStorage.ts
Normal file
166
BillNote_extension/src/composables/useWebExtensionStorage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
30
BillNote_extension/src/contentScripts/index.ts
Normal file
30
BillNote_extension/src/contentScripts/index.ts
Normal 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)
|
||||
})()
|
||||
9
BillNote_extension/src/contentScripts/views/App.vue
Normal file
9
BillNote_extension/src/contentScripts/views/App.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// P2 计划:在视频页注入悬浮按钮 → 一键调起 BiliNote 任务。
|
||||
// MVP 阶段无注入;保留组件外壳以便编译与未来扩展。
|
||||
import 'uno.css'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- intentionally empty in P1 MVP -->
|
||||
</template>
|
||||
14
BillNote_extension/src/env.ts
Normal file
14
BillNote_extension/src/env.ts
Normal 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
8
BillNote_extension/src/global.d.ts
vendored
Normal 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
|
||||
}
|
||||
69
BillNote_extension/src/logic/api.ts
Normal file
69
BillNote_extension/src/logic/api.ts
Normal 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) => ``)
|
||||
}
|
||||
15
BillNote_extension/src/logic/common-setup.ts
Normal file
15
BillNote_extension/src/logic/common-setup.ts
Normal 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)
|
||||
}
|
||||
1
BillNote_extension/src/logic/index.ts
Normal file
1
BillNote_extension/src/logic/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './storage'
|
||||
24
BillNote_extension/src/logic/platform.ts
Normal file
24
BillNote_extension/src/logic/platform.ts
Normal 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: '本地',
|
||||
}
|
||||
44
BillNote_extension/src/logic/storage.ts
Normal file
44
BillNote_extension/src/logic/storage.ts
Normal 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)
|
||||
}
|
||||
82
BillNote_extension/src/logic/types.ts
Normal file
82
BillNote_extension/src/logic/types.ts
Normal 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
|
||||
}
|
||||
91
BillNote_extension/src/manifest.ts
Normal file
91
BillNote_extension/src/manifest.ts
Normal 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
|
||||
}
|
||||
157
BillNote_extension/src/options/Options.vue
Normal file
157
BillNote_extension/src/options/Options.vue
Normal 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>
|
||||
12
BillNote_extension/src/options/index.html
Normal file
12
BillNote_extension/src/options/index.html
Normal 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>
|
||||
8
BillNote_extension/src/options/main.ts
Normal file
8
BillNote_extension/src/options/main.ts
Normal 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')
|
||||
212
BillNote_extension/src/popup/Popup.vue
Normal file
212
BillNote_extension/src/popup/Popup.vue
Normal 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>
|
||||
12
BillNote_extension/src/popup/index.html
Normal file
12
BillNote_extension/src/popup/index.html
Normal 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>
|
||||
8
BillNote_extension/src/popup/main.ts
Normal file
8
BillNote_extension/src/popup/main.ts
Normal 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')
|
||||
18
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal file
18
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal 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>
|
||||
12
BillNote_extension/src/sidepanel/index.html
Normal file
12
BillNote_extension/src/sidepanel/index.html
Normal 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>
|
||||
8
BillNote_extension/src/sidepanel/main.ts
Normal file
8
BillNote_extension/src/sidepanel/main.ts
Normal 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')
|
||||
3
BillNote_extension/src/styles/index.ts
Normal file
3
BillNote_extension/src/styles/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import './main.css'
|
||||
import 'uno.css'
|
||||
20
BillNote_extension/src/styles/main.css
Executable file
20
BillNote_extension/src/styles/main.css
Executable 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;
|
||||
}
|
||||
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('demo', () => {
|
||||
it('should work', () => {
|
||||
expect(1 + 1).toBe(2)
|
||||
})
|
||||
})
|
||||
24
BillNote_extension/tsconfig.json
Normal file
24
BillNote_extension/tsconfig.json
Normal 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"]
|
||||
}
|
||||
13
BillNote_extension/unocss.config.ts
Normal file
13
BillNote_extension/unocss.config.ts
Normal 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(),
|
||||
],
|
||||
})
|
||||
36
BillNote_extension/vite.config.background.mts
Normal file
36
BillNote_extension/vite.config.background.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
36
BillNote_extension/vite.config.content.mts
Normal file
36
BillNote_extension/vite.config.content.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
115
BillNote_extension/vite.config.mts
Normal file
115
BillNote_extension/vite.config.mts
Normal 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',
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user