feat(extension): P2 视频页悬浮按钮 + 右键菜单 + cookie 直通;P3 侧边栏首版

- contentScripts: 仅在支持的视频平台(B 站 / YouTube / 抖音 / 快手)注入悬浮 BiliNote 按钮,点击通过 webext-bridge 发 'bilinote-start' 给 background
- background: 处理 bilinote-start 与右键菜单点击;调 /api/generate_note;写 chrome.storage;自动打开侧边栏。logic/storage 是 Vue 反应式版本,service worker 不能 import,因此把常量抽到 logic/constants.ts
- contextMenus: onInstalled 时注册"用 BiliNote 总结此视频",限定 4 个支持平台域名
- 浏览器 Cookie 同步:options 页加按钮,按平台读 chrome.cookies.getAll,序列化为 'name=value; ...' 后 POST 给后端 /api/update_downloader_cookie。chrome.cookies + contextMenus 权限补到 manifest
- 侧边栏(P3 首版):从 storage 读最近任务并轮询,复用 TaskProgress + MarkdownView。markmap 思维导图与 RAG 问答推到后续
- 修 P1 endpoint 拼错的 bug:/api/get_models_by_provider 实际是 /api/model_enable,404 来自这里

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-07 11:46:09 +08:00
parent b8f359e7e7
commit 880587f2db
10 changed files with 435 additions and 50 deletions

View File

@@ -1,4 +1,7 @@
import { onMessage } from 'webext-bridge/background'
import type { Settings, TaskRecord } from '~/logic/types'
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
import { detectPlatform } from '~/logic/platform'
// only on dev mode
if (import.meta.hot) {
@@ -8,16 +11,162 @@ if (import.meta.hot) {
import('./contentScriptHMR')
}
// 工具栏图标点击 → 打开 popup默认行为无需配置
// side panel 留给 P3 阶段:在那时把 popup 替换成"action 行为"或加单独命令
// 此处不开启 openPanelOnActionClick否则会绕过 default_popup
// ---------- 直接操作 chrome.storageservice worker 里别用 Vue 反应式)----------
browser.runtime.onInstalled.addListener((): void => {
// eslint-disable-next-line no-console
console.log('BiliNote extension installed')
async function readSettings(): Promise<Settings> {
const obj = await browser.storage.local.get(SETTINGS_KEY)
const raw = obj[SETTINGS_KEY] as string | undefined
if (!raw)
return { ...DEFAULT_SETTINGS }
try {
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) }
}
catch {
return { ...DEFAULT_SETTINGS }
}
}
async function readTasks(): Promise<TaskRecord[]> {
const obj = await browser.storage.local.get(TASKS_KEY)
const raw = obj[TASKS_KEY] as string | undefined
if (!raw)
return []
try {
return JSON.parse(raw) as TaskRecord[]
}
catch {
return []
}
}
async function writeTasks(tasks: TaskRecord[]) {
await browser.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks.slice(0, MAX_TASKS)) })
}
async function upsertTask(record: TaskRecord) {
const tasks = await readTasks()
const idx = tasks.findIndex(t => t.taskId === record.taskId)
if (idx >= 0)
tasks.splice(idx, 1, { ...tasks[idx], ...record })
else
tasks.unshift(record)
await writeTasks(tasks)
}
// ---------- 启动任务 ----------
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
const platform = detectPlatform(url)
if (!platform)
return { ok: false, error: '当前链接不是支持的视频平台' }
const settings = await readSettings()
if (!settings.providerId || !settings.modelName)
return { ok: false, error: '请先在设置页选择供应商与模型' }
const backend = settings.backendUrl.replace(/\/$/, '')
try {
const res = await fetch(`${backend}/api/generate_note`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_url: url,
platform,
quality: settings.quality,
provider_id: settings.providerId,
model_name: settings.modelName,
screenshot: settings.screenshot,
link: settings.link,
style: settings.style || undefined,
format: [
...(settings.screenshot ? ['screenshot'] : []),
...(settings.link ? ['link'] : []),
],
}),
})
if (!res.ok)
return { ok: false, error: `HTTP ${res.status}` }
const body = await res.json() as { code: number, msg: string, data: { task_id: string } }
if (body.code !== 0)
return { ok: false, error: body.msg }
await upsertTask({
taskId: body.data.task_id,
videoUrl: url,
platform,
status: 'PENDING',
message: '已提交',
createdAt: Date.now(),
updatedAt: Date.now(),
})
return { ok: true, taskId: body.data.task_id }
}
catch (e) {
return { ok: false, error: (e as Error).message }
}
}
async function openSidePanelInTab(tabId?: number) {
try {
// @ts-expect-error chrome.sidePanel 类型在 webextension-polyfill 中尚未补全
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open && tabId !== undefined)
// @ts-expect-error see above
await chrome.sidePanel.open({ tabId })
}
catch (err) {
console.warn('打开侧边栏失败:', err)
}
}
// ---------- 消息桥 ----------
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
const result = await startTask(data.url)
// 成功就把侧边栏拉起来给用户看进度
if (result.ok)
await openSidePanelInTab(sender?.tabId)
return result
})
// 占位:未来 content script 通过 webext-bridge 触发任务时,由这里转发到后端
// ---------- 安装时事件 ----------
browser.runtime.onInstalled.addListener(() => {
console.log('BiliNote extension installed')
// 右键菜单:在视频页或视频链接上"用 BiliNote 总结"
try {
browser.contextMenus.create({
id: 'bilinote-summarize-page',
title: '用 BiliNote 总结此视频',
contexts: ['page', 'link', 'video'],
documentUrlPatterns: [
'*://*.bilibili.com/*',
'*://*.youtube.com/*',
'*://youtu.be/*',
'*://*.douyin.com/*',
'*://*.kuaishou.com/*',
],
})
}
catch (e) {
console.warn('注册右键菜单失败:', e)
}
})
browser.contextMenus?.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== 'bilinote-summarize-page')
return
const url = info.linkUrl || tab?.url
if (!url)
return
const result = await startTask(url)
if (result.ok)
await openSidePanelInTab(tab?.id)
else
console.warn('右键启动失败:', result.error)
})
// content script 占位握手 —— 未来可扩展为查询当前任务等
onMessage('get-current-tab', async () => {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
@@ -27,4 +176,3 @@ onMessage('get-current-tab', async () => {
return { title: undefined, url: undefined }
}
})

View File

@@ -1,19 +1,13 @@
/* 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'
import { detectPlatform } from '~/logic/platform'
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
// 只在支持的视频平台上挂悬浮按钮,避免污染其他网站
(() => {
console.info('[vitesse-webext] Hello world from content script')
if (!detectPlatform(window.location.href))
return
// 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')

View File

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

View File

@@ -34,7 +34,14 @@ export async function getProviders(): Promise<Provider[]> {
}
export async function getModelsByProvider(providerId: string): Promise<Model[]> {
return request<Model[]>(`/api/get_models_by_provider/${providerId}`)
return request<Model[]>(`/api/model_enable/${providerId}`)
}
export async function setDownloaderCookie(platform: string, cookie: string): Promise<void> {
await request('/api/update_downloader_cookie', {
method: 'POST',
body: JSON.stringify({ platform, cookie }),
})
}
export async function generateNote(payload: GenerateRequest): Promise<{ task_id: string }> {

View File

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

View File

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

View File

@@ -1,33 +1,22 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
import type { Settings, TaskRecord } from './types'
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from './constants'
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
export { DEFAULT_BACKEND_URL, DEFAULT_SETTINGS, MAX_TASKS } from './constants'
export const DEFAULT_SETTINGS: Settings = {
backendUrl: DEFAULT_BACKEND_URL,
providerId: '',
modelName: '',
quality: 'medium',
screenshot: false,
link: false,
style: '',
}
// 全局共享设置popup / options / sidepanel / background 都读这一份)
// 全局共享设置popup / options / sidepanel 三个 Vue 上下文都读这一份)
// 注意background service worker 不要 import 这个文件,改用 chrome.storage 直读
export const { data: settings, dataReady: settingsReady } = useWebExtensionStorage<Settings>(
'bilinote-settings',
SETTINGS_KEY,
DEFAULT_SETTINGS,
{ mergeDefaults: true },
)
// 历史任务列表,最近的在前
export const { data: tasks, dataReady: tasksReady } = useWebExtensionStorage<TaskRecord[]>(
'bilinote-tasks',
TASKS_KEY,
[],
)
export const MAX_TASKS = 30
export function upsertTask(record: TaskRecord) {
const list = tasks.value ?? []
const idx = list.findIndex(t => t.taskId === record.taskId)

View File

@@ -39,6 +39,8 @@ export async function getManifest() {
'storage',
'activeTab',
'sidePanel',
'contextMenus',
'cookies',
],
host_permissions: ['*://*/*'],
content_scripts: [

View File

@@ -2,7 +2,9 @@
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'
import { SUPPORTED_COOKIE_PLATFORMS, syncCookieToBackend } from '~/logic/cookies'
import { PLATFORM_LABELS } from '~/logic/platform'
import type { Model, Platform, Provider } from '~/logic/types'
const providers = ref<Provider[]>([])
const models = ref<Model[]>([])
@@ -49,6 +51,19 @@ async function testConnection() {
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS 配置' }
}
const cookieStatus = ref<Record<string, { kind: 'ok' | 'err' | 'idle', text: string }>>({})
const cookieBusy = ref<Record<string, boolean>>({})
async function syncCookie(platform: Exclude<Platform, 'local'>) {
cookieBusy.value[platform] = true
cookieStatus.value[platform] = { kind: 'idle', text: '同步中…' }
const res = await syncCookieToBackend(platform)
cookieStatus.value[platform] = res.ok
? { kind: 'ok', text: `已同步 ${res.count} 条 cookie ✓` }
: { kind: 'err', text: res.error || '同步失败' }
cookieBusy.value[platform] = false
}
watch(() => settings.value?.providerId, (id) => {
if (id)
refreshModels(id)
@@ -146,6 +161,36 @@ onMounted(async () => {
</div>
</section>
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
<h2 class="font-semibold">浏览器 Cookie 同步</h2>
<p class="text-xs text-gray-500">
从当前浏览器读取你已登录站点的 cookie并写入后端 (POST /api/update_downloader_cookie)
Bilibili / Douyin / Kuaishou 这类需要登录态的下载尤其需要这一步
</p>
<div class="flex flex-col gap-2">
<div
v-for="p in SUPPORTED_COOKIE_PLATFORMS"
:key="p"
class="flex items-center justify-between gap-2 text-sm"
>
<span class="w-20">{{ PLATFORM_LABELS[p] }}</span>
<button class="btn-secondary" :disabled="cookieBusy[p]" @click="syncCookie(p)">
{{ cookieBusy[p] ? '同步中' : '同步 Cookie' }}
</button>
<span
class="flex-1 text-xs"
:class="{
'text-green-700': cookieStatus[p]?.kind === 'ok',
'text-red-600': cookieStatus[p]?.kind === 'err',
'text-gray-500': cookieStatus[p]?.kind === 'idle',
}"
>
{{ cookieStatus[p]?.text || '' }}
</span>
</div>
</div>
</section>
<p class="text-xs text-gray-500">
所有设置自动保存不在桌面端管理供应商/模型请在 BiliNote web (http://localhost:3015) 完成。
</p>

View File

@@ -1,18 +1,114 @@
<script setup lang="ts">
function openOptionsPage() {
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { getTaskStatus } from '~/logic/api'
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
import type { TaskRecord } from '~/logic/types'
const activeTaskId = ref<string>('')
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
const errorMsg = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function poll(taskId: string) {
try {
const res = await getTaskStatus(taskId)
const cur = tasks.value?.find(t => t.taskId === taskId)
if (cur) {
upsertTask({
...cur,
status: res.status,
message: res.message,
result: res.result ?? cur.result,
updatedAt: Date.now(),
})
}
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
pollTimer = setTimeout(() => poll(taskId), 3000)
}
catch (e) {
errorMsg.value = (e as Error).message
pollTimer = setTimeout(() => poll(taskId), 5000)
}
}
function selectTask(id: string) {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
activeTaskId.value = id
const t = tasks.value?.find(x => x.taskId === id)
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
poll(id)
}
function openOptions() {
browser.runtime.openOptionsPage()
}
onMounted(async () => {
await Promise.all([settingsReady, tasksReady])
// 默认选中最近的任务(无论是否完成)
const latest = tasks.value?.[0]
if (latest) {
activeTaskId.value = latest.taskId
if (latest.status !== 'SUCCESS' && latest.status !== 'FAILED')
poll(latest.taskId)
}
})
onUnmounted(() => {
if (pollTimer)
clearTimeout(pollTimer)
})
</script>
<template>
<main class="w-full h-full 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 class="w-full h-full flex flex-col bg-white text-sm text-gray-800">
<header class="flex items-center justify-between px-3 py-2 border-b">
<div class="font-semibold">BiliNote 侧边栏</div>
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
</header>
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-2 break-words bg-red-50">
{{ errorMsg }}
</div>
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
还没有任务在视频页点悬浮按钮 popup 提交或右键菜单选 BiliNote 总结
</section>
<section v-else class="flex-1 flex flex-col gap-3 p-3 overflow-hidden">
<div class="text-xs text-gray-500 truncate" :title="activeTask.videoUrl">
{{ activeTask.videoUrl }}
</div>
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
<div class="flex-1 overflow-auto">
<MarkdownView
v-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown"
:markdown="activeTask.result.markdown"
:title="activeTask.result.audio_meta?.title"
/>
</div>
</section>
<details v-if="(tasks?.length ?? 0) > 1" class="text-xs border-t px-3 py-2">
<summary class="cursor-pointer text-gray-500">
历史任务{{ tasks!.length }}
</summary>
<ul class="mt-1 flex flex-col gap-1 max-h-40 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>